Skip to content

Commit d2a4d1a

Browse files
fix: address PR feedback on tool-type scaffold
Also fix ruff format and search benchmark date-window flake.
1 parent 00fda68 commit d2a4d1a

8 files changed

Lines changed: 157 additions & 60 deletions

File tree

CONTRIBUTING.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,10 +160,15 @@ Claude Code assistant `tool_use` blocks carry a `name` string (e.g. `"Read"`, `"
160160
1. **One edit:** create or update a JSON record under `tool_types/` (see `tool_types/README.md`).
161161
2. **One command:** `python scripts/scaffold_tool_type.py --record tool_types/<name>.json`
162162
Or from scratch: `python scripts/scaffold_tool_type.py --name my_tool`
163-
3. **Complete stubs:** field mapping in the dispatch builder, render HTML in JS modules, overlap fixtures when `priority > 0`.
164-
4. **Verify:** `pytest tests/test_tool_dispatch_sync.py tests/test_tool_dispatch_ordering.py tests/test_tool_dispatch_adversarial.py -q`
163+
3. **Complete stubs:** field mapping in the dispatch builder and render HTML in JS modules. When `result` is registered, also finish overlap fixtures when `priority > 0`.
164+
4. **Verify:**
165165

166-
The generator emits coordinated stubs across all seven sites and writes the registration record. Hand-editing drops from seven sites to the record plus finishing TODO stubs (typically 2–3 files).
166+
```bash
167+
pytest tests/test_scaffold_tool_type.py tests/test_tool_dispatch_sync.py tests/test_tool_dispatch_ordering.py tests/test_tool_dispatch_adversarial.py tests/test_jsonl_parser.py tests/test_real_session_fixtures.py -q
168+
npm test
169+
```
170+
171+
The generator emits coordinated stubs across all seven sites when `result` is registered (dispatch table, TypedDict/guards, Markdown branches, JS use/result modules, `registry.js`, parser fixture, and `static/tool_types.json`). Use-only tools (`--no-result`) omit result dispatch, TypedDict/guard, parser fixture, and overlap-fixture artifacts. Hand-editing drops from seven sites to the record plus finishing TODO stubs (typically 2–3 files).
167172

168173
Dry-run preview: `python scripts/scaffold_tool_type.py --name example_tool --dry-run`
169174

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ In `utils/tool_dispatch.py`, tool results are classified through `_parse_tool_re
7575

7676
When adding a new tool renderer:
7777

78-
1. **Preferred:** add a registration record under `tool_types/` and run `python scripts/scaffold_tool_type.py --record tool_types/<name>.json`. The generator emits `_TOOL_RESULT_DISPATCH` entries, TypedDict/guards, Markdown branches, JS module stubs, `registry.js` map entries, a parser fixture, and regenerates `static/tool_types.json`. See `CONTRIBUTING.md` § "Adding a new tool type" for the before/after workflow.
78+
1. **Preferred:** add a registration record under `tool_types/` and run `python scripts/scaffold_tool_type.py --record tool_types/<name>.json`. With `result` registered, the generator emits `_TOOL_RESULT_DISPATCH` entries, TypedDict/guards, Markdown branches, JS module stubs, `registry.js` map entries, a parser fixture, and regenerates `static/tool_types.json`. Use-only tools (`--no-result`) omit result dispatch, TypedDict/guard, parser fixture, and overlap-fixture artifacts. See `CONTRIBUTING.md` § "Adding a new tool type" for the before/after workflow.
7979
2. **Manual (legacy):** add a `ToolResultDispatchEntry` to `_TOOL_RESULT_DISPATCH` in `utils/tool_dispatch.py`. Set `priority` higher than any overlapping predicate it must beat without relying on registration order, and add a row to `ORDERING_INVARIANTS` in `tests/test_tool_dispatch_ordering.py` when overlaps exist. Documented exception: `plan` (priority 1) over `file_write` (0). Broad predicates like `task_message` use registration order instead of elevated priority.
8080
3. Add or extend a JSONL fixture under `tests/fixtures/` (especially for overlaps with existing predicates).
8181
4. Run `pytest tests/test_tool_dispatch_ordering.py tests/test_tool_dispatch_adversarial.py tests/test_jsonl_parser.py tests/test_real_session_fixtures.py tests/test_scaffold_tool_type.py -v`.

models/tool_type_registry.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ def from_mapping(cls, raw: dict[str, Any] | None) -> ToolResultRecord | None:
147147
if not isinstance(priority, int):
148148
msg = "result.priority must be an int"
149149
raise ValueError(msg)
150+
if priority < 0:
151+
msg = "result.priority must be a non-negative int"
152+
raise ValueError(msg)
150153
inv_raw = raw.get("overlap_invariants", [])
151154
if not isinstance(inv_raw, list):
152155
msg = "result.overlap_invariants must be a list"
@@ -263,11 +266,7 @@ def to_mapping(self) -> dict[str, Any]:
263266
if inv.before_guard is not None
264267
else {}
265268
),
266-
**(
267-
{"after_guard": inv.after_guard}
268-
if inv.after_guard is not None
269-
else {}
270-
),
269+
**({"after_guard": inv.after_guard} if inv.after_guard is not None else {}),
271270
}
272271
for inv in self.result.overlap_invariants
273272
],
@@ -288,11 +287,17 @@ def typed_dict_class(self) -> str:
288287

