From 02fdddf7f0aadd6b4e3d9694ea24cfc00194e322 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 6 Jun 2026 13:47:33 +0100 Subject: [PATCH] Parse Assistant tool input summaries --- .../v6/internal/subsystems/ai-runtime.md | 10 ++ .../components/AI/Chat/ToolExecutionBlock.tsx | 167 +++++++++++++++--- .../__tests__/ToolExecutionBlock.test.tsx | 60 +++++++ 3 files changed, 209 insertions(+), 28 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index f01347fa7..8f4ae5950 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -323,6 +323,16 @@ runtime cost control, and shared AI transport surfaces. useful, and keeping structured JSON, unavailable output, and the full raw payload behind Details. This preserves evidence for inspection without letting large command output dominate the Assistant answer flow. + Tool-row summaries are part of that same source-anchored contract, not + assistant prose cleanup: when provider or backend transport supplies a + function-style call such as `pulse_read(...)` or a friendly string backed by + structured raw input, Pulse must derive the visible row from the parsed tool + arguments and keep the raw transport form behind Details. This adapts + OpenCode's `ToolPart`/`InlineToolRow` rendering in + `packages/opencode/src/cli/cmd/tui/routes/session/index.tsx` and the + `session.next.tool.input.*` mutations in + `packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx`: typed tool state + owns the action row, while raw invocation syntax is inspection detail. Streaming thinking rows follow the same source-anchored reasoning-display contract: OpenCode commit `9ed17da55ab1f7360cc0e01075f763e27fa899e9` renders reasoning through diff --git a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx index bde330f07..aaf5e57c4 100644 --- a/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx +++ b/frontend-modern/src/components/AI/Chat/ToolExecutionBlock.tsx @@ -119,6 +119,85 @@ const targetSuffix = (record: Record) => { return ` on ${formatIdentifierLabel(target, { maxLength: 18 })}`; }; +const parseFunctionStyleToolInput = (input: string) => { + const match = /^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([\s\S]*)\)\s*$/.exec(input.trim()); + if (!match) return null; + + const args: Record = {}; + const body = match[2] || ''; + let index = 0; + + const skipWhitespace = () => { + while (index < body.length && /\s/.test(body[index])) index += 1; + }; + + const parseQuotedValue = (quote: string) => { + index += 1; + let value = ''; + while (index < body.length) { + const char = body[index]; + if (char === '\\') { + if (index + 1 < body.length) { + value += body[index + 1]; + index += 2; + continue; + } + return null; + } + if (char === quote) { + index += 1; + return value; + } + value += char; + index += 1; + } + return null; + }; + + while (index < body.length) { + skipWhitespace(); + if (index >= body.length) break; + + if (body[index] === ',') { + index += 1; + continue; + } + + const keyMatch = /^[a-zA-Z_][a-zA-Z0-9_]*/.exec(body.slice(index)); + if (!keyMatch) return null; + const key = keyMatch[0]; + index += key.length; + + skipWhitespace(); + if (body[index] !== '=') return null; + index += 1; + skipWhitespace(); + + const quote = body[index]; + let value: unknown; + if (quote === '"' || quote === "'") { + value = parseQuotedValue(quote); + if (value === null) return null; + } else { + const start = index; + while (index < body.length && body[index] !== ',') index += 1; + const rawValue = body.slice(start, index).trim(); + if (!rawValue) return null; + if (rawValue === 'true') value = true; + else if (rawValue === 'false') value = false; + else if (rawValue === 'null') value = null; + else if (/^-?\d+(?:\.\d+)?$/.test(rawValue)) value = Number(rawValue); + else value = rawValue; + } + + args[key] = value; + skipWhitespace(); + if (index < body.length && body[index] !== ',') return null; + } + + return { name: match[1], args }; +}; + const formatPartialRawInputSummary = (rawInput: string | undefined, toolName?: string) => { if (!rawInput?.trim()) return ''; @@ -251,43 +330,75 @@ const formatAlertsInputSummary = (record: Record) => { return action ? formatIdentifierLabel(action, { maxLength: 28 }) : 'read alerts'; }; -const parseToolInputSummary = (input: string, toolName?: string, rawInput?: string) => { +const formatStructuredInputSummary = ( + record: Record, + toolName?: string, + rawInput?: string, +) => { + if (Object.keys(record).length === 0) { + return formatPartialRawInputSummary(rawInput, toolName) || 'request'; + } + + const tool = normalizedToolName(toolName); + if (tool === 'read') { + return formatPulseReadInputSummary(record) || 'read resource'; + } + if (tool === 'run_command' || tool === 'control') { + return formatCommandSummary(record) || 'run command'; + } + if (tool === 'query') { + return formatQueryInputSummary(record); + } + if (tool === 'alerts') { + return formatAlertsInputSummary(record); + } + if (typeof record.action === 'string' && record.action.trim()) { + return formatIdentifierLabel(record.action, { maxLength: 28 }); + } + if (typeof record.command === 'string' && record.command.trim()) { + return formatIdentifierLabel(record.command, { maxLength: 28 }); + } + return 'request'; +}; + +const parseStructuredInputSummary = (input: string, toolName?: string, rawInput?: string) => { const trimmed = input.trim(); - if (!trimmed) return ''; + if (!trimmed) return null; try { const parsed = JSON.parse(trimmed) as unknown; if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - const record = parsed as Record; - if (Object.keys(record).length === 0) { - return formatPartialRawInputSummary(rawInput, toolName) || 'request'; - } - const tool = normalizedToolName(toolName); - if (tool === 'read') { - return formatPulseReadInputSummary(record) || 'read resource'; - } - if (tool === 'run_command' || tool === 'control') { - return formatCommandSummary(record) || 'run command'; - } - if (tool === 'query') { - return formatQueryInputSummary(record); - } - if (tool === 'alerts') { - return formatAlertsInputSummary(record); - } - if (typeof record.action === 'string' && record.action.trim()) { - return formatIdentifierLabel(record.action, { maxLength: 28 }); - } - if (typeof record.command === 'string' && record.command.trim()) { - return formatIdentifierLabel(record.command, { maxLength: 28 }); - } - return 'request'; + return formatStructuredInputSummary(parsed as Record, toolName, rawInput); } } catch { - return formatIdentifierLabel(trimmed, { maxLength: 28 }); + // Keep trying provider-style function-call input below. } - return 'request'; + const functionCall = parseFunctionStyleToolInput(trimmed); + if (functionCall) { + return formatStructuredInputSummary(functionCall.args, functionCall.name, rawInput); + } + + return null; +}; + +const parseToolInputSummary = (input: string, toolName?: string, rawInput?: string) => { + const trimmed = input.trim(); + if (!trimmed) return ''; + + const directSummary = parseStructuredInputSummary(trimmed, toolName, rawInput); + if (directSummary) { + return directSummary; + } + + if (rawInput && rawInput.trim() && rawInput.trim() !== trimmed) { + const rawSummary = parseStructuredInputSummary(rawInput, toolName); + if (rawSummary && !isPlaceholderToolInputSummary(rawSummary)) { + return rawSummary; + } + } + + return formatIdentifierLabel(trimmed, { maxLength: 28 }); }; const hasReadableToolOutput = (output: string) => { diff --git a/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx index dc6e7ce6a..6e63f2be6 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/ToolExecutionBlock.test.tsx @@ -195,6 +195,21 @@ describe('ToolExecutionBlock', () => { expect(screen.queryByText(/"target_host"/)).not.toBeInTheDocument(); }); + it('renders provider-style Pulse read function input as the actual command', () => { + render(() => ( + + )); + + expect(screen.getByText('$ ls /dev | wc -l on current resource')).toBeInTheDocument(); + expect(screen.queryByText(/pulse_read\(/)).not.toBeInTheDocument(); + }); + it('renders Pulse read log input as a readable log action', () => { render(() => ( { expect(screen.getByText('$ systemctl restart nginx on tower')).toBeInTheDocument(); }); + it('uses structured raw input over backend command display strings', () => { + render(() => ( + + )); + + expect(screen.getByText('$ systemctl restart nginx on tower')).toBeInTheDocument(); + expect(screen.queryByText('Running: systemctl restart nginx')).not.toBeInTheDocument(); + }); + // --- Output display --- it('previews plain-text output while keeping full details available', () => { @@ -477,6 +508,20 @@ describe('PendingToolBlock', () => { expect(screen.queryByText(/"command"/)).not.toBeInTheDocument(); }); + it('renders pending provider-style Pulse read function input as the command being prepared', () => { + render(() => ( + + )); + + expect(screen.getByText('$ lsblk -o NAME,SIZE on current resource')).toBeInTheDocument(); + expect(screen.queryByText(/pulse_read\(/)).not.toBeInTheDocument(); + }); + it('uses raw partial input while pending Pulse read command JSON is still streaming', () => { render(() => ( { expect(screen.getByText('$ ls /dev |')).toBeInTheDocument(); }); + it('uses structured raw input over pending backend command display strings', () => { + render(() => ( + + )); + + expect(screen.getByText('$ systemctl restart nginx on tower')).toBeInTheDocument(); + expect(screen.queryByText('Running: systemctl restart nginx')).not.toBeInTheDocument(); + }); + it('renders a command-specific pending label before governed command arguments arrive', () => { render(() => );