phase: 146_search_tool_scope
Phase 146 final verification pass complete — all green, tree untouched by me.
**Verified (final pass — both tasks already in `complete/`):**
- Scoped grep streams `{"type":"tool","name":"grep","argument":<pattern>,"path":<source/path>}`; bare grep/read/ls frames dump byte-identical (unit matrix in `test_sse_events.py` + builder pins in `test_chat_gate.py` L1364–1448)
- Tool line `🔎 Searching for <pattern> in <source/path>` (two `<code>` spans, `.grep-scope` sibling, no new emoji); bare line byte-identical (unit Node test + E2E)
- Status label "… is searching for X in Y" scoped-only; `path` persisted additively on the saved tool record and re-rendered on `/?chat=<id>` restore; pre-phase records restore byte-identically (E2E tests 2 & 3)
**Results (exact commands):**
- `uv run pytest --cov=app --cov-report=term-missing` → **3419 passed**, `app/` coverage **99%** (>90% ✓)
- `uv run pytest tests/e2e/test_search_tool_scope.py -v --no-cov` → **3 passed** in isolation ✓
- `uv run ruff check . && uv run pyright` → clean (0 errors) ✓
**Completion criteria:** all 6 ✓ (wire shape, UI line, status label, save/restore, gates, no completed-phase behavior change — full suite green includes all prior-phase pins).
**Documentation:** README.md (incl. its phase-146 section) and AGENTS.md reviewed in full against the codebase — verified consistent, no changes needed (`.env.example` ⊆ README config tables; no stale vars; phase-100 72rem rule in AGENTS.md §5 matches shipped CSS; no-CDN/smoke-DB/gpgsign claims all confirmed).
**Notable:** none — no defects found, no live-infrastructure touched, nothing committed per harness protocol.
**Next pending phase:** `147_sync_changed_only`.
This commit is contained in:
@@ -934,6 +934,40 @@ class _CannedLLM:
|
||||
yield StreamPiece("content", self.answer[i : i + 12])
|
||||
|
||||
|
||||
class _ToolCallCannedLLM(_CannedLLM):
|
||||
"""Streams ONE tool call (the given name + arguments) on the first
|
||||
request, then the canned answer — the phase-146 (task 01) frame-
|
||||
builder driver: the endpoint's ``ToolCallPiece`` branch (the
|
||||
``_pump`` loop in ``app.api.chat``) is the ONE extraction path
|
||||
every ``tool`` frame goes through, so the scope guard is pinned
|
||||
through the real endpoint (no parallel re-implementation)."""
|
||||
|
||||
def __init__(
|
||||
self, tool_name: str, arguments: dict[str, Any], answer: str = ANSWER
|
||||
) -> None:
|
||||
super().__init__(answer=answer)
|
||||
self.tool_name = tool_name
|
||||
self.arguments = arguments
|
||||
self._emitted = False
|
||||
|
||||
async def chat_stream(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
scaffolding: ScaffoldingFilter | None = None, # phase 71 pass-through
|
||||
):
|
||||
self.seen.append(messages)
|
||||
self.seen_tools.append(tools)
|
||||
if not self._emitted:
|
||||
self._emitted = True
|
||||
yield ToolCallPiece(
|
||||
id="call_1", name=self.tool_name, arguments=self.arguments
|
||||
)
|
||||
return
|
||||
for i in range(0, len(self.answer), 12):
|
||||
yield StreamPiece("content", self.answer[i : i + 12])
|
||||
|
||||
|
||||
class _FakeSteeringResult:
|
||||
"""Empty steering-note result (no stored notes in these unit tests)."""
|
||||
|
||||
@@ -1324,6 +1358,133 @@ def test_endpoint_suggested_folder_lines_reach_the_high_prompt(
|
||||
)
|
||||
|
||||
|
||||
# ---------- endpoint: grep scope frame (phase 146, task 01) ----------
|
||||
|
||||
|
||||
def test_endpoint_scoped_grep_frame_carries_path(
|
||||
client: TestClient,
|
||||
gate_env: tuple[_FakeSession, _CannedLLM],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 146 (task 01): a SCOPED grep (``grep(pattern, path)``)
|
||||
streams a ``tool`` frame that carries the scope — the re-searched
|
||||
document's combined ``source/path`` — as the additive ``path``
|
||||
field, with ``argument`` still the model's ``pattern`` (the ONLY
|
||||
case the field is present on the wire)."""
|
||||
_session, _llm = gate_env
|
||||
doc = _doc("The Ops Handbook", "page one\nthe grep me line\npage three")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)]))
|
||||
llm = _ToolCallCannedLLM("grep", {"pattern": "grep me", "path": f"Homelab/{doc.path}"})
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm
|
||||
monkeypatch.setattr(
|
||||
rag_agent,
|
||||
"find_document",
|
||||
_find_docs({(doc.source, doc.path): doc}),
|
||||
)
|
||||
|
||||
frames = _ask(client, "Where does the handbook mention grep me?")
|
||||
|
||||
tools = [f for f in frames if f["type"] == "tool"]
|
||||
assert tools == [
|
||||
{
|
||||
"type": "tool",
|
||||
"name": "grep",
|
||||
"argument": "grep me",
|
||||
"path": f"Homelab/{doc.path}",
|
||||
}
|
||||
]
|
||||
assert frames[-1]["type"] == "done"
|
||||
assert frames[-1]["deflected"] is False
|
||||
assert len(llm.seen) == 2 # the grep round + the answer round
|
||||
|
||||
|
||||
def test_endpoint_bare_grep_frame_has_no_path_key(
|
||||
client: TestClient,
|
||||
gate_env: tuple[_FakeSession, _CannedLLM],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 146 (task 01): a bare grep's frame is byte-identical to
|
||||
pre-phase — the ``path`` key is ABSENT (never ``null``) and no
|
||||
other frame of the turn gains a key either."""
|
||||
_session, _llm = gate_env
|
||||
doc = _doc("The Ops Handbook", "content")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)]))
|
||||
llm = _ToolCallCannedLLM("grep", {"pattern": "grep me"})
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm
|
||||
monkeypatch.setattr(rag_agent, "all_documents", lambda _db: [])
|
||||
|
||||
frames = _ask(client, "Search the whole KB for grep me")
|
||||
|
||||
tools = [f for f in frames if f["type"] == "tool"]
|
||||
assert tools == [{"type": "tool", "name": "grep", "argument": "grep me"}]
|
||||
assert all("path" not in f for f in frames)
|
||||
assert frames[-1]["type"] == "done"
|
||||
|
||||
|
||||
def test_endpoint_grep_non_string_path_scope_omitted(
|
||||
client: TestClient,
|
||||
gate_env: tuple[_FakeSession, _CannedLLM],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 146 (task 01): a NON-string ``path`` (the model-error
|
||||
shape — a list here; the agent refuses the call) never reaches the
|
||||
frame: the builder's string guard yields ``None`` → the key is
|
||||
omitted, so the frame is the exact bare-grep shape."""
|
||||
_session, _llm = gate_env
|
||||
doc = _doc("The Ops Handbook", "content")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)]))
|
||||
llm = _ToolCallCannedLLM("grep", {"pattern": "grep me", "path": ["s", "d.md"]})
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm
|
||||
monkeypatch.setattr(rag_agent, "all_documents", lambda _db: [])
|
||||
|
||||
frames = _ask(client, "Search the whole KB for grep me")
|
||||
|
||||
tools = [f for f in frames if f["type"] == "tool"]
|
||||
assert tools == [{"type": "tool", "name": "grep", "argument": "grep me"}]
|
||||
assert all("path" not in f for f in frames)
|
||||
assert frames[-1]["type"] == "done"
|
||||
|
||||
|
||||
def test_endpoint_read_and_ls_frames_never_carry_scope(
|
||||
client: TestClient,
|
||||
gate_env: tuple[_FakeSession, _CannedLLM],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 146 (task 01): ``read`` and ``ls`` NEVER read a scope from
|
||||
their arguments — even though their ``path`` argument IS a string
|
||||
(the combined ``source/path`` / the source name), it rides
|
||||
``argument`` (the pre-phase wire shape) and the frame has no
|
||||
``path`` key."""
|
||||
_session, _llm = gate_env
|
||||
doc = _doc("The Ops Handbook", "content")
|
||||
monkeypatch.setattr(chat_api, "retrieve", _fake_retriever([_chunk(doc, 0.90)]))
|
||||
monkeypatch.setattr(
|
||||
rag_agent,
|
||||
"find_document",
|
||||
_find_docs({(doc.source, doc.path): doc}),
|
||||
)
|
||||
monkeypatch.setattr(rag_agent, "list_source_names", lambda _db: [])
|
||||
|
||||
# A plain read (its ``path`` argument is a string — argument, not
|
||||
# scope; the frame stays the exact pre-phase shape).
|
||||
llm = _ToolCallCannedLLM("read", {"path": f"Homelab/{doc.path}"})
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm
|
||||
frames = _ask(client, "What does the handbook say?")
|
||||
reads = [f for f in frames if f["type"] == "tool"]
|
||||
assert reads == [{"type": "tool", "name": "read", "argument": f"Homelab/{doc.path}"}]
|
||||
assert all("path" not in f for f in frames)
|
||||
assert frames[-1]["type"] == "done"
|
||||
|
||||
# A scoped ls (same — the argument is the source-name string).
|
||||
llm = _ToolCallCannedLLM("ls", {"path": "Homelab"})
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: llm
|
||||
frames = _ask(client, "List my sources")
|
||||
lss = [f for f in frames if f["type"] == "tool"]
|
||||
assert lss == [{"type": "tool", "name": "ls", "argument": "Homelab"}]
|
||||
assert all("path" not in f for f in frames)
|
||||
assert frames[-1]["type"] == "done"
|
||||
|
||||
|
||||
# ---------- endpoint: KB overview row (phase 31) ----------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user