289288
@property
290289
def guard_name(self) -> str:
291-
return f"is_{self.snake_name}_tool_result"
290+
if self.result is None:
291+
msg = "guard_name requires result registration"
292+
raise ValueError(msg)
293+
return guard_name_for_dispatch_id(self.result.dispatch_id)
292294

293295
@property
294296
def builder_name(self) -> str:
295-
return f"_tool_result_build_{self.snake_name}"
297+
if self.result is None:
298+
msg = "builder_name requires result registration"
299+
raise ValueError(msg)
300+
return builder_name_for_dispatch_id(self.result.dispatch_id)
296301

297302

298303
def snake_to_pascal(snake: str) -> str:
@@ -336,13 +341,20 @@ def guard_name_for_dispatch_id(dispatch_id: str) -> str:
336341
return f"is_{dispatch_id}_tool_result"
337342

338343

344+
def builder_name_for_dispatch_id(dispatch_id: str) -> str:
345+
_validate_dispatch_id(dispatch_id)
346+
return f"_tool_result_build_{dispatch_id}"
347+
348+
339349
def _validate_dispatch_id(dispatch_id: str) -> None:
340350
if not _DISPATCH_ID_RE.fullmatch(dispatch_id):
341351
msg = (
342352
f"dispatch_id {dispatch_id!r} must be snake_case "
343353
"(e.g. example_tool); guard names derive from is_<dispatch_id>_tool_result"
344354
)
345355
raise ValueError(msg)
356+
357+
346358
def _dispatch_id_to_camel(dispatch_id: str) -> str:
347359
parts = dispatch_id.split("_")
348360
if not parts:

scripts/gen_tool_types_manifest.py

Lines changed: 7 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,43 +9,22 @@
99
from __future__ import annotations
1010

1111
import json
12-
import re
12+
import sys
1313
from pathlib import Path
1414

1515
_REPO_ROOT = Path(__file__).resolve().parents[1]
1616
_MANIFEST_PATH = _REPO_ROOT / "static" / "tool_types.json"
1717

18+
if str(_REPO_ROOT) not in sys.path:
19+
sys.path.insert(0, str(_REPO_ROOT))
20+
21+
from scripts.scaffold_tool_type import _parse_handlers_from_text # noqa: E402
22+
1823

