diff --git a/static/js/render/registry.test.js b/static/js/render/registry.test.js
index 28ee6b8..4f7b7b6 100644
--- a/static/js/render/registry.test.js
+++ b/static/js/render/registry.test.js
@@ -34,6 +34,10 @@ describe('TOOL_USE_RENDERERS', () => {
}
});
+ it('CORE_TOOL_USE stays in sync with registry keys', () => {
+ expect(new Set(CORE_TOOL_USE)).toEqual(new Set(Object.keys(TOOL_USE_RENDERERS)));
+ });
+
it('does not register the unknown dispatch sentinel as a tool renderer', () => {
expect(Object.prototype.hasOwnProperty.call(TOOL_USE_RENDERERS, UNKNOWN_DISPATCH_KEY)).toBe(false);
});
@@ -69,6 +73,10 @@ describe('TOOL_RESULT_RENDERERS', () => {
}
});
+ it('CORE_TOOL_RESULT stays in sync with registry keys', () => {
+ expect(new Set(CORE_TOOL_RESULT)).toEqual(new Set(Object.keys(TOOL_RESULT_RENDERERS)));
+ });
+
it('renderBashResult escapes stdout', () => {
const html = renderToolResult({
result_type: 'bash',
@@ -188,6 +196,56 @@ describe('renderTodoWriteResult', () => {
});
});
+/** Representative fixtures for registry-driven behavioral smoke tests. */
+const TOOL_USE_FIXTURES = {
+ Bash: { input: { command: 'echo hi', description: 'say hi' } },
+ Read: { input: { file_path: 'README.md' } },
+ Write: { input: { file_path: 'out.txt', content: 'data' } },
+ Edit: { input: { file_path: 'a.js', old_string: 'x', new_string: 'y' } },
+ Glob: { input: { pattern: '*.js', path: 'src' } },
+ Grep: { input: { pattern: 'TODO', path: 'lib' } },
+ Task: { input: { subagent_type: 'explore', description: 'scan', prompt: 'go' } },
+ TodoWrite: { input: { todos: [{ status: 'pending', content: 'task' }] } },
+ AskUserQuestion: { input: { questions: [{ question: 'OK?' }] } },
+ WebFetch: { input: { url: 'https://example.com' } },
+ WebSearch: { input: { query: 'vitest' } },
+};
+
+const TOOL_RESULT_FIXTURES = {
+ bash: { exit_code: 0, stdout: 'ok' },
+ file_read: { file_path: '/a.txt', num_lines: 10 },
+ file_edit: { file_path: 'b.js' },
+ file_write: { file_path: 'c.txt' },
+ glob: { num_files: 3 },
+ grep: { num_files: 2, num_lines: 5 },
+ web_search: { query: 'test', result_count: 1 },
+ web_fetch: { url: 'https://x.com', status_code: 200 },
+ task: { status: 'completed', total_duration_ms: 1000 },
+ todo_write: { todos: [{ status: 'pending', content: 'x' }] },
+ user_input: { questions: [{ question: 'Q' }], answers: { Q: 'A' } },
+ plan: { file_path: 'plan.md' },
+};
+
+describe('registry behavioral smoke tests', () => {
+ it('every registered tool_use renderer produces non-empty HTML', () => {
+ for (const name of CORE_TOOL_USE) {
+ expect(TOOL_USE_FIXTURES[name], name).toBeDefined();
+ const html = renderToolUse({ name, ...TOOL_USE_FIXTURES[name] });
+ expect(html, name).toContain('tool-call');
+ expect(html.length, name).toBeGreaterThan(20);
+ }
+ });
+
+ it('every registered tool_result renderer produces non-empty HTML', () => {
+ for (const rt of CORE_TOOL_RESULT) {
+ expect(TOOL_RESULT_FIXTURES[rt], rt).toBeDefined();
+ const html = renderToolResult({ result_type: rt, ...TOOL_RESULT_FIXTURES[rt] });
+ expect(html, rt).toContain('tool-result');
+ expect(html.length, rt).toBeGreaterThan(20);
+ }
+ });
+});
+
describe('renderToolResult fallback', () => {
it('renders raw result type and JSON payload for unknown result types', () => {
const html = renderToolResult({
diff --git a/static/js/render/test_helpers.js b/static/js/render/test_helpers.js
new file mode 100644
index 0000000..7213c86
--- /dev/null
+++ b/static/js/render/test_helpers.js
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest';
+import { renderToolResult } from './registry.js';
+
+/** Common XSS payloads for renderer escaping assertions. */
+export const XSS_SCRIPT = '';
+export const XSS_IMG = '
';
+
+/**
+ * Assert raw HTML fragments are escaped (not present verbatim).
+ */
+export function expectNoRawHtml(html, rawFragments) {
+ for (const frag of rawFragments) {
+ expect(html).not.toContain(frag);
+ }
+}
+
+/**
+ * Assert an HTML-escaped fragment appears in output.
+ */
+export function expectEscaped(html, escapedFragment) {
+ expect(html).toContain(escapedFragment);
+}
+
+/**
+ * Shared behavioral tests for summary-only tool_result renderers (Edited/Plan/Wrote).
+ */
+export function describeSummaryOnlyResult(
+ render,
+ { suiteName, resultType, label, samplePath },
+) {
+ describe(suiteName, () => {
+ it(`renders ${label.toLowerCase()} file path in summary`, () => {
+ const html = render({ result_type: resultType, file_path: samplePath });
+ expect(html).toContain(`${label}: ${samplePath}`);
+ expect(html).toContain('tool-result');
+ });
+
+ it('handles missing file path', () => {
+ const html = render({ result_type: resultType });
+ expect(html).toContain(`${label}:`);
+ expect(html).not.toContain('undefined');
+ });
+
+ it('escapes HTML in file path via registry', () => {
+ const html = renderToolResult({
+ result_type: resultType,
+ file_path: XSS_SCRIPT,
+ });
+ expectNoRawHtml(html, [XSS_SCRIPT]);
+ });
+ });
+}
diff --git a/static/js/render/tool_result/bash.test.js b/static/js/render/tool_result/bash.test.js
new file mode 100644
index 0000000..75a0ec7
--- /dev/null
+++ b/static/js/render/tool_result/bash.test.js
@@ -0,0 +1,46 @@
+import { describe, expect, it } from 'vitest';
+import { renderBashResult } from './bash.js';
+import { renderToolResult } from '../registry.js';
+import { expectNoRawHtml, XSS_IMG } from '../test_helpers.js';
+
+describe('renderBashResult', () => {
+ it('renders success status with stdout', () => {
+ const html = renderBashResult({
+ result_type: 'bash',
+ exit_code: 0,
+ stdout: 'hello\nworld',
+ });
+ expect(html).toContain('Bash Result (success)');
+ expect(html).toContain('hello');
+ expect(html).toContain('stdout');
+ expect(html).toContain('tool-result');
+ });
+
+ it('renders error status with stderr', () => {
+ const html = renderBashResult({
+ result_type: 'bash',
+ exit_code: 1,
+ is_error: true,
+ stderr: 'command failed',
+ });
+ expect(html).toContain('error (exit 1)');
+ expect(html).toContain('command failed');
+ expect(html).toContain('stderr');
+ });
+
+ it('renders interrupted status', () => {
+ const html = renderBashResult({ result_type: 'bash', interrupted: true });
+ expect(html).toContain('Bash Result (interrupted)');
+ });
+
+ it('escapes HTML in stdout and stderr', () => {
+ const html = renderToolResult({
+ result_type: 'bash',
+ exit_code: 0,
+ stdout: XSS_IMG,
+ stderr: '',
+ });
+ expectNoRawHtml(html, [XSS_IMG, '',
+ },
+ });
+ expectNoRawHtml(html, [XSS_SCRIPT, '', '