mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-09 19:29:41 +00:00
* refactor(web): export formatDuration for reuse * feat(web): show tool call duration in the detail dialog Show a completed tool's execution duration at the top of its detail dialog. The value is derived from the Claude entry's own timestamps (the execution machine's wall clock) rather than the hub's message-receive time, and is used only when both the tool_use and tool_result entries carry a real timestamp — otherwise it falls back to the hub receive times on both sides, so the two clocks are never mixed. Running/pending tools show nothing, the running-state live timer is unchanged, and clock skew is guarded against. Reuses the existing formatDuration formatter. No schema changes. * fix(web): backfill hub startedAt on reorder so duration isn't 0.0s When a tool_result entry is reduced before its tool_use, the tool block is created from the result, so the hub startedAt is the result receive time. The tool_use path only lowered the exec start, not the hub startedAt, so a timestamp-less pair (no exec duration available) fell back to startedAt === completedAt and the detail dialog showed 0.0s. Lower the hub startedAt to the earlier tool_use receive time as well.
1368 lines
53 KiB
TypeScript
1368 lines
53 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { reduceTimeline } from './reducerTimeline'
|
|
import { toolDurationMs } from '@/components/ToolCard/toolDuration'
|
|
import type { TracedMessage } from './tracer'
|
|
|
|
function makeContext() {
|
|
return {
|
|
permissionsById: new Map(),
|
|
groups: new Map(),
|
|
consumedGroupIds: new Set<string>(),
|
|
titleChangesByToolUseId: new Map(),
|
|
emittedTitleChangeToolUseIds: new Set<string>()
|
|
}
|
|
}
|
|
|
|
function makeUserMessage(text: string, overrides?: Partial<TracedMessage>): TracedMessage {
|
|
return {
|
|
id: 'msg-1',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'user',
|
|
content: { type: 'text', text },
|
|
isSidechain: false,
|
|
...overrides
|
|
} as TracedMessage
|
|
}
|
|
|
|
function makeAgentMessage(text: string, overrides?: Partial<TracedMessage>): TracedMessage {
|
|
return {
|
|
id: 'msg-agent-1',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'agent',
|
|
content: [{ type: 'text', text, uuid: 'u-1', parentUUID: null }],
|
|
isSidechain: false,
|
|
...overrides
|
|
} as TracedMessage
|
|
}
|
|
|
|
describe('reduceTimeline', () => {
|
|
it('renders user text as user-text block', () => {
|
|
const text = 'Hello, this is a normal message'
|
|
const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext())
|
|
|
|
expect(blocks).toHaveLength(1)
|
|
expect(blocks[0].kind).toBe('user-text')
|
|
})
|
|
|
|
it('does not filter XML-like user text (filtering is in normalize layer)', () => {
|
|
const text = '<task-notification> <summary>Some task</summary> </task-notification>'
|
|
const { blocks } = reduceTimeline([makeUserMessage(text)], makeContext())
|
|
|
|
expect(blocks).toHaveLength(1)
|
|
expect(blocks[0].kind).toBe('user-text')
|
|
})
|
|
|
|
it('suppresses "No response requested." when parentUUID points to an injected turn', () => {
|
|
// Simulate: sidechain message with uuid 'injected-uuid', then sentinel reply pointing to it
|
|
const injectedMsg: TracedMessage = {
|
|
id: 'msg-injected',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'agent',
|
|
content: [{ type: 'sidechain', uuid: 'injected-uuid', prompt: '<task-notification>...</task-notification>' }],
|
|
isSidechain: true
|
|
} as TracedMessage
|
|
|
|
const sentinelMsg: TracedMessage = {
|
|
id: 'msg-sentinel',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_000,
|
|
role: 'agent',
|
|
content: [{ type: 'text', text: 'No response requested.', uuid: 'u-1', parentUUID: 'injected-uuid' }],
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
|
|
const { blocks } = reduceTimeline([injectedMsg, sentinelMsg], makeContext())
|
|
const textBlocks = blocks.filter(b => b.kind === 'agent-text')
|
|
expect(textBlocks).toHaveLength(0)
|
|
})
|
|
|
|
it('keeps "No response requested." when parentUUID points to a normal turn (not injected)', () => {
|
|
// parentUUID points to a normal assistant message, not an injected turn
|
|
const normalMsg: TracedMessage = {
|
|
id: 'msg-normal',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'agent',
|
|
content: [{ type: 'text', text: 'Hello!', uuid: 'normal-uuid', parentUUID: null }],
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
|
|
const replyMsg: TracedMessage = {
|
|
id: 'msg-reply',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_000,
|
|
role: 'agent',
|
|
content: [{ type: 'text', text: 'No response requested.', uuid: 'u-2', parentUUID: 'normal-uuid' }],
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
|
|
const { blocks } = reduceTimeline([normalMsg, replyMsg], makeContext())
|
|
const textBlocks = blocks.filter(b => b.kind === 'agent-text')
|
|
// Should be 2: "Hello!" + "No response requested." (not filtered because parent is normal)
|
|
expect(textBlocks).toHaveLength(2)
|
|
})
|
|
|
|
it('keeps "No response requested." when parentUUID is null (first message)', () => {
|
|
const { blocks } = reduceTimeline([makeAgentMessage('No response requested.')], makeContext())
|
|
const textBlocks = blocks.filter(b => b.kind === 'agent-text')
|
|
expect(textBlocks).toHaveLength(1)
|
|
})
|
|
|
|
it('keeps "No response requested." when message also has other blocks (e.g. tool calls)', () => {
|
|
const injectedMsg: TracedMessage = {
|
|
id: 'msg-injected',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'agent',
|
|
content: [{ type: 'sidechain', uuid: 'injected-uuid', prompt: 'system content' }],
|
|
isSidechain: true
|
|
} as TracedMessage
|
|
|
|
const multiMsg: TracedMessage = {
|
|
id: 'msg-multi',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_000,
|
|
role: 'agent',
|
|
content: [
|
|
{ type: 'text', text: 'No response requested.', uuid: 'u-1', parentUUID: 'injected-uuid' },
|
|
{ type: 'tool-call', id: 'tc-1', name: 'Bash', input: { command: 'ls' }, description: null, uuid: 'u-1', parentUUID: 'injected-uuid' }
|
|
],
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
|
|
const { blocks } = reduceTimeline([injectedMsg, multiMsg], makeContext())
|
|
const textBlocks = blocks.filter(b => b.kind === 'agent-text')
|
|
expect(textBlocks).toHaveLength(1)
|
|
})
|
|
|
|
it('keeps normal assistant text blocks', () => {
|
|
const { blocks } = reduceTimeline([makeAgentMessage('Here is the answer.')], makeContext())
|
|
|
|
const textBlocks = blocks.filter(b => b.kind === 'agent-text')
|
|
expect(textBlocks).toHaveLength(1)
|
|
})
|
|
|
|
it('extracts task-notification summary as event from sidechain block', () => {
|
|
const msg: TracedMessage = {
|
|
id: 'msg-notif',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'agent',
|
|
content: [{ type: 'sidechain', uuid: 'n-1', prompt: '<task-notification> <summary>Background command stopped</summary> </task-notification>' }],
|
|
isSidechain: true
|
|
} as TracedMessage
|
|
|
|
const { blocks } = reduceTimeline([msg], makeContext())
|
|
const events = blocks.filter(b => b.kind === 'agent-event')
|
|
expect(events).toHaveLength(1)
|
|
expect((events[0] as any).event.message).toBe('Background command stopped')
|
|
})
|
|
|
|
it('suppresses sentinel reply to task-notification (summary path)', () => {
|
|
const notifMsg: TracedMessage = {
|
|
id: 'msg-notif',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'agent',
|
|
content: [{ type: 'sidechain', uuid: 'notif-uuid', prompt: '<task-notification> <summary>Done</summary> </task-notification>' }],
|
|
isSidechain: true
|
|
} as TracedMessage
|
|
|
|
const sentinelMsg: TracedMessage = {
|
|
id: 'msg-sentinel',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_000,
|
|
role: 'agent',
|
|
content: [{ type: 'text', text: 'No response requested.', uuid: 'u-1', parentUUID: 'notif-uuid' }],
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
|
|
const { blocks } = reduceTimeline([notifMsg, sentinelMsg], makeContext())
|
|
const textBlocks = blocks.filter(b => b.kind === 'agent-text')
|
|
expect(textBlocks).toHaveLength(0)
|
|
// But the event should still be present
|
|
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('collapses reasoning snapshots with the same stream id', () => {
|
|
const first: TracedMessage = {
|
|
id: 'reasoning-row-1',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'agent',
|
|
content: [{
|
|
type: 'reasoning',
|
|
text: 'first ',
|
|
uuid: 'reasoning-row-1',
|
|
streamId: 'reasoning-stream-1',
|
|
parentUUID: null
|
|
}],
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
const second: TracedMessage = {
|
|
id: 'reasoning-row-2',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_100,
|
|
role: 'agent',
|
|
content: [{
|
|
type: 'reasoning',
|
|
text: 'first second',
|
|
uuid: 'reasoning-row-2',
|
|
streamId: 'reasoning-stream-1',
|
|
parentUUID: null
|
|
}],
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
|
|
const { blocks } = reduceTimeline([first, second], makeContext())
|
|
const reasoningBlocks = blocks.filter((block) => block.kind === 'agent-reasoning')
|
|
|
|
expect(reasoningBlocks).toHaveLength(1)
|
|
expect(reasoningBlocks[0]).toMatchObject({
|
|
id: 'reasoning-row-1:0',
|
|
text: 'first second'
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
describe('exec-timestamp (Claude entry clock) tool duration source', () => {
|
|
function toolUse(agentTimestamp?: number): TracedMessage {
|
|
return {
|
|
id: 'msg-call',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'agent',
|
|
agentTimestamp,
|
|
content: [{
|
|
type: 'tool-call',
|
|
id: 'tc-exec',
|
|
name: 'Bash',
|
|
input: { command: 'sleep 2' },
|
|
description: null,
|
|
uuid: 'u-call',
|
|
parentUUID: null
|
|
}],
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
}
|
|
|
|
function toolResult(agentTimestamp?: number): TracedMessage {
|
|
return {
|
|
id: 'msg-result',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_800,
|
|
role: 'agent',
|
|
agentTimestamp,
|
|
content: [{
|
|
type: 'tool-result',
|
|
tool_use_id: 'tc-exec',
|
|
content: 'ok',
|
|
is_error: false,
|
|
uuid: 'u-result',
|
|
parentUUID: null
|
|
}],
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
}
|
|
|
|
it('records the Claude exec timestamps distinct from the hub receive times', () => {
|
|
const { blocks } = reduceTimeline([
|
|
toolUse(1_700_000_000_100),
|
|
toolResult(1_700_000_002_100)
|
|
], makeContext())
|
|
const toolBlock = blocks.find(b => b.kind === 'tool-call') as any
|
|
// hub receive times (createdAt) differ from the Claude entry stamps
|
|
expect(toolBlock.tool.startedAt).toBe(1_700_000_000_000)
|
|
expect(toolBlock.tool.completedAt).toBe(1_700_000_001_800)
|
|
expect(toolBlock.tool.execStartedAt).toBe(1_700_000_000_100)
|
|
expect(toolBlock.tool.execCompletedAt).toBe(1_700_000_002_100)
|
|
})
|
|
|
|
it('leaves exec timestamps null when no Claude timestamp is present (non-Claude flavor → hub fallback)', () => {
|
|
const { blocks } = reduceTimeline([toolUse(), toolResult()], makeContext())
|
|
const toolBlock = blocks.find(b => b.kind === 'tool-call') as any
|
|
expect(toolBlock.tool.execStartedAt).toBeNull()
|
|
expect(toolBlock.tool.execCompletedAt).toBeNull()
|
|
// hub times still recorded so the legacy duration path works
|
|
expect(toolBlock.tool.startedAt).toBe(1_700_000_000_000)
|
|
expect(toolBlock.tool.completedAt).toBe(1_700_000_001_800)
|
|
})
|
|
|
|
it('never coalesces a missing Claude timestamp to the hub receive time (both-or-neither)', () => {
|
|
// tool_use has a real Claude stamp, tool_result does not (e.g. a
|
|
// hub-synthesized result). execCompletedAt must stay null so the
|
|
// duration helper does not subtract two different clocks.
|
|
const { blocks } = reduceTimeline([
|
|
toolUse(1_700_000_000_100),
|
|
toolResult()
|
|
], makeContext())
|
|
const toolBlock = blocks.find(b => b.kind === 'tool-call') as any
|
|
expect(toolBlock.tool.execStartedAt).toBe(1_700_000_000_100)
|
|
expect(toolBlock.tool.execCompletedAt).toBeNull()
|
|
})
|
|
|
|
it('backfills execStartedAt when the tool_result entry is reduced before the tool_use', () => {
|
|
// Reorder: result first, then the use. execStartedAt must still land
|
|
// on the tool_use Claude stamp (earliest), not the result stamp.
|
|
const { blocks } = reduceTimeline([
|
|
toolResult(1_700_000_002_100),
|
|
toolUse(1_700_000_000_100)
|
|
], makeContext())
|
|
const toolBlock = blocks.find(b => b.kind === 'tool-call') as any
|
|
expect(toolBlock.tool.execStartedAt).toBe(1_700_000_000_100)
|
|
expect(toolBlock.tool.execCompletedAt).toBe(1_700_000_002_100)
|
|
})
|
|
|
|
it('leaves execStartedAt null on reorder when the tool_use entry has no timestamp (both-or-neither → legacy fallback)', () => {
|
|
// Case C: result reduced before a tool_use that carries no Claude
|
|
// stamp. execStartedAt must NOT be seeded from the result entry — it
|
|
// stays null so toolDurationMs falls back to hub times instead of
|
|
// collapsing to a bogus zero (execStartedAt === execCompletedAt).
|
|
const { blocks } = reduceTimeline([
|
|
toolResult(1_700_000_002_100),
|
|
toolUse()
|
|
], makeContext())
|
|
const toolBlock = blocks.find(b => b.kind === 'tool-call') as any
|
|
expect(toolBlock.tool.execStartedAt).toBeNull()
|
|
expect(toolBlock.tool.execCompletedAt).toBe(1_700_000_002_100)
|
|
})
|
|
|
|
it('backfills the hub startedAt on reorder so a timestamp-less pair is not shown as 0.0s', () => {
|
|
// Reorder + no Claude timestamps: the block is created from the
|
|
// tool_result, so the hub startedAt is the result receive time.
|
|
// Without lowering it to the later tool_use's (earlier) receive
|
|
// time, both hub ends equal the result time and the duration row
|
|
// would read 0.0s instead of the real hub receive window.
|
|
const { blocks } = reduceTimeline([
|
|
toolResult(),
|
|
toolUse()
|
|
], makeContext())
|
|
const toolBlock = blocks.find(b => b.kind === 'tool-call') as any
|
|
expect(toolBlock.tool.startedAt).toBe(1_700_000_000_000)
|
|
expect(toolBlock.tool.completedAt).toBe(1_700_000_001_800)
|
|
expect(toolDurationMs(toolBlock.tool)).toBe(1_800)
|
|
})
|
|
})
|
|
|
|
it('populates block.children for Agent tool (same as Task)', () => {
|
|
// Agent tool_use message with a sidechain group
|
|
const agentToolMsg: TracedMessage = {
|
|
id: 'msg-agent',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'agent',
|
|
content: [{
|
|
type: 'tool-call',
|
|
id: 'tc-agent-1',
|
|
name: 'Agent',
|
|
input: { prompt: 'explore stuff', subagent_type: 'general-purpose' },
|
|
description: null,
|
|
uuid: 'u-agent',
|
|
parentUUID: null
|
|
}],
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
|
|
// Sidechain child message that would be in the group for msg-agent
|
|
const sidechainChild: TracedMessage = {
|
|
id: 'sc-msg-1',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_000,
|
|
role: 'agent',
|
|
content: [{
|
|
type: 'tool-call',
|
|
id: 'tc-glob-1',
|
|
name: 'Glob',
|
|
input: { pattern: '**/*.ts' },
|
|
description: null,
|
|
uuid: 'u-sc-1',
|
|
parentUUID: null
|
|
}],
|
|
isSidechain: true,
|
|
sidechainId: 'msg-agent'
|
|
} as TracedMessage
|
|
|
|
// Build groups map the way the real pipeline does it (keyed by message id)
|
|
const groups = new Map<string, TracedMessage[]>()
|
|
groups.set('msg-agent', [sidechainChild])
|
|
|
|
const ctx = { ...makeContext(), groups }
|
|
const { blocks } = reduceTimeline([agentToolMsg], ctx)
|
|
|
|
const agentBlock = blocks.find(b => b.kind === 'tool-call') as any
|
|
expect(agentBlock).toBeDefined()
|
|
// block.children must be populated for Agent (was broken before fix)
|
|
expect(agentBlock.children.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('suppresses prompt-text duplicate for Agent tool (same as Task)', () => {
|
|
// When an agent message contains an Agent tool_use, Claude often writes
|
|
// the prompt as a text block before the tool_use. The reducer must skip
|
|
// that duplicate text just like it does for Task.
|
|
const prompt = 'explore the repository structure'
|
|
const agentMsg: TracedMessage = {
|
|
id: 'msg-agent-dup',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'agent',
|
|
content: [
|
|
{ type: 'text', text: prompt, uuid: 'u-text', parentUUID: null },
|
|
{
|
|
type: 'tool-call',
|
|
id: 'tc-agent-2',
|
|
name: 'Agent',
|
|
input: { prompt, subagent_type: 'Explore' },
|
|
description: null,
|
|
uuid: 'u-agent',
|
|
parentUUID: null
|
|
}
|
|
],
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
|
|
const { blocks } = reduceTimeline([agentMsg], makeContext())
|
|
// text block with same content as Agent.input.prompt must be suppressed
|
|
const textBlocks = blocks.filter(b => b.kind === 'agent-text')
|
|
expect(textBlocks).toHaveLength(0)
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
it('aggregates Codex agent-run events into one agent block with child trace', () => {
|
|
const messages: TracedMessage[] = [
|
|
{
|
|
id: 'agent-start',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-start',
|
|
cardId: 'spawn-1',
|
|
input: { message: 'inspect files', agent_type: 'explorer' },
|
|
status: 'starting',
|
|
statusText: 'Starting',
|
|
summary: 'Inspect files',
|
|
activity: 'Starting task',
|
|
activityKind: 'starting'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-update',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
status: 'running',
|
|
statusText: 'Running',
|
|
activity: 'Running command: ls'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-trace-tool',
|
|
localId: null,
|
|
createdAt: 1_700_000_002_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-trace',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
message: {
|
|
type: 'tool-call',
|
|
name: 'CodexBash',
|
|
callId: 'cmd-1',
|
|
input: { command: 'ls' }
|
|
}
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-trace-result',
|
|
localId: null,
|
|
createdAt: 1_700_000_003_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-trace',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
message: {
|
|
type: 'tool-call-result',
|
|
callId: 'cmd-1',
|
|
output: { stdout: 'ok\n', exit_code: 0 },
|
|
is_error: false
|
|
}
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-trace-message',
|
|
localId: null,
|
|
createdAt: 1_700_000_004_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-trace',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
message: {
|
|
type: 'message',
|
|
message: 'agent done'
|
|
}
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-done',
|
|
localId: null,
|
|
createdAt: 1_700_000_005_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
status: 'completed',
|
|
statusText: 'Completed',
|
|
activity: 'Completed: agent done',
|
|
result: 'agent done'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
]
|
|
|
|
const { blocks } = reduceTimeline(messages, makeContext())
|
|
|
|
expect(blocks).toHaveLength(1)
|
|
const agentBlock = blocks[0] as any
|
|
expect(agentBlock.kind).toBe('tool-call')
|
|
expect(agentBlock.tool.name).toBe('CodexAgent')
|
|
expect(agentBlock.tool.state).toBe('completed')
|
|
expect(agentBlock.tool.result).toBe('agent done')
|
|
expect(agentBlock.tool.input).toMatchObject({
|
|
agent_type: 'explorer',
|
|
agentId: 'agent-1',
|
|
statusText: 'Completed',
|
|
summary: 'Inspect files',
|
|
activity: 'Completed: agent done'
|
|
})
|
|
expect(agentBlock.children.some((child: any) => child.kind === 'tool-call' && child.tool.id === 'codex-agent:agent-1:call:cmd-1')).toBe(true)
|
|
expect(agentBlock.children.some((child: any) => child.kind === 'agent-text' && child.text === 'agent done')).toBe(true)
|
|
})
|
|
|
|
it('keeps new Codex agent trace commands nested under the existing agent block', () => {
|
|
const messages: TracedMessage[] = [
|
|
{
|
|
id: 'agent-start',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-start',
|
|
cardId: 'spawn-1',
|
|
input: { message: 'inspect files' },
|
|
status: 'starting',
|
|
summary: 'Inspect files',
|
|
activity: 'Starting'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-update',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
status: 'running',
|
|
activity: 'Running command: ls'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
...['cmd-1', 'cmd-2'].flatMap((callId, index) => ([
|
|
{
|
|
id: `${callId}-trace`,
|
|
localId: null,
|
|
createdAt: 1_700_000_002_000 + index * 2,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-trace',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
message: {
|
|
type: 'tool-call',
|
|
name: 'CodexBash',
|
|
callId,
|
|
input: { command: callId === 'cmd-1' ? 'ls' : 'pwd' }
|
|
}
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: `${callId}-result`,
|
|
localId: null,
|
|
createdAt: 1_700_000_003_000 + index * 2,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-trace',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
message: {
|
|
type: 'tool-call-result',
|
|
callId,
|
|
output: { stdout: 'ok\n', exit_code: 0 },
|
|
is_error: false
|
|
}
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
]))
|
|
]
|
|
|
|
const { blocks } = reduceTimeline(messages, makeContext())
|
|
const agentBlock = blocks[0] as any
|
|
|
|
expect(blocks).toHaveLength(1)
|
|
expect(agentBlock.tool.name).toBe('CodexAgent')
|
|
expect(agentBlock.children.filter((child: any) => child.kind === 'tool-call')).toHaveLength(2)
|
|
expect(agentBlock.children.map((child: any) => child.kind === 'tool-call' ? child.tool.id : null).filter(Boolean)).toEqual([
|
|
'codex-agent:agent-1:call:cmd-1',
|
|
'codex-agent:agent-1:call:cmd-2'
|
|
])
|
|
})
|
|
|
|
it('namespaces Codex child trace tool ids away from parent tool ids', () => {
|
|
const messages: TracedMessage[] = [
|
|
{
|
|
id: 'parent-tool',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'agent',
|
|
content: [{
|
|
type: 'tool-call',
|
|
id: 'cmd-1',
|
|
name: 'CodexBash',
|
|
input: { command: 'echo parent' },
|
|
description: null,
|
|
uuid: 'parent-tool',
|
|
parentUUID: null
|
|
}],
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-start',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-start',
|
|
cardId: 'spawn-1',
|
|
input: { message: 'inspect files' },
|
|
status: 'starting'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-update',
|
|
localId: null,
|
|
createdAt: 1_700_000_002_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
status: 'running'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-trace-tool',
|
|
localId: null,
|
|
createdAt: 1_700_000_003_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-trace',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
message: {
|
|
type: 'tool-call',
|
|
name: 'CodexBash',
|
|
callId: 'cmd-1',
|
|
input: { command: 'echo child' }
|
|
}
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
]
|
|
|
|
const { blocks } = reduceTimeline(messages, makeContext())
|
|
const parentBlock = blocks.find((block: any) => block.kind === 'tool-call' && block.tool.id === 'cmd-1') as any
|
|
const agentBlock = blocks.find((block: any) => block.kind === 'tool-call' && block.tool.name === 'CodexAgent') as any
|
|
|
|
expect(parentBlock).toBeDefined()
|
|
expect(parentBlock.tool.input).toEqual({ command: 'echo parent' })
|
|
expect(agentBlock).toBeDefined()
|
|
expect(agentBlock.children.some((child: any) => (
|
|
child.kind === 'tool-call'
|
|
&& child.tool.id === 'codex-agent:agent-1:call:cmd-1'
|
|
&& child.tool.input.command === 'echo child'
|
|
))).toBe(true)
|
|
})
|
|
|
|
it('merges fallback Codex agent card ids into the spawn card for the same agent', () => {
|
|
const messages: TracedMessage[] = [
|
|
{
|
|
id: 'agent-start',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-start',
|
|
cardId: 'spawn-1',
|
|
input: { message: 'inspect README' },
|
|
status: 'starting',
|
|
summary: 'Inspect README',
|
|
activity: 'Starting'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-early-update',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'codex-agent:agent-1',
|
|
agentId: 'agent-1',
|
|
status: 'running',
|
|
statusText: 'Running',
|
|
activity: 'Starting task'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-early-trace',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_500,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-trace',
|
|
cardId: 'codex-agent:agent-1',
|
|
agentId: 'agent-1',
|
|
message: {
|
|
type: 'tool-call',
|
|
name: 'CodexBash',
|
|
callId: 'cmd-1',
|
|
input: { command: 'pwd' }
|
|
}
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-linked-update',
|
|
localId: null,
|
|
createdAt: 1_700_000_002_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
status: 'running',
|
|
statusText: 'Running',
|
|
activity: 'Started'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-late-fallback-update',
|
|
localId: null,
|
|
createdAt: 1_700_000_003_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'codex-agent:agent-1',
|
|
agentId: 'agent-1',
|
|
status: 'running',
|
|
statusText: 'Waiting for agent',
|
|
activity: 'Waiting for agent',
|
|
activityKind: 'wait_agent'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
]
|
|
|
|
const { blocks, toolBlocksById } = reduceTimeline(messages, makeContext())
|
|
const agentBlocks = blocks.filter((block: any) => block.kind === 'tool-call' && block.tool.name === 'CodexAgent') as any[]
|
|
|
|
expect(agentBlocks).toHaveLength(1)
|
|
expect(agentBlocks[0].id).toBe('spawn-1')
|
|
expect(toolBlocksById.has('spawn-1')).toBe(true)
|
|
expect(toolBlocksById.has('codex-agent:agent-1')).toBe(false)
|
|
expect(agentBlocks[0].tool.input).toMatchObject({
|
|
agentId: 'agent-1',
|
|
summary: 'Inspect README',
|
|
activity: 'Waiting for agent',
|
|
activityKind: 'wait_agent'
|
|
})
|
|
expect(agentBlocks[0].children.some((child: any) => child.kind === 'tool-call' && child.tool.id === 'codex-agent:agent-1:call:cmd-1')).toBe(true)
|
|
})
|
|
|
|
it('does not create an orphan Codex agent card for fallback notFound updates', () => {
|
|
const messages: TracedMessage[] = [
|
|
{
|
|
id: 'stale-agent-wait',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'codex-agent:stale-agent',
|
|
agentId: 'stale-agent',
|
|
status: 'running',
|
|
statusText: 'Waiting for agent',
|
|
activity: 'Waiting for agent',
|
|
activityKind: 'wait_agent'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'stale-agent-not-found',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_500,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'codex-agent:stale-agent',
|
|
agentId: 'stale-agent',
|
|
status: 'notFound',
|
|
statusText: 'notFound',
|
|
activity: 'notFound: {"status":"notFound","message":null}'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'new-agent-start',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-start',
|
|
cardId: 'spawn-1',
|
|
input: { message: 'inspect README' },
|
|
status: 'starting',
|
|
summary: 'Inspect README',
|
|
activity: 'Starting'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'new-agent-done',
|
|
localId: null,
|
|
createdAt: 1_700_000_002_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
status: 'completed',
|
|
statusText: 'Completed',
|
|
activity: 'Completed: ok',
|
|
result: 'ok'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
]
|
|
|
|
const { blocks, toolBlocksById } = reduceTimeline(messages, makeContext())
|
|
const agentBlocks = blocks.filter((block: any) => block.kind === 'tool-call' && block.tool.name === 'CodexAgent') as any[]
|
|
|
|
expect(agentBlocks).toHaveLength(1)
|
|
expect(agentBlocks[0].id).toBe('spawn-1')
|
|
expect(toolBlocksById.has('codex-agent:stale-agent')).toBe(false)
|
|
})
|
|
|
|
it('shows notFound as an error when it belongs to a known Codex agent card', () => {
|
|
const messages: TracedMessage[] = [
|
|
{
|
|
id: 'agent-start',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-start',
|
|
cardId: 'spawn-1',
|
|
input: { message: 'inspect README' },
|
|
status: 'starting',
|
|
summary: 'Inspect README',
|
|
activity: 'Starting'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-linked',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
status: 'running',
|
|
activity: 'Started'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-not-found',
|
|
localId: null,
|
|
createdAt: 1_700_000_002_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'codex-agent:agent-1',
|
|
agentId: 'agent-1',
|
|
status: 'notFound',
|
|
statusText: 'notFound',
|
|
activity: 'notFound: {"status":"notFound","message":null}'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
]
|
|
|
|
const { blocks } = reduceTimeline(messages, makeContext())
|
|
const agentBlock = blocks[0] as any
|
|
|
|
expect(blocks).toHaveLength(1)
|
|
expect(agentBlock.id).toBe('spawn-1')
|
|
expect(agentBlock.tool.state).toBe('error')
|
|
expect(agentBlock.tool.input).toMatchObject({
|
|
agentId: 'agent-1',
|
|
agentStatus: 'notFound'
|
|
})
|
|
})
|
|
|
|
it('does not regress a completed Codex agent card to running on a later wait_agent begin', () => {
|
|
const messages: TracedMessage[] = [
|
|
{
|
|
id: 'agent-start',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-start',
|
|
cardId: 'spawn-1',
|
|
input: { message: 'inspect files' },
|
|
status: 'starting',
|
|
summary: 'Inspect files',
|
|
activity: 'Starting'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-done',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
status: 'completed',
|
|
statusText: 'Completed',
|
|
activity: 'Completed: done',
|
|
result: 'done'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-wait-begin',
|
|
localId: null,
|
|
createdAt: 1_700_000_002_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
status: 'running',
|
|
statusText: 'Waiting for agent',
|
|
activity: 'Waiting for agent',
|
|
activityKind: 'wait_agent'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
]
|
|
|
|
const { blocks } = reduceTimeline(messages, makeContext())
|
|
const agentBlock = blocks[0] as any
|
|
|
|
expect(agentBlock.tool.state).toBe('completed')
|
|
expect(agentBlock.tool.input).toMatchObject({
|
|
agentStatus: 'completed',
|
|
statusText: 'Completed',
|
|
activity: 'Completed: done'
|
|
})
|
|
})
|
|
|
|
it('keeps Codex agent elapsed time stable when the start event fell out of the visible window', () => {
|
|
const messages: TracedMessage[] = [
|
|
{
|
|
id: 'agent-update',
|
|
localId: null,
|
|
createdAt: 1_700_000_010_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
startedAt: 1_700_000_000_000,
|
|
status: 'running',
|
|
statusText: 'Running command',
|
|
activity: 'Running command: test'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-trace',
|
|
localId: null,
|
|
createdAt: 1_700_000_020_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-trace',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
startedAt: 1_700_000_000_000,
|
|
message: {
|
|
type: 'message',
|
|
message: 'still running'
|
|
}
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
]
|
|
|
|
const { blocks } = reduceTimeline(messages, makeContext())
|
|
const agentBlock = blocks[0] as any
|
|
|
|
expect(agentBlock.tool.name).toBe('CodexAgent')
|
|
expect(agentBlock.tool.state).toBe('running')
|
|
expect(agentBlock.tool.startedAt).toBe(1_700_000_000_000)
|
|
})
|
|
|
|
it('does not turn a completed Codex agent card into an error when close_agent cleans it up', () => {
|
|
const messages: TracedMessage[] = [
|
|
{
|
|
id: 'agent-start',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-start',
|
|
cardId: 'spawn-1',
|
|
input: { message: 'review diff' },
|
|
status: 'starting',
|
|
summary: 'Review diff',
|
|
activity: 'Starting'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-done',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
status: 'completed',
|
|
statusText: 'Completed',
|
|
activity: 'Completed: approved',
|
|
result: 'approved'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-close-begin',
|
|
localId: null,
|
|
createdAt: 1_700_000_002_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
status: 'running',
|
|
statusText: 'Closing agent',
|
|
activity: 'Closing agent',
|
|
activityKind: 'close_agent'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'agent-close-end',
|
|
localId: null,
|
|
createdAt: 1_700_000_003_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'spawn-1',
|
|
agentId: 'agent-1',
|
|
status: 'canceled',
|
|
statusText: 'Closed',
|
|
activity: 'Closed',
|
|
activityKind: 'canceled',
|
|
result: { previous_status: { completed: 'approved' }, agent_id: 'agent-1' }
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
]
|
|
|
|
const { blocks } = reduceTimeline(messages, makeContext())
|
|
const agentBlock = blocks[0] as any
|
|
|
|
expect(agentBlock.tool.state).toBe('completed')
|
|
expect(agentBlock.tool.result).toBe('approved')
|
|
expect(agentBlock.tool.input).toMatchObject({
|
|
agentStatus: 'completed',
|
|
activity: 'Completed: approved'
|
|
})
|
|
})
|
|
|
|
it('drops duplicate orphan Codex agent starts with the same work summary', () => {
|
|
const messages: TracedMessage[] = [
|
|
{
|
|
id: 'orphan-start',
|
|
localId: null,
|
|
createdAt: 1_700_000_000_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-start',
|
|
cardId: 'spawn-orphan',
|
|
input: { message: 'inspect README' },
|
|
status: 'starting',
|
|
summary: 'Inspect README',
|
|
activity: 'Starting'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'real-start',
|
|
localId: null,
|
|
createdAt: 1_700_000_001_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-start',
|
|
cardId: 'spawn-real',
|
|
input: { message: 'inspect README' },
|
|
status: 'starting',
|
|
summary: 'Inspect README',
|
|
activity: 'Starting'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage,
|
|
{
|
|
id: 'real-update',
|
|
localId: null,
|
|
createdAt: 1_700_000_002_000,
|
|
role: 'event',
|
|
content: {
|
|
type: 'agent-run-update',
|
|
cardId: 'spawn-real',
|
|
agentId: 'agent-real',
|
|
status: 'completed',
|
|
summary: 'Inspect README',
|
|
activity: 'Completed: ok',
|
|
result: 'ok'
|
|
},
|
|
isSidechain: false
|
|
} as TracedMessage
|
|
]
|
|
|
|
const { blocks } = reduceTimeline(messages, makeContext())
|
|
const agentBlocks = blocks.filter((block: any) => block.kind === 'tool-call' && block.tool.name === 'CodexAgent') as any[]
|
|
|
|
expect(agentBlocks).toHaveLength(1)
|
|
expect(agentBlocks[0].id).toBe('spawn-real')
|
|
expect(agentBlocks[0].tool.input).toMatchObject({
|
|
agentId: 'agent-real',
|
|
summary: 'Inspect README',
|
|
activity: 'Completed: ok'
|
|
})
|
|
})
|
|
})
|