Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

901 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"