Files
wehub-resource-sync e4dcfc49aa
Tests / Lint and Format (push) Waiting to run
Tests / Web Node Tests (push) Waiting to run
Tests / Import Check (Python 3.11) (push) Waiting to run
Tests / Import Check (Python 3.12) (push) Waiting to run
Tests / Import Check (Python 3.13) (push) Waiting to run
Tests / Import Check (Python 3.14) (push) Waiting to run
Tests / Python Tests (Python 3.11) (push) Blocked by required conditions
Tests / Python Tests (Python 3.12) (push) Blocked by required conditions
Tests / Python Tests (Python 3.13) (push) Blocked by required conditions
Tests / Python Tests (Python 3.14) (push) Blocked by required conditions
Tests / Test Summary (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

352 lines
12 KiB
Python

"""Tests for the CLI turn-stream renderer against the chat-loop protocol.
The chat agent loop (deeptutor/agents/chat/agent_loop.py) streams every
round's text as ``content`` chunks with ``trace_kind=llm_chunk`` and labels
the round afterwards via a ``call_status`` marker carrying ``call_role``
(``narration`` | ``finish``). These tests feed that exact event shape into
the renderer and assert the terminal output (narration demoted to dim text
before its tool calls, finish rendered as the answer, no stray blank
progress lines) plus the ``ask_user`` pause/resume flow.
"""
from __future__ import annotations
import json
from typing import Any
from typer.testing import CliRunner
from deeptutor.app import TurnRequest
from deeptutor_cli import common as cli_common
from deeptutor_cli.common import _resolve_answer
from deeptutor_cli.main import app
runner = CliRunner()
def _chunk(call_id: str, text: str) -> dict[str, Any]:
return {
"type": "content",
"content": text,
"metadata": {
"call_id": call_id,
"call_kind": "agent_loop_round",
"trace_kind": "llm_chunk",
},
}
def _running(call_id: str, label: str = "Exploring") -> dict[str, Any]:
return {
"type": "progress",
"content": label,
"metadata": {
"call_id": call_id,
"call_kind": "agent_loop_round",
"trace_kind": "call_status",
"call_state": "running",
},
}
def _marker(call_id: str, role: str) -> dict[str, Any]:
return {
"type": "progress",
"content": "",
"metadata": {
"call_id": call_id,
"call_kind": "agent_loop_round",
"trace_kind": "call_status",
"call_state": "complete",
"call_role": role,
},
}
def _agent_loop_turn_events() -> list[dict[str, Any]]:
"""One narration round (with a tool call) followed by a finish round."""
return [
{"type": "stage_start", "stage": "responding", "source": "chat"},
_running("r1"),
_chunk("r1", "Let me check the knowledge base."),
_marker("r1", "narration"),
{
"type": "tool_call",
"content": "rag",
"metadata": {"args": {"query": "spectral clustering"}},
},
{
"type": "tool_result",
"content": "retrieved passage",
"metadata": {"tool": "rag"},
},
_running("r2"),
_chunk("r2", "The answer is **42**."),
_marker("r2", "finish"),
{"type": "stage_end", "stage": "responding", "source": "chat"},
{
"type": "result",
"metadata": {
"response": "The answer is **42**.",
"engine": "agent_loop",
"rounds": 2,
"tool_steps": 1,
"metadata": {
"cost_summary": {
"total_cost_usd": 0.0042,
"total_tokens": 12345,
"total_calls": 2,
}
},
},
},
{"type": "done"},
]
def _install_fake_runtime(
monkeypatch,
events: list[dict[str, Any]],
*,
replies: list[dict[str, Any]] | None = None,
) -> None:
async def _start_turn(self, request): # noqa: ANN001
if isinstance(request, dict):
request = TurnRequest(**request)
return {"id": request.session_id or "session-1"}, {"id": "turn-1"}
async def _stream_turn(self, turn_id: str, after_seq: int = 0): # noqa: ANN001
for event in events:
yield event
async def _submit_user_reply(self, turn_id, text=None, *, answers=None): # noqa: ANN001
if replies is not None:
replies.append({"turn_id": turn_id, "text": text, "answers": answers})
return True
monkeypatch.setattr("deeptutor.app.facade.DeepTutorApp.start_turn", _start_turn)
monkeypatch.setattr("deeptutor.app.facade.DeepTutorApp.stream_turn", _stream_turn)
monkeypatch.setattr("deeptutor.app.facade.DeepTutorApp.submit_user_reply", _submit_user_reply)
def test_narration_renders_before_tools_and_finish_is_answer(monkeypatch) -> None:
_install_fake_runtime(monkeypatch, _agent_loop_turn_events())
result = runner.invoke(app, ["run", "chat", "hello"])
assert result.exit_code == 0, result.output
narration_at = result.output.find("Let me check the knowledge base.")
tool_at = result.output.find("rag(")
answer_at = result.output.find("42")
assert narration_at != -1 and tool_at != -1 and answer_at != -1
# Narration belongs to the round BEFORE its tool calls; the finish
# round's text is the answer and comes last.
assert narration_at < tool_at < answer_at
# The chat wrapper stage emits no banner.
assert "▶ responding" not in result.output
def test_done_summary_line_includes_rounds_tools_tokens_cost(monkeypatch) -> None:
_install_fake_runtime(monkeypatch, _agent_loop_turn_events())
result = runner.invoke(app, ["run", "chat", "hello"])
assert result.exit_code == 0, result.output
assert "rounds=2" in result.output
assert "tools=1" in result.output
assert "tokens=12.3k" in result.output
assert "cost=$0.0042" in result.output
def test_empty_call_status_markers_print_nothing(monkeypatch) -> None:
_install_fake_runtime(monkeypatch, _agent_loop_turn_events())
result = runner.invoke(app, ["run", "chat", "hello"])
lines = result.output.splitlines()
# The two call_status markers carry empty content; no bare dim lines
# may leak out of them (a leaked line is whitespace-only).
assert not any(line.strip() == "" and line != "" for line in lines)
def test_thinking_chunks_collapse_to_single_indicator(monkeypatch) -> None:
events = _agent_loop_turn_events()
thinking = [
{
"type": "thinking",
"content": piece,
"metadata": {
"call_id": "r1",
"call_kind": "agent_loop_round",
"trace_kind": "llm_chunk",
},
}
for piece in ("First ", "I ", "should ", "look ", "things ", "up.")
]
events[2:2] = thinking # before r1's content chunk
_install_fake_runtime(monkeypatch, events)
result = runner.invoke(app, ["run", "chat", "hello"])
assert result.exit_code == 0, result.output
assert result.output.count("thinking…") == 1
# Raw reasoning text must not splatter into the transcript.
assert "should" not in result.output
def test_legacy_capability_content_still_renders(monkeypatch) -> None:
events = [
{"type": "stage_start", "stage": "planning", "source": "deep_research"},
{"type": "content", "content": "Plan text here."},
{"type": "stage_end", "stage": "planning", "source": "deep_research"},
{"type": "done"},
]
_install_fake_runtime(monkeypatch, events)
result = runner.invoke(app, ["run", "deep_research", "question"])
assert result.exit_code == 0, result.output
assert "▶ planning" in result.output
assert "Plan text here." in result.output
def _ask_user_events() -> list[dict[str, Any]]:
return [
{"type": "stage_start", "stage": "responding", "source": "chat"},
{
"type": "tool_result",
"content": "[awaiting user reply to: Which topic?]",
"metadata": {
"tool": "ask_user",
"tool_metadata": {
"ask_user": {
"intro": "Quick check",
"questions": [
{
"id": "q1",
"prompt": "Which topic?",
"options": [
{"label": "Algebra", "description": "Linear systems"},
{"label": "Geometry", "description": None},
],
"multi_select": False,
"allow_free_text": True,
}
],
}
},
},
},
_running("r2"),
_chunk("r2", "Algebra it is."),
_marker("r2", "finish"),
{"type": "stage_end", "stage": "responding", "source": "chat"},
{"type": "done"},
]
def test_ask_user_interactive_submits_selected_option(monkeypatch) -> None:
replies: list[dict[str, Any]] = []
_install_fake_runtime(monkeypatch, _ask_user_events(), replies=replies)
monkeypatch.setattr(cli_common, "_stdin_interactive", lambda: True)
monkeypatch.setattr(cli_common.console, "input", lambda prompt="": "1")
result = runner.invoke(app, ["run", "chat", "hello"])
assert result.exit_code == 0, result.output
assert "Which topic?" in result.output
assert "Algebra" in result.output
assert replies == [
{
"turn_id": "turn-1",
"text": None,
"answers": [{"questionId": "q1", "text": "Algebra"}],
}
]
def test_ask_user_non_interactive_sends_empty_reply(monkeypatch) -> None:
replies: list[dict[str, Any]] = []
_install_fake_runtime(monkeypatch, _ask_user_events(), replies=replies)
monkeypatch.setattr(cli_common, "_stdin_interactive", lambda: False)
result = runner.invoke(app, ["run", "chat", "hello"])
assert result.exit_code == 0, result.output
assert replies == [{"turn_id": "turn-1", "text": "", "answers": None}]
def test_ask_user_json_mode_sends_empty_reply_and_streams_events(monkeypatch) -> None:
replies: list[dict[str, Any]] = []
_install_fake_runtime(monkeypatch, _ask_user_events(), replies=replies)
result = runner.invoke(app, ["run", "chat", "hello", "--format", "json"])
assert result.exit_code == 0, result.output
lines = [json.loads(line) for line in result.output.splitlines() if line.strip()]
assert any(line.get("type") == "tool_result" for line in lines)
assert replies == [{"turn_id": "turn-1", "text": "", "answers": None}]
def test_terminator_llm_output_renders_after_buffered_chunks(monkeypatch) -> None:
events = [
{"type": "stage_start", "stage": "responding", "source": "chat"},
_chunk("r1", "Buffered narration."),
{
"type": "content",
"content": "Terminator final text.",
"metadata": {
"call_id": "chat-final-response-1",
"call_kind": "llm_final_response",
"trace_kind": "llm_output",
},
},
{"type": "stage_end", "stage": "responding", "source": "chat"},
{"type": "done"},
]
_install_fake_runtime(monkeypatch, events)
result = runner.invoke(app, ["run", "chat", "hello"])
assert result.exit_code == 0, result.output
buffered_at = result.output.find("Buffered narration.")
final_at = result.output.find("Terminator final text.")
assert buffered_at != -1 and final_at != -1
assert buffered_at < final_at
def test_resolve_answer_maps_numbers_to_labels() -> None:
labels = ["Algebra", "Geometry", "Calculus"]
assert _resolve_answer("2", labels, multi=False) == "Geometry"
assert _resolve_answer("free text", labels, multi=False) == "free text"
assert _resolve_answer("", labels, multi=False) == ""
assert _resolve_answer("1, 3", labels, multi=True) == "Algebra, Calculus"
assert _resolve_answer("1, custom", labels, multi=True) == "Algebra, custom"
# Out-of-range numbers fall through as text rather than crashing.
assert _resolve_answer("9", labels, multi=False) == "9"
def test_sources_render_compact_list(monkeypatch) -> None:
events = _agent_loop_turn_events()
events.insert(
-2,
{
"type": "sources",
"metadata": {
"sources": [
{"title": "Paper A", "url": "https://example.com/a"},
{"title": "Paper A", "url": "https://example.com/a"}, # dedupe
{"filename": "notes.pdf", "file_path": "/tmp/notes.pdf"},
]
},
},
)
_install_fake_runtime(monkeypatch, events)
result = runner.invoke(app, ["run", "chat", "hello"])
assert result.exit_code == 0, result.output
assert "sources (2):" in result.output
assert "Paper A" in result.output
assert "notes.pdf" in result.output