Skip to content

Commit 554b2cf

Browse files
claude-code-chat-browser: Frontend tool registry - link to backend dispatch table via generated manifest (#105)
* feat: link frontend tool registry to generated tool_types.json manifest Generate static/tool_types.json from KNOWN_TOOL_TYPES and load it at SPA boot to cross-check TOOL_USE_RENDERERS with console.warn on drift. Improve unknown tool/result fallbacks to show raw type names and JSON payloads. Extend test_tool_dispatch_sync.py with manifest freshness and coverage assertions. Await manifest init before first route render; document the flow in CONTRIBUTING.md, Makefile, and docs/architecture.md. * fix: non-blocking manifest init, fetch timeout, stricter manifest parse * fix(test): ensure fake timers cleanup in manifest timeout test Wrap the initToolTypesManifest timeout test in try/finally so vi.useRealTimers() runs even when assertions fail. Also ruff-format test_tool_dispatch_sync.py manifest validation helper (CI gate). * fix(frontend): drop unused tool_types_state scaffolding
1 parent 7f685e4 commit 554b2cf

14 files changed

Lines changed: 242 additions & 15 deletions

CONTRIBUTING.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,9 @@ Claude Code assistant `tool_use` blocks carry a `name` string (e.g. `"Read"`, `"
149149
2. **`models/tool_results.py`** — add the name to `ToolNameLiteral` and, when the tool has a distinct result payload, add the TypedDict, type guard (`is_*_tool_result`), and union member on `ToolResultUnion`.
150150
3. **`utils/md_exporter.py`** — add an `elif name == "…"` branch in `_render_tool_use` (sync test parses these branches).
151151
4. **`static/js/render/registry.js`** — add a `TOOL_USE_RENDERERS` entry (and a `tool_use/*.js` renderer module).
152-
5. **Optional result UI** — if the backend emits a new `result_type`, add `TOOL_RESULT_RENDERERS` and a `tool_result/*.js` module.
153-
6. Run `pytest tests/test_tool_dispatch_sync.py -v` — failure names the site missing the new type.
152+
5. Regenerate **`static/tool_types.json`**: `python scripts/gen_tool_types_manifest.py`
153+
6. **Optional result UI** — if the backend emits a new `result_type`, add `TOOL_RESULT_RENDERERS` and a `tool_result/*.js` module.
154+
7. Run `pytest tests/test_tool_dispatch_sync.py -v` — failure names the site missing the new type.
154155

155156
## Getting help
156157

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: seed-baselines-local update-baselines check-benchmarks clean-benchmark-artifacts
1+
.PHONY: seed-baselines-local update-baselines gen-tool-types-manifest check-benchmarks clean-benchmark-artifacts
22

33
# WARNING: captures timings on THIS machine. Production baselines must match ubuntu-latest CI.
44
# Prefer downloading benchmark-results.json from a CI artifact, then:
@@ -11,6 +11,9 @@ seed-baselines-local:
1111
# Deprecated alias — kept for muscle memory; see seed-baselines-local warning above.
1212
update-baselines: seed-baselines-local
1313

14+
gen-tool-types-manifest:
15+
PYTHONPATH=. python scripts/gen_tool_types_manifest.py
16+
1417
check-benchmarks:
1518
PYTHONPATH=. pytest tests/benchmarks/ --benchmark-only --benchmark-json=benchmark-results.json -o addopts=
1619
PYTHONPATH=. python scripts/check_benchmark_regression.py benchmark-results.json benchmarks/baselines.json

docs/architecture.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ The UI is a **hash-routed** SPA with ES modules under `static/js/`:
106106
- `app.js` — routing and boot
107107
- `projects.js`, `sessions.js`, `search.js`, `export.js` — route handlers
108108
- `render/registry.js`**tool dispatch registry** for session UI: `TOOL_USE_RENDERERS` and `TOOL_RESULT_RENDERERS` map tool name / `result_type` → render function (one module per type under `render/tool_use/` and `render/tool_result/`). Parallels backend `utils/tool_dispatch.py` (backend uses ordered predicates; frontend uses direct key lookup + fallback).
109+
- `static/tool_types.json` — generated manifest of backend tool-use names (`python scripts/gen_tool_types_manifest.py` from `KNOWN_TOOL_TYPES`). Fetched non-blocking at boot by `render/tool_types_manifest.js` (`void initToolTypesManifest()` in `app.js`), which cross-checks `TOOL_USE_RENDERERS` and logs `console.warn` on drift.
109110
- `shared/markdown.js` — markdown + **DOMPurify** sanitization (do not render raw LLM HTML)
110111
- `shared/state.js`, `shared/utils.js`, `shared/theme.js` — shared UI state and helpers
111112

scripts/gen_tool_types_manifest.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env python3
2+
"""Write ``static/tool_types.json`` from ``KNOWN_TOOL_TYPES``.
3+
4+
Run after adding a tool type to ``utils/tool_dispatch.py``::
5+
6+
python scripts/gen_tool_types_manifest.py
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import sys
13+
from pathlib import Path
14+
15+
_REPO_ROOT = Path(__file__).resolve().parents[1]
16+
_MANIFEST_PATH = _REPO_ROOT / "static" / "tool_types.json"
17+
18+
19+
def write_tool_types_manifest(path: Path | None = None) -> int:
20+
if str(_REPO_ROOT) not in sys.path:
21+
sys.path.insert(0, str(_REPO_ROOT))
22+
from utils.tool_dispatch import KNOWN_TOOL_TYPES
23+
24+
dest = path or _MANIFEST_PATH
25+
payload = {"tool_types": sorted(KNOWN_TOOL_TYPES)}
26+
dest.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
27+
return len(KNOWN_TOOL_TYPES)
28+
29+
30+
def main() -> None:
31+
count = write_tool_types_manifest()
32+
print(f"Wrote {count} tool types to {_MANIFEST_PATH}")
33+
34+
35+
if __name__ == "__main__":
36+
main()

static/js/app.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { HLJS_THEME_SHEETS, applyTheme, toggleTheme } from './shared/theme.js';
88
import { showProjects } from './projects.js';
99
import { showWorkspace, loadSession } from './sessions.js';
1010
import { showSearchPage } from './search.js';
11+
import { initToolTypesManifest } from './render/tool_types_manifest.js';
1112

1213
// ==================== Router ====================
1314

@@ -52,6 +53,7 @@ document.addEventListener('DOMContentLoaded', () => {
5253
applyTheme(localStorage.getItem('theme') || 'dark');
5354
const yearEl = document.getElementById('footer-year');
5455
if (yearEl) yearEl.textContent = new Date().getFullYear();
56+
void initToolTypesManifest();
5557
handleRoute();
5658
window.addEventListener('hashchange', handleRoute);
5759

static/js/app.test.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ vi.mock('./shared/theme.js', () => ({
2323
toggleTheme,
2424
setWorkspaceMode: vi.fn(),
2525
}));
26+
vi.mock('./render/tool_types_manifest.js', () => ({
27+
initToolTypesManifest: vi.fn().mockResolvedValue(undefined),
28+
}));
2629

2730
describe('router (app.js)', () => {
2831
const origScrollTo = window.scrollTo;

static/js/render/registry.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,10 @@ function getToolUseRenderer(name) {
6666
}
6767

6868
function getToolResultRenderer(resultType) {
69-
return Object.prototype.hasOwnProperty.call(TOOL_RESULT_RENDERERS, resultType)
70-
? TOOL_RESULT_RENDERERS[resultType]
71-
: renderToolResultFallback;
69+
if (Object.prototype.hasOwnProperty.call(TOOL_RESULT_RENDERERS, resultType)) {
70+
return TOOL_RESULT_RENDERERS[resultType];
71+
}
72+
return renderToolResultFallback;
7273
}
7374

7475
export function renderToolUse(tool) {

static/js/render/registry.test.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,13 @@ describe('getToolSummary', () => {
133133
});
134134

135135
describe('renderToolUse fallback', () => {
136-
it('uses JSON fallback for unknown tools', () => {
136+
it('uses JSON fallback for unknown tools with raw type name', () => {
137137
const html = renderToolUse({
138138
name: 'UnknownToolXYZ',
139139
input: { foo: 'bar' },
140140
});
141141
expect(html).toContain('tool-call');
142+
expect(html).toContain('Unknown tool: UnknownToolXYZ');
142143
expect(html).toContain('"foo"');
143144
expect(TOOL_USE_RENDERERS.UnknownToolXYZ).toBeUndefined();
144145
});
@@ -188,15 +189,20 @@ describe('renderTodoWriteResult', () => {
188189
});
189190

190191
describe('renderToolResult fallback', () => {
191-
it('renders summary-only for unknown result types', () => {
192-
const html = renderToolResult({ result_type: 'custom_type' });
193-
expect(html).toContain('Tool result (custom_type)');
192+
it('renders raw result type and JSON payload for unknown result types', () => {
193+
const html = renderToolResult({
194+
result_type: 'custom_type',
195+
payload: { answer: 42 },
196+
});
197+
expect(html).toContain('Unknown tool result: custom_type');
194198
expect(html).toContain('tool-result');
199+
expect(html).toContain('"answer"');
200+
expect(html).toContain('42');
195201
});
196202

197203
it('uses fallback when result_type is an inherited property (e.g. constructor)', () => {
198204
const html = renderToolResult({ result_type: 'constructor' });
199-
expect(html).toContain('Tool result (constructor)');
205+
expect(html).toContain('Unknown tool result: constructor');
200206
expect(html).toContain('tool-result');
201207
expect(Object.prototype.hasOwnProperty.call(TOOL_RESULT_RENDERERS, 'constructor')).toBe(false);
202208
});
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import { esc, truncate } from '../../shared/utils.js';
12
import { finishToolResult } from './common.js';
23
import { UNKNOWN_DISPATCH_KEY } from '../constants.js';
34

45
export function renderToolResultFallback(parsed) {
56
const rt = parsed.result_type || UNKNOWN_DISPATCH_KEY;
6-
const summary = `Tool result (${rt})`;
7-
return finishToolResult(summary, '');
7+
const summary = `Unknown tool result: ${rt}`;
8+
const payload = JSON.stringify(parsed, null, 2);
9+
const body = `<pre><code>${esc(truncate(payload, 500))}</code></pre>`;
10+
return finishToolResult(summary, body);
811
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { TOOL_USE_RENDERERS } from './registry.js';
2+
3+
const MANIFEST_URL = '/static/tool_types.json';
4+
const MANIFEST_FETCH_TIMEOUT_MS = 5000;
5+
6+
/**
7+
* Load backend tool-type manifest and cross-check ``TOOL_USE_RENDERERS``.
8+
* Logs ``console.warn`` when the backend list and frontend registry diverge.
9+
*/
10+
export async function initToolTypesManifest() {
11+
const controller = new AbortController();
12+
const timeoutId = setTimeout(() => controller.abort(), MANIFEST_FETCH_TIMEOUT_MS);
13+
try {
14+
const res = await fetch(MANIFEST_URL, { signal: controller.signal });
15+
if (!res.ok) {
16+
console.warn(`[tool registry] Could not load ${MANIFEST_URL}: HTTP ${res.status}`);
17+
return;
18+
}
19+
const data = await res.json();
20+
const types = Array.isArray(data.tool_types) ? data.tool_types : [];
21+
const manifest = new Set(types.filter((t) => typeof t === 'string'));
22+
23+
for (const name of manifest) {
24+
if (!Object.prototype.hasOwnProperty.call(TOOL_USE_RENDERERS, name)) {
25+
console.warn(
26+
`[tool registry] Backend tool type "${name}" has no TOOL_USE_RENDERERS entry`,
27+
);
28+
}
29+
}
30+
for (const name of Object.keys(TOOL_USE_RENDERERS)) {
31+
if (!manifest.has(name)) {
32+
console.warn(
33+
`[tool registry] TOOL_USE_RENDERERS entry "${name}" is missing from ${MANIFEST_URL}`,
34+
);
35+
}
36+
}
37+
} catch (err) {
38+
if (err?.name === 'AbortError') {
39+
console.warn(
40+
`[tool registry] Could not load ${MANIFEST_URL}: timed out after ${MANIFEST_FETCH_TIMEOUT_MS}ms`,
41+
);
42+
return;
43+
}
44+
console.warn('[tool registry] Could not load tool types manifest:', err);
45+
} finally {
46+
clearTimeout(timeoutId);
47+
}
48+
}

0 commit comments

Comments
 (0)