1924
def _load_known_tool_types(repo_root: Path) -> frozenset[str]:
2025
path = repo_root / "utils" / "tool_dispatch.py"
2126
text = path.read_text(encoding="utf-8")
22-
marker = "_FILE_ACTIVITY_HANDLERS: dict"
23-
start = text.find(marker)
24-
if start == -1:
25-
msg = f"could not find {marker} in {path}"
26-
raise ValueError(msg)
27-
brace_start = text.find("{", start)
28-
if brace_start == -1:
29-
msg = f"could not find opening brace for {marker} in {path}"
30-
raise ValueError(msg)
31-
depth = 0
32-
i = brace_start
33-
while i < len(text):
34-
ch = text[i]
35-
if ch == "{":
36-
depth += 1
37-
elif ch == "}":
38-
depth -= 1
39-
if depth == 0:
40-
body = text[brace_start + 1 : i]
41-
keys = re.findall(r'"([^"]+)":', body)
42-
if not keys:
43-
msg = f"no tool names found in {marker} in {path}"
44-
raise ValueError(msg)
45-
return frozenset(keys)
46-
i += 1
47-
msg = f"unbalanced braces in {marker} in {path}"
48-
raise ValueError(msg)
27+
return _parse_handlers_from_text(text)
4928

5029

5130
def write_tool_types_manifest(path: Path | None = None, *, repo_root: Path | None = None) -> int:

