from __future__ import annotations import asyncio import json from types import SimpleNamespace from typing import Any import pytest from deeptutor.agents.chat.agent_loop import InlineThinkFilter from deeptutor.agents.chat.agentic_pipeline import AgenticChatPipeline from deeptutor.capabilities.explore_context import explorer as explorer_mod from deeptutor.capabilities.mastery import MASTERY_TOOL_NAMES from deeptutor.core.context import Attachment, UnifiedContext from deeptutor.core.stream import StreamEvent, StreamEventType from deeptutor.core.stream_bus import StreamBus from deeptutor.core.tool_protocol import ToolResult async def _collect_bus_events(bus: StreamBus) -> tuple[list[StreamEvent], asyncio.Task[Any]]: events: list[StreamEvent] = [] async def _consume() -> None: async for event in bus.subscribe(): events.append(event) consumer = asyncio.create_task(_consume()) await asyncio.sleep(0) return events, consumer # type: ignore[return-value] def _llm_chunk( *, content: str | None = None, tool_calls: list[dict[str, Any]] | None = None, ) -> SimpleNamespace: delta_fields: dict[str, Any] = {"content": content} if tool_calls is not None: delta_fields["tool_calls"] = [ SimpleNamespace( index=tc.get("index", i), id=tc.get("id"), function=SimpleNamespace( name=tc.get("name"), arguments=tc.get("arguments"), ), ) for i, tc in enumerate(tool_calls) ] else: delta_fields["tool_calls"] = None return SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(**delta_fields))]) async def _async_llm_stream(chunks: list[SimpleNamespace]): for chunk in chunks: yield chunk class _ScriptedChatClient: def __init__(self, scripted: list[list[SimpleNamespace]]) -> None: self._script = list(scripted) self.call_count = 0 self.calls: list[dict[str, Any]] = [] class _Completions: def __init__(self, parent: _ScriptedChatClient) -> None: self.parent = parent async def create(self, **kwargs): self.parent.call_count += 1 self.parent.calls.append({**kwargs, "messages": list(kwargs.get("messages") or [])}) if not self.parent._script: raise RuntimeError("Scripted client exhausted") return _async_llm_stream(self.parent._script.pop(0)) class _Chat: def __init__(self, parent: _ScriptedChatClient) -> None: self.completions = _Completions(parent) self.chat = _Chat(self) class _Registry: def __init__(self) -> None: self.executed: list[dict[str, Any]] = [] def deferred_tools(self): return [] def build_prompt_text(self, _enabled, **_kwargs): return "- `web_search` - Search the web" def build_openai_schemas(self, _enabled): return [ { "type": "function", "function": { "name": "web_search", "description": "Search", "parameters": { "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], }, }, }, { "type": "function", "function": { "name": "ask_user", "description": "Ask the user", "parameters": { "type": "object", "properties": {"questions": {"type": "array"}}, "required": ["questions"], }, }, }, ] async def execute(self, name: str, **kwargs): self.executed.append({"name": name, "kwargs": kwargs}) return ToolResult( content="tool answer", sources=[{"tool": name}], metadata={"tool": name}, success=True, ) @pytest.fixture(autouse=True) def _fake_llm_config(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( "deeptutor.agents.chat.agentic_pipeline.get_llm_config", lambda: SimpleNamespace( binding="openai", model="gpt-test", api_key="k", base_url="u", api_version=None, extra_headers={}, reasoning_effort=None, ), ) async def _run(pipeline: AgenticChatPipeline, context: UnifiedContext): bus = StreamBus() events, consumer = await _collect_bus_events(bus) await pipeline.run(context, bus) await asyncio.sleep(0) await bus.close() await consumer return events def _contents(events: list[StreamEvent]) -> list[str]: return [e.content for e in events if e.type == StreamEventType.CONTENT] def _call_roles(events: list[StreamEvent]) -> list[str]: """Ordered list of per-round call_role markers ('narration' | 'finish').""" return [ str(e.metadata.get("call_role")) for e in events if e.type == StreamEventType.PROGRESS and e.metadata.get("call_state") == "complete" and "call_role" in e.metadata ] def _result(events: list[StreamEvent]) -> StreamEvent: return [e for e in events if e.type == StreamEventType.RESULT][-1] class TestInlineThinkFilter: @staticmethod def _run(chunks: list[str]) -> list[tuple[str, str]]: f = InlineThinkFilter() out: list[tuple[str, str]] = [] for c in chunks: out.extend(f.feed(c)) out.extend(f.flush()) return out @staticmethod def _join(segments: list[tuple[str, str]], kind: str) -> str: return "".join(text for k, text in segments if k == kind) def test_plain_content_passes_through(self) -> None: segs = self._run(["hello ", "world"]) assert self._join(segs, "content") == "hello world" assert self._join(segs, "thinking") == "" def test_think_block_split_to_thinking(self) -> None: segs = self._run(["plananswer"]) assert self._join(segs, "thinking") == "plan" assert self._join(segs, "content") == "answer" def test_tag_split_across_chunks(self) -> None: segs = self._run(["beforeinnerafter"]) assert self._join(segs, "content") == "beforeafter" assert self._join(segs, "thinking") == "inner" def test_unclosed_think_stays_thinking(self) -> None: # Interrupted stream: an opened think block never closes — its text # must never surface as content (mirrors clean_thinking_tags). segs = self._run(["only reasoning, no answer"]) assert self._join(segs, "content") == "" assert "only reasoning" in self._join(segs, "thinking") def test_thinking_variant_tag(self) -> None: segs = self._run(["xy"]) assert self._join(segs, "thinking") == "x" assert self._join(segs, "content") == "y" def test_non_think_tags_untouched(self) -> None: segs = self._run(["a bold ", "and a < b comparison"]) assert self._join(segs, "content") == "a bold and a < b comparison" def test_multiple_think_blocks(self) -> None: segs = self._run(["1mid2end"]) assert self._join(segs, "content") == "midend" assert self._join(segs, "thinking") == "12" @pytest.mark.asyncio async def test_inline_think_streams_to_trace_not_bubble( monkeypatch: pytest.MonkeyPatch, ) -> None: """Providers that inline in the content channel: think text must stream as thinking events; only the post-think text is user content.""" registry = _Registry() client = _ScriptedChatClient( [ [ _llm_chunk(content="let me "), _llm_chunk(content="reason"), _llm_chunk(content="The answer."), ] ] ) pipeline = AgenticChatPipeline(language="en") pipeline.registry = registry monkeypatch.setattr(pipeline, "_compose_enabled_tools", lambda _context: []) monkeypatch.setattr(pipeline, "_build_openai_client", lambda: client) events = await _run(pipeline, UnifiedContext(session_id="s1", user_message="Hi")) assert _contents(events) == ["The answer."] thinking = "".join(e.content for e in events if e.type == StreamEventType.THINKING) assert "let me reason" in thinking result = _result(events) assert result.metadata["response"] == "The answer." assert result.metadata["completed"] is True @pytest.mark.asyncio async def test_empty_finish_gets_one_nudge_then_recovers( monkeypatch: pytest.MonkeyPatch, ) -> None: """A tool-less round that is ALL internal reasoning must not finalize as an empty answer: the loop nudges once and the model recovers.""" registry = _Registry() client = _ScriptedChatClient( [ # Round 1: the model "finishes" with nothing but think text. [_llm_chunk(content="I wrote a whole script here")], # Round 2 (after the nudge): a real answer. [_llm_chunk(content="Here is the real answer.")], ] ) pipeline = AgenticChatPipeline(language="en") pipeline.registry = registry monkeypatch.setattr(pipeline, "_compose_enabled_tools", lambda _context: []) monkeypatch.setattr(pipeline, "_build_openai_client", lambda: client) events = await _run(pipeline, UnifiedContext(session_id="s1", user_message="Make a PDF")) assert client.call_count == 2 # The nudge round keeps the raw think text in-conversation and appends # the nudge instruction as the trailing user message. second_round = client.calls[1]["messages"] assert second_round[-1]["role"] == "user" assert "internal reasoning" in second_round[-1]["content"] assert any( m.get("role") == "assistant" and "whole script" in str(m.get("content")) for m in second_round ) result = _result(events) assert result.metadata["response"] == "Here is the real answer." assert result.metadata["completed"] is True @pytest.mark.asyncio async def test_explore_context_pre_pass_seeds_loop_without_polluting_answer( monkeypatch: pytest.MonkeyPatch, ) -> None: """When the user attaches fresh context, the explore_context pre-pass runs before the answer loop and folds an objective briefing into the loop's user-message seed — while its own output never appears as the answer.""" def _fake_explore_stream(*_args, **_kwargs): async def _gen(): yield "The user and the external agent updated the navigation." return _gen() monkeypatch.setattr(explorer_mod, "llm_stream", _fake_explore_stream) monkeypatch.setattr( explorer_mod, "get_llm_config", lambda: SimpleNamespace( model="gpt-test", api_key="k", base_url="u", api_version=None, binding="openai" ), ) registry = _Registry() client = _ScriptedChatClient([[_llm_chunk(content="Here is what that chat did.")]]) pipeline = AgenticChatPipeline(language="en") pipeline.registry = registry monkeypatch.setattr(pipeline, "_compose_enabled_tools", lambda _context: []) monkeypatch.setattr(pipeline, "_build_openai_client", lambda: client) context = UnifiedContext( session_id="s1", user_message="what did this chat do?", source_manifest="[Attached Sources]\n- id=hs-imported_claude_code_x type=history", metadata={ "source_index": {"hs-imported_claude_code_x": "## Claude Code\nI updated the nav."}, "history_references": ["imported_claude_code_x"], }, ) events = await _run(pipeline, context) # The briefing rode into the answer loop's trailing user message (the seed). first_call_messages = client.calls[0]["messages"] seed_user_msg = first_call_messages[-1]["content"] assert "external agent updated the navigation" in seed_user_msg # The pre-pass streamed THINKING (reasoning trace), never CONTENT — the # answer is only the chat loop's finish text. assert _contents(events) == ["Here is what that chat did."] explore_thinking = "".join( e.content for e in events if e.type == StreamEventType.THINKING and str((e.metadata or {}).get("call_kind")) == "context_exploration" ) assert "external agent updated the navigation" in explore_thinking @pytest.mark.asyncio async def test_finish_first_round_no_tools(monkeypatch: pytest.MonkeyPatch) -> None: """A request that needs no exploration: the first round emits no tool calls, so it IS the finish — one LLM call, streamed straight to the user.""" registry = _Registry() client = _ScriptedChatClient([[_llm_chunk(content="A direct answer.")]]) pipeline = AgenticChatPipeline(language="en") pipeline.registry = registry monkeypatch.setattr(pipeline, "_compose_enabled_tools", lambda _context: []) monkeypatch.setattr(pipeline, "_build_openai_client", lambda: client) events = await _run(pipeline, UnifiedContext(session_id="s1", user_message="Hello")) assert client.call_count == 1 # The finish round's text streams to the user, not the trace. assert _contents(events) == ["A direct answer."] assert _call_roles(events) == ["finish"] result = _result(events) assert result.metadata["engine"] == "agent_loop" assert result.metadata["completed"] is True assert result.metadata["response"] == "A direct answer." assert result.metadata["rounds"] == 1 assert result.metadata["tool_steps"] == 0 @pytest.mark.asyncio async def test_tool_round_then_finish(monkeypatch: pytest.MonkeyPatch) -> None: """A tool round (narration text + a tool call) is followed by a tool-less finish round whose text is the answer — two LLM calls, no respond pass.""" registry = _Registry() client = _ScriptedChatClient( [ # Round 1: preamble (narration) text + a tool call. [ _llm_chunk(content="Searching."), _llm_chunk( tool_calls=[ { "id": "call-1", "name": "web_search", "arguments": json.dumps({"query": "Fourier transform"}), } ] ), ], # Round 2: the model sees the tool result in-protocol and finishes # by replying without tool calls. [_llm_chunk(content="Found what was needed.")], ] ) pipeline = AgenticChatPipeline(language="en") pipeline.registry = registry monkeypatch.setattr(pipeline, "_compose_enabled_tools", lambda _context: ["web_search"]) monkeypatch.setattr(pipeline, "_build_openai_client", lambda: client) events = await _run( pipeline, UnifiedContext( session_id="s1", user_message="Look up Fourier", enabled_tools=["web_search"] ), ) assert client.call_count == 2 assert registry.executed[0]["name"] == "web_search" assert registry.executed[0]["kwargs"]["query"] == "Fourier transform" # Both rounds' text streams to the user; the round roles distinguish them. assert _contents(events) == ["Searching.", "Found what was needed."] assert _call_roles(events) == ["narration", "finish"] # Round 2 sees the tool exchange in-protocol: the assistant tool_calls # message (with its preamble text) followed by the role=tool result. second_round = client.calls[1]["messages"] assistant_tc = [m for m in second_round if m.get("role") == "assistant" and m.get("tool_calls")] assert assistant_tc and assistant_tc[0]["tool_calls"][0]["function"]["name"] == "web_search" assert assistant_tc[0]["content"] == "Searching." tool_msgs = [m for m in second_round if m.get("role") == "tool"] assert tool_msgs and "tool answer" in tool_msgs[0]["content"] result = _result(events) assert result.metadata["tool_steps"] == 1 assert result.metadata["rounds"] == 2 # Only the finish round's text is the persisted answer. assert result.metadata["response"] == "Found what was needed." @pytest.mark.asyncio async def test_midloop_llm_failure_salvages_turn_with_forced_finish( monkeypatch: pytest.MonkeyPatch, ) -> None: """A mid-loop LLM failure (e.g. a timeout) after useful work must not nuke the turn — it is salvaged with a forced finish, not propagated.""" class _FailingThenFinishClient: def __init__(self) -> None: self.call_count = 0 self.calls: list[dict[str, Any]] = [] parent = self class _Completions: async def create(self, **kwargs): parent.call_count += 1 parent.calls.append({**kwargs, "messages": list(kwargs.get("messages") or [])}) if parent.call_count == 1: return _async_llm_stream( [ _llm_chunk(content="Searching."), _llm_chunk( tool_calls=[ { "id": "call-1", "name": "web_search", "arguments": json.dumps({"query": "q"}), } ] ), ] ) if parent.call_count == 2: raise TimeoutError("Request timed out.") return _async_llm_stream([_llm_chunk(content="Best-effort answer.")]) class _Chat: def __init__(self) -> None: self.completions = _Completions() self.chat = _Chat() registry = _Registry() client = _FailingThenFinishClient() pipeline = AgenticChatPipeline(language="en") pipeline.registry = registry monkeypatch.setattr(pipeline, "_compose_enabled_tools", lambda _context: ["web_search"]) monkeypatch.setattr(pipeline, "_build_openai_client", lambda: client) events = await _run( pipeline, UnifiedContext(session_id="s1", user_message="Look up", enabled_tools=["web_search"]), ) # 1 tool round + 1 failed round + 1 forced-finish call = 3 create() calls. assert client.call_count == 3 # The turn produced an answer instead of failing. result = _result(events) assert result.metadata["response"] == "Best-effort answer." # The forced-finish warning explains the salvage. progress = [ e.content for e in events if e.type == StreamEventType.PROGRESS and "A step failed" in str(e.content or "") ] assert progress, "expected the loop_error_finish notice to be emitted" @pytest.mark.asyncio async def test_first_round_llm_failure_propagates( monkeypatch: pytest.MonkeyPatch, ) -> None: """A failure on the very first round (no work gathered yet) has nothing to salvage and propagates so the orchestrator surfaces the error.""" class _AlwaysFailClient: def __init__(self) -> None: class _Completions: async def create(self, **kwargs): raise TimeoutError("Request timed out.") class _Chat: def __init__(self) -> None: self.completions = _Completions() self.chat = _Chat() pipeline = AgenticChatPipeline(language="en") pipeline.registry = _Registry() monkeypatch.setattr(pipeline, "_compose_enabled_tools", lambda _context: ["web_search"]) monkeypatch.setattr(pipeline, "_build_openai_client", lambda: _AlwaysFailClient()) bus = StreamBus() _events, consumer = await _collect_bus_events(bus) with pytest.raises(TimeoutError): await pipeline.run( UnifiedContext(session_id="s1", user_message="x", enabled_tools=["web_search"]), bus, ) await bus.close() await consumer @pytest.mark.asyncio async def test_context_checkpoint_folds_completed_tool_rounds( monkeypatch: pytest.MonkeyPatch, ) -> None: class _CheckpointRegistry(_Registry): async def execute(self, name: str, **kwargs): self.executed.append({"name": name, "kwargs": kwargs}) query = str(kwargs.get("query") or "") return ToolResult( content=f"noisy tool result for {query}", success=True, metadata={"_context_checkpoint": {"summary": f"checkpoint: {query}"}}, ) registry = _CheckpointRegistry() client = _ScriptedChatClient( [ [ _llm_chunk(content="Searching step one."), _llm_chunk( tool_calls=[ { "id": "call-1", "name": "web_search", "arguments": json.dumps({"query": "step one"}), } ] ), ], [ _llm_chunk(content="Searching step two."), _llm_chunk( tool_calls=[ { "id": "call-2", "name": "web_search", "arguments": json.dumps({"query": "step two"}), } ] ), ], [_llm_chunk(content="Final from checkpoints.")], ] ) pipeline = AgenticChatPipeline(language="en") pipeline.registry = registry monkeypatch.setattr(pipeline, "_compose_enabled_tools", lambda _context: ["web_search"]) monkeypatch.setattr(pipeline, "_build_openai_client", lambda: client) events = await _run( pipeline, UnifiedContext( session_id="s1", user_message="Research this", enabled_tools=["web_search"], ), ) assert client.call_count == 3 second_round = client.calls[1]["messages"] assert any( m.get("role") == "system" and "checkpoint: step one" in str(m.get("content")) for m in second_round ) assert not any(m.get("role") == "tool" for m in second_round) assert not any("Searching step one." in str(m.get("content")) for m in second_round) third_round = client.calls[2]["messages"] checkpoint_text = "\n".join( str(m.get("content") or "") for m in third_round if m.get("role") == "system" ) assert "checkpoint: step one" in checkpoint_text assert "checkpoint: step two" in checkpoint_text assert not any(m.get("role") == "tool" for m in third_round) assert not any("noisy tool result" in str(m.get("content")) for m in third_round) result = _result(events) assert result.metadata["response"] == "Final from checkpoints." @pytest.mark.asyncio async def test_ask_user_available_every_round(monkeypatch: pytest.MonkeyPatch) -> None: """The single loop offers the full tool belt — including ask_user — on every round; there is no respond stage that narrows tools to ask_user.""" registry = _Registry() client = _ScriptedChatClient([[_llm_chunk(content="Final answer.")]]) pipeline = AgenticChatPipeline(language="en") pipeline.registry = registry monkeypatch.setattr( pipeline, "_compose_enabled_tools", lambda _context: ["web_search", "ask_user"] ) monkeypatch.setattr(pipeline, "_build_openai_client", lambda: client) await _run( pipeline, UnifiedContext( session_id="s1", user_message="Quick question", enabled_tools=["web_search", "ask_user"], ), ) loop_tools = {t["function"]["name"] for t in client.calls[0]["tools"]} assert loop_tools == {"web_search", "ask_user"} @pytest.mark.asyncio async def test_ask_user_pause_resumes_and_streams_interleaved( monkeypatch: pytest.MonkeyPatch, ) -> None: class _PausingRegistry(_Registry): async def execute(self, name: str, **kwargs): self.executed.append({"name": name, "kwargs": kwargs}) if name == "ask_user": return ToolResult( content="Asked the user.", success=True, pause_for_user={"questions": [{"id": "q1", "prompt": "Which topic?"}]}, ) return await super().execute(name, **kwargs) registry = _PausingRegistry() client = _ScriptedChatClient( [ # Round 1: a clarification (narration text + ask_user tool call). [ _llm_chunk(content="Let me check one thing."), _llm_chunk( tool_calls=[ { "id": "call-1", "name": "ask_user", "arguments": json.dumps( {"questions": [{"id": "q1", "prompt": "Which topic?"}]} ), } ] ), ], # Round 2 finishes after the user's reply resumes the loop. [_llm_chunk(content="The answer.")], ] ) pipeline = AgenticChatPipeline(language="en") pipeline.registry = registry monkeypatch.setattr(pipeline, "_compose_enabled_tools", lambda _context: ["ask_user"]) monkeypatch.setattr(pipeline, "_build_openai_client", lambda: client) async def _waiter(): return {"text": "Topic A"} events = await _run( pipeline, UnifiedContext( session_id="s1", user_message="Quick question", enabled_tools=["ask_user"], metadata={"wait_for_user_reply": _waiter}, ), ) assert client.call_count == 2 assert _contents(events) == ["Let me check one thing.", "The answer."] assert _call_roles(events) == ["narration", "finish"] # The reply was substituted into the role=tool message in-protocol. final_round = client.calls[-1]["messages"] tool_msgs = [m for m in final_round if m.get("role") == "tool"] assert tool_msgs and "Topic A" in tool_msgs[-1]["content"] result = _result(events) # The persisted answer is the finish round's text. assert result.metadata["response"] == "The answer." assert result.metadata["completed"] is True @pytest.mark.asyncio async def test_unresolved_ask_user_halts_turn(monkeypatch: pytest.MonkeyPatch) -> None: class _PausingRegistry(_Registry): async def execute(self, name: str, **kwargs): self.executed.append({"name": name, "kwargs": kwargs}) return ToolResult( content="Asked the user.", success=True, pause_for_user={"questions": [{"id": "q1", "prompt": "Which topic?"}]}, ) registry = _PausingRegistry() client = _ScriptedChatClient( [ [ _llm_chunk( tool_calls=[ { "id": "call-1", "name": "ask_user", "arguments": json.dumps({"questions": []}), } ] ), ], # No further scripted responses: with no wait_for_user_reply waiter # the loop must halt here instead of producing another answer. ] ) pipeline = AgenticChatPipeline(language="en") pipeline.registry = registry monkeypatch.setattr(pipeline, "_compose_enabled_tools", lambda _context: ["ask_user"]) monkeypatch.setattr(pipeline, "_build_openai_client", lambda: client) events = await _run( pipeline, UnifiedContext(session_id="s1", user_message="Help me study", enabled_tools=["ask_user"]), ) assert client.call_count == 1 assert _contents(events) == ["Which topic?"] result = _result(events) assert result.metadata["completed"] is False @pytest.mark.asyncio async def test_round_budget_forces_tool_less_finish(monkeypatch: pytest.MonkeyPatch) -> None: registry = _Registry() client = _ScriptedChatClient( [ [ _llm_chunk( tool_calls=[ { "id": "call-1", "name": "web_search", "arguments": json.dumps({"query": "step one"}), } ] ), ], [_llm_chunk(content="Best effort answer.")], ] ) pipeline = AgenticChatPipeline(language="en") pipeline.registry = registry pipeline._max_rounds = 1 monkeypatch.setattr(pipeline, "_compose_enabled_tools", lambda _context: ["web_search"]) monkeypatch.setattr(pipeline, "_build_openai_client", lambda: client) events = await _run( pipeline, UnifiedContext(session_id="s1", user_message="Research this", enabled_tools=["web_search"]), ) assert client.call_count == 2 # The forced finish round disables tools and tells the model to answer now. assert "tools" not in client.calls[-1] forced_instruction = client.calls[-1]["messages"][-1]["content"] assert "round budget ran out" in forced_instruction result = _result(events) assert result.metadata["response"] == "Best effort answer." assert result.metadata["completed"] is True def test_compose_enabled_tools_injects_rag_when_kb_selected( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( "deeptutor.services.memory.get_memory_store", lambda: SimpleNamespace(read_raw=lambda *_args, **_kwargs: ""), ) monkeypatch.setattr( "deeptutor.services.notebook.get_notebook_manager", lambda: SimpleNamespace(list_notebooks=lambda: []), ) pipeline = AgenticChatPipeline.__new__(AgenticChatPipeline) pipeline._deferred_loader = None pipeline._exec_enabled = False pipeline.registry = SimpleNamespace( get_enabled=lambda selected: [SimpleNamespace(name=n) for n in selected] ) context = UnifiedContext( user_message="hi", enabled_tools=["web_search"], knowledge_bases=["kb-a"], ) assert "rag" in pipeline._compose_enabled_tools(context) assert "web_search" in pipeline._compose_enabled_tools(context) def test_compose_enabled_tools_mounts_mastery_plugin_only_in_mastery_mode( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( "deeptutor.services.memory.get_memory_store", lambda: SimpleNamespace(read_raw=lambda *_args, **_kwargs: ""), ) monkeypatch.setattr( "deeptutor.services.notebook.get_notebook_manager", lambda: SimpleNamespace(list_notebooks=lambda: []), ) pipeline = AgenticChatPipeline.__new__(AgenticChatPipeline) pipeline._deferred_loader = None pipeline._exec_enabled = False pipeline.registry = SimpleNamespace( get_enabled=lambda selected: [SimpleNamespace(name=n) for n in selected] ) ordinary = UnifiedContext(user_message="hi") mastery = UnifiedContext( user_message="teach me", metadata={"mastery_mode": True, "mastery_path_id": "path-a"}, ) ordinary_tools = pipeline._compose_enabled_tools(ordinary) mastery_tools = pipeline._compose_enabled_tools(mastery) assert not set(MASTERY_TOOL_NAMES).intersection(ordinary_tools) assert set(MASTERY_TOOL_NAMES).issubset(mastery_tools) # Additive plugin surface: a mastery turn reuses chat's full built-in # surface (always-on defaults included) and just adds its owned tools. assert {"web_fetch", "github", "cron"}.issubset(mastery_tools) assert {"web_fetch", "github", "cron"}.issubset(ordinary_tools) def test_augment_tool_kwargs_injects_mastery_path_id() -> None: pipeline = AgenticChatPipeline.__new__(AgenticChatPipeline) context = UnifiedContext( user_message="teach", metadata={"mastery_mode": True, "mastery_path_id": "book-1"}, ) augmented = pipeline._augment_tool_kwargs("mastery_status", {}, context) assert augmented["_mastery_path_id"] == "book-1" def test_augment_tool_kwargs_injects_geogebra_image() -> None: pipeline = AgenticChatPipeline.__new__(AgenticChatPipeline) pipeline.language = "zh" context = UnifiedContext( user_message="solve this triangle", attachments=[ Attachment( type="image", base64="REAL_IMG_BYTES", filename="problem.png", mime_type="image/png", ), ], language="zh", ) augmented = pipeline._augment_tool_kwargs( "geogebra_analysis", {"image_base64": "HALLUCINATED"}, context, ) assert augmented["image_base64"] == "data:image/png;base64,REAL_IMG_BYTES" assert augmented["language"] == "zh" def test_build_llm_tool_schemas_kb_name_enum_matches_attached() -> None: pipeline = AgenticChatPipeline.__new__(AgenticChatPipeline) pipeline.registry = _Registry() schemas = pipeline._build_llm_tool_schemas( ["web_search"], UnifiedContext(knowledge_bases=["kb-a", "kb-b"]), ) assert schemas[0]["function"]["parameters"]["additionalProperties"] is False