-
Notifications
You must be signed in to change notification settings - Fork 1
refactor(frontend): decompose sessions.js tool rendering into dispatc… #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
512e71c
refactor(frontend): decompose sessions.js tool rendering into dispatc…
clean6378-max-it 4d1edfd
refactor(frontend): decompose sessions.js tool rendering into dispatc…
clean6378-max-it 05b9f40
refactor(frontend): decompose sessions.js tool rendering into dispatc…
clean6378-max-it 2c1bedd
fix(frontend): single-layer tool summary escaping and registry tests
clean6378-max-it 6a01ac5
fix(frontend): harden render registry dispatch and test toolResultHas…
clean6378-max-it File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| /** | ||
| * Sentinel when tool.name or result_type is missing. | ||
| * Used for fallback routing only — do not add UNKNOWN_DISPATCH_KEY to TOOL_*_RENDERERS. | ||
| */ | ||
| export const UNKNOWN_DISPATCH_KEY = 'unknown'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import { renderBashUse } from './tool_use/bash.js'; | ||
| import { renderReadUse } from './tool_use/read.js'; | ||
| import { renderWriteUse } from './tool_use/write.js'; | ||
| import { renderEditUse } from './tool_use/edit.js'; | ||
| import { renderGlobUse } from './tool_use/glob.js'; | ||
| import { renderGrepUse } from './tool_use/grep.js'; | ||
| import { renderTaskUse } from './tool_use/task.js'; | ||
| import { renderTodoWriteUse } from './tool_use/todo_write.js'; | ||
| import { renderAskUserQuestionUse } from './tool_use/ask_user_question.js'; | ||
| import { renderWebFetchUse } from './tool_use/web_fetch.js'; | ||
| import { renderWebSearchUse } from './tool_use/web_search.js'; | ||
| import { renderToolUseFallback } from './tool_use/fallback.js'; | ||
| import { getToolSummary } from './tool_use/summary.js'; | ||
| import { UNKNOWN_DISPATCH_KEY } from './constants.js'; | ||
|
|
||
| import { renderBashResult } from './tool_result/bash.js'; | ||
| import { renderFileReadResult } from './tool_result/file_read.js'; | ||
| import { renderFileEditResult } from './tool_result/file_edit.js'; | ||
| import { renderFileWriteResult } from './tool_result/file_write.js'; | ||
| import { renderGlobResult } from './tool_result/glob.js'; | ||
| import { renderGrepResult } from './tool_result/grep.js'; | ||
| import { renderWebSearchResult } from './tool_result/web_search.js'; | ||
| import { renderWebFetchResult } from './tool_result/web_fetch.js'; | ||
| import { renderTaskResult } from './tool_result/task.js'; | ||
| import { renderTodoWriteResult } from './tool_result/todo_write.js'; | ||
| import { renderUserInputResult } from './tool_result/user_input.js'; | ||
| import { renderPlanResult } from './tool_result/plan.js'; | ||
| import { renderToolResultFallback } from './tool_result/fallback.js'; | ||
| import { toolResultHasBody } from './tool_result/utils.js'; | ||
|
|
||
| export { getToolSummary, toolResultHasBody }; | ||
|
|
||
| export const TOOL_USE_RENDERERS = { | ||
| Bash: renderBashUse, | ||
| Read: renderReadUse, | ||
| Write: renderWriteUse, | ||
| Edit: renderEditUse, | ||
| Glob: renderGlobUse, | ||
| Grep: renderGrepUse, | ||
| Task: renderTaskUse, | ||
| TodoWrite: renderTodoWriteUse, | ||
| AskUserQuestion: renderAskUserQuestionUse, | ||
| WebFetch: renderWebFetchUse, | ||
| WebSearch: renderWebSearchUse, | ||
| }; | ||
|
|
||
| export const TOOL_RESULT_RENDERERS = { | ||
| bash: renderBashResult, | ||
| file_read: renderFileReadResult, | ||
| file_edit: renderFileEditResult, | ||
| file_write: renderFileWriteResult, | ||
| glob: renderGlobResult, | ||
| grep: renderGrepResult, | ||
| web_search: renderWebSearchResult, | ||
| web_fetch: renderWebFetchResult, | ||
| task: renderTaskResult, | ||
| todo_write: renderTodoWriteResult, | ||
| user_input: renderUserInputResult, | ||
| plan: renderPlanResult, | ||
| }; | ||
|
|
||
| function getToolUseRenderer(name) { | ||
| return Object.prototype.hasOwnProperty.call(TOOL_USE_RENDERERS, name) | ||
| ? TOOL_USE_RENDERERS[name] | ||
| : renderToolUseFallback; | ||
| } | ||
|
|
||
| function getToolResultRenderer(resultType) { | ||
| return Object.prototype.hasOwnProperty.call(TOOL_RESULT_RENDERERS, resultType) | ||
| ? TOOL_RESULT_RENDERERS[resultType] | ||
| : renderToolResultFallback; | ||
| } | ||
|
|
||
| export function renderToolUse(tool) { | ||
| if (!tool) return ''; | ||
| const name = tool.name || UNKNOWN_DISPATCH_KEY; | ||
| return getToolUseRenderer(name)(tool); | ||
| } | ||
|
|
||
| export function renderToolResult(parsed) { | ||
| if (!parsed) return ''; | ||
| const rt = parsed.result_type || UNKNOWN_DISPATCH_KEY; | ||
| return getToolResultRenderer(rt)(parsed); | ||
| } | ||
|
clean6378-max-it marked this conversation as resolved.
|
||
|
clean6378-max-it marked this conversation as resolved.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { | ||
| TOOL_USE_RENDERERS, | ||
| TOOL_RESULT_RENDERERS, | ||
| renderToolUse, | ||
| renderToolResult, | ||
| getToolSummary, | ||
| toolResultHasBody, | ||
| } from './registry.js'; | ||
| import { UNKNOWN_DISPATCH_KEY } from './constants.js'; | ||
| import { renderWebFetchUse } from './tool_use/web_fetch.js'; | ||
|
|
||
| const CORE_TOOL_USE = ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'Task', 'TodoWrite', 'AskUserQuestion', 'WebFetch', 'WebSearch']; | ||
|
|
||
| const CORE_TOOL_RESULT = [ | ||
| 'bash', | ||
| 'file_read', | ||
| 'file_edit', | ||
| 'file_write', | ||
| 'glob', | ||
| 'grep', | ||
| 'web_search', | ||
| 'web_fetch', | ||
| 'task', | ||
| 'todo_write', | ||
| 'user_input', | ||
| 'plan', | ||
| ]; | ||
|
|
||
| describe('TOOL_USE_RENDERERS', () => { | ||
| it('registers core tool names', () => { | ||
| for (const name of CORE_TOOL_USE) { | ||
| expect(TOOL_USE_RENDERERS[name], name).toBeTypeOf('function'); | ||
| } | ||
| }); | ||
|
|
||
| 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); | ||
| }); | ||
|
|
||
| it('renderBashUse escapes HTML in command', () => { | ||
| const html = renderToolUse({ | ||
| name: 'Bash', | ||
| input: { command: '<script>alert(1)</script>' }, | ||
| }); | ||
| expect(html).not.toContain('<script>'); | ||
| expect(html).toContain('<script>'); | ||
| }); | ||
|
|
||
| it('returns empty string for null or undefined tool', () => { | ||
| expect(renderToolUse(null)).toBe(''); | ||
| expect(renderToolUse(undefined)).toBe(''); | ||
| }); | ||
|
|
||
| it('renderReadUse escapes file path in body and summary', () => { | ||
| const html = renderToolUse({ | ||
| name: 'Read', | ||
| input: { file_path: 'C:\\tmp\\<evil>.txt' }, | ||
| }); | ||
| expect(html).toContain('<evil>'); | ||
| expect(html).not.toContain('<evil>'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('TOOL_RESULT_RENDERERS', () => { | ||
| it('registers core result types', () => { | ||
| for (const rt of CORE_TOOL_RESULT) { | ||
| expect(TOOL_RESULT_RENDERERS[rt], rt).toBeTypeOf('function'); | ||
| } | ||
| }); | ||
|
|
||
| it('renderBashResult escapes stdout', () => { | ||
| const html = renderToolResult({ | ||
| result_type: 'bash', | ||
| exit_code: 0, | ||
| stdout: '<img onerror=alert(1)>', | ||
| }); | ||
| expect(html).not.toContain('<img'); | ||
| expect(html).toContain('<img'); | ||
| }); | ||
|
|
||
| it('renderBashResult avoids undefined in summary when exit_code missing', () => { | ||
| const html = renderToolResult({ result_type: 'bash' }); | ||
| expect(html).toContain('Bash Result (unknown)'); | ||
| expect(html).not.toContain('undefined'); | ||
| }); | ||
|
|
||
| it('returns empty string for null or undefined parsed', () => { | ||
| expect(renderToolResult(null)).toBe(''); | ||
| expect(renderToolResult(undefined)).toBe(''); | ||
| }); | ||
| }); | ||
|
|
||
| describe('toolResultHasBody', () => { | ||
| it('returns false for null or undefined', () => { | ||
| expect(toolResultHasBody(null)).toBe(false); | ||
| expect(toolResultHasBody(undefined)).toBe(false); | ||
| }); | ||
|
|
||
| it('returns true for bash with stdout or stderr', () => { | ||
| expect(toolResultHasBody({ result_type: 'bash', stdout: 'ok' })).toBe(true); | ||
| expect(toolResultHasBody({ result_type: 'bash', stderr: 'err' })).toBe(true); | ||
| expect(toolResultHasBody({ result_type: 'bash' })).toBe(false); | ||
| }); | ||
|
|
||
| it('returns false for summary-only result types', () => { | ||
| expect(toolResultHasBody({ result_type: 'file_read', file_path: '/a' })).toBe(false); | ||
| expect(toolResultHasBody({ result_type: 'glob', num_files: 3 })).toBe(false); | ||
| }); | ||
|
|
||
| it('returns true for user_input and todo_write with todos', () => { | ||
| expect(toolResultHasBody({ result_type: 'user_input' })).toBe(true); | ||
| expect(toolResultHasBody({ result_type: 'todo_write', todos: [{ content: 'x' }] })).toBe(true); | ||
| expect(toolResultHasBody({ result_type: 'todo_write', todo_count: 1 })).toBe(false); | ||
| }); | ||
|
|
||
| it('returns true for task when duration, retrieval, or description is set', () => { | ||
| expect(toolResultHasBody({ result_type: 'task', description: 'subagent' })).toBe(true); | ||
| expect(toolResultHasBody({ result_type: 'task', total_duration_ms: 100 })).toBe(true); | ||
| expect(toolResultHasBody({ result_type: 'task', status: 'completed' })).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('getToolSummary', () => { | ||
| it('formats Bash summary', () => { | ||
| expect(getToolSummary('Bash', { command: 'ls -la' })).toMatch(/Bash:/); | ||
| expect(getToolSummary('Bash', { command: 'ls -la' })).toContain('ls -la'); | ||
| }); | ||
|
|
||
| it('formats Read summary as plain text (escaping deferred to wrapToolUse)', () => { | ||
| expect(getToolSummary('Read', { file_path: 'a<b' })).toBe('Read: a<b'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('renderToolUse fallback', () => { | ||
| it('uses JSON fallback for unknown tools', () => { | ||
| const html = renderToolUse({ | ||
| name: 'UnknownToolXYZ', | ||
| input: { foo: 'bar' }, | ||
| }); | ||
| expect(html).toContain('tool-call'); | ||
| expect(html).toContain('"foo"'); | ||
| expect(TOOL_USE_RENDERERS.UnknownToolXYZ).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('uses fallback when name is an inherited property (e.g. constructor)', () => { | ||
| const html = renderToolUse({ | ||
| name: 'constructor', | ||
| input: { foo: 'bar' }, | ||
| }); | ||
| expect(html).toContain('tool-call'); | ||
| expect(html).toContain('"foo"'); | ||
| }); | ||
|
|
||
| it('dispatches WebFetch to registered renderer (not generic unknown-tool fallback)', () => { | ||
| expect(TOOL_USE_RENDERERS.WebFetch).toBe(renderWebFetchUse); | ||
| const html = renderToolUse({ | ||
| name: 'WebFetch', | ||
| input: { url: 'https://example.com' }, | ||
| }); | ||
| expect(html).toContain('tool-call'); | ||
| expect(html).toContain('example.com'); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| it('renders WebSearch via registry', () => { | ||
| const html = renderToolUse({ | ||
| name: 'WebSearch', | ||
| input: { query: 'vitest registry' }, | ||
| }); | ||
| expect(html).toContain('tool-call'); | ||
| expect(html).toContain('vitest registry'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('renderTodoWriteResult', () => { | ||
| it('summary count matches parsed.todos length when todos are present', () => { | ||
| const html = renderToolResult({ | ||
| result_type: 'todo_write', | ||
| todo_count: 99, | ||
| todos: [ | ||
| { status: 'pending', content: 'one' }, | ||
| { status: 'completed', content: 'two' }, | ||
| ], | ||
| }); | ||
| expect(html).toContain('Todos updated (2 items)'); | ||
| expect(html).not.toContain('99 items'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('renderToolResult fallback', () => { | ||
| it('renders summary-only for unknown result types', () => { | ||
| const html = renderToolResult({ result_type: 'custom_type' }); | ||
| expect(html).toContain('Tool result (custom_type)'); | ||
| expect(html).toContain('tool-result'); | ||
| }); | ||
|
|
||
| it('uses fallback when result_type is an inherited property (e.g. constructor)', () => { | ||
| const html = renderToolResult({ result_type: 'constructor' }); | ||
| expect(html).toContain('Tool result (constructor)'); | ||
| expect(html).toContain('tool-result'); | ||
| expect(Object.prototype.hasOwnProperty.call(TOOL_RESULT_RENDERERS, 'constructor')).toBe(false); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import { esc, truncate } from '../../shared/utils.js'; | ||
| import { finishToolResult } from './common.js'; | ||
|
|
||
| export function renderBashResult(parsed) { | ||
| const exitCode = parsed.exit_code; | ||
| let status; | ||
| if (parsed.interrupted) { | ||
| status = 'interrupted'; | ||
| } else if (parsed.is_error) { | ||
| status = typeof exitCode === 'number' ? `error (exit ${exitCode})` : 'error'; | ||
| } else if (exitCode === 0) { | ||
| status = 'success'; | ||
| } else if (typeof exitCode === 'number') { | ||
| status = `exit ${exitCode}`; | ||
| } else { | ||
| status = 'unknown'; | ||
| } | ||
| const summary = `Bash Result (${status})`; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| let body = ''; | ||
| if (parsed.stdout) body += `<div class="tool-call-section"><div class="tool-call-section-title">stdout</div><pre><code>${esc(truncate(parsed.stdout, 2000))}</code></pre></div>`; | ||
| if (parsed.stderr) body += `<div class="tool-call-section"><div class="tool-call-section-title">stderr</div><pre style="border-left:3px solid var(--danger)"><code>${esc(truncate(parsed.stderr, 1000))}</code></pre></div>`; | ||
| return finishToolResult(summary, body); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { esc } from '../../shared/utils.js'; | ||
|
|
||
| export function finishToolResult(summary, body) { | ||
| if (!body) { | ||
| return `<div class="tool-result"><span class="tool-result-summary">${esc(summary)}</span></div>`; | ||
| } | ||
| return `<details class="tool-result"><summary class="tool-result-summary">${esc(summary)}</summary><div class="tool-call-body">${body}</div></details>`; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import { finishToolResult } from './common.js'; | ||
| import { UNKNOWN_DISPATCH_KEY } from '../constants.js'; | ||
|
|
||
| export function renderToolResultFallback(parsed) { | ||
| const rt = parsed.result_type || UNKNOWN_DISPATCH_KEY; | ||
| const summary = `Tool result (${rt})`; | ||
| return finishToolResult(summary, ''); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { finishToolResult } from './common.js'; | ||
|
|
||
| export function renderFileEditResult(parsed) { | ||
| const summary = `Edited: ${parsed.file_path || ''}`; | ||
| return finishToolResult(summary, ''); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { finishToolResult } from './common.js'; | ||
|
|
||
| export function renderFileReadResult(parsed) { | ||
| const numLines = parsed.num_lines ? ` (${parsed.num_lines} lines)` : ''; | ||
| const summary = `Read: ${parsed.file_path || ''}${numLines}`; | ||
| return finishToolResult(summary, ''); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { finishToolResult } from './common.js'; | ||
|
|
||
| export function renderFileWriteResult(parsed) { | ||
| const summary = `Wrote: ${parsed.file_path || ''}`; | ||
| return finishToolResult(summary, ''); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { finishToolResult } from './common.js'; | ||
|
|
||
| export function renderGlobResult(parsed) { | ||
| const trunc = parsed.truncated ? ' (truncated)' : ''; | ||
| const summary = `Glob: ${parsed.num_files || 0} files found${trunc}`; | ||
| return finishToolResult(summary, ''); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { finishToolResult } from './common.js'; | ||
|
|
||
| export function renderGrepResult(parsed) { | ||
| const summary = `Grep: ${parsed.num_files || 0} files, ${parsed.num_lines || 0} lines`; | ||
| return finishToolResult(summary, ''); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import { finishToolResult } from './common.js'; | ||
|
|
||
| export function renderPlanResult(parsed) { | ||
| const summary = `Plan: ${parsed.file_path || ''}`; | ||
| return finishToolResult(summary, ''); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.