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:
@@ -197,7 +197,9 @@ def test_arm_has_exactly_one_live_call_site() -> None:
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
branch = js[tool_idx:delta_idx]
|
||||
assert "armToolLineClock(wrap)" in branch, "the live tool branch arms the clock"
|
||||
append = branch.find("appendToolLine(wrap, name, argument, frameOffset, frameLimit)")
|
||||
append = branch.find(
|
||||
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)"
|
||||
)
|
||||
arm = branch.find("armToolLineClock(wrap)")
|
||||
assert -1 < append < arm, (
|
||||
"the arm follows the line's append — the baseline resets per line"
|
||||
|
||||
@@ -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) ----------
|
||||
|
||||
|
||||
|
||||
@@ -62,7 +62,10 @@ def test_tool_branch_is_a_first_class_turn_branch() -> None:
|
||||
assert "uiState === UI_STATE.thinking" in branch, (
|
||||
"label updates only while the state is still thinking"
|
||||
)
|
||||
assert "appendToolLine(wrap, name, argument, frameOffset, frameLimit)" in branch
|
||||
assert (
|
||||
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)"
|
||||
in branch
|
||||
)
|
||||
# No setUiState in the branch: the state stays "thinking" (never stale).
|
||||
assert "setUiState" not in branch, (
|
||||
"the tool branch must keep uiState=thinking (button stays disabled)"
|
||||
@@ -98,9 +101,15 @@ def test_calling_tool_label_strings() -> None:
|
||||
"the read status requires the name (new or legacy) AND a string argument"
|
||||
)
|
||||
# The search status — new + legacy name, sitting BETWEEN the read
|
||||
# branch and the listing fallback in the ternary.
|
||||
# branch and the listing fallback in the ternary. Phase 146
|
||||
# (task 02): the label gains the additive scope — `` in <path>``
|
||||
# ONLY when the frame carried one (a bare grep renders the exact
|
||||
# pre-phase label — the template's empty-string fallback).
|
||||
assert 'name === "grep" || name === "search_documents") && argument' in branch
|
||||
assert "`${brand()} is searching for ${argument}`" in branch
|
||||
assert (
|
||||
"`${brand()} is searching for ${argument}"
|
||||
"${framePath ? ` in ${framePath}` : \"\"}`" in branch
|
||||
)
|
||||
# Phase 70: the scoped ls status mirrors the scoped tool line; the
|
||||
# unscoped listing stays the final fallback.
|
||||
assert 'name === "ls" && argument' in branch
|
||||
@@ -227,9 +236,11 @@ def test_tool_lines_re_render_on_restore() -> None:
|
||||
end = js.find("function restoreConversation")
|
||||
body = js[fn:end]
|
||||
assert "Array.isArray(m.tools)" in body
|
||||
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body, (
|
||||
"phase 139: the stored range rides the SAME helper — a pre-phase"
|
||||
" record (no fields) renders byte-identical"
|
||||
assert (
|
||||
"appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
|
||||
), (
|
||||
"phase 139: the stored range + the phase-146 stored scope ride the "
|
||||
"SAME helper — a pre-phase record (no fields) renders byte-identical"
|
||||
)
|
||||
thinking_restore = body.find("if (m.thinking)")
|
||||
tools_restore = body.find("Array.isArray(m.tools)")
|
||||
|
||||
@@ -113,12 +113,15 @@ def test_read_range_suffix_shape_order() -> None:
|
||||
def test_append_tool_line_signature_takes_optional_range() -> None:
|
||||
"""appendToolLine gains the additive ``offset``/``limit`` params
|
||||
(default ``null`` — a pre-phase 3-arg call renders byte-identical),
|
||||
and the read arm renders the suffix through the SAME helper."""
|
||||
and the read arm renders the suffix through the SAME helper.
|
||||
Phase 146 (task 02) extends the signature one more additive param
|
||||
(``scope = null`` — the scoped grep's document) — the 5-arg
|
||||
pre-phase-146 call shape renders byte-identically."""
|
||||
body = _extract(_js(), "appendToolLine")
|
||||
assert (
|
||||
"function appendToolLine(wrap, name, argument, offset = null, limit = null)"
|
||||
in _js()
|
||||
), "the signature carries the additive offset/limit (default null)"
|
||||
"function appendToolLine(wrap, name, argument, offset = null, limit = null, "
|
||||
"scope = null)" in _js()
|
||||
), "the signature carries the additive offset/limit/scope (default null)"
|
||||
assert "readRangeSuffix(offset, limit)" in body, (
|
||||
"the read arm renders the suffix through the SAME helper"
|
||||
)
|
||||
@@ -185,15 +188,21 @@ def test_live_tool_branch_reads_the_range_with_the_guard() -> None:
|
||||
"limit joins the record ONLY when non-null"
|
||||
)
|
||||
assert "toolAcc.push(toolRecord)" in branch, "every tool frame is recorded"
|
||||
# Both values pass to the SAME helper (the line + the status label).
|
||||
assert "appendToolLine(wrap, name, argument, frameOffset, frameLimit)" in branch
|
||||
# Phase 146 (task 02): the scope joins the SAME two pass-throughs.
|
||||
assert (
|
||||
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, framePath)"
|
||||
in branch
|
||||
)
|
||||
|
||||
|
||||
def test_live_read_status_carries_the_range_suffix() -> None:
|
||||
"""ASSUMPTION A2: the read status label carries the SAME
|
||||
requested-range suffix as the line (``readRangeSuffix`` — the single
|
||||
source of truth); a non-range read renders byte-identical (the
|
||||
helper returns ``""``). The grep / ls labels are untouched."""
|
||||
helper returns ``""``). The ls labels are untouched; the grep label
|
||||
gains the phase-146 additive scope (`` in <path>`` ONLY when the
|
||||
frame carried one — a bare grep renders the exact pre-phase label).
|
||||
"""
|
||||
js = _js()
|
||||
tool_idx = js.find('ev.type === "tool"')
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
@@ -202,8 +211,12 @@ def test_live_read_status_carries_the_range_suffix() -> None:
|
||||
"`${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`"
|
||||
in branch
|
||||
), "the read status carries the suffix (A2)"
|
||||
# The other labels stay byte-identical (no suffix).
|
||||
assert "`${brand()} is searching for ${argument}`" in branch
|
||||
# The other labels: the grep scope extension (phase 146) + the
|
||||
# ls labels untouched.
|
||||
assert (
|
||||
"`${brand()} is searching for ${argument}"
|
||||
"${framePath ? ` in ${framePath}` : \"\"}`" in branch
|
||||
)
|
||||
assert "`${brand()} is listing documents in ${argument}`" in branch
|
||||
assert "`${brand()} is listing documents`" in branch
|
||||
|
||||
@@ -230,8 +243,8 @@ def test_restore_path_passes_the_stored_range_through() -> None:
|
||||
assert "Number.isInteger(t.limit) && t.limit >= 1 ? t.limit : null" in body, (
|
||||
"the stored limit reads through the house guard"
|
||||
)
|
||||
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body, (
|
||||
"the stored range rides the SAME helper"
|
||||
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body, (
|
||||
"the stored range + the phase-146 stored scope ride the SAME helper"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ def test_restore_path_renders_the_stored_marker() -> None:
|
||||
assert fn != -1
|
||||
end = js.find("function restoreConversation")
|
||||
body = js[fn:end]
|
||||
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body
|
||||
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
|
||||
assert "t.truncated && arg" in body, (
|
||||
"only an argument-bearing (Reading) record with the flag renders"
|
||||
" the marker"
|
||||
@@ -173,7 +173,7 @@ def test_restore_path_renders_the_stored_marker() -> None:
|
||||
" Number(t.chars_total) || 0)" in body
|
||||
)
|
||||
# The marker append sits INSIDE the tools loop, after the line append.
|
||||
line_idx = body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit)")
|
||||
line_idx = body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)")
|
||||
note_idx = body.find("appendTruncatedNote(wrap, arg,")
|
||||
assert -1 < line_idx < note_idx
|
||||
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
"""Unit: the phase-146 (task 02) "the search tool shows WHERE"
|
||||
frontend + record contract.
|
||||
|
||||
The wire half (the ``ChatToolEvent`` additive ``path`` + the frame
|
||||
builder) is pinned by phase 146 task 01 (``test_sse_events.py`` /
|
||||
``test_chat_gate.py``). This task's behavior lives in
|
||||
``frontend/assets/app.js`` (``appendToolLine``'s additive ``scope``
|
||||
param + the ``.grep-scope`` span, the live ``tool`` branch's
|
||||
``ev.path`` read + the ``toolRecord.path`` stamp + the status label's
|
||||
`` in <scope>`` extension, the restore path's ``t.path`` pass-through)
|
||||
and ``app/schemas.py`` (the saved record's additive ``path`` on
|
||||
``ToolCall`` — the persistence boundary that carries the scope from
|
||||
the live record through the ``bor.chat.v1`` JSONB to the restore
|
||||
path). Like the other frontend-adjacent unit files
|
||||
(``test_read_range_tool_lines.py`` is the phase-139 precedent,
|
||||
``test_frontend_tool_states.py`` the phase-37 one), this module pins
|
||||
the JS markers the story depends on so a silent regression in the
|
||||
scope wording, the string guard, the persistence shape, or the
|
||||
byte-identity of bare greps is caught without a browser. A small
|
||||
node-driven behavioral check (skipped when node is unavailable, the
|
||||
phase-139 pattern) proves the scoped line's structure (two ``<code>``
|
||||
spans + the `` in `` text node) and the bare line's byte-identity.
|
||||
The E2E gate is the phase's story suite
|
||||
(``tests/e2e/test_search_tool_scope.py``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.schemas import ChatMessage, ToolCall
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
APP_JS = FRONTEND / "assets" / "app.js"
|
||||
|
||||
HAVE_NODE = shutil.which("node") is not None
|
||||
|
||||
#: The saved record's scope cap (mirrors ``argument`` — the combined
|
||||
#: ``source/path`` identity is ≤ 120 + 1 + 1000; 2 000 is the same
|
||||
#: ×2 headroom).
|
||||
PATH_CAP = 2000
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
return APP_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract(js: str, name: str) -> str:
|
||||
"""One top-level function by name — from its ``function NAME`` to
|
||||
its column-0 closing ``}`` (the house extraction convention)."""
|
||||
fn = js.find(f"function {name}")
|
||||
assert fn != -1, f"{name} not found"
|
||||
end = js.find("\n}\n", fn)
|
||||
assert end != -1, f"could not find the end of {name}"
|
||||
return js[fn : end + 2]
|
||||
|
||||
|
||||
def _live_branch(js: str) -> str:
|
||||
"""The live ``tool`` SSE branch (up to the ``delta`` branch)."""
|
||||
tool_idx = js.find('ev.type === "tool"')
|
||||
delta_idx = js.find('ev.type === "delta"')
|
||||
assert -1 < tool_idx < delta_idx
|
||||
return js[tool_idx:delta_idx]
|
||||
|
||||
|
||||
def _restore_body(js: str) -> str:
|
||||
"""The phase-14 restore path (``renderStoredMessage``)."""
|
||||
fn = js.find("function renderStoredMessage")
|
||||
assert fn != -1
|
||||
end = js.find("function restoreConversation")
|
||||
assert fn < end
|
||||
return js[fn:end]
|
||||
|
||||
|
||||
def _run_node(script: str) -> str:
|
||||
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
|
||||
assert proc.returncode == 0, f"node failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# app.js — appendToolLine: the .grep-scope span on the Searching line
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_append_tool_line_signature_takes_optional_scope() -> None:
|
||||
"""appendToolLine gains the additive ``scope`` param (fifth,
|
||||
default ``null`` — a pre-phase 5-arg call renders byte-identical),
|
||||
and the grep arm appends the scope span through the pinned
|
||||
structure (a ``.grep-scope`` span SIBLING after the pattern
|
||||
``<code>`` — the phase-139 read-range-suffix precedent)."""
|
||||
js = _js()
|
||||
assert (
|
||||
"function appendToolLine(wrap, name, argument, offset = null, "
|
||||
"limit = null, scope = null)" in js
|
||||
), "the signature carries the additive scope (default null)"
|
||||
body = _extract(js, "appendToolLine")
|
||||
assert "if (typeof scope === \"string\" && scope)" in body, (
|
||||
"the span is appended ONLY when the scope is a non-empty string"
|
||||
)
|
||||
assert 'scopeSpan.className = "grep-scope"' in body, (
|
||||
"the scope rides a .grep-scope span"
|
||||
)
|
||||
assert 'document.createTextNode(" in ")' in body, (
|
||||
"the ` in ` join is a text node (the scope is data, never markup)"
|
||||
)
|
||||
assert "scopeCode.textContent = scope" in body, (
|
||||
"the scope is data — textContent, never innerHTML"
|
||||
)
|
||||
|
||||
|
||||
def test_grep_scope_span_is_a_sibling_after_the_pattern_code() -> None:
|
||||
"""The .grep-scope span is a SIBLING appended AFTER the pattern
|
||||
``<code>`` child — the pinned ``🔎 Searching for `` + ``<code>``
|
||||
bytes are untouched (the phase-70/87/117 label pins + the frontend
|
||||
emoji guard stay green; no new emoji)."""
|
||||
body = _extract(_js(), "appendToolLine")
|
||||
assert 'line.textContent = "🔎 Searching for "' in body, (
|
||||
"the pinned grep label literal is untouched"
|
||||
)
|
||||
# Inside the grep arm: the pattern <code> is appended first, the
|
||||
# scope span after it.
|
||||
grep_arm = body.find("🔎 Searching for ")
|
||||
ls_arm = body.find("🔎 Listing documents in ")
|
||||
assert -1 < grep_arm < ls_arm
|
||||
arm = body[grep_arm:ls_arm]
|
||||
code_append = arm.find("line.appendChild(code)")
|
||||
scope_append = arm.find("line.appendChild(scopeSpan)")
|
||||
assert -1 < code_append < scope_append, (
|
||||
"the scope span lands AFTER the pattern <code> child"
|
||||
)
|
||||
assert "innerHTML" not in body, (
|
||||
"no HTML injection surface on tool lines — textContent only"
|
||||
)
|
||||
|
||||
|
||||
def test_only_the_grep_arm_reads_the_scope() -> None:
|
||||
"""The scope param feeds ONLY the grep arm: the read / ls / listing
|
||||
arms never consult it (a scope on a non-grep call renders exactly
|
||||
the pre-phase line — no .grep-scope span outside the grep arm)."""
|
||||
body = _extract(_js(), "appendToolLine")
|
||||
grep_arm = body.find("🔎 Searching for ")
|
||||
ls_arm = body.find("🔎 Listing documents in ")
|
||||
guard = body.find("typeof scope === \"string\" && scope")
|
||||
# The scope guard appears exactly once in the function — inside the
|
||||
# grep arm (after the Searching label, before the ls arm).
|
||||
assert body.count("typeof scope === \"string\" && scope") == 1
|
||||
assert -1 < grep_arm < guard < ls_arm, (
|
||||
"the scope guard sits inside the grep arm only"
|
||||
)
|
||||
# The read arm renders the range suffix only — never the scope.
|
||||
read_arm_end = body.find("🔎 Searching for ")
|
||||
read_arm = body[:read_arm_end]
|
||||
assert "scopeSpan" not in read_arm
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# app.js — the live `tool` branch: ev.path + the toolAcc stamp + label
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_live_tool_branch_reads_the_scope_with_the_guard() -> None:
|
||||
"""The live ``tool`` branch reads ``ev.path`` with the SAME string
|
||||
guard as ``argument`` (``typeof ev.path === "string" && ev.path``
|
||||
→ ``null`` when absent / empty / malformed), and the record gains
|
||||
``path`` ONLY when non-null — a bare grep keeps the exact
|
||||
pre-phase ``{name, argument}`` shape (the additive ``bor.chat.v1``
|
||||
JSONB — old records restore byte-identically, no migration)."""
|
||||
branch = _live_branch(_js())
|
||||
assert (
|
||||
"typeof ev.path === \"string\" && ev.path ? ev.path : null" in branch
|
||||
), "ev.path reads through the house string guard → null when absent"
|
||||
assert "const toolRecord = { name, argument }" in branch, (
|
||||
"the base record stays {name, argument}-generic"
|
||||
)
|
||||
assert "if (framePath !== null) toolRecord.path = framePath" in branch, (
|
||||
"path joins the record ONLY when non-null (additive key)"
|
||||
)
|
||||
assert "toolAcc.push(toolRecord)" in branch, "every tool frame is recorded"
|
||||
# The scope rides the SAME helper (the line's fifth argument).
|
||||
assert (
|
||||
"appendToolLine(wrap, name, argument, frameOffset, frameLimit, "
|
||||
"framePath)" in branch
|
||||
)
|
||||
|
||||
|
||||
def test_live_grep_status_carries_the_scope() -> None:
|
||||
"""The grep status label shows WHERE the search runs:
|
||||
``… is searching for <pattern> in <source/path>`` — the scope rides
|
||||
the template's additive `` in <scope>`` extension (empty-string
|
||||
fallback when the frame carried none, so a bare grep renders the
|
||||
EXACT pre-phase label). The read / ls labels are untouched."""
|
||||
branch = _live_branch(_js())
|
||||
assert (
|
||||
"`${brand()} is searching for ${argument}"
|
||||
"${framePath ? ` in ${framePath}` : \"\"}`" in branch
|
||||
), "the grep status carries the scope (the in-scope extension)"
|
||||
# The read / ls labels are byte-identical to pre-phase.
|
||||
assert (
|
||||
"`${brand()} is reading ${argument}${readRangeSuffix(frameOffset, frameLimit)}`"
|
||||
in branch
|
||||
)
|
||||
assert "`${brand()} is listing documents in ${argument}`" in branch
|
||||
assert "`${brand()} is listing documents`" in branch
|
||||
# The OLD bare-grep label literal is gone (superseded by the
|
||||
# additive template) — the byte-identity lives in the template's
|
||||
# empty-string fallback (pinned behaviorally under node below).
|
||||
assert "`${brand()} is searching for ${argument}`" not in branch
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# app.js — the restore path: the stored scope re-renders the line
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_restore_path_passes_the_stored_scope_through() -> None:
|
||||
"""The phase-14 restore path (renderStoredMessage): a stored tool
|
||||
record with ``path`` (the live ``toolAcc`` stamp — additive on
|
||||
``bor.chat.v1``) re-renders the SAME scope through the SAME helper;
|
||||
a record saved before the phase (no ``path`` key) renders
|
||||
byte-identically (the guard → null → no span)."""
|
||||
body = _restore_body(_js())
|
||||
assert (
|
||||
"typeof t.path === \"string\" && t.path ? t.path : null" in body
|
||||
), "the stored scope reads through the house string guard"
|
||||
assert (
|
||||
"appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
|
||||
), "the stored scope rides the SAME helper"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# app/schemas.py — ToolCall.path: the persistence boundary
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tool_call_path_absent_by_default_and_omitted() -> None:
|
||||
"""A tool record without ``path`` (every pre-phase record, every
|
||||
bare grep) validates UNCHANGED and serializes WITHOUT the key —
|
||||
ABSENT, never null: the saved shape stays byte-identical to
|
||||
pre-phase (the phase-50 backward-compat rule, no migration)."""
|
||||
call = ToolCall.model_validate({"name": "grep", "argument": "p"})
|
||||
assert call.path is None
|
||||
dumped = call.model_dump()
|
||||
assert "path" not in dumped, dumped
|
||||
# The full pre-phase key set is exactly what round-trips.
|
||||
assert set(dumped) == {
|
||||
"name",
|
||||
"argument",
|
||||
"truncated",
|
||||
"chars_shown",
|
||||
"chars_total",
|
||||
"offset",
|
||||
"limit",
|
||||
}, dumped
|
||||
|
||||
|
||||
def test_tool_call_path_round_trips_when_set() -> None:
|
||||
"""A SCOPED grep's record carries ``path`` (the re-searched
|
||||
document's combined ``source/path``) through validate → dump —
|
||||
the save → restore round-trip keeps it, so the restore path and
|
||||
the shared page can re-render the scope."""
|
||||
call = ToolCall.model_validate(
|
||||
{"name": "grep", "argument": "p", "path": "search_docs/reese-notes.md"}
|
||||
)
|
||||
assert call.path == "search_docs/reese-notes.md"
|
||||
assert call.model_dump()["path"] == "search_docs/reese-notes.md"
|
||||
|
||||
|
||||
def test_tool_call_path_at_cap_validates() -> None:
|
||||
call = ToolCall.model_validate({"name": "grep", "argument": "p", "path": "a" * PATH_CAP})
|
||||
assert len(call.path or "") == PATH_CAP
|
||||
|
||||
|
||||
def test_tool_call_path_one_over_cap_rejects() -> None:
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
ToolCall.model_validate(
|
||||
{"name": "grep", "argument": "p", "path": "a" * (PATH_CAP + 1)}
|
||||
)
|
||||
assert any(e["loc"] == ("path",) for e in exc.value.errors()), exc.value.errors()
|
||||
|
||||
|
||||
def test_chat_message_scoped_tool_record_crosses_the_boundary() -> None:
|
||||
"""The full message boundary (``extra="forbid"`` on
|
||||
``ChatMessage``): ``path`` is an ACCEPTED key on the nested tool
|
||||
record — a scoped grep's brain record does not 422 at the
|
||||
anonymous write surface (the phase-113 lesson: a missing key
|
||||
silently unsaves the record), and the dump keeps it."""
|
||||
msg = ChatMessage.model_validate(
|
||||
{
|
||||
"who": "brain",
|
||||
"text": "ok",
|
||||
"tools": [
|
||||
{"name": "grep", "argument": "p", "path": "search_docs/reese-notes.md"},
|
||||
{"name": "grep", "argument": "p"},
|
||||
],
|
||||
}
|
||||
)
|
||||
dumped = msg.model_dump()
|
||||
assert dumped["tools"][0]["path"] == "search_docs/reese-notes.md"
|
||||
assert "path" not in dumped["tools"][1], dumped["tools"][1]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# behavior (node): the scoped line's structure + the bare line's
|
||||
# byte-identity
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
#: A minimal DOM stub — the phase-139 stub plus ``createTextNode``
|
||||
#: (the .grep-scope span holds a text node + a <code> child). Every
|
||||
#: node is snapshotted as a JSON row (the test-document-viewer
|
||||
#: pattern).
|
||||
_DOM_STUB = """
|
||||
class TextNode {
|
||||
constructor(text) { this._text = String(text); }
|
||||
get textContent() { return this._text; }
|
||||
}
|
||||
class El {
|
||||
constructor(tag) {
|
||||
this.tag = tag;
|
||||
this.children = [];
|
||||
this.attrs = {};
|
||||
this.className = "";
|
||||
this._text = "";
|
||||
this.open = false;
|
||||
}
|
||||
setAttribute(k, v) { this.attrs[k] = String(v); }
|
||||
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
|
||||
appendChild(c) { this.children.push(c); return c; }
|
||||
insertBefore(c, ref) {
|
||||
if (!ref) { this.children.push(c); return c; }
|
||||
const i = this.children.indexOf(ref);
|
||||
if (i === -1) this.children.push(c); else this.children.splice(i, 0, c);
|
||||
return c;
|
||||
}
|
||||
_match(el, sel) {
|
||||
if (sel.charAt(0) === ".")
|
||||
return (el.className || "").split(/\\s+/).indexOf(sel.slice(1)) !== -1;
|
||||
if (sel.charAt(0) === "#") return el.id === sel.slice(1);
|
||||
return el.tag === sel;
|
||||
}
|
||||
querySelector(sel) {
|
||||
for (const c of this.children) {
|
||||
if (this._match(c, sel)) return c;
|
||||
const r = c.querySelector(sel);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
querySelectorAll(sel) {
|
||||
const out = [];
|
||||
for (const c of this.children) {
|
||||
if (this._match(c, sel)) out.push(c);
|
||||
out.push(...c.querySelectorAll(sel));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
get textContent() {
|
||||
let t = this._text;
|
||||
for (const c of this.children) t += c.textContent;
|
||||
return t;
|
||||
}
|
||||
set textContent(v) { this._text = String(v); this.children = []; }
|
||||
}
|
||||
const document = {
|
||||
createElement: (t) => new El(t),
|
||||
createTextNode: (t) => new TextNode(t),
|
||||
};
|
||||
function makeWrap() {
|
||||
const wrap = document.createElement("div");
|
||||
const body = document.createElement("div");
|
||||
body.className = "msg-body";
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className = "bubble";
|
||||
body.appendChild(bubble);
|
||||
wrap.appendChild(body);
|
||||
return wrap;
|
||||
}
|
||||
function describe(el) {
|
||||
return {
|
||||
text: el.textContent,
|
||||
children: el.children.map(
|
||||
(c) =>
|
||||
c.tag
|
||||
? {
|
||||
tag: c.tag,
|
||||
cls: c.className,
|
||||
text: c.textContent,
|
||||
children: describe(c).children,
|
||||
}
|
||||
: { text: c.textContent }
|
||||
),
|
||||
};
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_scoped_grep_line_and_bare_byte_identity_under_node() -> None:
|
||||
"""The REAL app.js ``appendToolLine`` under node (against the DOM
|
||||
stub): a BARE grep (no scope — including an empty string and a
|
||||
non-string scope, the guard's fallbacks) renders the pre-phase line
|
||||
byte-identically — ``🔎 Searching for <code>pattern</code>``, a
|
||||
single ``code`` child, NO ``.grep-scope`` span; a SCOPED grep
|
||||
appends the ``.grep-scope`` span AFTER the pattern ``<code>``
|
||||
holding the `` in `` text node + the second ``<code>`` (the scope,
|
||||
data, never markup). The legacy name (``search_documents``) keeps
|
||||
the same byte-identity for a bare call."""
|
||||
append = _extract(_js(), "appendToolLine")
|
||||
driver = (
|
||||
_DOM_STUB
|
||||
+ "\n"
|
||||
+ append
|
||||
+ '\nconst PATTERN = "reese-sentinel-42";\n'
|
||||
+ 'const SCOPE = "search_docs/reese-notes.md";\n'
|
||||
# bare grep (no scope)
|
||||
+ "const w0 = makeWrap();\n"
|
||||
+ 'appendToolLine(w0, "grep", PATTERN);\n'
|
||||
+ 'const bare = describe(w0.querySelector(".tool-call"));\n'
|
||||
# bare grep, legacy name (no scope)
|
||||
+ "const w1 = makeWrap();\n"
|
||||
+ 'appendToolLine(w1, "search_documents", PATTERN);\n'
|
||||
+ 'const legacy = describe(w1.querySelector(".tool-call"));\n'
|
||||
# guarded fallbacks: empty string + non-string scope
|
||||
+ "const w2 = makeWrap();\n"
|
||||
+ 'appendToolLine(w2, "grep", PATTERN, null, null, "");\n'
|
||||
+ 'const emptyScope = describe(w2.querySelector(".tool-call"));\n'
|
||||
+ "const w3 = makeWrap();\n"
|
||||
+ 'appendToolLine(w3, "grep", PATTERN, null, null, 42);\n'
|
||||
+ 'const badScope = describe(w3.querySelector(".tool-call"));\n'
|
||||
# the scoped grep
|
||||
+ "const w4 = makeWrap();\n"
|
||||
+ 'appendToolLine(w4, "grep", PATTERN, null, null, SCOPE);\n'
|
||||
+ 'const scoped = describe(w4.querySelector(".tool-call"));\n'
|
||||
"console.log(JSON.stringify("
|
||||
"{ bare, legacy, emptyScope, badScope, scoped }));"
|
||||
)
|
||||
out = json.loads(_run_node(driver))
|
||||
bare, legacy, empty_scope, bad_scope, scoped = (
|
||||
out["bare"],
|
||||
out["legacy"],
|
||||
out["emptyScope"],
|
||||
out["badScope"],
|
||||
out["scoped"],
|
||||
)
|
||||
|
||||
# The bare line: byte-identical to pre-phase — ONE <code> child,
|
||||
# no .grep-scope span, the exact pinned text.
|
||||
assert bare["text"] == "🔎 Searching for reese-sentinel-42", bare
|
||||
assert bare["children"] == [
|
||||
{
|
||||
"tag": "code",
|
||||
"cls": "",
|
||||
"text": "reese-sentinel-42",
|
||||
"children": [],
|
||||
}
|
||||
], bare
|
||||
# The legacy-name bare call renders the SAME bytes (no migration).
|
||||
assert legacy == bare, (legacy, bare)
|
||||
# The guard's fallbacks (empty string / non-string) → the bare line.
|
||||
assert empty_scope == bare, (empty_scope, bare)
|
||||
assert bad_scope == bare, (bad_scope, bare)
|
||||
|
||||
# The scoped line: the pinned prefix + pattern, then the scope span.
|
||||
assert (
|
||||
scoped["text"]
|
||||
== "🔎 Searching for reese-sentinel-42 in search_docs/reese-notes.md"
|
||||
), scoped
|
||||
assert scoped["children"][0] == {
|
||||
"tag": "code",
|
||||
"cls": "",
|
||||
"text": "reese-sentinel-42",
|
||||
"children": [],
|
||||
}, scoped
|
||||
span = scoped["children"][1]
|
||||
assert span["tag"] == "span" and span["cls"] == "grep-scope", scoped
|
||||
assert span["text"] == " in search_docs/reese-notes.md", scoped
|
||||
# TWO <code> spans (pattern, then scope) with the " in " text node
|
||||
# between them — the textContent structure, not a regex over the
|
||||
# whole line (the phase-139 range-suffix pin style).
|
||||
assert span["children"][0] == {"text": " in "}, scoped
|
||||
assert span["children"][1] == {
|
||||
"tag": "code",
|
||||
"cls": "",
|
||||
"text": "search_docs/reese-notes.md",
|
||||
"children": [],
|
||||
}, scoped
|
||||
# Document order of the line's <code> elements: the pattern first
|
||||
# (the direct child), then the scope (inside the span).
|
||||
codes = [scoped["children"][0]["text"]] + [
|
||||
c["text"] for c in span["children"] if c.get("tag") == "code"
|
||||
]
|
||||
assert codes == ["reese-sentinel-42", "search_docs/reese-notes.md"], scoped
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
|
||||
def test_non_grep_arms_ignoring_the_scope_stay_byte_identical() -> None:
|
||||
"""A non-grep call that somehow carried a scope (a read / an ls /
|
||||
the plain listing) renders EXACTLY the pre-phase line — the scope
|
||||
param feeds only the grep arm, so no .grep-scope span can leak
|
||||
into the other three lines."""
|
||||
append = _extract(_js(), "appendToolLine")
|
||||
range_suffix = _extract(_js(), "readRangeSuffix")
|
||||
driver = (
|
||||
_DOM_STUB
|
||||
+ "\n"
|
||||
+ range_suffix
|
||||
+ "\n"
|
||||
+ append
|
||||
+ "const w0 = makeWrap();\n"
|
||||
+ 'appendToolLine(w0, "read", "s/d.md", null, null, "x");\n'
|
||||
+ 'const readLine = describe(w0.querySelector(".tool-call"));\n'
|
||||
+ "const w1 = makeWrap();\n"
|
||||
+ 'appendToolLine(w1, "ls", "s", null, null, "x");\n'
|
||||
+ 'const lsLine = describe(w1.querySelector(".tool-call"));\n'
|
||||
+ "const w2 = makeWrap();\n"
|
||||
+ 'appendToolLine(w2, "ls", null, null, null, "x");\n'
|
||||
+ 'const plainLine = describe(w2.querySelector(".tool-call"));\n'
|
||||
"console.log(JSON.stringify({ readLine, lsLine, plainLine }));"
|
||||
)
|
||||
out = json.loads(_run_node(driver))
|
||||
for line in (out["readLine"], out["lsLine"], out["plainLine"]):
|
||||
assert all(c.get("cls") != "grep-scope" for c in line["children"]), line
|
||||
assert out["readLine"]["text"] == "📄 Reading s/d.md", out["readLine"]
|
||||
assert out["lsLine"]["text"] == "🔎 Listing documents in s", out["lsLine"]
|
||||
assert out["plainLine"]["text"] == "🔎 Listing documents", out["plainLine"]
|
||||
@@ -167,6 +167,69 @@ def test_tool_event_range_fields_absent_not_null_when_omitted() -> None:
|
||||
assert set(dumped.keys()) == {"type", "name", "argument"}, dumped
|
||||
|
||||
|
||||
def test_tool_frame_scoped_grep_carries_path() -> None:
|
||||
"""Phase 146 (task 01): a SCOPED grep's frame carries the scope —
|
||||
the re-searched document's combined ``source/path`` — as the
|
||||
additive ``path`` field (the ONLY case the field is present on the
|
||||
wire), AFTER ``argument`` (the model's ``pattern``)."""
|
||||
frame = sse_event(
|
||||
ChatToolEvent(name="grep", argument="pattern", path="srcs/doc.md").model_dump()
|
||||
)
|
||||
assert frame == (
|
||||
'data: {"type": "tool", "name": "grep", "argument": "pattern", '
|
||||
'"path": "srcs/doc.md"}\n\n'
|
||||
)
|
||||
assert _payload(frame) == {
|
||||
"type": "tool",
|
||||
"name": "grep",
|
||||
"argument": "pattern",
|
||||
"path": "srcs/doc.md",
|
||||
}
|
||||
|
||||
|
||||
def test_tool_frame_bare_grep_has_no_path_key() -> None:
|
||||
"""Phase 146 (task 01): a bare grep's frame has NO ``path`` key at
|
||||
all — ABSENT, never ``null`` (the omission pattern; a ``null`` value
|
||||
would change the pre-phase wire bytes)."""
|
||||
dumped = ChatToolEvent(name="grep", argument="pattern").model_dump()
|
||||
assert "path" not in dumped
|
||||
assert dumped == {"type": "tool", "name": "grep", "argument": "pattern"}
|
||||
|
||||
|
||||
def test_tool_frame_non_scoped_tools_dump_the_exact_pre_phase_shape() -> None:
|
||||
"""Phase 146 (task 01): every frame that is not a scoped grep —
|
||||
``read`` with and without a range, ``ls`` with and without an
|
||||
argument — dumps to the EXACT pre-phase dict (the equality pins:
|
||||
byte-identity of the non-scoped frames is the point of the
|
||||
additive-omission serializer). A non-string ``path`` never reaches
|
||||
the model at all: the frame builder (``app.api.chat``) string-guards
|
||||
it to ``None`` before construction (pinned at the builder level in
|
||||
``tests/unit/test_chat_gate.py``)."""
|
||||
assert (
|
||||
ChatToolEvent(name="read", argument="S/p.md").model_dump()
|
||||
== {"type": "tool", "name": "read", "argument": "S/p.md"}
|
||||
)
|
||||
assert (
|
||||
ChatToolEvent(name="read", argument="S/p.md", offset=1500, limit=30).model_dump()
|
||||
== {
|
||||
"type": "tool",
|
||||
"name": "read",
|
||||
"argument": "S/p.md",
|
||||
"offset": 1500,
|
||||
"limit": 30,
|
||||
}
|
||||
)
|
||||
assert ChatToolEvent(name="ls").model_dump() == {
|
||||
"type": "tool",
|
||||
"name": "ls",
|
||||
"argument": None,
|
||||
}
|
||||
assert (
|
||||
ChatToolEvent(name="ls", argument="Homelab").model_dump()
|
||||
== {"type": "tool", "name": "ls", "argument": "Homelab"}
|
||||
)
|
||||
|
||||
|
||||
def test_retry_frame_serializes_exactly() -> None:
|
||||
"""Phase 67: the ``retry`` frame is exactly ``{type: "retry",
|
||||
attempt: int, max_attempts: int}`` — a transient status the client's
|
||||
|
||||
@@ -136,10 +136,10 @@ def test_restore_path_folds_the_disclosure() -> None:
|
||||
fn = js.find("function renderStoredMessage")
|
||||
assert fn != -1
|
||||
body = js[fn : js.find("function restoreConversation", fn)]
|
||||
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit)" in body
|
||||
assert "appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)" in body
|
||||
assert "closeToolCalls(wrap)" in body
|
||||
assert (
|
||||
body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit)")
|
||||
body.find("appendToolLine(wrap, t.name, arg, tOffset, tLimit, tPath)")
|
||||
< body.find("closeToolCalls(wrap)")
|
||||
), "the fold sits AFTER the tool-line restore loop"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user