项目文件夹

文件
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 行
34 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
"""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"