mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-10 19:42:21 +00:00
* Add Kimi agent support via ACP protocol Add full integration for the Kimi Code CLI agent using the standard Agent Client Protocol (ACP). Includes: - kimi command and CLI registry wiring - Local launcher spawning kimi directly - Remote launcher with ACP stdio transport via AcpSdkBackend - Session management with resume support - Permission handler supporting all Kimi permission modes - Terminal UI display component - Runtime config resolving model from env and ~/.kimi/config.toml via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Fix Kimi ACP tool call input decoding on web Kimi streams tool arguments as JSON text inside the content array (e.g. {\"command\": \"df -h\"}) instead of rawInput/kind. The handler now extracts input from three sources in priority order: 1. rawInput (Claude/Codex path) 2. kind + title fallback (Gemini path) 3. content JSON text (Kimi path) Also handles: - rawInput: null no longer blocks the kind+title fallback - Title prefixes like \"Shell: free -h\" are stripped to extract args - Stale placeholder inputs are re-derived when the title updates - Normalized kind aliases (shell, run, read_file, write, etc.) via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * Add kimi support to web UI * Fix some bugs * fix(kimi): dedupe repeated tool_call display in terminal UI * fix(web): keep tool block immutable so React detects input/state changes * fix(web): recognise Kimi subagent titles like 'Agent: ...' as subagent tools * fix(web): allow-for-session for ACP agents (kimi, cursor) PermissionFooter treated all non-codex sessions as Claude, sending Claude-specific acceptEdits/allowTools to ACP agents that don't support them. Hub rejected acceptEdits for kimi, and the ACP PermissionAdapter ignored allowTools. - Only show 'allow all edits' for Claude sessions - Send decision: approved_for_session for non-Claude ACP agents - Update status display to check decision field * fix(web): lookup subagent sidechains by tool-call id instead of msg id * fix(web): don't trim newest messages when loading older history fetchOlderMessages was using trimVisible(merged, 'prepend') which kept the oldest 400 messages and dropped the newest ones. This caused: 1. Latest messages to disappear when user loaded older history 2. User to see no visible change when new old messages were drowned in the 400-message window. Remove the incorrect trim so all fetched older messages are retained alongside the current window. Subsequent ingestIncomingMessages (append mode) will naturally keep the window bounded when new agent messages arrive. * fix(cli): route Kimi session resume to runKimi instead of runCursor Kimi was present in AGENT_FLAVORS but dispatchLocalResume had no branch for it, so resuming a Kimi session fell through to the Cursor launcher. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(cli): pass selected model to Kimi ACP backend via KIMI_MODEL env createKimiBackend was ignoring opts.model and only setting KIMI_PROJECT_DIR. Use buildKimiEnv so the selected model reaches the subprocess as KIMI_MODEL. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): bound message window on older loads with dedicated larger cap fetchOlderMessages was keeping all messages unbounded, causing sessionStorage bloat on repeated pagination. Reintroduce trimming with OLDER_LOAD_WINDOW_SIZE (800) so growth is capped while the newest messages are still preserved for far longer than before. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): revert sidechain lookup to message id, matching tracer/grouping pipeline tracer.ts sets sidechainId to the parent message id, and reducer.ts groups by sidechainId. A prior commit changed reducerTimeline.ts to look up by tool-call id (c.id), which broke sidechain attachment. Revert to msg.id so the lookup matches the actual grouping key end-to-end. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(cli): gate ACP title prefix stripping to known tool-kind labels extractTitleArgument stripped at the first colon unconditionally, corrupting commands/paths like curl http://localhost:3000 or Windows paths. Now it only strips when the prefix normalizes to the same tool kind as the event, verified via regex. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(shared): include kimi in isCodexFamilyFlavor for ACP permission UI Kimi is an ACP-style agent that supports the abort decision, but isCodexFamilyFlavor excluded it, so PermissionFooter rendered the non-Codex Allow/Deny UI without the Abort button. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> --------- Co-authored-by: HAPI <noreply@hapi.run>
1248 lines
47 KiB
TypeScript
1248 lines
47 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { reduceTimeline } from './reducerTimeline'
|
|
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)
|
|
})
|
|
|
|
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'
|
|
})
|
|
})
|
|
})
|