From 7e1f7860d62f05350c1bd12c2e2c45784b56766c Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 23 Mar 2026 20:08:48 +0000 Subject: [PATCH 1/2] feat(dashboard): redesign LLM Calls tab with activity visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a rich Activity column to the LLM Calls table so users can see what each call was doing at a glance — without expanding every row. **Parser layer (new)** - Add `src/utils/llmResponseParser.ts` and frontend twin `web/src/lib/llm-response-parser.ts` that normalize all four engine response formats (Claude Code/OpenCode JSON arrays, Codex JSON object, LLMist gadget markup, raw text fallback) into a canonical `ParsedLlmResponse` with `blocks[]`, `toolNames[]`, and `textPreview`. - `toolNames` carries one entry per invocation (NOT deduplicated) so ×N badge counts reflect actual call frequency. - `summarizeInput` extracts human-readable summaries for well-known tools (Read/Write/Edit → file_path, Bash → command, Glob/Grep → pattern, etc.) with consistent 100-char truncation. - Internal helpers (`flushGadgetArg`, `finalizeGadget`, `handleGadgetStart`, `processLlmistLine`, `processClaudeBlock`) extracted to module scope so each function stays under the cognitive complexity limit. **API layer** - `listLlmCallsMeta` now selects `response` alongside token/cost metadata. - `listLlmCalls` router runs the parser server-side and returns `toolNames` + `textPreview` per call; raw `response` payloads are NOT forwarded to the client. **UI — list view** - New Activity column: colored tool-name badges (sky=read, amber=bash, emerald=write, violet=web/agent) with ×N counts + truncated text preview. - `getToolStyle` extracted to `web/src/lib/tool-style.ts` (was duplicated in two components). - Inter-call Δ Time column computed from `createdAt` timestamps. - Summary stat cards (Total Calls, Input Tokens, Output Tokens, Cached %). - Row expansion uses `` to avoid React key warnings. - Keyboard-accessible rows (`tabIndex={0}`, Enter/Space toggles). - `isError` guard added alongside the existing `isLoading` guard. **UI — detail view** - Structured blocks view: text (muted bg), tool_use (colored badge + monospace inputSummary), thinking (collapsible `
`). - "Raw" / "Structured" toggle; raw JSON parse is lazy (only runs when the pane is shown). - Metadata bar with model (font-mono), time, tokens, cached, cost. - `buildMetaItems` extracted to reduce component complexity; `MetaItem` type replaces fragile string-identity check for `font-mono`. - `isError` guard added. **Tests** - New `tests/unit/utils/llmResponseParser.test.ts` covering all five format branches (Claude Code, Codex, LLMist, fallback, empty/null) plus edge cases (dedup, truncation, multi-line args, unknown blocks). - New router test verifies `toolNames` and `textPreview` are extracted from a real Claude Code response payload, not just from `null` responses. Co-Authored-By: Claude Sonnet 4.6 --- src/api/routers/runs.ts | 21 +- src/db/repositories/llmCallsRepository.ts | 1 + src/utils/llmResponseParser.ts | 290 ++++++++++++++++++ tests/unit/api/routers/runs.test.ts | 43 ++- tests/unit/utils/llmResponseParser.test.ts | 258 ++++++++++++++++ .../components/llm-calls/llm-call-detail.tsx | 172 +++++++++-- .../components/llm-calls/llm-call-list.tsx | 232 ++++++++++---- web/src/lib/llm-response-parser.ts | 290 ++++++++++++++++++ web/src/lib/tool-style.ts | 29 ++ 9 files changed, 1237 insertions(+), 99 deletions(-) create mode 100644 src/utils/llmResponseParser.ts create mode 100644 tests/unit/utils/llmResponseParser.test.ts create mode 100644 web/src/lib/llm-response-parser.ts create mode 100644 web/src/lib/tool-style.ts diff --git a/src/api/routers/runs.ts b/src/api/routers/runs.ts index 374ca937..a8a0684f 100644 --- a/src/api/routers/runs.ts +++ b/src/api/routers/runs.ts @@ -16,6 +16,7 @@ import { } from '../../db/repositories/runsRepository.js'; import { publishCancelCommand } from '../../queue/cancel.js'; import { isAnalysisRunning } from '../../triggers/shared/debug-status.js'; +import { parseLlmResponse } from '../../utils/llmResponseParser.js'; import { logger } from '../../utils/logging.js'; import { protectedProcedure, router, superAdminProcedure } from '../trpc.js'; import { verifyProjectOrgAccess } from './_shared/projectAccess.js'; @@ -116,7 +117,25 @@ export const runsRouter = router({ if (!ctx.effectiveOrgId) throw new TRPCError({ code: 'UNAUTHORIZED' }); await verifyProjectOrgAccess(run.projectId, ctx.effectiveOrgId); } - return listLlmCallsMeta(input.runId); + const raw = await listLlmCallsMeta(input.runId); + const calls = raw.map((c) => { + const { toolNames, textPreview } = parseLlmResponse(c.response); + return { + id: c.id, + runId: c.runId, + callNumber: c.callNumber, + inputTokens: c.inputTokens, + outputTokens: c.outputTokens, + cachedTokens: c.cachedTokens, + costUsd: c.costUsd, + durationMs: c.durationMs, + model: c.model, + createdAt: c.createdAt, + toolNames, + textPreview, + }; + }); + return { engine: run.engine ?? 'unknown', calls }; }), getLlmCall: protectedProcedure diff --git a/src/db/repositories/llmCallsRepository.ts b/src/db/repositories/llmCallsRepository.ts index e698a649..b2d1a396 100644 --- a/src/db/repositories/llmCallsRepository.ts +++ b/src/db/repositories/llmCallsRepository.ts @@ -90,6 +90,7 @@ export async function listLlmCallsMeta(runId: string) { durationMs: agentRunLlmCalls.durationMs, model: agentRunLlmCalls.model, createdAt: agentRunLlmCalls.createdAt, + response: agentRunLlmCalls.response, }) .from(agentRunLlmCalls) .where(eq(agentRunLlmCalls.runId, runId)) diff --git a/src/utils/llmResponseParser.ts b/src/utils/llmResponseParser.ts new file mode 100644 index 00000000..a3f573b8 --- /dev/null +++ b/src/utils/llmResponseParser.ts @@ -0,0 +1,290 @@ +/** + * Normalizes LLM call response payloads from different engines into a canonical structure. + * + * Engine formats: + * - Claude Code / OpenCode: JSON array of content blocks [{type, ...}] + * - Codex: JSON object {turn, text?, tools?: string[], usage?} + * - LLMist: raw text with !!!GADGET_START:ToolName / !!!GADGET_END markup + * + * NOTE: This file is mirrored at web/src/lib/llm-response-parser.ts. Keep both in sync. + */ + +export type ParsedBlock = + | { kind: 'text'; text: string } + | { kind: 'tool_use'; name: string; inputSummary: string } + | { kind: 'thinking'; text: string }; + +export interface ParsedLlmResponse { + blocks: ParsedBlock[]; + /** + * Tool names in invocation order, one entry per call (NOT deduplicated). + * Callers that need unique names should use `new Set(toolNames)`. + * Includes duplicates so that ×N badge counts reflect actual call frequency. + */ + toolNames: string[]; + /** First text block truncated to ~120 chars, empty string if none */ + textPreview: string; +} + +const GADGET_START = '!!!GADGET_START:'; +const GADGET_END = '!!!GADGET_END'; +const GADGET_ARG = '!!!ARG:'; + +// Args to prefer when building an inputSummary for LLMist gadget calls +const PRIORITY_ARGS = [ + 'command', + 'filename', + 'path', + 'file_path', + 'workItemId', + 'query', + 'comment', +]; + +/** Accumulator for one in-progress LLMist gadget call */ +interface GadgetAccum { + name: string; + args: Record; + currentArgKey: string | null; + currentArgLines: string[]; +} + +/** Mutable state threaded through the LLMist line-by-line parser */ +interface LlmistState { + blocks: ParsedBlock[]; + toolNames: string[]; + current: GadgetAccum | null; + preGadgetLines: string[]; + foundFirstGadget: boolean; + textPreview: string; +} + +/** Truncate a string to maxLen chars, appending `…` if truncated */ +function truncate(s: string, maxLen: number): string { + return s.length > maxLen ? `${s.slice(0, maxLen)}…` : s; +} + +/** Flush the current in-progress arg value into accum.args */ +function flushGadgetArg(accum: GadgetAccum): void { + if (accum.currentArgKey !== null) { + accum.args[accum.currentArgKey] = accum.currentArgLines.join('\n').trim(); + accum.currentArgKey = null; + accum.currentArgLines = []; + } +} + +/** Finalize a completed gadget call and push it onto the output arrays */ +function finalizeGadget(accum: GadgetAccum, blocks: ParsedBlock[], toolNames: string[]): void { + flushGadgetArg(accum); + const { name, args } = accum; + + // Build inputSummary: scan priority args first, then fall back to first arg + let inputSummary = ''; + for (const key of PRIORITY_ARGS) { + const val = args[key]; + if (typeof val === 'string' && val) { + inputSummary = truncate(val.replace(/\n/g, ' ').trim(), 100); + break; + } + } + if (!inputSummary) { + const firstVal = Object.values(args)[0]; + if (firstVal) inputSummary = truncate(firstVal.replace(/\n/g, ' ').trim(), 80); + } + + blocks.push({ kind: 'tool_use', name, inputSummary }); + toolNames.push(name); // one entry per invocation, not deduplicated +} + +function handleGadgetStart(line: string, s: LlmistState): void { + if (!s.foundFirstGadget) { + s.foundFirstGadget = true; + const pre = s.preGadgetLines.join('\n').trim(); + if (pre) { + s.blocks.push({ kind: 'text', text: pre }); + s.textPreview = truncate(pre, 120); + } + } + if (s.current) finalizeGadget(s.current, s.blocks, s.toolNames); + const name = line.slice(GADGET_START.length).split(':')[0].trim(); + s.current = { name, args: {}, currentArgKey: null, currentArgLines: [] }; +} + +function handleGadgetEnd(s: LlmistState): void { + if (s.current) finalizeGadget(s.current, s.blocks, s.toolNames); + s.current = null; +} + +function handleGadgetArg(line: string, s: LlmistState): void { + if (!s.current) return; + flushGadgetArg(s.current); + s.current.currentArgKey = line.slice(GADGET_ARG.length).trim(); + s.current.currentArgLines = []; +} + +function processLlmistLine(line: string, s: LlmistState): void { + if (line.startsWith(GADGET_START)) { + handleGadgetStart(line, s); + } else if (line.startsWith(GADGET_END)) { + handleGadgetEnd(s); + } else if (line.startsWith(GADGET_ARG)) { + handleGadgetArg(line, s); + } else if (s.current !== null && s.current.currentArgKey !== null) { + s.current.currentArgLines.push(line); + } else if (!s.foundFirstGadget) { + s.preGadgetLines.push(line); + } +} + +/** + * Build a human-readable summary of a tool's input object. + * Prefers well-known field names over raw JSON stringification. + */ +function summarizeInput(name: string, input: unknown): string { + if (!input || typeof input !== 'object') return ''; + const obj = input as Record; + + switch (name) { + case 'Read': + case 'Write': + case 'Edit': + if (typeof obj.file_path === 'string') return truncate(obj.file_path, 100); + break; + case 'Glob': + case 'Grep': + if (typeof obj.pattern === 'string') return truncate(obj.pattern, 100); + break; + case 'Bash': + if (typeof obj.command === 'string') + return truncate(obj.command.replace(/\n/g, ' ').trim(), 100); + break; + case 'WebFetch': + if (typeof obj.url === 'string') return truncate(obj.url, 100); + break; + case 'WebSearch': + if (typeof obj.query === 'string') return truncate(obj.query, 100); + break; + } + + try { + return truncate(JSON.stringify(input), 80); + } catch { + return ''; + } +} + +/** Process one Claude Code content block; returns a textPreview candidate or null */ +function processClaudeBlock( + block: Record, + blocks: ParsedBlock[], + toolNames: string[], +): string | null { + if (block.type === 'text' && typeof block.text === 'string') { + blocks.push({ kind: 'text', text: block.text }); + return block.text; + } + if (block.type === 'tool_use' && typeof block.name === 'string') { + const name = block.name; + blocks.push({ kind: 'tool_use', name, inputSummary: summarizeInput(name, block.input) }); + toolNames.push(name); // one entry per invocation, not deduplicated + } else if (block.type === 'thinking' && typeof block.thinking === 'string') { + blocks.push({ kind: 'thinking', text: block.thinking }); + } + return null; +} + +/** Parse LLMist format: raw text with !!!GADGET_START:Name / !!!ARG:key / !!!GADGET_END markers */ +function parseLlmistResponse(rawResponse: string): ParsedLlmResponse { + const s: LlmistState = { + blocks: [], + toolNames: [], + current: null, + preGadgetLines: [], + foundFirstGadget: false, + textPreview: '', + }; + + for (const line of rawResponse.split('\n')) { + processLlmistLine(line, s); + } + if (s.current) finalizeGadget(s.current, s.blocks, s.toolNames); + + return { blocks: s.blocks, toolNames: s.toolNames, textPreview: s.textPreview }; +} + +/** Parse Claude Code / OpenCode format: JSON array of typed content blocks */ +function parseClaudeCodeBlocks(parsed: unknown[]): ParsedLlmResponse { + const blocks: ParsedBlock[] = []; + const toolNames: string[] = []; + let textPreview = ''; + + for (const item of parsed) { + if (!item || typeof item !== 'object') continue; + const candidate = processClaudeBlock(item as Record, blocks, toolNames); + if (candidate !== null && !textPreview) { + textPreview = truncate(candidate, 120); + } + } + + return { blocks, toolNames, textPreview }; +} + +/** Parse Codex format: {turn, text?, tools?: string[], usage?} */ +function parseCodexPayload(parsed: Record): ParsedLlmResponse { + const blocks: ParsedBlock[] = []; + const toolNames: string[] = []; + let textPreview = ''; + + if (typeof parsed.text === 'string' && parsed.text) { + blocks.push({ kind: 'text', text: parsed.text }); + textPreview = truncate(parsed.text, 120); + } + + if (Array.isArray(parsed.tools)) { + for (const name of parsed.tools) { + if (typeof name === 'string') { + blocks.push({ kind: 'tool_use', name, inputSummary: '' }); + toolNames.push(name); + } + } + } + + return { blocks, toolNames, textPreview }; +} + +export function parseLlmResponse(rawResponse: string | null | undefined): ParsedLlmResponse { + if (!rawResponse) return { blocks: [], toolNames: [], textPreview: '' }; + + // LLMist: raw text with gadget call markup + if (rawResponse.includes(GADGET_START)) { + return parseLlmistResponse(rawResponse); + } + + let parsed: unknown; + try { + parsed = JSON.parse(rawResponse); + } catch { + // Unparseable — treat as raw text + return { + blocks: [{ kind: 'text', text: rawResponse }], + toolNames: [], + textPreview: truncate(rawResponse, 120), + }; + } + + // Claude Code / OpenCode: array of content blocks + if (Array.isArray(parsed)) { + return parseClaudeCodeBlocks(parsed); + } + + // Codex: object with tools array and/or text + if ( + parsed !== null && + typeof parsed === 'object' && + ('tools' in (parsed as object) || 'text' in (parsed as object)) + ) { + return parseCodexPayload(parsed as Record); + } + + return { blocks: [], toolNames: [], textPreview: '' }; +} diff --git a/tests/unit/api/routers/runs.test.ts b/tests/unit/api/routers/runs.test.ts index 638ddac7..7e252ab0 100644 --- a/tests/unit/api/routers/runs.test.ts +++ b/tests/unit/api/routers/runs.test.ts @@ -375,19 +375,22 @@ describe('runsRouter', () => { }); describe('listLlmCalls', () => { - it('returns LLM call metadata list', async () => { + it('returns LLM call metadata list with engine and enriched calls', async () => { const mockMeta = [ - { callNumber: 1, inputTokens: 100 }, - { callNumber: 2, inputTokens: 200 }, + { callNumber: 1, inputTokens: 100, response: null }, + { callNumber: 2, inputTokens: 200, response: null }, ]; - mockGetRunById.mockResolvedValue({ id: RUN_UUID, projectId: 'p1' }); + mockGetRunById.mockResolvedValue({ id: RUN_UUID, projectId: 'p1', engine: 'claude-code' }); mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); mockListLlmCallsMeta.mockResolvedValue(mockMeta); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); const result = await caller.listLlmCalls({ runId: RUN_UUID }); - expect(result).toEqual(mockMeta); + expect(result.engine).toBe('claude-code'); + expect(result.calls).toHaveLength(2); + expect(result.calls[0]).toMatchObject({ callNumber: 1, inputTokens: 100 }); + expect(result.calls[1]).toMatchObject({ callNumber: 2, inputTokens: 200 }); }); it('includes model and createdAt in returned metadata', async () => { @@ -399,21 +402,40 @@ describe('runsRouter', () => { outputTokens: 50, model: 'claude-sonnet-4-5', createdAt, + response: null, }, ]; - mockGetRunById.mockResolvedValue({ id: RUN_UUID, projectId: 'p1' }); + mockGetRunById.mockResolvedValue({ id: RUN_UUID, projectId: 'p1', engine: 'llmist' }); mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); mockListLlmCallsMeta.mockResolvedValue(mockMeta); const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); const result = await caller.listLlmCalls({ runId: RUN_UUID }); - expect(result[0]).toMatchObject({ + expect(result.calls[0]).toMatchObject({ model: 'claude-sonnet-4-5', createdAt, }); }); + it('extracts toolNames and textPreview from a Claude Code response payload', async () => { + const claudeCodeResponse = JSON.stringify([ + { type: 'text', text: 'Let me read the file.' }, + { type: 'tool_use', name: 'Read', input: { file_path: '/src/index.ts' } }, + { type: 'tool_use', name: 'Read', input: { file_path: '/src/utils.ts' } }, + { type: 'tool_use', name: 'Bash', input: { command: 'npm test' } }, + ]); + mockGetRunById.mockResolvedValue({ id: RUN_UUID, projectId: 'p1', engine: 'claude-code' }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockListLlmCallsMeta.mockResolvedValue([{ callNumber: 1, response: claudeCodeResponse }]); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.listLlmCalls({ runId: RUN_UUID }); + + expect(result.calls[0].toolNames).toEqual(['Read', 'Read', 'Bash']); + expect(result.calls[0].textPreview).toBe('Let me read the file.'); + }); + it('throws NOT_FOUND when run does not exist', async () => { mockGetRunById.mockResolvedValue(null); @@ -434,14 +456,15 @@ describe('runsRouter', () => { }); it('allows superadmin to list LLM calls from any org', async () => { - mockGetRunById.mockResolvedValue({ id: RUN_UUID, projectId: 'p1' }); - mockListLlmCallsMeta.mockResolvedValue([{ callNumber: 1 }]); + mockGetRunById.mockResolvedValue({ id: RUN_UUID, projectId: 'p1', engine: 'codex' }); + mockListLlmCallsMeta.mockResolvedValue([{ callNumber: 1, response: null }]); const superAdmin = createMockSuperAdmin(); const caller = createCaller({ user: superAdmin, effectiveOrgId: 'other-org' }); const result = await caller.listLlmCalls({ runId: RUN_UUID }); - expect(result).toEqual([{ callNumber: 1 }]); + expect(result.engine).toBe('codex'); + expect(result.calls[0]).toMatchObject({ callNumber: 1 }); expect(mockDbSelect).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/utils/llmResponseParser.test.ts b/tests/unit/utils/llmResponseParser.test.ts new file mode 100644 index 00000000..2cf05db8 --- /dev/null +++ b/tests/unit/utils/llmResponseParser.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from 'vitest'; +import { parseLlmResponse } from '../../../src/utils/llmResponseParser.js'; + +describe.concurrent('parseLlmResponse', () => { + describe('null / empty input', () => { + it('returns empty result for null', () => { + const result = parseLlmResponse(null); + expect(result).toEqual({ blocks: [], toolNames: [], textPreview: '' }); + }); + + it('returns empty result for undefined', () => { + const result = parseLlmResponse(undefined); + expect(result).toEqual({ blocks: [], toolNames: [], textPreview: '' }); + }); + + it('returns empty result for empty string', () => { + const result = parseLlmResponse(''); + expect(result).toEqual({ blocks: [], toolNames: [], textPreview: '' }); + }); + + it('returns fresh objects (not a shared singleton)', () => { + const a = parseLlmResponse(null); + const b = parseLlmResponse(null); + expect(a).not.toBe(b); + expect(a.toolNames).not.toBe(b.toolNames); + }); + }); + + describe('Claude Code / OpenCode format (JSON array)', () => { + it('parses text block', () => { + const response = JSON.stringify([{ type: 'text', text: 'Hello world' }]); + const result = parseLlmResponse(response); + expect(result.blocks).toEqual([{ kind: 'text', text: 'Hello world' }]); + expect(result.textPreview).toBe('Hello world'); + expect(result.toolNames).toEqual([]); + }); + + it('parses tool_use block with inputSummary for Read', () => { + const response = JSON.stringify([ + { type: 'tool_use', name: 'Read', input: { file_path: '/src/foo.ts' } }, + ]); + const result = parseLlmResponse(response); + expect(result.blocks).toEqual([ + { kind: 'tool_use', name: 'Read', inputSummary: '/src/foo.ts' }, + ]); + expect(result.toolNames).toEqual(['Read']); + }); + + it('parses tool_use block with inputSummary for Bash', () => { + const response = JSON.stringify([ + { type: 'tool_use', name: 'Bash', input: { command: 'npm test\nnpm run build' } }, + ]); + const result = parseLlmResponse(response); + expect(result.blocks[0]).toMatchObject({ kind: 'tool_use', name: 'Bash' }); + expect((result.blocks[0] as { inputSummary: string }).inputSummary).toBe( + 'npm test npm run build', + ); + }); + + it('parses thinking block', () => { + const response = JSON.stringify([{ type: 'thinking', thinking: 'Let me think...' }]); + const result = parseLlmResponse(response); + expect(result.blocks).toEqual([{ kind: 'thinking', text: 'Let me think...' }]); + expect(result.toolNames).toEqual([]); + }); + + it('preserves duplicate tool names (not deduplicated)', () => { + const response = JSON.stringify([ + { type: 'tool_use', name: 'Read', input: { file_path: '/a.ts' } }, + { type: 'tool_use', name: 'Read', input: { file_path: '/b.ts' } }, + { type: 'tool_use', name: 'Bash', input: { command: 'ls' } }, + ]); + const result = parseLlmResponse(response); + expect(result.toolNames).toEqual(['Read', 'Read', 'Bash']); + }); + + it('sets textPreview from first text block only', () => { + const response = JSON.stringify([ + { type: 'text', text: 'First' }, + { type: 'text', text: 'Second' }, + ]); + const result = parseLlmResponse(response); + expect(result.textPreview).toBe('First'); + }); + + it('truncates textPreview at 120 chars', () => { + const longText = 'A'.repeat(200); + const response = JSON.stringify([{ type: 'text', text: longText }]); + const result = parseLlmResponse(response); + expect(result.textPreview).toBe(`${'A'.repeat(120)}…`); + }); + + it('truncates long file_path inputSummary at 100 chars', () => { + const longPath = `/very/deep/${'a'.repeat(200)}`; + const response = JSON.stringify([ + { type: 'tool_use', name: 'Read', input: { file_path: longPath } }, + ]); + const result = parseLlmResponse(response); + const block = result.blocks[0] as { inputSummary: string }; + expect(block.inputSummary.length).toBe(101); // 100 chars + '…' + expect(block.inputSummary.endsWith('…')).toBe(true); + }); + + it('skips unknown block types', () => { + const response = JSON.stringify([ + { type: 'unknown', data: 'ignored' }, + { type: 'text', text: 'visible' }, + ]); + const result = parseLlmResponse(response); + expect(result.blocks).toHaveLength(1); + expect(result.blocks[0]).toMatchObject({ kind: 'text', text: 'visible' }); + }); + + it('falls back to JSON.stringify for unknown tool input', () => { + const response = JSON.stringify([ + { type: 'tool_use', name: 'CustomTool', input: { foo: 'bar' } }, + ]); + const result = parseLlmResponse(response); + expect(result.blocks[0]).toMatchObject({ + kind: 'tool_use', + name: 'CustomTool', + inputSummary: '{"foo":"bar"}', + }); + }); + }); + + describe('Codex format (JSON object)', () => { + it('parses text and tools', () => { + const response = JSON.stringify({ turn: 1, text: 'Done', tools: ['Read', 'Bash'] }); + const result = parseLlmResponse(response); + expect(result.blocks).toEqual([ + { kind: 'text', text: 'Done' }, + { kind: 'tool_use', name: 'Read', inputSummary: '' }, + { kind: 'tool_use', name: 'Bash', inputSummary: '' }, + ]); + expect(result.toolNames).toEqual(['Read', 'Bash']); + expect(result.textPreview).toBe('Done'); + }); + + it('handles missing text field', () => { + const response = JSON.stringify({ turn: 1, tools: ['Write'] }); + const result = parseLlmResponse(response); + expect(result.blocks).toEqual([{ kind: 'tool_use', name: 'Write', inputSummary: '' }]); + expect(result.textPreview).toBe(''); + }); + + it('handles missing tools field', () => { + const response = JSON.stringify({ turn: 1, text: 'Summary only' }); + const result = parseLlmResponse(response); + expect(result.blocks).toEqual([{ kind: 'text', text: 'Summary only' }]); + expect(result.toolNames).toEqual([]); + }); + }); + + describe('LLMist format (gadget markup)', () => { + const gadget = (name: string, args: Record) => { + const argLines = Object.entries(args) + .map(([k, v]) => `!!!ARG:${k}\n${v}`) + .join('\n'); + return `!!!GADGET_START:${name}\n${argLines}\n!!!GADGET_END`; + }; + + it('parses a single gadget call', () => { + const response = gadget('Read', { file_path: '/src/index.ts' }); + const result = parseLlmResponse(response); + expect(result.blocks).toEqual([ + { kind: 'tool_use', name: 'Read', inputSummary: '/src/index.ts' }, + ]); + expect(result.toolNames).toEqual(['Read']); + }); + + it('parses pre-gadget text as textPreview', () => { + const response = `I will read the file now.\n${gadget('Read', { file_path: '/a.ts' })}`; + const result = parseLlmResponse(response); + expect(result.blocks[0]).toMatchObject({ kind: 'text', text: 'I will read the file now.' }); + expect(result.textPreview).toBe('I will read the file now.'); + expect(result.blocks[1]).toMatchObject({ kind: 'tool_use', name: 'Read' }); + }); + + it('preserves duplicate tool names (not deduplicated)', () => { + const response = [ + gadget('Read', { file_path: '/a.ts' }), + gadget('Read', { file_path: '/b.ts' }), + gadget('Bash', { command: 'ls' }), + ].join('\n'); + const result = parseLlmResponse(response); + expect(result.toolNames).toEqual(['Read', 'Read', 'Bash']); + }); + + it('prefers priority args for inputSummary', () => { + const response = gadget('CustomTool', { some_other: 'ignored', command: 'npm test' }); + const result = parseLlmResponse(response); + expect((result.blocks[0] as { inputSummary: string }).inputSummary).toBe('npm test'); + }); + + it('falls back to first arg when no priority arg present', () => { + const response = gadget('CustomTool', { custom_arg: 'some value' }); + const result = parseLlmResponse(response); + expect((result.blocks[0] as { inputSummary: string }).inputSummary).toBe('some value'); + }); + + it('handles multi-line arg values', () => { + const response = + '!!!GADGET_START:Write\n!!!ARG:file_path\n/a.ts\n!!!ARG:content\nline1\nline2\n!!!GADGET_END'; + const result = parseLlmResponse(response); + expect(result.blocks[0]).toMatchObject({ + kind: 'tool_use', + name: 'Write', + inputSummary: '/a.ts', + }); + }); + + it('truncates long inputSummary at 100 chars', () => { + const longPath = `/very/deep/${'x'.repeat(200)}`; + const response = gadget('Read', { file_path: longPath }); + const block = result_inputSummary(parseLlmResponse(response)); + expect(block.length).toBe(101); + expect(block.endsWith('…')).toBe(true); + }); + }); + + describe('fallback for unparseable input', () => { + it('treats plain text as a text block', () => { + const response = 'This is just plain text, not JSON.'; + const result = parseLlmResponse(response); + expect(result.blocks).toEqual([{ kind: 'text', text: response }]); + expect(result.toolNames).toEqual([]); + expect(result.textPreview).toBe(response); + }); + + it('truncates long plain text preview', () => { + const response = 'X'.repeat(200); + const result = parseLlmResponse(response); + expect(result.textPreview).toBe(`${'X'.repeat(120)}…`); + }); + }); + + describe('edge cases', () => { + it('ignores non-object items in JSON array', () => { + const response = JSON.stringify([null, 'string', 42, { type: 'text', text: 'ok' }]); + const result = parseLlmResponse(response); + expect(result.blocks).toEqual([{ kind: 'text', text: 'ok' }]); + }); + + it('returns empty for JSON object without text or tools keys', () => { + const response = JSON.stringify({ turn: 1, usage: { total: 100 } }); + const result = parseLlmResponse(response); + expect(result).toEqual({ blocks: [], toolNames: [], textPreview: '' }); + }); + }); +}); + +// Helper to extract inputSummary from first block +function result_inputSummary(result: ReturnType): string { + const block = result.blocks[0]; + if (block?.kind === 'tool_use') return block.inputSummary; + return ''; +} diff --git a/web/src/components/llm-calls/llm-call-detail.tsx b/web/src/components/llm-calls/llm-call-detail.tsx index 794f1581..5f0eab66 100644 --- a/web/src/components/llm-calls/llm-call-detail.tsx +++ b/web/src/components/llm-calls/llm-call-detail.tsx @@ -1,5 +1,7 @@ +import { type ParsedBlock, parseLlmResponse } from '@/lib/llm-response-parser.js'; +import { getToolStyle } from '@/lib/tool-style.js'; import { trpc } from '@/lib/trpc.js'; -import { cn } from '@/lib/utils.js'; +import { formatCost } from '@/lib/utils.js'; import { useQuery } from '@tanstack/react-query'; import { useState } from 'react'; @@ -8,52 +10,156 @@ interface LlmCallDetailProps { callNumber: number; } -type DetailTab = 'request' | 'response'; +interface MetaItem { + label: string; + mono?: boolean; +} + +function TextBlock({ text }: { text: string }) { + return ( +
+ {text} +
+ ); +} + +function ToolUseBlock({ name, inputSummary }: { name: string; inputSummary: string }) { + const { bg, text } = getToolStyle(name); + return ( +
+ + {name} + + {inputSummary && ( + + {inputSummary} + + )} +
+ ); +} + +function ThinkingBlock({ text }: { text: string }) { + return ( +
+ + Thinking ({text.length.toLocaleString()} chars) + +
+				{text}
+			
+
+ ); +} + +function ParsedBlockList({ blocks }: { blocks: ParsedBlock[] }) { + return ( +
+ {blocks.map((block, i) => { + const key = `${i}-${block.kind}`; + if (block.kind === 'text') return ; + if (block.kind === 'tool_use') + return ; + if (block.kind === 'thinking') return ; + return null; + })} +
+ ); +} + +function formatRawContent(response: string | null | undefined): string { + if (!response) return 'No content'; + try { + return JSON.stringify(JSON.parse(response), null, 2); + } catch { + return response; + } +} + +function buildMetaItems(call: { + model?: string | null; + createdAt?: Date | string | null; + inputTokens?: number | null; + outputTokens?: number | null; + cachedTokens?: number | null; + costUsd?: string | null; +}): MetaItem[] { + const items: MetaItem[] = []; + if (call.model) items.push({ label: call.model, mono: true }); + if (call.createdAt) { + const timeStr = new Date(call.createdAt).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + items.push({ label: timeStr }); + } + const tokenParts: string[] = []; + if (call.inputTokens != null) tokenParts.push(`${call.inputTokens.toLocaleString()} in`); + if (call.outputTokens != null) tokenParts.push(`${call.outputTokens.toLocaleString()} out`); + if (tokenParts.length > 0) items.push({ label: tokenParts.join(' / ') }); + if (call.cachedTokens && call.cachedTokens > 0) + items.push({ label: `+${call.cachedTokens.toLocaleString()} cached` }); + const costStr = formatCost(call.costUsd); + if (costStr !== '—') items.push({ label: costStr }); + return items; +} export function LlmCallDetail({ runId, callNumber }: LlmCallDetailProps) { - const [tab, setTab] = useState('response'); + const [showRaw, setShowRaw] = useState(false); const callQuery = useQuery(trpc.runs.getLlmCall.queryOptions({ runId, callNumber })); if (callQuery.isLoading) { - return
Loading call detail...
; + return
Loading...
; } - if (!callQuery.data) { + if (callQuery.isError || !callQuery.data) { return
Failed to load call
; } - const content = tab === 'request' ? callQuery.data.request : callQuery.data.response; - - let formattedContent: string; - try { - formattedContent = content ? JSON.stringify(JSON.parse(content), null, 2) : 'No content'; - } catch { - formattedContent = content ?? 'No content'; - } + const call = callQuery.data; + const parsed = parseLlmResponse(call.response); + const hasContent = parsed.blocks.length > 0; + const metaItems = buildMetaItems(call); return ( -
-
- {(['request', 'response'] as const).map((t) => ( - - ))} +
+ {/* Metadata bar */} + {metaItems.length > 0 && ( +
+ {metaItems.map((item) => ( + + {item.label} + + ))} +
+ )} + + {/* Raw toggle */} +
+ Content +
-
-				{formattedContent}
-			
+ + {showRaw ? ( +
+					{formatRawContent(call.response)}
+				
+ ) : !hasContent ? ( +
+ No response payload stored for this engine. +
+ ) : ( + + )}
); } diff --git a/web/src/components/llm-calls/llm-call-list.tsx b/web/src/components/llm-calls/llm-call-list.tsx index a1377ed0..13737263 100644 --- a/web/src/components/llm-calls/llm-call-list.tsx +++ b/web/src/components/llm-calls/llm-call-list.tsx @@ -1,13 +1,131 @@ +import { getToolStyle } from '@/lib/tool-style.js'; import { trpc } from '@/lib/trpc.js'; -import { formatCost, formatDuration } from '@/lib/utils.js'; +import { formatCost } from '@/lib/utils.js'; import { useQuery } from '@tanstack/react-query'; -import { useState } from 'react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { Fragment, useState } from 'react'; import { LlmCallDetail } from './llm-call-detail.js'; interface LlmCallListProps { runId: string; } +type CallMeta = { + callNumber: number; + inputTokens?: number | null; + outputTokens?: number | null; + cachedTokens?: number | null; + costUsd?: string | null; + durationMs?: number | null; + model?: string | null; + createdAt?: Date | string | null; + toolNames?: string[] | null; + textPreview?: string | null; +}; + +function ToolBadges({ toolNames }: { toolNames: string[] }) { + if (toolNames.length === 0) return null; + + // Count occurrences + const counts = new Map(); + for (const name of toolNames) { + counts.set(name, (counts.get(name) ?? 0) + 1); + } + const unique = [...new Set(toolNames)]; + + return ( + + {unique.map((name) => { + const { bg, text } = getToolStyle(name); + const count = counts.get(name) ?? 1; + return ( + + {name} + {count > 1 && ×{count}} + + ); + })} + + ); +} + +function formatDelta(ms: number): string { + if (ms < 1000) return `+${ms}ms`; + if (ms < 60_000) return `+${(ms / 1000).toFixed(0)}s`; + const m = Math.floor(ms / 60_000); + const s = Math.round((ms % 60_000) / 1000); + return s > 0 ? `+${m}m ${s}s` : `+${m}m`; +} + +interface CallRowProps { + runId: string; + call: CallMeta; + delta: string | null; + isExpanded: boolean; + onToggle: () => void; +} + +function CallRow({ runId, call, delta, isExpanded, onToggle }: CallRowProps) { + return ( + + { + if (e.key === 'Enter' || e.key === ' ') onToggle(); + }} + tabIndex={0} + className="cursor-pointer border-b border-border transition-colors hover:bg-muted/30" + > + + {call.callNumber} + + +
+ + {call.textPreview && ( + + {call.textPreview} + + )} + {!call.toolNames?.length && !call.textPreview && ( + + )} +
+ + + {call.inputTokens?.toLocaleString() ?? '—'} + + + {call.outputTokens?.toLocaleString() ?? '—'} + + + {formatCost(call.costUsd)} + + + {delta ?? '—'} + + + {isExpanded ? ( + + ) : ( + + )} + + + {isExpanded && ( + + + + + + )} +
+ ); +} + export function LlmCallList({ runId }: LlmCallListProps) { const [expandedCall, setExpandedCall] = useState(null); @@ -17,7 +135,11 @@ export function LlmCallList({ runId }: LlmCallListProps) { return
Loading LLM calls...
; } - const calls = callsQuery.data ?? []; + if (callsQuery.isError) { + return
Failed to load LLM calls
; + } + + const calls = callsQuery.data?.calls ?? []; if (calls.length === 0) { return
No LLM calls recorded
; @@ -33,6 +155,7 @@ export function LlmCallList({ runId }: LlmCallListProps) { return (
+ {/* Summary stat cards */}
Total Calls
@@ -47,78 +170,77 @@ export function LlmCallList({ runId }: LlmCallListProps) {
{totalTokensOut.toLocaleString()}
-
Total Cost
-
{formatCost(totalCost)}
+
+
Cached
+ {totalCachedTokens > 0 && totalTokensIn > 0 && ( +
+ {((totalCachedTokens / totalTokensIn) * 100).toFixed(0)}% +
+ )} +
+
+ {totalCachedTokens > 0 ? totalCachedTokens.toLocaleString() : '—'} +
+ {/* Cost row */} + {totalCost > 0 && ( +
+ Total cost: {formatCost(totalCost)} +
+ )} + + {/* Calls table */}
- - + + - - - - + - {calls.map((call) => ( - <> - { + // Inter-call delta from createdAt + let delta: string | null = null; + const prevCreatedAt = calls[idx - 1]?.createdAt; + if (idx > 0 && call.createdAt && prevCreatedAt) { + const prev = new Date(prevCreatedAt).getTime(); + const curr = new Date(call.createdAt).getTime(); + const diff = curr - prev; + if (diff >= 0) delta = formatDelta(diff); + } + + return ( + + runId={runId} + call={call} + delta={delta} + isExpanded={expandedCall === call.callNumber} + onToggle={() => setExpandedCall(expandedCall === call.callNumber ? null : call.callNumber) } - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') - setExpandedCall(expandedCall === call.callNumber ? null : call.callNumber); - }} - className="cursor-pointer border-b border-border transition-colors hover:bg-muted/30" - > - - - - - - - - {expandedCall === call.callNumber && ( - - - - )} - - ))} + /> + ); + })}
# - Input Tokens + #Activity + In - Output Tokens + + Out - Cached + + Cost Cost - Duration + + Δ Time
{call.callNumber} - {call.inputTokens?.toLocaleString() ?? '-'} - - {call.outputTokens?.toLocaleString() ?? '-'} - - {call.cachedTokens?.toLocaleString() ?? '-'} - {formatCost(call.costUsd)} - {formatDuration(call.durationMs)} -
- -
- - {totalCachedTokens > 0 && ( -
- Cache hit rate: {((totalCachedTokens / totalTokensIn) * 100).toFixed(1)}% of input tokens -
- )}
); } diff --git a/web/src/lib/llm-response-parser.ts b/web/src/lib/llm-response-parser.ts new file mode 100644 index 00000000..a1d570f8 --- /dev/null +++ b/web/src/lib/llm-response-parser.ts @@ -0,0 +1,290 @@ +/** + * Normalizes LLM call response payloads from different engines into a canonical structure. + * + * Engine formats: + * - Claude Code / OpenCode: JSON array of content blocks [{type, ...}] + * - Codex: JSON object {turn, text?, tools?: string[], usage?} + * - LLMist: raw text with !!!GADGET_START:ToolName / !!!GADGET_END markup + * + * NOTE: This file is mirrored at src/utils/llmResponseParser.ts. Keep both in sync. + */ + +export type ParsedBlock = + | { kind: 'text'; text: string } + | { kind: 'tool_use'; name: string; inputSummary: string } + | { kind: 'thinking'; text: string }; + +export interface ParsedLlmResponse { + blocks: ParsedBlock[]; + /** + * Tool names in invocation order, one entry per call (NOT deduplicated). + * Callers that need unique names should use `new Set(toolNames)`. + * Includes duplicates so that ×N badge counts reflect actual call frequency. + */ + toolNames: string[]; + /** First text block truncated to ~120 chars, empty string if none */ + textPreview: string; +} + +const GADGET_START = '!!!GADGET_START:'; +const GADGET_END = '!!!GADGET_END'; +const GADGET_ARG = '!!!ARG:'; + +// Args to prefer when building an inputSummary for LLMist gadget calls +const PRIORITY_ARGS = [ + 'command', + 'filename', + 'path', + 'file_path', + 'workItemId', + 'query', + 'comment', +]; + +/** Accumulator for one in-progress LLMist gadget call */ +interface GadgetAccum { + name: string; + args: Record; + currentArgKey: string | null; + currentArgLines: string[]; +} + +/** Mutable state threaded through the LLMist line-by-line parser */ +interface LlmistState { + blocks: ParsedBlock[]; + toolNames: string[]; + current: GadgetAccum | null; + preGadgetLines: string[]; + foundFirstGadget: boolean; + textPreview: string; +} + +/** Truncate a string to maxLen chars, appending `…` if truncated */ +function truncate(s: string, maxLen: number): string { + return s.length > maxLen ? `${s.slice(0, maxLen)}…` : s; +} + +/** Flush the current in-progress arg value into accum.args */ +function flushGadgetArg(accum: GadgetAccum): void { + if (accum.currentArgKey !== null) { + accum.args[accum.currentArgKey] = accum.currentArgLines.join('\n').trim(); + accum.currentArgKey = null; + accum.currentArgLines = []; + } +} + +/** Finalize a completed gadget call and push it onto the output arrays */ +function finalizeGadget(accum: GadgetAccum, blocks: ParsedBlock[], toolNames: string[]): void { + flushGadgetArg(accum); + const { name, args } = accum; + + // Build inputSummary: scan priority args first, then fall back to first arg + let inputSummary = ''; + for (const key of PRIORITY_ARGS) { + const val = args[key]; + if (typeof val === 'string' && val) { + inputSummary = truncate(val.replace(/\n/g, ' ').trim(), 100); + break; + } + } + if (!inputSummary) { + const firstVal = Object.values(args)[0]; + if (firstVal) inputSummary = truncate(firstVal.replace(/\n/g, ' ').trim(), 80); + } + + blocks.push({ kind: 'tool_use', name, inputSummary }); + toolNames.push(name); // one entry per invocation, not deduplicated +} + +function handleGadgetStart(line: string, s: LlmistState): void { + if (!s.foundFirstGadget) { + s.foundFirstGadget = true; + const pre = s.preGadgetLines.join('\n').trim(); + if (pre) { + s.blocks.push({ kind: 'text', text: pre }); + s.textPreview = truncate(pre, 120); + } + } + if (s.current) finalizeGadget(s.current, s.blocks, s.toolNames); + const name = line.slice(GADGET_START.length).split(':')[0].trim(); + s.current = { name, args: {}, currentArgKey: null, currentArgLines: [] }; +} + +function handleGadgetEnd(s: LlmistState): void { + if (s.current) finalizeGadget(s.current, s.blocks, s.toolNames); + s.current = null; +} + +function handleGadgetArg(line: string, s: LlmistState): void { + if (!s.current) return; + flushGadgetArg(s.current); + s.current.currentArgKey = line.slice(GADGET_ARG.length).trim(); + s.current.currentArgLines = []; +} + +function processLlmistLine(line: string, s: LlmistState): void { + if (line.startsWith(GADGET_START)) { + handleGadgetStart(line, s); + } else if (line.startsWith(GADGET_END)) { + handleGadgetEnd(s); + } else if (line.startsWith(GADGET_ARG)) { + handleGadgetArg(line, s); + } else if (s.current !== null && s.current.currentArgKey !== null) { + s.current.currentArgLines.push(line); + } else if (!s.foundFirstGadget) { + s.preGadgetLines.push(line); + } +} + +/** + * Build a human-readable summary of a tool's input object. + * Prefers well-known field names over raw JSON stringification. + */ +function summarizeInput(name: string, input: unknown): string { + if (!input || typeof input !== 'object') return ''; + const obj = input as Record; + + switch (name) { + case 'Read': + case 'Write': + case 'Edit': + if (typeof obj.file_path === 'string') return truncate(obj.file_path, 100); + break; + case 'Glob': + case 'Grep': + if (typeof obj.pattern === 'string') return truncate(obj.pattern, 100); + break; + case 'Bash': + if (typeof obj.command === 'string') + return truncate(obj.command.replace(/\n/g, ' ').trim(), 100); + break; + case 'WebFetch': + if (typeof obj.url === 'string') return truncate(obj.url, 100); + break; + case 'WebSearch': + if (typeof obj.query === 'string') return truncate(obj.query, 100); + break; + } + + try { + return truncate(JSON.stringify(input), 80); + } catch { + return ''; + } +} + +/** Process one Claude Code content block; returns a textPreview candidate or null */ +function processClaudeBlock( + block: Record, + blocks: ParsedBlock[], + toolNames: string[], +): string | null { + if (block.type === 'text' && typeof block.text === 'string') { + blocks.push({ kind: 'text', text: block.text }); + return block.text; + } + if (block.type === 'tool_use' && typeof block.name === 'string') { + const name = block.name; + blocks.push({ kind: 'tool_use', name, inputSummary: summarizeInput(name, block.input) }); + toolNames.push(name); // one entry per invocation, not deduplicated + } else if (block.type === 'thinking' && typeof block.thinking === 'string') { + blocks.push({ kind: 'thinking', text: block.thinking }); + } + return null; +} + +/** Parse LLMist format: raw text with !!!GADGET_START:Name / !!!ARG:key / !!!GADGET_END markers */ +function parseLlmistResponse(rawResponse: string): ParsedLlmResponse { + const s: LlmistState = { + blocks: [], + toolNames: [], + current: null, + preGadgetLines: [], + foundFirstGadget: false, + textPreview: '', + }; + + for (const line of rawResponse.split('\n')) { + processLlmistLine(line, s); + } + if (s.current) finalizeGadget(s.current, s.blocks, s.toolNames); + + return { blocks: s.blocks, toolNames: s.toolNames, textPreview: s.textPreview }; +} + +/** Parse Claude Code / OpenCode format: JSON array of typed content blocks */ +function parseClaudeCodeBlocks(parsed: unknown[]): ParsedLlmResponse { + const blocks: ParsedBlock[] = []; + const toolNames: string[] = []; + let textPreview = ''; + + for (const item of parsed) { + if (!item || typeof item !== 'object') continue; + const candidate = processClaudeBlock(item as Record, blocks, toolNames); + if (candidate !== null && !textPreview) { + textPreview = truncate(candidate, 120); + } + } + + return { blocks, toolNames, textPreview }; +} + +/** Parse Codex format: {turn, text?, tools?: string[], usage?} */ +function parseCodexPayload(parsed: Record): ParsedLlmResponse { + const blocks: ParsedBlock[] = []; + const toolNames: string[] = []; + let textPreview = ''; + + if (typeof parsed.text === 'string' && parsed.text) { + blocks.push({ kind: 'text', text: parsed.text }); + textPreview = truncate(parsed.text, 120); + } + + if (Array.isArray(parsed.tools)) { + for (const name of parsed.tools) { + if (typeof name === 'string') { + blocks.push({ kind: 'tool_use', name, inputSummary: '' }); + toolNames.push(name); + } + } + } + + return { blocks, toolNames, textPreview }; +} + +export function parseLlmResponse(rawResponse: string | null | undefined): ParsedLlmResponse { + if (!rawResponse) return { blocks: [], toolNames: [], textPreview: '' }; + + // LLMist: raw text with gadget call markup + if (rawResponse.includes(GADGET_START)) { + return parseLlmistResponse(rawResponse); + } + + let parsed: unknown; + try { + parsed = JSON.parse(rawResponse); + } catch { + // Unparseable — treat as raw text + return { + blocks: [{ kind: 'text', text: rawResponse }], + toolNames: [], + textPreview: truncate(rawResponse, 120), + }; + } + + // Claude Code / OpenCode: array of content blocks + if (Array.isArray(parsed)) { + return parseClaudeCodeBlocks(parsed); + } + + // Codex: object with tools array and/or text + if ( + parsed !== null && + typeof parsed === 'object' && + ('tools' in (parsed as object) || 'text' in (parsed as object)) + ) { + return parseCodexPayload(parsed as Record); + } + + return { blocks: [], toolNames: [], textPreview: '' }; +} diff --git a/web/src/lib/tool-style.ts b/web/src/lib/tool-style.ts new file mode 100644 index 00000000..7405d8b1 --- /dev/null +++ b/web/src/lib/tool-style.ts @@ -0,0 +1,29 @@ +/** + * Returns Tailwind color classes for a tool/gadget name badge. + * Used by both the LLM call list row and the call detail panel. + */ +export function getToolStyle(name: string): { bg: string; text: string } { + // Read-like: file reads, searches, work item fetching + if (/^(Read|Glob|Grep|LS|ReadWorkItem|ReadFile|FetchWorkItem)$/.test(name)) + return { bg: 'bg-sky-100 dark:bg-sky-900/30', text: 'text-sky-700 dark:text-sky-400' }; + // Bash-like: shell execution, tmux sessions + if (/^(Bash|Shell|Tmux|RunCommand|Exec)$/.test(name)) + return { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-400' }; + // Write-like: file writes, task/todo mutations + if ( + /^(Write|Edit|Create|NotebookEdit|TodoUpsert|TodoUpdateStatus|WriteFile|CreateFile|UpdateWorkItem|PostComment|AddComment)$/.test( + name, + ) + ) + return { + bg: 'bg-emerald-100 dark:bg-emerald-900/30', + text: 'text-emerald-700 dark:text-emerald-400', + }; + // Web/external: network requests, agent spawning, messaging + if (/^(WebFetch|WebSearch|Agent|SendMessage|Fetch|HttpRequest)$/.test(name)) + return { + bg: 'bg-violet-100 dark:bg-violet-900/30', + text: 'text-violet-700 dark:text-violet-400', + }; + return { bg: 'bg-muted', text: 'text-muted-foreground' }; +} From 85be898e1de3a9fe081ff4405a8e3e4521ca9e16 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 23 Mar 2026 20:13:37 +0000 Subject: [PATCH 2/2] fix(tests): update listLlmCallsMeta integration test for response field The test asserted response was excluded from listLlmCallsMeta, but the router now needs response to extract toolNames/textPreview server-side. Update the test to reflect the new contract: request is still excluded, response is now included. Co-Authored-By: Claude Sonnet 4.6 --- tests/integration/db/runsRepository.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/integration/db/runsRepository.test.ts b/tests/integration/db/runsRepository.test.ts index 8b6f7fce..f58ff7fe 100644 --- a/tests/integration/db/runsRepository.test.ts +++ b/tests/integration/db/runsRepository.test.ts @@ -323,7 +323,7 @@ describe('runsRepository (integration)', () => { }); describe('listLlmCallsMeta', () => { - it('returns calls metadata without request/response bodies', async () => { + it('returns calls metadata with response but without request body', async () => { const id = await createRun({ projectId: 'test-project', agentType: 'implementation', @@ -343,9 +343,10 @@ describe('runsRepository (integration)', () => { const meta = await listLlmCallsMeta(id); expect(meta).toHaveLength(1); expect(meta[0].inputTokens).toBe(100); - // listLlmCallsMeta does not return request/response + // listLlmCallsMeta excludes the large request body but includes + // the response so the router can extract toolNames/textPreview expect('request' in meta[0]).toBe(false); - expect('response' in meta[0]).toBe(false); + expect(meta[0].response).toBe('big response body'); }); });