e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
901 lines
34 KiB
Python
901 lines
34 KiB
Python
"""Tests for the subagent driver layer: command building, event parsing,
|
||
the streaming-subprocess primitive, and settings.
|
||
|
||
Event parsing is exercised by feeding crafted CLI events straight into each
|
||
backend's handler — no real ``claude`` / ``codex`` process is spawned, so the
|
||
suite is fast and deterministic. The one live subprocess test drives ``python``
|
||
to prove the streaming primitive surfaces stdout/stderr in order with an exit.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
|
||
import pytest
|
||
|
||
from deeptutor.services.subagent.claude_code import ClaudeCodeBackend
|
||
from deeptutor.services.subagent.codex import CodexBackend
|
||
from deeptutor.services.subagent.config import (
|
||
CONSULT_BUDGET_MAX,
|
||
DEFAULT_CONSULT_BUDGET,
|
||
BackendConfig,
|
||
SubagentSettings,
|
||
settings_from_dict,
|
||
)
|
||
from deeptutor.services.subagent.process import stream_process_lines
|
||
from deeptutor.services.subagent.types import ConsultResult
|
||
|
||
# ---- command building --------------------------------------------------------
|
||
|
||
|
||
def test_claude_command_build_fresh_and_resume() -> None:
|
||
backend = ClaudeCodeBackend()
|
||
cfg = BackendConfig(permission_mode="acceptEdits", extra_args=["--foo"])
|
||
fresh = backend._build_command("hi", session_id=None, config=cfg)
|
||
assert fresh[:3] == ["claude", "-p", "hi"]
|
||
assert "--output-format" in fresh and "stream-json" in fresh and "--verbose" in fresh
|
||
assert "--permission-mode" in fresh and "acceptEdits" in fresh
|
||
assert "--resume" not in fresh
|
||
assert fresh[-1] == "--foo"
|
||
|
||
resumed = backend._build_command("again", session_id="sess-1", config=cfg)
|
||
assert "--resume" in resumed and "sess-1" in resumed
|
||
|
||
|
||
def test_codex_command_build_sandbox_and_resume() -> None:
|
||
backend = CodexBackend()
|
||
cfg = BackendConfig(sandbox="workspace-write")
|
||
fresh = backend._build_command("hi", session_id=None, config=cfg)
|
||
assert fresh[:2] == ["codex", "exec"]
|
||
assert "resume" not in fresh
|
||
assert "--json" in fresh and "--skip-git-repo-check" in fresh
|
||
assert "-c" in fresh and 'sandbox_mode="workspace-write"' in fresh
|
||
assert fresh[-1] == "hi" # prompt is the trailing positional
|
||
|
||
resumed = backend._build_command("again", session_id="abc", config=cfg)
|
||
assert resumed[1:3] == ["exec", "resume"] and "abc" in resumed
|
||
|
||
|
||
def test_claude_command_applies_model_effort_system_prompt() -> None:
|
||
backend = ClaudeCodeBackend()
|
||
cfg = BackendConfig(model="opus", effort="high", system_prompt="consulted by DeepTutor")
|
||
cmd = backend._build_command("hi", session_id=None, config=cfg)
|
||
assert "--model" in cmd and "opus" in cmd
|
||
assert "--effort" in cmd and "high" in cmd
|
||
assert "--append-system-prompt" in cmd and "consulted by DeepTutor" in cmd
|
||
|
||
|
||
def test_codex_command_applies_model_effort_network_ephemeral() -> None:
|
||
backend = CodexBackend()
|
||
cfg = BackendConfig(
|
||
model="gpt-5-codex",
|
||
effort="high",
|
||
network_access=True,
|
||
ephemeral=True,
|
||
approval="never",
|
||
sandbox="workspace-write",
|
||
)
|
||
fresh = backend._build_command("hi", session_id=None, config=cfg)
|
||
assert "-m" in fresh and "gpt-5-codex" in fresh
|
||
assert 'model_reasoning_effort="high"' in fresh
|
||
assert 'approval_policy="never"' in fresh
|
||
assert "sandbox_workspace_write.network_access=true" in fresh
|
||
assert "--ephemeral" in fresh
|
||
# --ephemeral is a fresh-only flag — resuming an existing session omits it.
|
||
resumed = backend._build_command("hi", session_id="s1", config=cfg)
|
||
assert "--ephemeral" not in resumed
|
||
|
||
|
||
def test_codex_command_bypass_uses_real_flag() -> None:
|
||
backend = CodexBackend()
|
||
cmd = backend._build_command("hi", session_id=None, config=BackendConfig(sandbox="bypass"))
|
||
assert "--dangerously-bypass-approvals-and-sandbox" in cmd
|
||
assert "-c" not in cmd # bypass replaces the sandbox_mode override
|
||
|
||
|
||
def test_claude_command_forwards_images_via_read() -> None:
|
||
# CC's -p has no image flag, so we point its Read tool at the files and grant
|
||
# their directory with --add-dir.
|
||
backend = ClaudeCodeBackend()
|
||
imgs = ["/tmp/dt-x/00_a.png", "/tmp/dt-x/01_b.png"]
|
||
cmd = backend._build_command("look", session_id=None, config=BackendConfig(), images=imgs)
|
||
prompt = cmd[2]
|
||
assert "Read tool" in prompt
|
||
assert "/tmp/dt-x/00_a.png" in prompt and "/tmp/dt-x/01_b.png" in prompt
|
||
assert "--add-dir" in cmd and "/tmp/dt-x" in cmd
|
||
|
||
|
||
def test_codex_command_attaches_images_with_repeated_flag() -> None:
|
||
# Repeated -i (not the variadic form) so the trailing prompt is never swallowed.
|
||
backend = CodexBackend()
|
||
imgs = ["/tmp/dt-y/00_a.png", "/tmp/dt-y/01_b.png"]
|
||
cmd = backend._build_command("look", session_id=None, config=BackendConfig(), images=imgs)
|
||
assert cmd.count("-i") == 2
|
||
assert cmd[cmd.index("-i") + 1] == "/tmp/dt-y/00_a.png"
|
||
assert cmd[-1] == "look" # prompt stays the trailing positional
|
||
|
||
|
||
# ---- Claude Code event parsing -----------------------------------------------
|
||
|
||
|
||
async def _drive(backend, events):
|
||
"""Feed crafted events through a backend handler, collecting emitted events."""
|
||
result = ConsultResult()
|
||
emitted: list[tuple[str, str]] = []
|
||
|
||
async def emit(kind, text, raw, meta=None):
|
||
emitted.append((kind, text))
|
||
|
||
if isinstance(backend, ClaudeCodeBackend):
|
||
assistant_text: list[str] = []
|
||
stream: dict = {"msg_id": "", "blocks": {}}
|
||
for ev in events:
|
||
await backend._handle_event(ev, result, assistant_text, stream, emit)
|
||
if not result.final_text and assistant_text:
|
||
result.final_text = "\n".join(assistant_text)
|
||
else:
|
||
for ev in events:
|
||
await backend._handle_event(ev, result, emit)
|
||
return result, emitted
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_claude_event_parsing_captures_session_text_and_tools() -> None:
|
||
backend = ClaudeCodeBackend()
|
||
events = [
|
||
{"type": "system", "subtype": "init", "session_id": "s1", "model": "opus"},
|
||
{
|
||
"type": "assistant",
|
||
"session_id": "s1",
|
||
"message": {
|
||
"content": [
|
||
{"type": "text", "text": "Looking into it."},
|
||
{"type": "tool_use", "name": "Read", "input": {"file": "a.py"}},
|
||
]
|
||
},
|
||
},
|
||
{
|
||
"type": "user",
|
||
"message": {"content": [{"type": "tool_result", "content": "file body"}]},
|
||
},
|
||
# The final answer arrives as an assistant text block …
|
||
{
|
||
"type": "assistant",
|
||
"session_id": "s1",
|
||
"message": {"content": [{"type": "text", "text": "Final answer."}]},
|
||
},
|
||
# … and the result event mirrors it (we don't re-emit — no duplicate row).
|
||
{"type": "result", "subtype": "success", "result": "Final answer.", "session_id": "s1"},
|
||
]
|
||
result, emitted = await _drive(backend, events)
|
||
assert result.session_id == "s1"
|
||
assert result.final_text == "Final answer."
|
||
kinds = [k for k, _ in emitted]
|
||
assert "text" in kinds and "tool" in kinds and "tool_result" in kinds
|
||
# The answer text is emitted exactly once (as the assistant block, not again
|
||
# as a separate result event).
|
||
assert [t for k, t in emitted if t == "Final answer."] == ["Final answer."]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_claude_falls_back_to_assistant_text_without_result() -> None:
|
||
backend = ClaudeCodeBackend()
|
||
events = [
|
||
{
|
||
"type": "assistant",
|
||
"message": {"content": [{"type": "text", "text": "Partial thought."}]},
|
||
},
|
||
]
|
||
result, _ = await _drive(backend, events)
|
||
assert result.final_text == "Partial thought."
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_claude_partial_messages_stream_cumulative_text() -> None:
|
||
# --include-partial-messages emits stream_event deltas; we accumulate them
|
||
# into cumulative text under one merge id, and the complete assistant block
|
||
# finalizes the same row (no duplicate). The frontend keeps the latest.
|
||
backend = ClaudeCodeBackend()
|
||
captured: list[tuple[str, str, str]] = []
|
||
|
||
async def emit(kind, text, raw, meta=None):
|
||
captured.append((kind, text, (meta or {}).get("merge_id", "")))
|
||
|
||
result = ConsultResult()
|
||
assistant_text: list[str] = []
|
||
stream: dict = {"msg_id": "", "blocks": {}}
|
||
events = [
|
||
{"type": "stream_event", "event": {"type": "message_start", "message": {"id": "msg_1"}}},
|
||
{
|
||
"type": "stream_event",
|
||
"event": {
|
||
"type": "content_block_start",
|
||
"index": 0,
|
||
"content_block": {"type": "text", "text": ""},
|
||
},
|
||
},
|
||
{
|
||
"type": "stream_event",
|
||
"event": {
|
||
"type": "content_block_delta",
|
||
"index": 0,
|
||
"delta": {"type": "text_delta", "text": "Hel"},
|
||
},
|
||
},
|
||
{
|
||
"type": "stream_event",
|
||
"event": {
|
||
"type": "content_block_delta",
|
||
"index": 0,
|
||
"delta": {"type": "text_delta", "text": "lo"},
|
||
},
|
||
},
|
||
{
|
||
"type": "assistant",
|
||
"message": {"id": "msg_1", "content": [{"type": "text", "text": "Hello"}]},
|
||
},
|
||
]
|
||
for ev in events:
|
||
await backend._handle_event(ev, result, assistant_text, stream, emit)
|
||
|
||
texts = [(t, mid) for k, t, mid in captured if k == "text"]
|
||
assert texts == [
|
||
("Hel", "txt:msg_1:0"),
|
||
("Hello", "txt:msg_1:0"),
|
||
("Hello", "txt:msg_1:0"),
|
||
]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_claude_result_without_assistant_text_is_surfaced() -> None:
|
||
# Degenerate run: the answer arrives only in the result event (no assistant
|
||
# text block streamed) — surface it so the user still sees an answer.
|
||
backend = ClaudeCodeBackend()
|
||
result, emitted = await _drive(
|
||
backend, [{"type": "result", "subtype": "success", "result": "Only here."}]
|
||
)
|
||
assert result.final_text == "Only here."
|
||
assert ("text", "Only here.") in emitted
|
||
|
||
|
||
# ---- Codex event parsing -----------------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_codex_event_parsing_thread_items_and_final() -> None:
|
||
backend = CodexBackend()
|
||
events = [
|
||
{"type": "thread.started", "thread_id": "t1"},
|
||
{"type": "turn.started"},
|
||
{"type": "item.started", "item": {"type": "command_execution", "command": "ls"}},
|
||
{
|
||
"type": "item.completed",
|
||
"item": {
|
||
"type": "command_execution",
|
||
"command": "ls",
|
||
"exit_code": 0,
|
||
"output": "a\nb",
|
||
},
|
||
},
|
||
{"type": "item.completed", "item": {"type": "agent_message", "text": "All done."}},
|
||
{"type": "turn.completed", "usage": {}},
|
||
]
|
||
result, emitted = await _drive(backend, events)
|
||
assert result.session_id == "t1"
|
||
assert result.final_text == "All done."
|
||
kinds = [k for k, _ in emitted]
|
||
# The answer renders as the terminal text of the run (not a separate result).
|
||
assert "tool" in kinds and "tool_result" in kinds and "text" in kinds
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_codex_web_search_start_and_finish_share_merge_id() -> None:
|
||
# web_search streams a start placeholder then a filled finish; both carry
|
||
# the SAME merge id so the UI collapses them into one evolving row (rather
|
||
# than two lines, one empty). command_execution is unaffected (two rows).
|
||
backend = CodexBackend()
|
||
captured: list[tuple[str, str]] = []
|
||
|
||
async def emit(kind, text, raw, meta=None):
|
||
captured.append((text, (meta or {}).get("merge_id", "")))
|
||
|
||
result = ConsultResult()
|
||
for ev in (
|
||
{"type": "item.started", "item": {"id": "ws_1", "type": "web_search", "query": ""}},
|
||
{
|
||
"type": "item.completed",
|
||
"item": {"id": "ws_1", "type": "web_search", "query": "agentic RAG"},
|
||
},
|
||
):
|
||
await backend._handle_event(ev, result, emit)
|
||
|
||
web = [(t, mid) for t, mid in captured if "web search" in t]
|
||
assert [t for t, _ in web] == ["web search", "web search · agentic RAG"]
|
||
assert {mid for _, mid in web} == {"ws_1"} # both phases share the merge id
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_codex_non_web_items_render_once_on_completion() -> None:
|
||
# agent_message has no meaningful start line → renders once, on completion.
|
||
backend = CodexBackend()
|
||
events = [
|
||
{"type": "item.started", "item": {"id": "m1", "type": "agent_message", "text": ""}},
|
||
{"type": "item.completed", "item": {"id": "m1", "type": "agent_message", "text": "done"}},
|
||
]
|
||
result, emitted = await _drive(backend, events)
|
||
assert [t for _k, t in emitted] == ["done"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_codex_item_updated_streams_cumulative_answer() -> None:
|
||
# "*.updated" frames carry the answer's growing text; we stream the running
|
||
# text under the item id, and ".completed" finalizes the same merged row.
|
||
backend = CodexBackend()
|
||
captured: list[tuple[str, str, str]] = []
|
||
|
||
async def emit(kind, text, raw, meta=None):
|
||
captured.append((kind, text, (meta or {}).get("merge_id", "")))
|
||
|
||
result = ConsultResult()
|
||
events = [
|
||
{"type": "item.started", "item": {"id": "m1", "type": "agent_message", "text": ""}},
|
||
{"type": "item.updated", "item": {"id": "m1", "type": "agent_message", "text": "The ans"}},
|
||
{
|
||
"type": "item.updated",
|
||
"item": {"id": "m1", "type": "agent_message", "text": "The answer"},
|
||
},
|
||
{
|
||
"type": "item.completed",
|
||
"item": {"id": "m1", "type": "agent_message", "text": "The answer is 42"},
|
||
},
|
||
]
|
||
for ev in events:
|
||
await backend._handle_event(ev, result, emit)
|
||
|
||
texts = [(t, mid) for k, t, mid in captured if k == "text"]
|
||
assert texts == [
|
||
("The ans", "m1"),
|
||
("The answer", "m1"),
|
||
("The answer is 42", "m1"),
|
||
]
|
||
assert result.final_text == "The answer is 42"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_codex_turn_failed_marks_error() -> None:
|
||
backend = CodexBackend()
|
||
result, emitted = await _drive(backend, [{"type": "turn.failed", "message": "boom"}])
|
||
assert result.success is False and result.error == "boom"
|
||
assert ("error", "boom") in emitted
|
||
|
||
|
||
# ---- streaming subprocess primitive ------------------------------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stream_process_lines_interleaves_and_reports_exit() -> None:
|
||
script = "import sys; print('out1'); print('err1', file=sys.stderr); print('out2')"
|
||
seen: list[tuple[str, str]] = []
|
||
async for channel, line in stream_process_lines([sys.executable, "-c", script]):
|
||
seen.append((channel, line))
|
||
assert ("stdout", "out1") in seen
|
||
assert ("stdout", "out2") in seen
|
||
assert ("stderr", "err1") in seen
|
||
assert seen[-1] == ("exit", "0")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stream_process_lines_reports_nonzero_exit() -> None:
|
||
seen = [
|
||
item
|
||
async for item in stream_process_lines([sys.executable, "-c", "import sys; sys.exit(3)"])
|
||
]
|
||
assert seen[-1] == ("exit", "3")
|
||
|
||
|
||
# ---- settings ----------------------------------------------------------------
|
||
|
||
|
||
def test_settings_from_dict_clamps_budget_and_reads_backends() -> None:
|
||
s = settings_from_dict(
|
||
{
|
||
"consult_budget": 999,
|
||
"backends": {"codex": {"sandbox": "read-only", "enabled": False}},
|
||
}
|
||
)
|
||
assert s.consult_budget == CONSULT_BUDGET_MAX
|
||
assert s.backend("codex").sandbox == "read-only"
|
||
assert s.backend("codex").enabled is False
|
||
# Unknown backend → defaults.
|
||
assert s.backend("claude_code").permission_mode == BackendConfig().permission_mode
|
||
|
||
|
||
def test_settings_defaults() -> None:
|
||
assert SubagentSettings().consult_budget == DEFAULT_CONSULT_BUDGET
|
||
assert settings_from_dict({}).consult_budget == DEFAULT_CONSULT_BUDGET
|
||
|
||
|
||
# ---- image forwarding (materialization) --------------------------------------
|
||
|
||
|
||
def test_materialize_images_writes_only_resolvable_images(tmp_path) -> None:
|
||
import base64 as _b64
|
||
from pathlib import Path
|
||
|
||
from deeptutor.core.context import Attachment
|
||
from deeptutor.services.subagent.images import materialize_images
|
||
|
||
atts = [
|
||
Attachment(
|
||
type="image", base64=_b64.b64encode(b"\x89PNGdata").decode(), mime_type="image/png"
|
||
),
|
||
Attachment(
|
||
type="image",
|
||
base64="data:image/jpeg;base64," + _b64.b64encode(b"JPEGdata").decode(),
|
||
filename="shot.jpg",
|
||
),
|
||
Attachment(type="file", base64=_b64.b64encode(b"x").decode()), # not an image
|
||
Attachment(type="image", url="https://example.com/remote.png"), # external, unresolvable
|
||
]
|
||
paths = materialize_images(atts, tmp_path)
|
||
|
||
assert len(paths) == 2 # the two inline images only
|
||
assert paths[0].endswith(".png") and paths[1].endswith(".jpg")
|
||
assert Path(paths[0]).read_bytes() == b"\x89PNGdata"
|
||
assert Path(paths[1]).read_bytes() == b"JPEGdata"
|
||
|
||
|
||
# ---- Claude Code /model scraping (live model sync) ---------------------------
|
||
|
||
# A faithful render of the ``/model`` picker (as pyte produces it: real columns,
|
||
# a ❯ cursor, a ✔ on the active row, wrapped description continuation lines).
|
||
_CLAUDE_MODEL_SCREEN = """\
|
||
Select model
|
||
Switch between Claude models. Your pick becomes the default for new
|
||
sessions. For other/previous model names, specify with --model.
|
||
1. Default (recommended) Opus 4.8 with 1M context · Best for everyday,
|
||
complex tasks
|
||
❯ 2. Opus ✔ Opus 4.8 with 1M context · Best for everyday,
|
||
complex tasks
|
||
3. Sonnet Sonnet 4.6 · Efficient for routine tasks
|
||
4. Sonnet (1M context) Sonnet 4.6 with 1M context · Draws from usage
|
||
credits · $3/$15 per Mtok
|
||
5. Haiku Haiku 4.5 · Fastest for quick answers
|
||
6. Fable (disabled) Claude Fable 5 is currently unavailable. Learn
|
||
more: https://www.anthropic.com/news/fable
|
||
◉ xHigh effort ←/→ to adjust
|
||
Enter to set as default · s to use this session only · Esc to cancel
|
||
"""
|
||
|
||
|
||
def test_parse_claude_model_screen() -> None:
|
||
from deeptutor.services.subagent.claude_models import _parse_model_screen
|
||
|
||
models = _parse_model_screen(_CLAUDE_MODEL_SCREEN)
|
||
# Default (recommended) → CLI default (skipped); Fable (disabled) → skipped.
|
||
# Sonnet's 1M variant maps to the [1m] alias.
|
||
assert models == [
|
||
{"slug": "opus", "display_name": "Opus 4.8 with 1M context"},
|
||
{"slug": "sonnet", "display_name": "Sonnet 4.6"},
|
||
{"slug": "sonnet[1m]", "display_name": "Sonnet 4.6 with 1M context"},
|
||
{"slug": "haiku", "display_name": "Haiku 4.5"},
|
||
]
|
||
|
||
|
||
def test_claude_models_cache_roundtrip(monkeypatch, tmp_path) -> None:
|
||
from deeptutor.services.subagent import claude_models as cm
|
||
|
||
monkeypatch.setattr(cm, "_cache_path", lambda: tmp_path / "claude_models_cache.json")
|
||
assert cm.load_cached_claude_models() == ([], "")
|
||
|
||
cm._write_cache([{"slug": "opus", "display_name": "Opus 4.8"}], "2026-06-17T00:00:00Z")
|
||
models, fetched = cm.load_cached_claude_models()
|
||
assert models == [{"slug": "opus", "display_name": "Opus 4.8"}]
|
||
assert fetched == "2026-06-17T00:00:00Z"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_claude_options_prefers_synced_cache(monkeypatch) -> None:
|
||
"""When a /model sync has cached a catalog, _claude_options uses it over the
|
||
curated fallback."""
|
||
from deeptutor.services.subagent import claude_models as cm
|
||
from deeptutor.services.subagent import models as models_mod
|
||
|
||
monkeypatch.setattr(
|
||
cm,
|
||
"load_cached_claude_models",
|
||
lambda: ([{"slug": "opus", "display_name": "Opus 4.8 (synced)"}], "2026-06-17T00:00:00Z"),
|
||
)
|
||
|
||
async def fake_probe(cmd):
|
||
return True, "claude x.y"
|
||
|
||
monkeypatch.setattr(models_mod, "probe_version", fake_probe)
|
||
|
||
opts = await models_mod._claude_options()
|
||
assert [m.slug for m in opts.models] == ["opus"]
|
||
assert opts.models[0].display_name == "Opus 4.8 (synced)"
|
||
assert opts.synced_at == "2026-06-17T00:00:00Z"
|
||
|
||
|
||
# ---- persistent session registry (cross-turn continuity) ---------------------
|
||
|
||
|
||
def test_session_registry_roundtrip(monkeypatch, tmp_path) -> None:
|
||
from deeptutor.services.subagent import sessions as sess
|
||
|
||
monkeypatch.setattr(sess, "_path", lambda: tmp_path / "subagent_sessions.json")
|
||
key = sess.session_key("chat1", "agentX")
|
||
assert sess.get_session(key) is None
|
||
|
||
sess.remember_session(key, "sid-9", kind="codex", cwd="/p")
|
||
sess.remember_session("chat1::other", "sid-2")
|
||
assert sess.get_session(key) == "sid-9"
|
||
|
||
# Disconnecting agentX drops only its sessions.
|
||
sess.forget_connection("agentX")
|
||
assert sess.get_session(key) is None
|
||
assert sess.get_session("chat1::other") == "sid-2"
|
||
|
||
# Empty session id is a no-op (never persisted).
|
||
sess.remember_session("chat1::agentZ", "")
|
||
assert sess.get_session("chat1::agentZ") is None
|
||
|
||
|
||
# ---- backend options (models.py, the /settings sync source) ------------------
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_backend_options_reads_codex_cache(monkeypatch, tmp_path) -> None:
|
||
"""Codex options come from the live models_cache.json + config.toml default;
|
||
Claude Code falls back to its aliases and allows a free-text model."""
|
||
import json
|
||
|
||
from deeptutor.services.subagent import models as models_mod
|
||
|
||
home = tmp_path / "codex"
|
||
home.mkdir()
|
||
(home / "models_cache.json").write_text(
|
||
json.dumps(
|
||
{
|
||
"fetched_at": "2026-06-17T00:00:00Z",
|
||
"models": [
|
||
{
|
||
"slug": "gpt-5.5",
|
||
"display_name": "GPT-5.5",
|
||
"default_reasoning_level": "medium",
|
||
"supported_reasoning_levels": [
|
||
{"effort": "low"},
|
||
{"effort": "medium"},
|
||
{"effort": "high"},
|
||
],
|
||
}
|
||
],
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
(home / "config.toml").write_text('model = "gpt-5.5"\n', encoding="utf-8")
|
||
monkeypatch.setenv("CODEX_HOME", str(home))
|
||
|
||
async def fake_probe(cmd): # never spawn a real CLI in tests
|
||
return True, f"{cmd[0]} x.y"
|
||
|
||
monkeypatch.setattr(models_mod, "probe_version", fake_probe)
|
||
|
||
options = {o.kind: o for o in await models_mod.list_backend_options()}
|
||
|
||
codex = options["codex"]
|
||
assert codex.default_model == "gpt-5.5"
|
||
assert codex.synced_at == "2026-06-17T00:00:00Z"
|
||
assert codex.models[0].slug == "gpt-5.5"
|
||
assert codex.models[0].default_effort == "medium"
|
||
assert codex.models[0].efforts == ["low", "medium", "high"]
|
||
|
||
claude = options["claude_code"]
|
||
assert claude.allow_custom_model is True
|
||
assert {m.slug for m in claude.models} >= {"opus", "sonnet", "haiku"}
|
||
assert "high" in claude.efforts
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_codex_options_tolerate_missing_cache(monkeypatch, tmp_path) -> None:
|
||
"""No models_cache.json → empty model list, still allows a custom model."""
|
||
from deeptutor.services.subagent import models as models_mod
|
||
|
||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "empty-codex"))
|
||
|
||
async def fake_probe(cmd):
|
||
return False, "codex CLI not found on PATH"
|
||
|
||
monkeypatch.setattr(models_mod, "probe_version", fake_probe)
|
||
|
||
options = {o.kind: o for o in await models_mod.list_backend_options()}
|
||
codex = options["codex"]
|
||
assert codex.available is False
|
||
assert codex.models == []
|
||
assert codex.allow_custom_model is True
|
||
|
||
|
||
# ---- registry: partner backend is registered but not a local CLI -------------
|
||
|
||
|
||
def test_registry_partner_is_non_cli_backend() -> None:
|
||
from deeptutor.services.subagent import PARTNER_BACKEND_KIND, get_backend, list_backend_kinds
|
||
|
||
assert PARTNER_BACKEND_KIND in list_backend_kinds()
|
||
backend = get_backend(PARTNER_BACKEND_KIND)
|
||
assert backend is not None
|
||
assert backend.local_cli is False
|
||
assert get_backend("claude_code").local_cli is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_detect_all_excludes_partner_backend() -> None:
|
||
from deeptutor.services.subagent import detect_all
|
||
|
||
kinds = {d.kind for d in await detect_all()}
|
||
assert "partner" not in kinds
|
||
assert kinds <= {"claude_code", "codex"}
|
||
|
||
|
||
# ---- partner backend: drive a partner as a subagent --------------------------
|
||
|
||
|
||
class _FakePartnerInstance:
|
||
def __init__(self, running: bool = True) -> None:
|
||
self.running = running
|
||
|
||
|
||
class _FakePartnerManager:
|
||
"""Stands in for the partner manager: records calls, scripts a reply/trace."""
|
||
|
||
def __init__(
|
||
self, *, exists: bool = True, running: bool = True, reply: str = "Hi from partner."
|
||
) -> None:
|
||
self._exists = exists
|
||
self._running = running
|
||
self._reply = reply
|
||
self.started: list[str] = []
|
||
self.sent: list[dict] = []
|
||
self._trace: list = []
|
||
|
||
def script_trace(self, events: list) -> None:
|
||
self._trace = events
|
||
|
||
def partner_exists(self, pid: str) -> bool:
|
||
return self._exists
|
||
|
||
def get_partner(self, pid: str):
|
||
return _FakePartnerInstance(self._running) if self._running else None
|
||
|
||
async def start_partner(self, pid: str):
|
||
self.started.append(pid)
|
||
self._running = True
|
||
return _FakePartnerInstance(True)
|
||
|
||
async def send_message(self, pid, content, *, session_key, media=None, on_event=None):
|
||
self.sent.append(
|
||
{"pid": pid, "content": content, "session_key": session_key, "media": media or []}
|
||
)
|
||
if on_event is not None:
|
||
for ev in self._trace:
|
||
await on_event(ev)
|
||
return self._reply
|
||
|
||
|
||
def _patch_manager(monkeypatch, manager) -> None:
|
||
import deeptutor.services.partners as partners_pkg
|
||
|
||
monkeypatch.setattr(partners_pkg, "get_partner_manager", lambda: manager)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_partner_consult_mints_session_key_and_returns_reply(monkeypatch) -> None:
|
||
from deeptutor.core.stream import StreamEvent, StreamEventType
|
||
from deeptutor.services.subagent.partner import PartnerBackend
|
||
|
||
manager = _FakePartnerManager(reply="The answer.")
|
||
manager.script_trace(
|
||
[
|
||
StreamEvent(type=StreamEventType.THINKING, content="thinking…"),
|
||
StreamEvent(
|
||
type=StreamEventType.TOOL_CALL,
|
||
content="web_search",
|
||
metadata={"call_id": "c1", "args": {"q": "x"}},
|
||
),
|
||
StreamEvent(
|
||
type=StreamEventType.CONTENT, content="The answer.", metadata={"call_id": "c2"}
|
||
),
|
||
StreamEvent(type=StreamEventType.DONE), # bookkeeping → dropped
|
||
]
|
||
)
|
||
_patch_manager(monkeypatch, manager)
|
||
|
||
emitted: list[tuple[str, str]] = []
|
||
|
||
async def on_event(ev):
|
||
emitted.append((ev.kind, ev.text))
|
||
|
||
result = await PartnerBackend().consult("hello", on_event=on_event, partner_id="paul")
|
||
|
||
assert result.success is True
|
||
assert result.final_text == "The answer."
|
||
# First consult mints a fresh, colon-free partner session key …
|
||
assert result.session_id.startswith("dt-")
|
||
# … and send_message used exactly that key.
|
||
assert manager.sent[0]["session_key"] == result.session_id
|
||
assert manager.sent[0]["pid"] == "paul"
|
||
# The substantive trace is forwarded; the DONE marker is dropped.
|
||
kinds = [k for k, _ in emitted]
|
||
assert "reasoning" in kinds and "tool" in kinds and "text" in kinds
|
||
assert result.event_count == 3
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_partner_consult_resumes_given_session(monkeypatch) -> None:
|
||
from deeptutor.services.subagent.partner import PartnerBackend
|
||
|
||
manager = _FakePartnerManager()
|
||
_patch_manager(monkeypatch, manager)
|
||
|
||
async def on_event(ev):
|
||
pass
|
||
|
||
result = await PartnerBackend().consult(
|
||
"again", on_event=on_event, partner_id="paul", session_id="dt-abc123"
|
||
)
|
||
# A remembered key is reused verbatim — the partner session continues.
|
||
assert result.session_id == "dt-abc123"
|
||
assert manager.sent[0]["session_key"] == "dt-abc123"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_partner_consult_starts_partner_when_idle(monkeypatch) -> None:
|
||
from deeptutor.services.subagent.partner import PartnerBackend
|
||
|
||
manager = _FakePartnerManager(running=False)
|
||
_patch_manager(monkeypatch, manager)
|
||
|
||
async def on_event(ev):
|
||
pass
|
||
|
||
await PartnerBackend().consult("q", on_event=on_event, partner_id="paul")
|
||
assert manager.started == ["paul"] # brought online before messaging
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_partner_consult_requires_partner_id() -> None:
|
||
from deeptutor.services.subagent.partner import PartnerBackend
|
||
|
||
async def on_event(ev):
|
||
pass
|
||
|
||
result = await PartnerBackend().consult("q", on_event=on_event)
|
||
assert result.success is False
|
||
assert "partner" in result.error.lower()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_partner_consult_unknown_partner(monkeypatch) -> None:
|
||
from deeptutor.services.subagent.partner import PartnerBackend
|
||
|
||
manager = _FakePartnerManager(exists=False)
|
||
_patch_manager(monkeypatch, manager)
|
||
|
||
async def on_event(ev):
|
||
pass
|
||
|
||
result = await PartnerBackend().consult("q", on_event=on_event, partner_id="ghost")
|
||
assert result.success is False
|
||
assert manager.sent == [] # never messaged a non-existent partner
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_partner_consult_empty_reply_is_unsuccessful(monkeypatch) -> None:
|
||
from deeptutor.services.subagent.partner import PartnerBackend
|
||
|
||
manager = _FakePartnerManager(reply="")
|
||
_patch_manager(monkeypatch, manager)
|
||
|
||
async def on_event(ev):
|
||
pass
|
||
|
||
result = await PartnerBackend().consult("q", on_event=on_event, partner_id="paul")
|
||
assert result.success is False
|
||
assert result.final_text == ""
|
||
|
||
|
||
def _partner_trace_state() -> dict[str, dict[str, str]]:
|
||
return {"text": {}, "reason": {}, "pending_tools": {}}
|
||
|
||
|
||
def test_partner_event_mapping_covers_channels() -> None:
|
||
from deeptutor.core.stream import StreamEvent, StreamEventType
|
||
from deeptutor.services.subagent.partner import _to_subagent_events
|
||
|
||
def kinds(etype, **kw):
|
||
return [
|
||
e.kind
|
||
for e in _to_subagent_events(StreamEvent(type=etype, **kw), _partner_trace_state())
|
||
]
|
||
|
||
assert kinds(StreamEventType.CONTENT, content="hi") == ["text"]
|
||
assert kinds(StreamEventType.THINKING, content="plan") == ["reasoning"]
|
||
# A tool call without a call_id can't be paired -> emitted immediately.
|
||
assert kinds(StreamEventType.TOOL_CALL, content="rag") == ["tool"]
|
||
# A tool result with no buffered call -> just the result row.
|
||
assert kinds(StreamEventType.TOOL_RESULT, content="rows") == ["tool_result"]
|
||
assert kinds(StreamEventType.ERROR, content="boom") == ["error"]
|
||
# Status / bookkeeping markers are dropped (the tool rows already carry the
|
||
# substance - keeping the trace clean like the CLI backends).
|
||
assert kinds(StreamEventType.PROGRESS, content="step 1") == []
|
||
assert kinds(StreamEventType.RESULT, content="final") == []
|
||
assert kinds(StreamEventType.DONE) == []
|
||
# Only a truly-empty CONTENT delta is dropped; whitespace is preserved so
|
||
# streamed spacing survives the accumulation.
|
||
assert kinds(StreamEventType.CONTENT, content="") == []
|
||
assert kinds(StreamEventType.CONTENT, content=" ") == ["text"]
|
||
|
||
|
||
def test_partner_tool_call_pairs_with_its_result_adjacently() -> None:
|
||
# The loop dispatches tools in parallel - both TOOL_CALL events, then both
|
||
# TOOL_RESULT events - sharing a call_id per tool. Each call is buffered and
|
||
# re-emitted right before its own result, so the trace reads as adjacent
|
||
# call -> result pairs (never two calls then two results).
|
||
from deeptutor.core.stream import StreamEvent, StreamEventType
|
||
from deeptutor.services.subagent.partner import _to_subagent_events
|
||
|
||
st = _partner_trace_state()
|
||
a = _to_subagent_events(
|
||
StreamEvent(
|
||
type=StreamEventType.TOOL_CALL,
|
||
content="partner_search",
|
||
metadata={"call_id": "c1", "args": {"query": "Agentic RAG"}},
|
||
),
|
||
st,
|
||
)
|
||
b = _to_subagent_events(
|
||
StreamEvent(
|
||
type=StreamEventType.TOOL_CALL,
|
||
content="read_skill",
|
||
metadata={"call_id": "c2", "args": {"name": "x"}},
|
||
),
|
||
st,
|
||
)
|
||
assert a == [] and b == [] # both deferred until their results
|
||
|
||
r1 = _to_subagent_events(
|
||
StreamEvent(type=StreamEventType.TOOL_RESULT, content="hits", metadata={"call_id": "c1"}),
|
||
st,
|
||
)
|
||
assert [e.kind for e in r1] == ["tool", "tool_result"]
|
||
assert "partner_search" in r1[0].text and "Agentic RAG" in r1[0].text
|
||
assert r1[1].text == "hits"
|
||
|
||
r2 = _to_subagent_events(
|
||
StreamEvent(type=StreamEventType.TOOL_RESULT, content="body", metadata={"call_id": "c2"}),
|
||
st,
|
||
)
|
||
assert [e.kind for e in r2] == ["tool", "tool_result"]
|
||
assert "read_skill" in r2[0].text
|
||
# No shared merge_id - each call and result is its own row.
|
||
assert not any(e.meta.get("merge_id") for e in r1 + r2)
|
||
|
||
|
||
def test_partner_content_accumulates_cumulatively() -> None:
|
||
# Incremental CONTENT deltas accumulate into a growing full-text row under a
|
||
# stable merge_id - so the streamed answer never gets wiped by a new chunk.
|
||
from deeptutor.core.stream import StreamEvent, StreamEventType
|
||
from deeptutor.services.subagent.partner import _to_subagent_events
|
||
|
||
st = _partner_trace_state()
|
||
a = _to_subagent_events(
|
||
StreamEvent(type=StreamEventType.CONTENT, content="The ", metadata={"call_id": "f1"}), st
|
||
)
|
||
b = _to_subagent_events(
|
||
StreamEvent(type=StreamEventType.CONTENT, content="answer", metadata={"call_id": "f1"}), st
|
||
)
|
||
assert a[0].text == "The " and b[0].text == "The answer"
|
||
assert a[0].meta["merge_id"] == b[0].meta["merge_id"] == "text:f1"
|