scripts/scaffold_tool_type.py

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ def _flush(self, plan: _EmitPlan) -> None:
133133
rel = path.relative_to(self.root)
134134
if self.dry_run:
135135
self.stdout.write(f"--- would write {rel} ({len(content)} bytes) ---\n")
136+
self._written.append(path)
136137
continue
137138
path.parent.mkdir(parents=True, exist_ok=True)
138139
path.write_text(content, encoding="utf-8")
@@ -157,7 +158,8 @@ def transform(text: str) -> str:
157158
)
158159
guard = record.guard_name
159160
if f" {guard}," not in text:
160-
text = text.replace(
161+
text = self._replace_once(
162+
text,
161163
" is_web_search_tool_result,\n)",
162164
f" is_web_search_tool_result,\n {guard},\n)",
163165
)
@@ -188,7 +190,7 @@ def _render_dispatch_entry(self, record: ToolTypeRecord) -> str:
188190
priority = record.result.priority
189191
if priority > 0:
190192
return (
191-
f' ToolResultDispatchEntry(\n'
193+
f" ToolResultDispatchEntry(\n"
192194
f' "{record.result.dispatch_id}",\n'
193195
f" {record.guard_name},\n"
194196
f" {record.builder_name},\n"
@@ -215,11 +217,13 @@ def transform(text: str) -> str:
215217
member,
216218
)
217219
anchor = ' return "questions" in tr and "answers" in tr\n\n\n'
218-
text = text.replace(
220+
text = self._replace_once(
221+
text,
219222
anchor + "# Tool names on assistant",
220223
anchor + f"{self._render_guard(record)}\n\n\n# Tool names on assistant",
221224
)
222-
text = text.replace(
225+
text = self._replace_once(
226+
text,
223227
' "WebSearch",\n]',
224228
f' "WebSearch",\n "{record.name}",\n]',
225229
)
@@ -258,13 +262,15 @@ def transform(text: str) -> str:
258262
unknown_branch = (
259263
' else:\n lines.append(f">\\n> Input (unknown tool type): `{str(inp)}`")'
260264
)
261-
text = text.replace(
265+
text = self._replace_once(
266+
text,
262267
unknown_branch,
263268
use_branch + unknown_branch,
264269
)
265270
if record.result is not None:
266271
result_branch = self._render_md_tool_result(record)
267-
text = text.replace(
272+
text = self._replace_once(
273+
text,
268274
' elif rt == "plan":',
269275
result_branch + ' elif rt == "plan":',
270276
)
@@ -276,7 +282,7 @@ def _render_md_tool_use(self, record: ToolTypeRecord) -> str:
276282
keys = record.use_input_keys or ("input",)
277283
lines = [f' elif name == "{record.name}":']
278284
for key in keys:
279-
lines.append(f' lines.append(f">\\n> {key}: {{inp.get({key!r}, \'\')}}")')
285+
lines.append(f" lines.append(f\">\\n> {key}: {{inp.get({key!r}, '')}}\")")
280286
return "\n".join(lines) + "\n"
281287

282288
def _render_md_tool_result(self, record: ToolTypeRecord) -> str:
@@ -327,7 +333,8 @@ def _plan_registry_js(self, record: ToolTypeRecord, plan: _EmitPlan) -> None:
327333

328334
def transform(text: str) -> str:
329335
if use_import.strip() not in text:
330-
text = text.replace(
336+
text = self._replace_once(
337+
text,
331338
"import { renderToolUseFallback }",
332339
use_import + "import { renderToolUseFallback }",
333340
)
@@ -343,7 +350,8 @@ def transform(text: str) -> str:
343350
module = f"./tool_result/{record.result.dispatch_id}.js"
344351
result_import = f"import {{ {result_fn} }} from '{module}';\n"
345352
if result_import.strip() not in text:
346-
text = text.replace(
353+
text = self._replace_once(
354+
text,
347355
"import { renderToolResultFallback }",
348356
result_import + "import { renderToolResultFallback }",
349357
)
@@ -368,15 +376,24 @@ def transform(text: str) -> str:
368376
before_guard = inv.resolved_before_guard()
369377
after_guard = inv.resolved_after_guard()
370378
if before_guard not in text:
371-
replacement = (
372-
f" is_task_async_tool_result,\n"
373-
f" {before_guard},\n"
374-
f" {after_guard},\n)"
375-
)
376-
text = text.replace(
379+
text = self._replace_once(
380+
text,
377381
" is_task_async_tool_result,\n)",
378-
replacement,
382+
f" is_task_async_tool_result,\n {before_guard},\n)",
379383
)
384+
if after_guard not in text:
385+
if before_guard in text:
386+
text = self._replace_once(
387+
text,
388+
f" {before_guard},\n)",
389+
f" {before_guard},\n {after_guard},\n)",
390+
)
391+
else:
392+
text = self._replace_once(
393+
text,
394+
" is_task_async_tool_result,\n)",
395+
f" is_task_async_tool_result,\n {after_guard},\n)",
396+
)
380397
row = (
381398
f" (\n"
382399
f" {before_guard},\n"
@@ -453,6 +470,13 @@ def _insert_before(
453470
return text[:line_start] + insertion + text[line_start:]
454471
return text[:idx] + insertion + text[idx:]
455472

473+
@staticmethod
474+
def _replace_once(text: str, anchor: str, replacement: str) -> str:
475+
if anchor not in text:
476+
msg = f"scaffold replacement anchor not found: {anchor!r}"
477+
raise ValueError(msg)
478+
return text.replace(anchor, replacement, 1)
479+
456480

457481
def _parse_handlers_from_text(text: str) -> frozenset[str]:
458482
marker = "_FILE_ACTIVITY_HANDLERS: dict"
@@ -565,7 +589,12 @@ def main(argv: list[str] | None = None) -> int:
565589
else:
566590
print(f"Scaffolded {record.name}: {len(written)} files updated")
567591
print("Next: complete TODO stubs, then run:")
568-
print(" pytest tests/test_tool_dispatch_sync.py tests/test_tool_dispatch_ordering.py -q")
592+
print(
593+
" pytest tests/test_scaffold_tool_type.py tests/test_tool_dispatch_sync.py "
594+
"tests/test_tool_dispatch_ordering.py tests/test_tool_dispatch_adversarial.py "
595+
"tests/test_jsonl_parser.py tests/test_real_session_fixtures.py -q"
596+
)
597+
print(" npm test")
569598
return 0
570599

571600

tests/benchmarks/test_search_bench.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def test_search_full_corpus(
1212
bench_client_search_corpus: FlaskClient,
1313
) -> None:
1414
def _run() -> object:
15-
return bench_client_search_corpus.get("/api/search?q=searchable&limit=50")
15+
return bench_client_search_corpus.get("/api/search?q=searchable&limit=50&all_history=1")
1616

1717
resp = benchmark(_run)
1818
assert resp.status_code == 200

tests/test_scaffold_tool_type.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,75 @@ def test_guard_name_for_dispatch_id() -> None:
105105
assert guard_name_for_dispatch_id("example_tool") == "is_example_tool_tool_result"
106106

107107

108+
def test_guard_and_builder_names_use_dispatch_id() -> None:
109+
record = ToolTypeRecord.from_mapping(
110+
{
111+
"name": "MyTool",
112+
"result": {"dispatch_id": "custom_dispatch"},
113+
}
114+
)
115+
assert record.guard_name == "is_custom_dispatch_tool_result"
116+
assert record.builder_name == "_tool_result_build_custom_dispatch"
117+
assert js_render_result_name(record) == "renderCustomDispatchResult"
118+
119+
120+
def test_from_mapping_rejects_negative_priority() -> None:
121+
with pytest.raises(ValueError, match="non-negative"):
122+
ToolTypeRecord.from_mapping(
123+
{
124+
"name": "BadTool",
125+
"result": {"dispatch_id": "bad_tool", "priority": -1},
126+
}
127+
)
128+
129+
130+
def test_dry_run_reports_planned_artifact_count(capsys: pytest.CaptureFixture[str]) -> None:
131+
code = main(["--name", "example_tool", "--dry-run"])
132+
assert code == 0
133+
out = capsys.readouterr().out
134+
assert "artifacts planned)" in out
135+
count = int(out.rsplit("(", 1)[-1].split(" artifacts", 1)[0])
136+
assert count > 0
137+
138+
139+
def test_write_record_only_writes_json_without_codegen(tmp_path: Path) -> None:
140+
repo = _mirror_repo(tmp_path)
141+
code = main(
142+
[
143+
"--name",
144+
"record_only_tool",
145+
"--write-record-only",
146+
"--root",
147+
str(repo),
148+
]
149+
)
150+
assert code == 0
151+
record_path = repo / "tool_types" / "record_only_tool.json"
152+
assert record_path.is_file()
153+
loaded = ToolTypeRecord.load(record_path)
154+
assert loaded.name == "RecordOnlyTool"
155+
dispatch = (repo / "utils" / "tool_dispatch.py").read_text(encoding="utf-8")
156+
assert "RecordOnlyTool" not in dispatch
157+
assert not (repo / "static" / "js" / "render" / "tool_use" / "record_only_tool.js").exists()
158+
159+
160+
def test_emit_anchor_miss_in_tool_results_leaves_repo_unchanged(tmp_path: Path) -> None:
161+
repo = _mirror_repo(tmp_path)
162+
tool_results = repo / "models" / "tool_results.py"
163+
dispatch = repo / "utils" / "tool_dispatch.py"
164+
corrupt = tool_results.read_text(encoding="utf-8").replace(
165+
' "WebSearch",\n]',
166+
' "WebSearch",\n "Legacy",\n]',
167+
)
168+
tool_results.write_text(corrupt, encoding="utf-8")
169+
dispatch_before = dispatch.read_text(encoding="utf-8")
170+
record = ToolTypeRecord.from_cli_name("example_tool")
171+
with pytest.raises(ValueError, match="replacement anchor not found"):
172+
ScaffoldEmitter(repo).emit(record)
173+
assert tool_results.read_text(encoding="utf-8") == corrupt
174+
assert dispatch.read_text(encoding="utf-8") == dispatch_before
175+
176+
108177
def test_dry_run_main_exits_zero(capsys: pytest.CaptureFixture[str]) -> None:
109178
code = main(["--name", "example_tool", "--dry-run"])
110179
assert code == 0

0 commit comments

Comments
 (0)