diff --git a/cli/src/claude/types.test.ts b/cli/src/claude/types.test.ts new file mode 100644 index 00000000..97a18812 --- /dev/null +++ b/cli/src/claude/types.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { RawJSONLinesSchema } from "./types"; + +describe("RawJSONLinesSchema", () => { + describe("system / turn_duration record", () => { + it("preserves messageId so the web reducer can match the duration to the right block", () => { + // Claude code emits turn_duration as a system record carrying the + // assistant message uuid in `messageId`. If Zod strips that field, + // the web matcher in normalizeAgent / reducerTimeline falls back + // to "the last visible block" and can attach the duration to a + // wrong block in interleaved/tool-heavy turns. + const parsed = RawJSONLinesSchema.parse({ + type: "system", + subtype: "turn_duration", + uuid: "evt-1", + durationMs: 4250, + messageId: "assistant-uuid-42" + }); + if (parsed.type !== "system") throw new Error("expected system record"); + expect(parsed.messageId).toBe("assistant-uuid-42"); + expect(parsed.durationMs).toBe(4250); + }); + + it("keeps messageId optional so legacy records without it still parse", () => { + const parsed = RawJSONLinesSchema.parse({ + type: "system", + subtype: "turn_duration", + uuid: "evt-2", + durationMs: 1000 + }); + if (parsed.type !== "system") throw new Error("expected system record"); + expect(parsed.messageId).toBeUndefined(); + }); + }); + + describe("assistant record", () => { + it("preserves message.model so the per-message model label can render", () => { + const parsed = RawJSONLinesSchema.parse({ + type: "assistant", + uuid: "msg-1", + message: { + role: "assistant", + content: [{ type: "text", text: "hi" }], + model: "claude-sonnet-4-6", + usage: { input_tokens: 3, output_tokens: 5 } + } + }); + if (parsed.type !== "assistant") throw new Error("expected assistant record"); + expect(parsed.message?.model).toBe("claude-sonnet-4-6"); + }); + + it("passes through message fields not declared on the schema", () => { + // `RawMessageSchema.passthrough()` keeps keys that the cli does not + // explicitly know about, so future SDK additions reach the hub + // without another schema change. + const parsed = RawJSONLinesSchema.parse({ + type: "assistant", + uuid: "msg-2", + message: { + role: "assistant", + content: [{ type: "text", text: "hi" }], + futureField: { nested: "value" } + } + }); + if (parsed.type !== "assistant") throw new Error("expected assistant record"); + expect((parsed.message as Record | undefined)?.futureField).toEqual({ nested: "value" }); + }); + }); + + describe("passthrough on system records", () => { + it("keeps undeclared fields on system records (e.g. future turn_duration metadata)", () => { + const parsed = RawJSONLinesSchema.parse({ + type: "system", + subtype: "turn_duration", + uuid: "evt-3", + durationMs: 1234, + messageId: "asst-99", + futureBreakdown: { tokens: { in: 1, out: 2 } } + }); + if (parsed.type !== "system") throw new Error("expected system record"); + expect((parsed as Record).futureBreakdown).toEqual({ tokens: { in: 1, out: 2 } }); + }); + }); +}); diff --git a/cli/src/claude/types.ts b/cli/src/claude/types.ts index 5a61d948..4fe88945 100644 --- a/cli/src/claude/types.ts +++ b/cli/src/claude/types.ts @@ -14,11 +14,16 @@ export const UsageSchema = z.object({ service_tier: z.string().optional(), }); +// `passthrough` keeps fields the SDK adds going forward (e.g. `model`, future +// usage breakdowns) so the hub forwards them verbatim. Without it, Zod's +// default `strip` mode silently drops every undeclared key — the metadata +// pipeline lost `message.model` and `system/turn_duration.messageId` that way. const RawMessageSchema = z.object({ role: z.string().optional(), content: z.unknown(), usage: UsageSchema.optional(), -}); + model: z.string().optional(), +}).passthrough(); const RawJSONLinesBaseSchema = z.object({ uuid: z.string().optional(), @@ -62,7 +67,9 @@ export const RawJSONLinesSchema = z.discriminatedUnion("type", [ leafUuid: z.string(), }), - // System message - validates uuid and subtype data used by the UI + // System message - validates uuid and subtype data used by the UI. + // `passthrough` preserves fields like `messageId` on `turn_duration` and any + // future system subtype data the hub forwards to the web reducer. RawJSONLinesBaseSchema.extend({ type: z.literal("system"), uuid: z.string(), @@ -74,7 +81,8 @@ export const RawJSONLinesSchema = z.discriminatedUnion("type", [ maxRetries: z.number().optional(), error: z.unknown().optional(), durationMs: z.number().optional(), - }), + messageId: z.string().optional(), + }).passthrough(), ]); export type RawJSONLines = z.infer; diff --git a/web/src/chat/normalize.ts b/web/src/chat/normalize.ts index 0215dac8..7b6b73fb 100644 --- a/web/src/chat/normalize.ts +++ b/web/src/chat/normalize.ts @@ -23,7 +23,7 @@ export function normalizeDecryptedMessage(message: DecryptedMessage): Normalized if (record.role === 'user') { const normalized = normalizeUserRecord(message.id, message.localId, message.createdAt, record.content, record.meta) return normalized - ? { ...normalized, status: message.status, originalText: message.originalText } + ? { ...normalized, status: message.status, originalText: message.originalText, invokedAt: message.invokedAt } : { id: message.id, localId: message.localId, @@ -33,7 +33,8 @@ export function normalizeDecryptedMessage(message: DecryptedMessage): Normalized content: { type: 'text', text: safeStringify(record.content) }, meta: record.meta, status: message.status, - originalText: message.originalText + originalText: message.originalText, + invokedAt: message.invokedAt } } if (record.role === 'agent') { @@ -45,7 +46,7 @@ export function normalizeDecryptedMessage(message: DecryptedMessage): Normalized return null } return normalized - ? { ...normalized, status: message.status, originalText: message.originalText } + ? { ...normalized, status: message.status, originalText: message.originalText, invokedAt: message.invokedAt } : { id: message.id, localId: message.localId, @@ -55,7 +56,8 @@ export function normalizeDecryptedMessage(message: DecryptedMessage): Normalized content: [{ type: 'text', text: safeStringify(record.content), uuid: message.id, parentUUID: null }], meta: record.meta, status: message.status, - originalText: message.originalText + originalText: message.originalText, + invokedAt: message.invokedAt } } @@ -68,6 +70,7 @@ export function normalizeDecryptedMessage(message: DecryptedMessage): Normalized content: [{ type: 'text', text: safeStringify(record.content), uuid: message.id, parentUUID: null }], meta: record.meta, status: message.status, - originalText: message.originalText + originalText: message.originalText, + invokedAt: message.invokedAt } } diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 74c257c0..2e1fd7c8 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -105,7 +105,7 @@ function normalizeAssistantOutput( localId: string | null, createdAt: number, data: Record, - meta?: unknown + meta?: unknown, ): NormalizedMessage | null { const uuid = asString(data.uuid) ?? messageId const parentUUID = asString(data.parentUuid) ?? null @@ -142,11 +142,13 @@ function normalizeAssistantOutput( const usage = isObject(message.usage) ? (message.usage as Record) : null const inputTokens = usage ? asNumber(usage.input_tokens) : null const outputTokens = usage ? asNumber(usage.output_tokens) : null + const model = asString(message.model) ?? null return { id: messageId, localId, createdAt, + model, role: 'agent', isSidechain, content: blocks, @@ -166,7 +168,7 @@ function normalizeUserOutput( localId: string | null, createdAt: number, data: Record, - meta?: unknown + meta?: unknown, ): NormalizedMessage | null { const uuid = asString(data.uuid) ?? messageId const parentUUID = asString(data.parentUuid) ?? null @@ -305,7 +307,7 @@ export function normalizeAgentRecord( localId: string | null, createdAt: number, content: unknown, - meta?: unknown + meta?: unknown, ): NormalizedMessage | null { if (!isObject(content) || typeof content.type !== 'string') return null @@ -359,7 +361,8 @@ export function normalizeAgentRecord( role: 'event', content: { type: 'turn-duration', - durationMs: asNumber(data.durationMs) ?? 0 + durationMs: asNumber(data.durationMs) ?? 0, + targetMessageId: asString(data.messageId) ?? undefined }, isSidechain: false, meta diff --git a/web/src/chat/normalizeUser.ts b/web/src/chat/normalizeUser.ts index 3785c8f6..a9733e89 100644 --- a/web/src/chat/normalizeUser.ts +++ b/web/src/chat/normalizeUser.ts @@ -32,7 +32,7 @@ export function normalizeUserRecord( localId: string | null, createdAt: number, content: unknown, - meta?: unknown + meta?: unknown, ): NormalizedMessage | null { if (typeof content === 'string') { return { diff --git a/web/src/chat/reducerCliOutput.test.ts b/web/src/chat/reducerCliOutput.test.ts new file mode 100644 index 00000000..71b751b6 --- /dev/null +++ b/web/src/chat/reducerCliOutput.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from 'vitest' +import { mergeCliOutputBlocks, createCliOutputBlock } from './reducerCliOutput' +import type { CliOutputBlock } from './types' + +function makeBlock(props: Partial & Pick): CliOutputBlock { + return createCliOutputBlock({ + id: props.id, + localId: props.localId ?? null, + createdAt: props.createdAt ?? 0, + invokedAt: props.invokedAt, + usage: props.usage, + model: props.model, + text: props.text, + source: props.source ?? 'assistant', + meta: props.meta + }) +} + +describe('mergeCliOutputBlocks', () => { + it('prefers the command-name block (prev) metadata over the stdout follow-up (block)', () => { + // The command-name block originated from the assistant message and + // carries the real metadata. The stdout follow-up is a synthetic + // split with no first-class metadata. The merge must keep prev's + // values for every metadata field; block only fills in fields that + // prev does not have. + const prev = makeBlock({ + id: 'msg:0', + text: 'foo', + invokedAt: 1000, + usage: { input_tokens: 1, output_tokens: 2 }, + model: 'claude-sonnet-4-6' + }) + prev.durationMs = 500 + + const stdoutBlock = makeBlock({ + id: 'msg:1', + text: 'bar', + invokedAt: 2000, // would be wrong if it overrode prev + usage: { input_tokens: 99, output_tokens: 99 }, + model: 'wrong-model' + }) + stdoutBlock.durationMs = 9999 + + const [merged] = mergeCliOutputBlocks([prev, stdoutBlock]) + if (merged.kind !== 'cli-output') throw new Error('expected cli-output') + expect(merged.invokedAt).toBe(1000) + expect(merged.durationMs).toBe(500) + expect(merged.usage).toEqual({ input_tokens: 1, output_tokens: 2 }) + expect(merged.model).toBe('claude-sonnet-4-6') + expect(merged.text).toContain('foo') + expect(merged.text).toContain('bar') + }) + + it('falls back to block metadata when prev does not have the field', () => { + const prev = makeBlock({ + id: 'msg:0', + text: 'foo' + // no metadata on prev + }) + + const stdoutBlock = makeBlock({ + id: 'msg:1', + text: 'bar', + invokedAt: 2000, + usage: { input_tokens: 5, output_tokens: 6 }, + model: 'fallback-model' + }) + stdoutBlock.durationMs = 750 + + const [merged] = mergeCliOutputBlocks([prev, stdoutBlock]) + if (merged.kind !== 'cli-output') throw new Error('expected cli-output') + expect(merged.invokedAt).toBe(2000) + expect(merged.durationMs).toBe(750) + expect(merged.usage).toEqual({ input_tokens: 5, output_tokens: 6 }) + expect(merged.model).toBe('fallback-model') + }) +}) diff --git a/web/src/chat/reducerCliOutput.ts b/web/src/chat/reducerCliOutput.ts index 3fa9da82..209fab97 100644 --- a/web/src/chat/reducerCliOutput.ts +++ b/web/src/chat/reducerCliOutput.ts @@ -1,4 +1,4 @@ -import type { ChatBlock, CliOutputBlock } from '@/chat/types' +import type { ChatBlock, CliOutputBlock, UsageData } from '@/chat/types' const CLI_TAG_REGEX = /<(?:local-command-[a-z-]+|command-(?:name|message|args))>/i const CLI_COMMAND_NAME_REGEX = //i @@ -30,6 +30,9 @@ export function createCliOutputBlock(props: { id: string localId: string | null createdAt: number + invokedAt?: number | null + usage?: UsageData + model?: string | null text: string source: CliOutputBlock['source'] meta?: unknown @@ -39,6 +42,9 @@ export function createCliOutputBlock(props: { id: props.id, localId: props.localId, createdAt: props.createdAt, + invokedAt: props.invokedAt, + usage: props.usage, + model: props.model, text: props.text, source: props.source, meta: props.meta @@ -64,7 +70,18 @@ export function mergeCliOutputBlocks(blocks: ChatBlock[]): ChatBlock[] { && hasLocalCommandStdoutTag(block.text) ) { const separator = prev.text.endsWith('\n') || block.text.startsWith('\n') ? '' : '\n' - merged[merged.length - 1] = { ...prev, text: `${prev.text}${separator}${block.text}` } + // The command-name block (`prev`) carries the assistant message's + // metadata; the stdout follow-up (`block`) is a synthetic split + // with no first-class metadata of its own. Always prefer prev's + // values; fall back to block only if prev is missing one. + merged[merged.length - 1] = { + ...prev, + text: `${prev.text}${separator}${block.text}`, + invokedAt: prev.invokedAt ?? block.invokedAt, + durationMs: prev.durationMs ?? block.durationMs, + usage: prev.usage ?? block.usage, + model: prev.model ?? block.model + } continue } diff --git a/web/src/chat/reducerTimeline.test.ts b/web/src/chat/reducerTimeline.test.ts index f28550fc..96fd09dd 100644 --- a/web/src/chat/reducerTimeline.test.ts +++ b/web/src/chat/reducerTimeline.test.ts @@ -186,4 +186,175 @@ describe('reduceTimeline', () => { const events = blocks.filter(b => b.kind === 'agent-event') expect(events).toHaveLength(1) }) + + it('merges turn-duration event into assistant block by targetMessageId', () => { + const assistantMsg = makeAgentMessage('Thinking...', { id: 'target-msg-id' }) + const durationEvent: TracedMessage = { + id: 'event-1', + role: 'event', + createdAt: 1_700_000_002_000, + content: { type: 'turn-duration', durationMs: 1500, targetMessageId: 'target-msg-id' } + } as TracedMessage + + const { blocks } = reduceTimeline([assistantMsg, durationEvent], makeContext()) + const agentTextBlock = blocks.find(b => b.kind === 'agent-text') as any + expect(agentTextBlock).toBeDefined() + expect(agentTextBlock.durationMs).toBe(1500) + }) + + it('merges turn-duration event into the last assistant block as fallback', () => { + const assistantMsg = makeAgentMessage('Hello') + const durationEvent: TracedMessage = { + id: 'event-1', + role: 'event', + createdAt: 1_700_000_002_000, + content: { type: 'turn-duration', durationMs: 2500 } // No targetMessageId + } as TracedMessage + + const { blocks } = reduceTimeline([assistantMsg, durationEvent], makeContext()) + const agentTextBlock = blocks.find(b => b.kind === 'agent-text') as any + expect(agentTextBlock).toBeDefined() + expect(agentTextBlock.durationMs).toBe(2500) + }) + + it('propagates model information to assistant blocks', () => { + const assistantMsg = makeAgentMessage('Hello', { model: 'claude-3-opus' }) + const { blocks } = reduceTimeline([assistantMsg], makeContext()) + + const agentTextBlock = blocks.find(b => b.kind === 'agent-text') as any + expect(agentTextBlock).toBeDefined() + expect(agentTextBlock.model).toBe('claude-3-opus') + }) + + it('preserves per-message model across mid-session model switches', () => { + const earlier = makeAgentMessage('Earlier reply', { + id: 'msg-earlier', + createdAt: 1_700_000_000_000, + model: 'claude-3-opus' + }) + const later = makeAgentMessage('Later reply', { + id: 'msg-later', + createdAt: 1_700_000_001_000, + model: 'gemini-3-flash-preview', + content: [{ type: 'text', text: 'Later reply', uuid: 'u-2', parentUUID: null }] + }) + + const { blocks } = reduceTimeline([earlier, later], makeContext()) + const earlierBlock = blocks.find(b => b.id === 'msg-earlier:0') as any + const laterBlock = blocks.find(b => b.id === 'msg-later:0') as any + expect(earlierBlock.model).toBe('claude-3-opus') + expect(laterBlock.model).toBe('gemini-3-flash-preview') + }) + + it('leaves model undefined when message lacks per-message model', () => { + const assistantMsg = makeAgentMessage('Hello without model') + const { blocks } = reduceTimeline([assistantMsg], makeContext()) + + const agentTextBlock = blocks.find(b => b.kind === 'agent-text') as any + expect(agentTextBlock).toBeDefined() + expect(agentTextBlock.model).toBeUndefined() + }) + + it('falls back to the last duration-bearing block when targetMessageId resolves to a non-duration block', () => { + // Regression: the matcher used to take the first id-prefix match and + // then silently drop the duration when that block was not duration- + // bearing (agent-event / user-text). The fallback search must run. + const userMsg = makeUserMessage('Earlier user text', { id: 'u-prefix' }) + const assistantMsg = makeAgentMessage('Assistant reply', { id: 'asst-1' }) + const durationEvent: TracedMessage = { + id: 'event-fallback', + role: 'event', + createdAt: 1_700_000_002_000, + // targetMessageId matches a user-text block id by prefix; the + // matcher must skip it (kind is not duration-bearing) and fall + // back to the last assistant-like block. + content: { type: 'turn-duration', durationMs: 9999, targetMessageId: 'u-prefix' } + } as TracedMessage + + const { blocks } = reduceTimeline([userMsg, assistantMsg, durationEvent], makeContext()) + const userBlock = blocks.find(b => b.kind === 'user-text') as any + const agentBlock = blocks.find(b => b.kind === 'agent-text') as any + expect((userBlock as { durationMs?: number }).durationMs).toBeUndefined() + expect(agentBlock.durationMs).toBe(9999) + }) + + it('preserves the original tool-call invokedAt when the matching tool-result message arrives later', () => { + // Regression: the second `ensureToolBlock` call (driven by a + // tool-result message) used to overwrite the tool-call's invokedAt + // with the result message's invokedAt, so the rendered "Invoke" + // timestamp told the user when the result was processed instead of + // when the tool was invoked. + const toolUseMsg: TracedMessage = { + id: 'msg-call', + localId: null, + createdAt: 1_700_000_000_000, + invokedAt: 1_700_000_000_500, + role: 'agent', + content: [{ + type: 'tool-call', + id: 'tc-invoked-at', + name: 'Bash', + input: { command: 'ls' }, + description: null, + uuid: 'u-1', + parentUUID: null + }], + isSidechain: false + } as TracedMessage + const toolResultMsg: TracedMessage = { + id: 'msg-result', + localId: null, + createdAt: 1_700_000_001_000, + invokedAt: 1_700_000_002_000, // would clobber the tool-call invokedAt without the guard + role: 'agent', + content: [{ + type: 'tool-result', + tool_use_id: 'tc-invoked-at', + content: 'ok', + is_error: false, + uuid: 'u-2', + parentUUID: null + }], + isSidechain: false + } as TracedMessage + + const { blocks } = reduceTimeline([toolUseMsg, toolResultMsg], makeContext()) + const toolBlock = blocks.find(b => b.kind === 'tool-call') as any + expect(toolBlock).toBeDefined() + expect(toolBlock.invokedAt).toBe(1_700_000_000_500) + }) + + it('keeps toolBlocksById reference identity when applying turn-duration to a tool-call', () => { + const toolCallMsg: TracedMessage = { + id: 'msg-tool', + localId: null, + createdAt: 1_700_000_000_000, + role: 'agent', + content: [{ + type: 'tool-call', + id: 'tc-1', + name: 'Bash', + input: { command: 'ls' }, + description: null, + uuid: 'u-1', + parentUUID: null + }], + isSidechain: false + } as TracedMessage + const durationEvent: TracedMessage = { + id: 'event-1', + role: 'event', + createdAt: 1_700_000_001_000, + content: { type: 'turn-duration', durationMs: 1234, targetMessageId: 'msg-tool' } + } as TracedMessage + + const { blocks, toolBlocksById } = reduceTimeline([toolCallMsg, durationEvent], makeContext()) + const toolBlock = blocks.find(b => b.kind === 'tool-call') as any + expect(toolBlock).toBeDefined() + expect(toolBlock.durationMs).toBe(1234) + // The block in `blocks` and the one indexed in `toolBlocksById` must be + // the same object reference, so that subsequent permission/result + // mutations land on the rendered block instead of a stale clone. + expect(toolBlocksById.get('tc-1')).toBe(toolBlock) + }) }) diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index f7fd9b31..fcd432e7 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -1,4 +1,4 @@ -import type { ChatBlock, ToolCallBlock, ToolPermission } from '@/chat/types' +import type { AgentReasoningBlock, AgentTextBlock, ChatBlock, CliOutputBlock, ToolCallBlock, ToolPermission } from '@/chat/types' import type { TracedMessage } from '@/chat/tracer' import { createCliOutputBlock, isCliOutputText, mergeCliOutputBlocks } from '@/chat/reducerCliOutput' import { parseMessageAsEvent } from '@/chat/reducerEvents' @@ -41,10 +41,40 @@ export function reduceTimeline( if (msg.content.type === 'token-count') { continue } + if (msg.content.type === 'turn-duration') { + const targetId = msg.content.targetMessageId + const durationMs = msg.content.durationMs as number + type DurationBearingBlock = AgentTextBlock | AgentReasoningBlock | CliOutputBlock | ToolCallBlock + const isDurationTarget = (b: ChatBlock): b is DurationBearingBlock => + b.kind === 'agent-text' || b.kind === 'agent-reasoning' || b.kind === 'cli-output' || b.kind === 'tool-call' + let foundIndex = -1 + + if (targetId) { + foundIndex = blocks.findLastIndex(b => isDurationTarget(b) && (b.id === targetId || b.id.startsWith(`${targetId}:`))) + if (foundIndex === -1) { + foundIndex = blocks.findLastIndex(b => b.kind === 'tool-call' && b.tool.id === targetId) + } + } + + if (foundIndex === -1) { + foundIndex = blocks.findLastIndex(isDurationTarget) + } + + if (foundIndex !== -1) { + const b = blocks[foundIndex] + if (isDurationTarget(b)) { + b.durationMs = durationMs + } + } + continue + } + blocks.push({ kind: 'agent-event', id: msg.id, createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + model: msg.model, event: msg.content, meta: msg.meta }) @@ -57,6 +87,8 @@ export function reduceTimeline( kind: 'agent-event', id: msg.id, createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + model: msg.model, event, meta: msg.meta }) @@ -69,6 +101,7 @@ export function reduceTimeline( id: msg.id, localId: msg.localId, createdAt: msg.createdAt, + invokedAt: msg.invokedAt, text: msg.content.text, source: 'user', meta: msg.meta @@ -80,6 +113,7 @@ export function reduceTimeline( id: msg.id, localId: msg.localId, createdAt: msg.createdAt, + invokedAt: msg.invokedAt, text: msg.content.text, attachments: msg.content.attachments, status: msg.status, @@ -136,6 +170,9 @@ export function reduceTimeline( id: `${msg.id}:${idx}`, localId: msg.localId, createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + usage: msg.usage, + model: msg.model, text: c.text, source: 'assistant', meta: msg.meta @@ -147,6 +184,9 @@ export function reduceTimeline( id: `${msg.id}:${idx}`, localId: msg.localId, createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + usage: msg.usage, + model: msg.model, text: c.text, meta: msg.meta }) @@ -159,6 +199,9 @@ export function reduceTimeline( id: `${msg.id}:${idx}`, localId: msg.localId, createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + usage: msg.usage, + model: msg.model, text: c.text, meta: msg.meta }) @@ -170,6 +213,8 @@ export function reduceTimeline( kind: 'agent-event', id: `${msg.id}:${idx}`, createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + model: msg.model, event: { type: 'message', message: c.summary }, meta: msg.meta }) @@ -185,6 +230,8 @@ export function reduceTimeline( kind: 'agent-event', id: `${msg.id}:${idx}`, createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + model: msg.model, event: { type: 'title-changed', title }, meta: msg.meta }) @@ -196,6 +243,9 @@ export function reduceTimeline( const block = ensureToolBlock(blocks, toolBlocksById, c.id, { createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + usage: msg.usage, + model: msg.model, localId: msg.localId, meta: msg.meta, name: c.name, @@ -230,6 +280,8 @@ export function reduceTimeline( kind: 'agent-event', id: `${msg.id}:${idx}`, createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + model: msg.model, event: { type: 'title-changed', title }, meta: msg.meta }) @@ -261,6 +313,9 @@ export function reduceTimeline( const block = ensureToolBlock(blocks, toolBlocksById, c.tool_use_id, { createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + usage: msg.usage, + model: msg.model, localId: msg.localId, meta: msg.meta, name: permissionEntry?.toolName ?? 'Tool', @@ -285,6 +340,8 @@ export function reduceTimeline( kind: 'agent-event', id: `${msg.id}:${idx}`, createdAt: msg.createdAt, + invokedAt: msg.invokedAt, + model: msg.model, event: { type: 'message', message: summary }, meta: msg.meta }) diff --git a/web/src/chat/reducerTools.ts b/web/src/chat/reducerTools.ts index 7031a5ac..918028fd 100644 --- a/web/src/chat/reducerTools.ts +++ b/web/src/chat/reducerTools.ts @@ -1,5 +1,5 @@ import type { AgentState } from '@/types/api' -import type { ChatBlock, ChatToolCall, NormalizedMessage, ToolCallBlock, ToolPermission } from '@/chat/types' +import type { ChatBlock, ChatToolCall, NormalizedMessage, ToolCallBlock, ToolPermission, UsageData } from '@/chat/types' export type PermissionEntry = { toolName: string @@ -56,6 +56,10 @@ export function ensureToolBlock( id: string, seed: { createdAt: number + invokedAt?: number | null + durationMs?: number + usage?: UsageData + model?: string | null localId: string | null meta?: unknown name: string @@ -91,6 +95,23 @@ export function ensureToolBlock( if (seed.description !== null) { existing.tool.description = seed.description } + // The first call (tool_use) records when the tool was invoked. The + // second call (tool_result) carries the result message's invokedAt, + // which is when the result was processed — not when the tool was + // invoked. Preserve the original timestamp so the metadata footer + // still answers "when was this tool invoked?" correctly. + if (seed.invokedAt !== undefined && existing.invokedAt == null) { + existing.invokedAt = seed.invokedAt + } + if (seed.durationMs !== undefined) { + existing.durationMs = seed.durationMs + } + if (seed.usage !== undefined) { + existing.usage = seed.usage + } + if (seed.model !== undefined) { + existing.model = seed.model + } return existing } @@ -117,6 +138,10 @@ export function ensureToolBlock( id, localId: seed.localId, createdAt: seed.createdAt, + invokedAt: seed.invokedAt, + durationMs: seed.durationMs, + usage: seed.usage, + model: seed.model, tool, children: [], meta: seed.meta diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 1944ed02..163d4807 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -18,7 +18,7 @@ export type AgentEvent = | { type: 'limit-warning'; /** 0–1 ratio (e.g. 0.9 = 90%), integer-precision via CLI pipe format */ utilization: number; endsAt: number; limitType: string } | { type: 'ready' } | { type: 'api-error'; retryAttempt: number; maxRetries: number; error: unknown } - | { type: 'turn-duration'; durationMs: number } + | { type: 'turn-duration'; durationMs: number; targetMessageId?: string } | { type: 'microcompact'; trigger: string; preTokens: number; tokensSaved: number } | { type: 'compact'; trigger: string; preTokens: number } | ({ type: string } & Record) @@ -87,6 +87,8 @@ export type NormalizedMessage = ({ usage?: UsageData status?: MessageStatus originalText?: string + invokedAt?: number | null + model?: string | null } export type ToolPermission = { @@ -120,6 +122,7 @@ export type UserTextBlock = { id: string localId: string | null createdAt: number + invokedAt?: number | null text: string attachments?: AttachmentMetadata[] status?: MessageStatus @@ -132,6 +135,10 @@ export type AgentTextBlock = { id: string localId: string | null createdAt: number + invokedAt?: number | null + durationMs?: number + usage?: UsageData + model?: string | null text: string meta?: unknown } @@ -141,6 +148,10 @@ export type AgentReasoningBlock = { id: string localId: string | null createdAt: number + invokedAt?: number | null + durationMs?: number + usage?: UsageData + model?: string | null text: string meta?: unknown } @@ -150,6 +161,10 @@ export type CliOutputBlock = { id: string localId: string | null createdAt: number + invokedAt?: number | null + durationMs?: number + usage?: UsageData + model?: string | null text: string source: 'user' | 'assistant' meta?: unknown @@ -159,6 +174,8 @@ export type AgentEventBlock = { kind: 'agent-event' id: string createdAt: number + invokedAt?: number | null + model?: string | null event: AgentEvent meta?: unknown } @@ -168,6 +185,10 @@ export type ToolCallBlock = { id: string localId: string | null createdAt: number + invokedAt?: number | null + durationMs?: number + usage?: UsageData + model?: string | null tool: ChatToolCall children: ChatBlock[] meta?: unknown diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx index 12aa55df..b70105df 100644 --- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx +++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx @@ -1,3 +1,4 @@ +import { useCallback, useState, type KeyboardEvent, type MouseEvent } from 'react' import { MessagePrimitive, useAssistantState } from '@assistant-ui/react' import { MarkdownText } from '@/components/assistant-ui/markdown-text' import { Reasoning, ReasoningGroup } from '@/components/assistant-ui/reasoning' @@ -8,6 +9,8 @@ import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime' import { getAssistantCopyText } from '@/components/AssistantChat/messages/assistantCopyText' import { getConversationMessageAnchorId } from '@/chat/outline' +import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata' +import { isNestedInteractiveEvent } from '@/components/AssistantChat/messages/metadataToggle' const TOOL_COMPONENTS = { Fallback: HappyToolMessage @@ -22,6 +25,11 @@ const MESSAGE_PART_COMPONENTS = { export function HappyAssistantMessage() { const { copied, copy } = useCopyToClipboard() + const [showMetadata, setShowMetadata] = useState(false) + const toggleMetadata = useCallback((event: MouseEvent) => { + if (isNestedInteractiveEvent(event)) return + setShowMetadata((open) => !open) + }, []) const messageId = useAssistantState(({ message }) => message.id) const isCliOutput = useAssistantState(({ message }) => { const custom = message.metadata.custom as Partial | undefined @@ -41,6 +49,25 @@ export function HappyAssistantMessage() { if (message.role !== 'assistant') return '' return getAssistantCopyText(message.content) }) + + const invokedAt = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.invokedAt) + const durationMs = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.durationMs) + const usage = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.usage) + const messageModel = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.model) + + const hasMetadata = invokedAt != null + || (typeof durationMs === 'number' && durationMs >= 0) + || usage != null + || (messageModel != null && messageModel !== '') + + const onMetadataKeyDown = useCallback((event: KeyboardEvent) => { + if (isNestedInteractiveEvent(event)) return + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setShowMetadata((open) => !open) + } + }, []) + const rootClass = toolOnly ? 'py-1 min-w-0 max-w-full overflow-x-hidden' : 'px-1 min-w-0 max-w-full overflow-x-hidden' @@ -52,6 +79,25 @@ export function HappyAssistantMessage() { className="scroll-mt-4 px-1 min-w-0 max-w-full overflow-x-hidden" > + {hasMetadata && ( + + )} + {showMetadata && ( + + )} ) } @@ -61,9 +107,25 @@ export function HappyAssistantMessage() { id={getConversationMessageAnchorId(messageId)} className={`${rootClass} ${copyText ? 'group/msg' : ''} scroll-mt-4`} > -
+
+ {showMetadata && ( + + )} {copyText && (
+
+ )} + {showMetadata && invokedAt != null && ( + + )}
) @@ -68,29 +103,42 @@ export function HappyUserMessage() { return ( -
-
- {hasText && } - {hasAttachments && } -
- {(hasText || status) && ( -
- {hasText && ( - - )} - {status && } +
+
+
+ {hasText && } + {hasAttachments && }
+ {(hasText || status) && ( +
+ {hasText && ( + + )} + {status && } +
+ )} +
+ {showMetadata && invokedAt != null && ( + )}
diff --git a/web/src/components/AssistantChat/messages/metadataToggle.test.ts b/web/src/components/AssistantChat/messages/metadataToggle.test.ts new file mode 100644 index 00000000..7862fe63 --- /dev/null +++ b/web/src/components/AssistantChat/messages/metadataToggle.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import type { KeyboardEvent, MouseEvent } from 'react' +import { isNestedInteractiveEvent } from './metadataToggle' + +function makeMouseEvent(target: HTMLElement, currentTarget?: HTMLElement): MouseEvent { + return { target, currentTarget } as unknown as MouseEvent +} + +function makeKeyboardEvent(target: HTMLElement, currentTarget?: HTMLElement): KeyboardEvent { + return { target, currentTarget } as unknown as KeyboardEvent +} + +describe('isNestedInteractiveEvent', () => { + it('returns true when the click target is itself a button', () => { + const button = document.createElement('button') + expect(isNestedInteractiveEvent(makeMouseEvent(button))).toBe(true) + }) + + it('returns true when the click target is nested inside a button (e.g. icon)', () => { + const button = document.createElement('button') + const icon = document.createElement('span') + button.appendChild(icon) + expect(isNestedInteractiveEvent(makeMouseEvent(icon))).toBe(true) + }) + + it('returns true for role="button" elements (Radix triggers, Markdown copy button)', () => { + const div = document.createElement('div') + div.setAttribute('role', 'button') + const inner = document.createElement('span') + div.appendChild(inner) + expect(isNestedInteractiveEvent(makeMouseEvent(inner))).toBe(true) + }) + + it('returns true for anchors and form controls', () => { + const a = document.createElement('a') + const input = document.createElement('input') + const textarea = document.createElement('textarea') + const select = document.createElement('select') + expect(isNestedInteractiveEvent(makeMouseEvent(a))).toBe(true) + expect(isNestedInteractiveEvent(makeMouseEvent(input))).toBe(true) + expect(isNestedInteractiveEvent(makeMouseEvent(textarea))).toBe(true) + expect(isNestedInteractiveEvent(makeMouseEvent(select))).toBe(true) + }) + + it('returns false for plain message body text', () => { + const root = document.createElement('div') + const paragraph = document.createElement('p') + paragraph.textContent = 'Hello' + root.appendChild(paragraph) + expect(isNestedInteractiveEvent(makeMouseEvent(paragraph))).toBe(false) + }) + + it('returns false when target is not an Element', () => { + expect(isNestedInteractiveEvent({ target: null } as unknown as MouseEvent)).toBe(false) + }) + + it('returns true when the click target is an SVG icon inside a button', () => { + // Icon-only controls (copy, retry, code-copy) render an / + // child of the