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
316 lines
11 KiB
Python
316 lines
11 KiB
Python
"""Unit tests for the explore-context loop capability and its explorer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from deeptutor.capabilities.explore_context import ExploreContextCapability
|
|
from deeptutor.capabilities.explore_context import explorer as explorer_mod
|
|
from deeptutor.capabilities.protocol import PromptBlock
|
|
from deeptutor.core.context import Attachment, UnifiedContext
|
|
from deeptutor.core.stream import StreamEventType
|
|
from deeptutor.core.stream_bus import StreamBus
|
|
|
|
|
|
def _ctx(**metadata: Any) -> UnifiedContext:
|
|
attachments = metadata.pop("_attachments", [])
|
|
manifest = metadata.pop("_manifest", "")
|
|
return UnifiedContext(
|
|
session_id="s1",
|
|
user_message="what did this chat do?",
|
|
attachments=attachments,
|
|
source_manifest=manifest,
|
|
metadata=metadata,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_active
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_is_active_requires_readable_sources() -> None:
|
|
cap = ExploreContextCapability()
|
|
# A readable source attached this turn → active.
|
|
assert cap.is_active(_ctx(source_index={"hs-x": "t"}, history_references=["hs-x"])) is True
|
|
# A source carried over from an earlier turn (no fresh ref this turn) is
|
|
# ALSO active now — the investigation runs query-driven on every turn that
|
|
# has sources to read.
|
|
assert cap.is_active(_ctx(source_index={"hs-x": "t"})) is True
|
|
# A fresh reference but no readable source index (e.g. non-chat turn) → skip.
|
|
assert cap.is_active(_ctx(history_references=["hs-x"])) is False
|
|
# Empty turn → skip.
|
|
assert cap.is_active(_ctx()) is False
|
|
|
|
|
|
def test_is_active_image_only_turn_has_no_readable_source() -> None:
|
|
cap = ExploreContextCapability()
|
|
# Image attachments never enter the source index, so an image-only turn
|
|
# carries no readable source and the pre-pass stays dormant.
|
|
img = Attachment(type="image", id="i1")
|
|
assert cap.is_active(_ctx(_attachments=[img])) is False
|
|
|
|
|
|
def test_capability_owns_no_tools_and_no_system_block() -> None:
|
|
cap = ExploreContextCapability()
|
|
assert cap.owned_tools == ()
|
|
assert cap.pre_loop_seed(_ctx()) == ""
|
|
assert cap.system_block(_ctx(), language="en", prompts={}) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Single-pass fallback (non-native tool-calling models)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _fake_stream(briefing_chunks: list[str]):
|
|
async def _stream(*_args: Any, **_kwargs: Any):
|
|
for chunk in briefing_chunks:
|
|
yield chunk
|
|
|
|
return _stream
|
|
|
|
|
|
@pytest.fixture
|
|
def _force_single_pass(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Pin the explorer onto its single-pass (llm_stream) fallback path."""
|
|
monkeypatch.setattr(explorer_mod, "can_use_native_tool_calling", lambda **_kwargs: False)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_pass_returns_investigation_block(
|
|
monkeypatch: pytest.MonkeyPatch, _force_single_pass: None
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
explorer_mod,
|
|
"llm_stream",
|
|
_fake_stream(["In this transcript, the user and ", "the other agent updated the nav."]),
|
|
)
|
|
cap = ExploreContextCapability()
|
|
ctx = _ctx(
|
|
source_index={"hs-imported_claude_code_x": "## Claude Code\nI updated the nav."},
|
|
history_references=["imported_claude_code_x"],
|
|
_manifest="[Attached Sources]\n- id=hs-imported_claude_code_x type=history name='nav'",
|
|
)
|
|
|
|
block = await cap.pre_loop(ctx, StreamBus(), usage=None)
|
|
|
|
assert isinstance(block, PromptBlock)
|
|
assert block.name == "explore_context"
|
|
# Header framing + the model's third-person account are both present.
|
|
assert "Context Investigation" in block.content
|
|
assert "the other agent updated the nav." in block.content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pre_loop_emits_no_content_events(
|
|
monkeypatch: pytest.MonkeyPatch, _force_single_pass: None
|
|
) -> None:
|
|
"""The pre-pass must never stream CONTENT — only that channel is captured
|
|
as the turn's user-facing answer."""
|
|
monkeypatch.setattr(explorer_mod, "llm_stream", _fake_stream(["briefing"]))
|
|
cap = ExploreContextCapability()
|
|
ctx = _ctx(source_index={"hs-x": "transcript"}, history_references=["x"])
|
|
bus = StreamBus()
|
|
|
|
await cap.pre_loop(ctx, bus, usage=None)
|
|
|
|
kinds = [e.type for e in bus._history]
|
|
assert StreamEventType.CONTENT not in kinds
|
|
assert StreamEventType.STAGE_START in kinds
|
|
assert StreamEventType.THINKING in kinds
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pre_loop_inactive_returns_none() -> None:
|
|
cap = ExploreContextCapability()
|
|
# No readable sources → inactive → no pre-pass, no LLM call.
|
|
assert await cap.pre_loop(_ctx(history_references=["x"]), StreamBus(), usage=None) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pre_loop_empty_investigation_returns_none(
|
|
monkeypatch: pytest.MonkeyPatch, _force_single_pass: None
|
|
) -> None:
|
|
monkeypatch.setattr(explorer_mod, "llm_stream", _fake_stream([" "]))
|
|
cap = ExploreContextCapability()
|
|
ctx = _ctx(source_index={"hs-x": "t"}, history_references=["x"])
|
|
assert await cap.pre_loop(ctx, StreamBus(), usage=None) is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pre_loop_swallows_llm_failure(
|
|
monkeypatch: pytest.MonkeyPatch, _force_single_pass: None
|
|
) -> None:
|
|
def _boom(*_args: Any, **_kwargs: Any):
|
|
raise RuntimeError("provider down")
|
|
|
|
monkeypatch.setattr(explorer_mod, "llm_stream", _boom)
|
|
cap = ExploreContextCapability()
|
|
ctx = _ctx(source_index={"hs-x": "t"}, history_references=["x"])
|
|
# A failed pre-pass degrades to no investigation, never raises.
|
|
assert await cap.pre_loop(ctx, StreamBus(), usage=None) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Agentic loop (native tool-calling models)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeFunc:
|
|
def __init__(self, name: str | None = None, arguments: str | None = None) -> None:
|
|
self.name = name
|
|
self.arguments = arguments
|
|
|
|
|
|
class _FakeToolCall:
|
|
def __init__(self, index: int, call_id: str, name: str, arguments: str) -> None:
|
|
self.index = index
|
|
self.id = call_id
|
|
self.function = _FakeFunc(name=name, arguments=arguments)
|
|
|
|
|
|
class _FakeDelta:
|
|
def __init__(
|
|
self,
|
|
content: str | None = None,
|
|
tool_calls: list[_FakeToolCall] | None = None,
|
|
) -> None:
|
|
self.content = content
|
|
self.tool_calls = tool_calls
|
|
self.reasoning_content = None
|
|
self.reasoning = None
|
|
|
|
|
|
class _FakeChoice:
|
|
def __init__(self, delta: _FakeDelta) -> None:
|
|
self.delta = delta
|
|
self.finish_reason = None
|
|
|
|
|
|
class _FakeChunk:
|
|
def __init__(self, delta: _FakeDelta) -> None:
|
|
self.choices = [_FakeChoice(delta)]
|
|
self.usage = None
|
|
|
|
|
|
class _FakeResponseStream:
|
|
def __init__(self, chunks: list[_FakeChunk]) -> None:
|
|
self._chunks = chunks
|
|
|
|
async def __aiter__(self):
|
|
for chunk in self._chunks:
|
|
yield chunk
|
|
|
|
|
|
class _FakeCompletions:
|
|
"""Returns one queued response per ``create`` call (one per loop round)."""
|
|
|
|
def __init__(self, rounds: list[list[_FakeChunk]]) -> None:
|
|
self._rounds = list(rounds)
|
|
self.calls: list[dict[str, Any]] = []
|
|
|
|
async def create(self, **kwargs: Any) -> _FakeResponseStream:
|
|
self.calls.append(kwargs)
|
|
chunks = self._rounds.pop(0) if self._rounds else []
|
|
return _FakeResponseStream(chunks)
|
|
|
|
|
|
class _FakeClient:
|
|
def __init__(self, rounds: list[list[_FakeChunk]]) -> None:
|
|
self.chat = type("Chat", (), {"completions": _FakeCompletions(rounds)})()
|
|
|
|
|
|
@pytest.fixture
|
|
def _force_loop(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(explorer_mod, "can_use_native_tool_calling", lambda **_kwargs: True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loop_reads_source_then_writes_investigation(
|
|
monkeypatch: pytest.MonkeyPatch, _force_loop: None
|
|
) -> None:
|
|
"""The investigator calls read_source on a source, then writes an
|
|
investigation grounded in the loaded full text."""
|
|
rounds = [
|
|
# Round 0: ask to read the attached transcript.
|
|
[
|
|
_FakeChunk(
|
|
_FakeDelta(
|
|
tool_calls=[_FakeToolCall(0, "call_1", "read_source", '{"source_id": "hs-x"}')]
|
|
)
|
|
)
|
|
],
|
|
# Round 1: no tool calls → this text is the investigation.
|
|
[_FakeChunk(_FakeDelta(content="The other agent rewrote the nav and shipped it."))],
|
|
]
|
|
fake_client = _FakeClient(rounds)
|
|
monkeypatch.setattr(explorer_mod, "build_openai_client", lambda _cfg: fake_client)
|
|
|
|
cap = ExploreContextCapability()
|
|
ctx = _ctx(
|
|
source_index={"hs-x": "## Claude Code\nI rewrote the nav and shipped it."},
|
|
history_references=["x"],
|
|
_manifest="[Attached Sources]\n- id=hs-x type=history name='nav'",
|
|
)
|
|
bus = StreamBus()
|
|
|
|
block = await cap.pre_loop(ctx, bus, usage=None)
|
|
|
|
assert isinstance(block, PromptBlock)
|
|
assert "Context Investigation" in block.content
|
|
assert "rewrote the nav" in block.content
|
|
# read_source was dispatched (the model's source_id reached the create
|
|
# call only via the tool round, and a tool result fed the second round).
|
|
completions = fake_client.chat.completions
|
|
assert len(completions.calls) == 2
|
|
# Never streamed CONTENT — the investigation rides the thinking channel.
|
|
kinds = [e.type for e in bus._history]
|
|
assert StreamEventType.CONTENT not in kinds
|
|
assert StreamEventType.TOOL_CALL in kinds
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_loop_falls_back_to_single_pass_on_client_error(
|
|
monkeypatch: pytest.MonkeyPatch, _force_loop: None
|
|
) -> None:
|
|
"""A loop that errors before producing anything degrades to the robust
|
|
single-pass dump-and-brief so the answer loop still gets grounding."""
|
|
|
|
def _boom_client(_cfg: Any) -> Any:
|
|
raise RuntimeError("schema rejected")
|
|
|
|
monkeypatch.setattr(explorer_mod, "build_openai_client", _boom_client)
|
|
monkeypatch.setattr(explorer_mod, "llm_stream", _fake_stream(["fallback briefing text"]))
|
|
|
|
cap = ExploreContextCapability()
|
|
ctx = _ctx(source_index={"hs-x": "transcript body"}, history_references=["x"])
|
|
|
|
block = await cap.pre_loop(ctx, StreamBus(), usage=None)
|
|
|
|
assert isinstance(block, PromptBlock)
|
|
assert "fallback briefing text" in block.content
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ContextExplorer source assembly (single-pass helpers)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_explorer_clips_and_caps_sources() -> None:
|
|
explorer = explorer_mod.ContextExplorer(language="en", prompts={})
|
|
big = {f"nb-{i}": "y" * 100_000 for i in range(explorer_mod.MAX_SOURCES + 5)}
|
|
blocks = explorer._render_source_blocks(big)
|
|
assert blocks.count("### [") <= explorer_mod.MAX_SOURCES
|
|
assert "(truncated)" in blocks
|
|
assert len(blocks) <= explorer_mod.TOTAL_CHARS + 5_000 # blocks + headers/notes
|
|
|
|
|
|
def test_explorer_kind_label_from_prefix() -> None:
|
|
explorer = explorer_mod.ContextExplorer(language="en", prompts={})
|
|
assert explorer._kind_label("hs-abc") == "Conversation transcript"
|
|
assert explorer._kind_label("at-abc") == "Document"
|
|
assert explorer._kind_label("zz-abc") == "Source"
|