diff --git a/src/server/__tests__/sessionMessageRouter.test.ts b/src/server/__tests__/sessionMessageRouter.test.ts new file mode 100644 index 000000000..63ddae1ec --- /dev/null +++ b/src/server/__tests__/sessionMessageRouter.test.ts @@ -0,0 +1,449 @@ +import { describe, expect, test, vi, afterEach } from 'bun:test' +import { routeMessage, type StdoutMessage, type MessageRouterCtx, type SubagentInfo } from '../sessionMessageRouter.js' +import { resetAllState } from '../streamStateTracker.js' +import type { ControlChannel } from '../sessionControlChannel.js' +import type { SessionState, SessionBusyStatus, InitData, PendingPermission, PendingQuestion } from '../types.js' +import type { SessionMessage } from '../transcriptReader.js' + +type OCEvent = { event: string; properties: Record } + +type TestCtx = MessageRouterCtx & { + oc: OCEvent[] + ev: Array<{ event: string; data: Record }> + msgs: StdoutMessage[] + buf: SessionMessage[] +} + +afterEach(() => { + resetAllState('test-session-1') +}) + +function makeCtx(overrides?: Partial): TestCtx { + const oc: OCEvent[] = [] + const ev: Array<{ event: string; data: Record }> = [] + const msgs: StdoutMessage[] = [] + const buf: SessionMessage[] = [] + let _model: string | undefined + let _providerId: string | undefined + const _activeSubagents = new Map() + const base: MessageRouterCtx = { + sessionId: 'test-session-1', + silent: false, + getStatus: () => 'running' as SessionState, + getBusyStatus: () => ({ type: 'idle' }) as SessionBusyStatus, + getModel: () => _model, + getProviderId: () => _providerId, + getInitRequestId: () => null, + getLastMessageUuid: () => null, + setStatus: vi.fn(), + setBusyStatus: vi.fn(), + setModel: (m: string) => { _model = m }, + setProviderId: (id: string) => { _providerId = id }, + setInitData: vi.fn(), + setLastMessageUuid: vi.fn(), + setLastActiveAt: vi.fn(), + getActiveSubagents: () => _activeSubagents, + registerSubagent: (info: SubagentInfo) => { _activeSubagents.set(info.agentId, info) }, + unregisterSubagent: (agentId: string) => { _activeSubagents.delete(agentId) }, + getControlChannel: () => ({ tryResolve: vi.fn() }) as unknown as ControlChannel, + getPendingPermissions: () => new Map(), + getPendingQuestions: () => new Map(), + resolveInit: vi.fn(), + resolvePrompt: vi.fn(), + addCost: vi.fn(), + addInputTokens: vi.fn(), + addOutputTokens: vi.fn(), + emitEvent: (event, data) => { ev.push({ event, data }) }, + emitOpencodeEvent: (event, properties) => { oc.push({ event, properties }) }, + emitBusyStatus: vi.fn(), + emitMessage: (msg) => { msgs.push(msg) }, + writeStdin: vi.fn(), + pushBufferMessage: (msg) => { buf.push(msg) }, + addTombstonedUuid: vi.fn(), + } + const merged = { ...base, ...overrides } + return { ...merged, oc, ev, msgs, buf } as TestCtx +} + +describe('routeMessage — stream_event', () => { + test('message_start emits message.updated + step-start', () => { + const ctx = makeCtx() + routeMessage({ + type: 'stream_event', + event: { type: 'message_start', message: { model: 'claude-sonnet-4', usage: { input_tokens: 50 } } }, + }, ctx) + const types = ctx.oc.map(e => e.event) + expect(types).toContain('message.updated') + expect(types).toContain('message.part.updated') + }) + + test('stream_event without event field is silently dropped', () => { + const ctx = makeCtx() + routeMessage({ type: 'stream_event' }, ctx) + expect(ctx.oc.length).toBe(0) + expect(ctx.ev.length).toBe(0) + }) +}) + +describe('routeMessage — system subtype dispatch', () => { + test('task_started → task.started + virtual session events + assistant message', () => { + const ctx = makeCtx() + routeMessage({ type: 'system', subtype: 'task_started', task_id: 'task-1', description: 'Running agent', task_type: 'local_agent' }, ctx) + expect(ctx.oc.length).toBe(4) + expect(ctx.oc[0].event).toBe('task.started') + expect(ctx.oc[0].properties).toMatchObject({ sessionID: 'test-session-1', taskID: 'task-1', taskType: 'local_agent' }) + expect(ctx.oc[1].event).toBe('session.created') + expect(ctx.oc[1].properties).toMatchObject({ sessionID: 'task-1', info: { parentID: 'test-session-1', agent: 'local_agent' } }) + expect(ctx.oc[2].event).toBe('message.updated') + expect(ctx.oc[2].properties).toMatchObject({ sessionID: 'task-1', info: { role: 'assistant', modelID: 'subagent' } }) + expect(ctx.oc[3].event).toBe('session.status') + expect(ctx.oc[3].properties).toMatchObject({ sessionID: 'task-1', status: { type: 'busy' } }) + expect(ctx.getActiveSubagents().has('task-1')).toBe(true) + }) + + test('task_progress → task.progress', () => { + const ctx = makeCtx() + routeMessage({ type: 'system', subtype: 'task_progress', task_id: 'task-1', usage: { total_tokens: 100 } }, ctx) + expect(ctx.oc[0].event).toBe('task.progress') + }) + + test('task_progress with tool_uses emits tool parts on subagent session', () => { + const ctx = makeCtx() + routeMessage({ type: 'system', subtype: 'task_started', task_id: 'task-1', description: 'Test agent' }, ctx) + const assistantMsgID = (ctx.oc.find(e => e.event === 'message.updated' && e.properties.sessionID === 'task-1')?.properties as Record)?.info as Record + expect(assistantMsgID).toBeDefined() + expect(assistantMsgID.role).toBe('assistant') + ctx.oc.length = 0 + + routeMessage({ + type: 'system', + subtype: 'task_progress', + task_id: 'task-1', + last_tool_name: 'Bash', + description: 'Running ls', + usage: { duration_ms: 1000, tool_uses: 1, total_tokens: 0 }, + }, ctx) + + const progress = ctx.oc.find(e => e.event === 'task.progress') + expect(progress).toBeDefined() + + const toolParts = ctx.oc.filter(e => e.event === 'message.part.updated') + expect(toolParts.length).toBe(1) + const part = toolParts[0].properties.part as Record + expect(part.type).toBe('tool') + expect(part.tool).toBe('bash') + expect((part.state as Record).status).toBe('completed') + expect(part.messageID).toBe(assistantMsgID.id) + expect(part.sessionID).toBe('task-1') + }) + + test('task_progress emits incremental tool parts as tool_uses increases', () => { + const ctx = makeCtx() + routeMessage({ type: 'system', subtype: 'task_started', task_id: 'task-1', description: 'Test agent' }, ctx) + ctx.oc.length = 0 + + routeMessage({ + type: 'system', subtype: 'task_progress', task_id: 'task-1', + last_tool_name: 'Bash', description: 'Running ls', usage: { tool_uses: 1 }, + }, ctx) + expect(ctx.oc.filter(e => e.event === 'message.part.updated').length).toBe(1) + + routeMessage({ + type: 'system', subtype: 'task_progress', task_id: 'task-1', + last_tool_name: 'Read', description: 'Reading file', usage: { tool_uses: 2 }, + }, ctx) + expect(ctx.oc.filter(e => e.event === 'message.part.updated').length).toBe(2) + + const parts = ctx.oc.filter(e => e.event === 'message.part.updated') + const tool1 = parts[0].properties.part as Record + const tool2 = parts[1].properties.part as Record + expect(tool1.tool).toBe('bash') + expect(tool2.tool).toBe('read') + expect(tool1.id).not.toBe(tool2.id) + }) + + test('task_progress without prior task_started is a no-op for tool parts', () => { + const ctx = makeCtx() + routeMessage({ + type: 'system', subtype: 'task_progress', task_id: 'unknown-task', + last_tool_name: 'Bash', usage: { tool_uses: 1 }, + }, ctx) + expect(ctx.oc.filter(e => e.event === 'message.part.updated').length).toBe(0) + }) + + test('task_notification → task.completed + assistant message finalize + virtual session idle', () => { + const ctx = makeCtx() + routeMessage({ type: 'system', subtype: 'task_started', task_id: 'task-1', agent_id: 'task-1', description: 'Test' }, ctx) + ctx.oc.length = 0 + routeMessage({ type: 'system', subtype: 'task_notification', task_id: 'task-1', agent_id: 'task-1', status: 'completed', summary: 'Done', output_file: '/tmp/out' }, ctx) + expect(ctx.oc[0].event).toBe('task.completed') + expect(ctx.oc[0].properties).toMatchObject({ taskID: 'task-1', status: 'completed' }) + const msgUpdated = ctx.oc.filter(e => e.event === 'message.updated') + expect(msgUpdated.length).toBe(1) + expect(msgUpdated[0].properties).toMatchObject({ sessionID: 'task-1', info: { role: 'assistant', modelID: 'subagent' } }) + const info = msgUpdated[0].properties.info as Record + expect((info.time as Record).completed).toBeDefined() + const sessionUpdated = ctx.oc.filter(e => e.event === 'session.updated') + expect(sessionUpdated.length).toBe(1) + const sessionStatus = ctx.oc.filter(e => e.event === 'session.status') + expect(sessionStatus.length).toBe(1) + expect(sessionStatus[0].properties).toMatchObject({ sessionID: 'task-1', status: { type: 'idle' } }) + const idle = ctx.oc.filter(e => e.event === 'session.idle') + expect(idle.length).toBe(1) + expect(idle[0].properties).toMatchObject({ sessionID: 'task-1' }) + expect(ctx.getActiveSubagents().has('task-1')).toBe(false) + }) + + test('api_error → session.error', () => { + const ctx = makeCtx() + routeMessage({ type: 'system', subtype: 'api_error', content: 'Rate limited', retry_in_ms: 5000, retry_attempt: 2, max_retries: 3 }, ctx) + expect(ctx.oc[0].event).toBe('session.error') + const err = ctx.oc[0].properties.error as Record + expect(err.subtype).toBe('api_error') + expect(err.retryInMs).toBe(5000) + }) + + test('compact_boundary → compaction part', () => { + const ctx = makeCtx() + routeMessage({ type: 'system', subtype: 'compact_boundary', uuid: 'uuid-1' }, ctx) + expect(ctx.oc[0].event).toBe('message.part.updated') + const part = ctx.oc[0].properties.part as Record + expect(part.type).toBe('compaction') + expect(part.auto).toBe(false) + }) + + test('microcompact_boundary → compaction with auto=true', () => { + const ctx = makeCtx() + routeMessage({ type: 'system', subtype: 'microcompact_boundary' }, ctx) + const part = ctx.oc[0].properties.part as Record + expect(part.auto).toBe(true) + }) + + test('stop_hook_summary → session.hook_summary', () => { + const ctx = makeCtx() + routeMessage({ type: 'system', subtype: 'stop_hook_summary', hook_count: 2, total_duration_ms: 100 }, ctx) + expect(ctx.oc[0].event).toBe('session.hook_summary') + }) + + test('turn_duration → session.metrics', () => { + const ctx = makeCtx() + routeMessage({ type: 'system', subtype: 'turn_duration', duration: 5000 }, ctx) + expect(ctx.oc[0].event).toBe('session.metrics') + }) + + test('cache_warning → session.warning', () => { + const ctx = makeCtx() + routeMessage({ type: 'system', subtype: 'cache_warning', content: 'Miss' }, ctx) + expect(ctx.oc[0].event).toBe('session.warning') + }) + + test('informational → session.info', () => { + const ctx = makeCtx() + routeMessage({ type: 'system', subtype: 'informational', content: 'Info' }, ctx) + expect(ctx.oc[0].event).toBe('session.info') + }) + + test('unknown subtype → session.info', () => { + const ctx = makeCtx() + routeMessage({ type: 'system', subtype: 'custom_subtype', content: 'data' }, ctx) + expect(ctx.oc[0].event).toBe('session.info') + expect(ctx.oc[0].properties.subtype).toBe('custom_subtype') + }) + + test('session_state_changed → session.status', () => { + const ctx = makeCtx() + routeMessage({ type: 'system', subtype: 'session_state_changed', status: 'compacting' }, ctx) + expect(ctx.oc[0].event).toBe('session.status') + }) +}) + +describe('routeMessage — attachment', () => { + test('attachment → message.attachment', () => { + const ctx = makeCtx() + routeMessage({ type: 'attachment', attachment: { type: 'hook_success', data: 'ok' } }, ctx) + expect(ctx.oc.length).toBe(1) + expect(ctx.oc[0].event).toBe('message.attachment') + expect(ctx.oc[0].properties.attachmentType).toBe('hook_success') + }) +}) + +describe('routeMessage — progress', () => { + test('progress → tool.progress', () => { + const ctx = makeCtx() + routeMessage({ type: 'progress', toolUseID: 'tool-1', data: { type: 'bash_progress' } }, ctx) + expect(ctx.oc.length).toBe(1) + expect(ctx.oc[0].event).toBe('tool.progress') + expect(ctx.oc[0].properties.toolUseID).toBe('tool-1') + }) +}) + +describe('routeMessage — tombstone', () => { + test('tombstone → message.removed', () => { + const ctx = makeCtx() + routeMessage({ type: 'tombstone', message: { uuid: 'dead-msg-uuid' } }, ctx) + expect(ctx.oc.length).toBe(1) + expect(ctx.oc[0].event).toBe('message.removed') + expect(ctx.oc[0].properties.messageID).toBe('dead-msg-uuid') + }) + + test('tombstone without uuid is dropped', () => { + const ctx = makeCtx() + routeMessage({ type: 'tombstone', message: {} }, ctx) + expect(ctx.oc.length).toBe(0) + }) +}) + +describe('routeMessage — assistant (non-streaming)', () => { + test('emits message.updated + parts for content blocks', () => { + const ctx = makeCtx() + routeMessage({ + type: 'assistant', + uuid: 'msg-1', + model: 'claude-sonnet-4', + provider_id: 'anthropic', + timestamp: '2026-05-18T00:00:00Z', + parentUuid: 'parent-1', + message: { + content: [ + { type: 'text', text: 'Hello' }, + { type: 'thinking', thinking: 'Let me think...' }, + { type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'ls' } }, + ], + }, + }, ctx) + + const updated = ctx.oc.filter(e => e.event === 'message.updated') + const parts = ctx.oc.filter(e => e.event === 'message.part.updated') + expect(updated.length).toBe(1) + expect(updated[0].properties.info).toMatchObject({ role: 'assistant', modelID: 'claude-sonnet-4', parentID: 'parent-1' }) + expect(parts.length).toBe(3) + const partTypes = parts.map(e => (e.properties.part as Record)?.type) + expect(partTypes).toEqual(['text', 'reasoning', 'tool']) + }) + + test('redacted_thinking emits reasoning part with redacted=true', () => { + const ctx = makeCtx() + routeMessage({ type: 'assistant', uuid: 'msg-1', message: { content: [{ type: 'redacted_thinking' }] } }, ctx) + const part = ctx.oc.find(e => e.event === 'message.part.updated') + const p = part?.properties.part as Record + expect(p.type).toBe('reasoning') + expect(p.redacted).toBe(true) + }) + + test('pushes to buffer when uuid present', () => { + const ctx = makeCtx() + routeMessage({ type: 'assistant', uuid: 'msg-1', message: { content: [] } }, ctx) + expect(ctx.buf.length).toBe(1) + expect(ctx.buf[0].uuid).toBe('msg-1') + }) + + test('API error content emits message.updated with role=error + error object', () => { + const ctx = makeCtx() + routeMessage({ + type: 'assistant', + uuid: 'err-1', + model: 'claude-sonnet-4', + provider_id: 'anthropic', + message: { + content: [{ type: 'text', text: 'API Error: 429 {"error":{"code":"1305","message":"rate limited"}}' }], + }, + }, ctx) + const updated = ctx.oc.filter(e => e.event === 'message.updated') + expect(updated.length).toBe(1) + const info = updated[0].properties.info as Record + expect(info.role).toBe('assistant') + expect(info.error).toMatchObject({ name: 'APIError', data: { statusCode: 429, isRetryable: true } }) + expect((info.error as any).data.message).toContain('rate limited') + expect(ctx.buf[0].role).toBe('assistant') + expect(ctx.buf[0].error).toBeDefined() + }) + + test('CoStrict API error content also detected', () => { + const ctx = makeCtx() + routeMessage({ + type: 'assistant', + uuid: 'err-2', + message: { + content: [{ type: 'text', text: 'CoStrict API Error: something broke' }], + }, + }, ctx) + const info = (ctx.oc.find(e => e.event === 'message.updated')?.properties as Record)?.info as Record + expect(info.role).toBe('assistant') + expect((info.error as any).data.message).toBe('something broke') + }) +}) + +describe('routeMessage — result', () => { + test('success emits session.result only', () => { + const ctx = makeCtx() + routeMessage({ type: 'result', subtype: 'success', cost_usd: 0.05, usage: { input_tokens: 100, output_tokens: 50 } }, ctx) + expect(ctx.oc.filter(e => e.event === 'session.result').length).toBe(1) + expect(ctx.oc.filter(e => e.event === 'session.error').length).toBe(0) + }) + + test('error subtype emits session.error', () => { + const ctx = makeCtx() + routeMessage({ type: 'result', subtype: 'error_max_turns', is_error: true, errors: [{ message: 'Max turns reached' }] }, ctx) + const errors = ctx.oc.filter(e => e.event === 'session.error') + expect(errors.length).toBe(1) + expect(errors[0].properties.error).toMatchObject({ subtype: 'error_max_turns', level: 'error', message: 'Max turns reached' }) + }) + + test('accumulates cost and tokens', () => { + const ctx = makeCtx() + routeMessage({ type: 'result', subtype: 'success', cost_usd: 0.05, usage: { input_tokens: 100, output_tokens: 50 } }, ctx) + expect(ctx.addCost).toHaveBeenCalledWith(0.05) + expect(ctx.addInputTokens).toHaveBeenCalledWith(100) + expect(ctx.addOutputTokens).toHaveBeenCalledWith(50) + }) +}) + +describe('routeMessage — init', () => { + test('init response emits session.updated', () => { + const ctx = makeCtx({ + getStatus: () => 'starting', + getInitRequestId: () => 'init-req-1', + }) + routeMessage({ + type: 'control_response', + response: { + subtype: 'success', + request_id: 'init-req-1', + response: { models: [{ value: 'claude-sonnet-4' }], account: { apiProvider: 'anthropic' } }, + }, + }, ctx) + const updated = ctx.oc.filter(e => e.event === 'session.updated') + expect(updated.length).toBe(1) + expect(updated[0].properties).toMatchObject({ status: 'running', model: 'claude-sonnet-4', providerID: 'anthropic' }) + }) +}) + +describe('routeMessage — user', () => { + test('emits message.updated', () => { + const ctx = makeCtx() + routeMessage({ type: 'user', uuid: 'user-1', content: 'Hello' }, ctx) + expect(ctx.oc.length).toBe(1) + expect(ctx.oc[0].event).toBe('message.updated') + expect(ctx.oc[0].properties.info).toMatchObject({ role: 'user' }) + }) + + test('replay is dropped', () => { + const ctx = makeCtx() + routeMessage({ type: 'user', content: 'replay', isReplay: true }, ctx) + expect(ctx.oc.length).toBe(0) + }) + + test('local-command-stdout is dropped', () => { + const ctx = makeCtx() + routeMessage({ type: 'user', content: 'out' }, ctx) + expect(ctx.oc.length).toBe(0) + }) +}) + +describe('routeMessage — unknown type', () => { + test('unknown message type is silently dropped', () => { + const ctx = makeCtx() + routeMessage({ type: 'unknown_type', data: 'test' }, ctx) + expect(ctx.oc.length).toBe(0) + }) +}) diff --git a/src/server/__tests__/streamStateTracker.test.ts b/src/server/__tests__/streamStateTracker.test.ts new file mode 100644 index 000000000..d9720ad1f --- /dev/null +++ b/src/server/__tests__/streamStateTracker.test.ts @@ -0,0 +1,341 @@ +import { describe, expect, test, beforeEach } from 'bun:test' +import { processStreamEvent, resetAllState } from '../streamStateTracker.js' + +describe('processStreamEvent', () => { + const sessionID = 'test-session-1' + + beforeEach(() => { + resetAllState(sessionID) + }) + + test('message_start emits message.updated + step-start', () => { + const events = processStreamEvent(sessionID, { + type: 'message_start', + message: { + id: 'msg-1', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 100, cache_read_input_tokens: 50, cache_creation_input_tokens: 10 }, + }, + }) + + expect(events.length).toBe(2) + expect(events[0].type).toBe('message.updated') + expect(events[0].properties).toMatchObject({ + sessionID, + info: { role: 'assistant', modelID: 'claude-sonnet-4-20250514' }, + }) + expect(events[1].type).toBe('message.part.updated') + expect(events[1].properties).toMatchObject({ + sessionID, + part: { type: 'step-start' }, + }) + }) + + test('content_block_start (text) emits text part', () => { + const events = processStreamEvent(sessionID, { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }) + + expect(events.length).toBe(1) + expect(events[0].type).toBe('message.part.updated') + expect(events[0].properties.part).toMatchObject({ type: 'text', text: '' }) + }) + + test('content_block_start (thinking) emits reasoning part', () => { + const events = processStreamEvent(sessionID, { + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '' }, + }) + + expect(events.length).toBe(1) + expect(events[0].properties.part).toMatchObject({ type: 'reasoning', redacted: false }) + }) + + test('content_block_start (redacted_thinking) emits reasoning part with redacted=true', () => { + const events = processStreamEvent(sessionID, { + type: 'content_block_start', + index: 0, + content_block: { type: 'redacted_thinking' }, + }) + + expect(events.length).toBe(1) + expect(events[0].properties.part).toMatchObject({ type: 'reasoning', redacted: true }) + }) + + test('content_block_start (tool_use) emits pending tool part', () => { + const events = processStreamEvent(sessionID, { + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', id: 'tool-1', name: 'Bash' }, + }) + + expect(events.length).toBe(1) + expect(events[0].properties.part).toMatchObject({ + type: 'tool', + callID: 'tool-1', + tool: 'bash', + state: { status: 'pending', input: {} }, + }) + }) + + test('content_block_delta (text_delta) emits part delta', () => { + processStreamEvent(sessionID, { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }) + + const events = processStreamEvent(sessionID, { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hello' }, + }) + + expect(events.length).toBe(1) + expect(events[0].type).toBe('message.part.delta') + expect(events[0].properties).toMatchObject({ field: 'text', delta: 'Hello' }) + }) + + test('content_block_delta (thinking_delta) emits part delta', () => { + processStreamEvent(sessionID, { + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '' }, + }) + + const events = processStreamEvent(sessionID, { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'Let me think...' }, + }) + + expect(events[0].properties).toMatchObject({ field: 'text', delta: 'Let me think...' }) + }) + + test('content_block_delta (input_json_delta) accumulates and emits', () => { + processStreamEvent(sessionID, { + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', id: 'tool-1', name: 'Bash' }, + }) + + const events1 = processStreamEvent(sessionID, { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{"command":"ls' }, + }) + expect(events1[0].properties.delta).toBe('{"command":"ls') + + const events2 = processStreamEvent(sessionID, { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '"}' }, + }) + expect(events2[0].properties.delta).toBe('"}') + }) + + test('content_block_stop (tool_use) emits running state with parsed input', () => { + processStreamEvent(sessionID, { + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', id: 'tool-1', name: 'Read' }, + }) + processStreamEvent(sessionID, { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{"file_path":"/test.ts"}' }, + }) + + const events = processStreamEvent(sessionID, { + type: 'content_block_stop', + index: 0, + }) + + expect(events.length).toBe(1) + expect((events[0].properties.part as Record)?.state).toMatchObject({ + status: 'running', + input: { filePath: '/test.ts' }, + }) + }) + + test('content_block_stop (tool_use) handles corrupted JSON gracefully', () => { + processStreamEvent(sessionID, { + type: 'content_block_start', + index: 0, + content_block: { type: 'tool_use', id: 'tool-1', name: 'Bash' }, + }) + processStreamEvent(sessionID, { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{broken' }, + }) + + const events = processStreamEvent(sessionID, { + type: 'content_block_stop', + index: 0, + }) + + expect((events[0].properties.part as Record)?.state).toMatchObject({ input: {} }) + }) + + test('content_block_stop (text) emits part with text and time.end', () => { + processStreamEvent(sessionID, { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }) + processStreamEvent(sessionID, { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'hello' }, + }) + + const events = processStreamEvent(sessionID, { + type: 'content_block_stop', + index: 0, + }) + + expect(events.length).toBe(1) + expect(events[0].type).toBe('message.part.updated') + const part = events[0].properties.part as Record + expect(part.type).toBe('text') + expect(part.text).toBe('hello') + expect((part.time as Record).end).toBeDefined() + }) + + test('message_delta extracts usage and stopReason (no event emitted)', () => { + const events = processStreamEvent(sessionID, { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 42 }, + }) + + expect(events.length).toBe(0) + }) + + test('message_stop emits step-finish with tokens', () => { + processStreamEvent(sessionID, { + type: 'message_start', + message: { + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 100, cache_read_input_tokens: 50, cache_creation_input_tokens: 10 }, + }, + }) + processStreamEvent(sessionID, { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 42 }, + }) + + const events = processStreamEvent(sessionID, { type: 'message_stop' }) + + expect(events.length).toBe(2) + expect(events[0].type).toBe('message.part.updated') + expect(events[0].properties.part).toMatchObject({ + type: 'step-finish', + reason: 'end_turn', + tokens: { + input: 100, + output: 42, + reasoning: 0, + cache: { read: 50, write: 10 }, + }, + }) + expect(events[1].type).toBe('message.updated') + expect((events[1].properties.info as Record).time).toMatchObject({ completed: expect.any(Number) }) + }) + + test('full text flow: start → delta → stop → message_delta → message_stop', () => { + const allEvents: Array<{ type: string; properties: Record }> = [] + + const collect = (evts: Array<{ type: string; properties: Record }>) => { + allEvents.push(...evts) + } + + collect(processStreamEvent(sessionID, { + type: 'message_start', + message: { model: 'test-model', usage: { input_tokens: 10 } }, + })) + collect(processStreamEvent(sessionID, { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + })) + collect(processStreamEvent(sessionID, { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'Hello ' }, + })) + collect(processStreamEvent(sessionID, { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'world' }, + })) + collect(processStreamEvent(sessionID, { + type: 'content_block_stop', + index: 0, + })) + collect(processStreamEvent(sessionID, { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 5 }, + })) + collect(processStreamEvent(sessionID, { type: 'message_stop' })) + + const types = allEvents.map(e => e.type) + expect(types).toEqual([ + 'message.updated', + 'message.part.updated', + 'message.part.updated', + 'message.part.delta', + 'message.part.delta', + 'message.part.updated', + 'message.part.updated', + 'message.updated', + ]) + + const deltas = allEvents.filter(e => e.type === 'message.part.delta') as Array<{ type: string; properties: Record }> + expect(deltas[0].properties.delta).toBe('Hello ') + expect(deltas[1].properties.delta).toBe('world') + }) + + test('multi-step (tool loop): second message_start creates new step', () => { + processStreamEvent(sessionID, { type: 'message_stop' }) + + const events = processStreamEvent(sessionID, { + type: 'message_start', + message: { model: 'test', usage: { input_tokens: 50 } }, + }) + + const stepStarts = events.filter(e => + e.type === 'message.part.updated' && + (e.properties.part as Record)?.type === 'step-start', + ) + expect(stepStarts.length).toBe(1) + }) + + test('unknown event type returns empty array', () => { + const events = processStreamEvent(sessionID, { type: 'ping' }) + expect(events).toEqual([]) + }) + + test('content_block_delta with missing block state returns empty', () => { + const events = processStreamEvent(sessionID, { + type: 'content_block_delta', + index: 99, + delta: { type: 'text_delta', text: 'orphan' }, + }) + expect(events).toEqual([]) + }) + + test('content_block_start with no content_block returns empty', () => { + const events = processStreamEvent(sessionID, { + type: 'content_block_start', + index: 0, + }) + expect(events).toEqual([]) + }) +}) diff --git a/src/server/__tests__/transcriptReader.test.ts b/src/server/__tests__/transcriptReader.test.ts index 06ae98a15..644a03750 100644 --- a/src/server/__tests__/transcriptReader.test.ts +++ b/src/server/__tests__/transcriptReader.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, beforeEach } from 'bun:test' -import { readSessionMessages, readSessionTodos, clearPathCache } from '../transcriptReader.js' +import { readSessionMessages, readSessionTodos, readSessionTasks, clearPathCache } from '../transcriptReader.js' describe('transcriptReader', () => { beforeEach(() => { @@ -20,6 +20,13 @@ describe('transcriptReader', () => { expect(result).toEqual([]) }) + test('readSessionTasks returns empty for non-existent session', async () => { + const result = await readSessionTasks({ + sessionId: 'nonexistent-session-id-12345', + }) + expect(result).toEqual([]) + }) + test('readSessionMessages respects limit', async () => { const result = await readSessionMessages({ sessionId: 'nonexistent-session-id-12345', diff --git a/src/server/__tests__/transcriptReaderParts.test.ts b/src/server/__tests__/transcriptReaderParts.test.ts new file mode 100644 index 000000000..979272e86 --- /dev/null +++ b/src/server/__tests__/transcriptReaderParts.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'bun:test' +import { decomposeMessageToParts, type SessionMessage } from '../transcriptReader.js' + +describe('decomposeMessageToParts', () => { + test('assistant with content blocks produces parts', () => { + const msg: SessionMessage = { + uuid: 'msg-1', + type: 'assistant', + role: 'assistant', + content: [ + { type: 'text', text: 'Hello' }, + { type: 'thinking', thinking: 'Let me think...' }, + { type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'ls' } }, + ], + timestamp: Date.now(), + parent_uuid: null, + } + + const result = decomposeMessageToParts(msg) + expect(result.parts).toBeDefined() + expect(result.parts!.length).toBe(3) + expect(result.parts![0].type).toBe('text') + expect(result.parts![1].type).toBe('reasoning') + expect(result.parts![2].type).toBe('tool') + const toolPart = result.parts![2] as { tool: string; callID: string } + expect(toolPart.tool).toBe('bash') + expect(toolPart.callID).toBe('tool-1') + }) + + test('assistant with redacted_thinking produces reasoning part', () => { + const msg: SessionMessage = { + uuid: 'msg-1', + type: 'assistant', + role: 'assistant', + content: [{ type: 'redacted_thinking' }], + timestamp: Date.now(), + parent_uuid: null, + } + + const result = decomposeMessageToParts(msg) + expect(result.parts!.length).toBe(1) + const p = result.parts![0] as { redacted?: boolean } + expect(p.redacted).toBe(true) + }) + + test('user with tool_result content produces tool-result part', () => { + const msg: SessionMessage = { + uuid: 'msg-1', + type: 'user', + role: 'user', + content: [ + { type: 'text', text: 'Here is the result' }, + { type: 'tool_result', tool_use_id: 'tool-1', content: 'file contents' }, + ], + timestamp: Date.now(), + parent_uuid: null, + } + + const result = decomposeMessageToParts(msg) + expect(result.parts!.length).toBe(2) + expect(result.parts![0].type).toBe('text') + expect(result.parts![1].type).toBe('tool-result') + }) + + test('message without array content returns unchanged', () => { + const msg: SessionMessage = { + uuid: 'msg-1', + type: 'assistant', + role: 'assistant', + content: 'plain text', + timestamp: Date.now(), + parent_uuid: null, + } + + const result = decomposeMessageToParts(msg) + expect(result.parts).toBeUndefined() + }) + + test('message with existing parts returns unchanged', () => { + const msg: SessionMessage = { + uuid: 'msg-1', + type: 'assistant', + role: 'assistant', + content: [], + timestamp: Date.now(), + parent_uuid: null, + parts: [{ type: 'text', id: 'p-1', text: 'existing' }], + } + + const result = decomposeMessageToParts(msg) + expect(result.parts!.length).toBe(1) + }) + + test('system message returns unchanged', () => { + const msg: SessionMessage = { + uuid: 'msg-1', + type: 'system', + role: 'system', + content: 'some info', + timestamp: Date.now(), + parent_uuid: null, + } + + const result = decomposeMessageToParts(msg) + expect(result.parts).toBeUndefined() + }) + + test('tool name normalization: Write → edit, Agent → task', () => { + const msg: SessionMessage = { + uuid: 'msg-1', + type: 'assistant', + role: 'assistant', + content: [ + { type: 'tool_use', id: 't1', name: 'Write', input: { file_path: '/test.ts' } }, + { type: 'tool_use', id: 't2', name: 'Agent', input: { prompt: 'do stuff' } }, + ], + timestamp: Date.now(), + parent_uuid: null, + } + + const result = decomposeMessageToParts(msg) + const tool1 = result.parts![0] as { tool: string } + const tool2 = result.parts![1] as { tool: string } + expect(tool1.tool).toBe('edit') + expect(tool2.tool).toBe('task') + }) +}) diff --git a/src/server/sessionHandle.ts b/src/server/sessionHandle.ts index e101cffd5..eae21cd82 100644 --- a/src/server/sessionHandle.ts +++ b/src/server/sessionHandle.ts @@ -13,6 +13,7 @@ import { routeMessage, type StdoutMessage, type MessageRouterCtx, + type SubagentInfo, } from './sessionMessageRouter.js' import type { SessionBusyStatus, @@ -85,6 +86,16 @@ export class SessionHandle implements DisposableChildProcess { private _titleGenerationAttempted = false private _firstPromptContent?: string private _messageBuffer: SessionMessage[] = [] + private _tombstonedUuids = new Set() + private _activeSubagents = new Map() + + get tombstonedUuids(): ReadonlySet { + return this._tombstonedUuids + } + + get activeSubagents(): ReadonlyMap { + return this._activeSubagents + } get status(): SessionState { return this._status @@ -265,7 +276,8 @@ export class SessionHandle implements DisposableChildProcess { this._status = 'stopped' this._busyStatus = { type: 'idle' } this.emitBusyStatus() - this.emitEvent('deleted', { + this.emitOpencodeEvent('session.deleted', { + sessionID: this.sessionId, status: 'stopped', exit_code: code, signal: signal ?? null, @@ -310,7 +322,8 @@ export class SessionHandle implements DisposableChildProcess { this.initResolve = null this.initReject = null this._status = 'stopped' - this.emitEvent('deleted', { + this.emitOpencodeEvent('session.deleted', { + sessionID: this.sessionId, status: 'stopped', reason: 'init_timeout', }) @@ -401,11 +414,8 @@ export class SessionHandle implements DisposableChildProcess { properties: Record, ): void { if (this.opts.silent) return - if (properties.type === event && 'properties' in properties) { - this.eventBus.publish(event, properties as Record) - return - } this.eventBus.publish(event, { + _native_opencode: true, session_id: properties.sessionID, ...properties, }) @@ -520,12 +530,22 @@ export class SessionHandle implements DisposableChildProcess { emitMessage: msg => this.emitMessage(msg), writeStdin: data => this.writeStdin(data), pushBufferMessage: msg => this.pushMessage(msg), + addTombstonedUuid: uuid => this._tombstonedUuids.add(uuid), + getActiveSubagents: () => this._activeSubagents, + registerSubagent: info => this._activeSubagents.set(info.agentId, info), + unregisterSubagent: agentId => this._activeSubagents.delete(agentId), } } setTitle(title: string): void { this._title = title - this.emitEvent('ready', this.getInfo()) + this.emitOpencodeEvent('session.updated', { + sessionID: this.sessionId, + status: this._status, + model: this._model, + title: this._title, + providerID: this._providerId, + }) } async sendControlRequest( @@ -596,13 +616,16 @@ export class SessionHandle implements DisposableChildProcess { parent_tool_use_id: null, }) - this.emitEvent('message', { - type: 'user', - content, - uuid, - session_id: this.sessionId, - model: this._model ?? '', - provider_id: this._providerId ?? '', + this.emitOpencodeEvent('message.updated', { + sessionID: this.sessionId, + info: { + id: uuid, + role: 'user', + modelID: this._model ?? '', + providerID: this._providerId ?? '', + time: { created: Date.now() }, + parentID: null, + }, }) this.pushMessage({ @@ -645,6 +668,18 @@ export class SessionHandle implements DisposableChildProcess { this._busyStatus = { type: 'idle' } this.emitBusyStatus() + // Emit a result event with is_interrupted so the adapter layer can + // generate the full session.idle event chain that external clients + // expect. Without this, if the child is killed before it yields its + // own result, the adapter never calls adaptResultEvent and the + // client never receives session.idle. + this.emitOpencodeEvent('session.result', { + sessionID: this.sessionId, + subtype: 'error_during_execution', + is_error: true, + is_interrupted: true, + }) + if (!this.promptResolve) return await new Promise(resolve => { diff --git a/src/server/sessionMessageRouter.ts b/src/server/sessionMessageRouter.ts index 1fa2d3abc..29da3fc3a 100644 --- a/src/server/sessionMessageRouter.ts +++ b/src/server/sessionMessageRouter.ts @@ -1,8 +1,106 @@ +import { randomUUID } from 'crypto' import { jsonStringify } from '../utils/slowOperations.js' +import { processStreamEvent, hasActiveStream, consumeStreamedTurn, getCompletedToolInfo, registerAgentSession } from './streamStateTracker.js' import type { ControlChannel } from './sessionControlChannel.js' import type { SessionBusyStatus, SessionState, InitData, PendingPermission, PendingQuestion } from './types.js' import type { SessionMessage } from './transcriptReader.js' +const API_ERROR_PREFIX_RE = /^API Error:\s*/ +const COSTRICT_API_ERROR_PREFIX_RE = /^CoStrict API Error:\s*/ + +type SubagentToolState = { + emittedToolCount: number + assistantMessageID: string + toolPartIDs: Map + mainPartID: string + mainMessageID: string + mainToolUseID: string + progressLines: string[] +} + +const subagentToolState = new Map() + +function isApiErrorContent(content: unknown): boolean { + if (typeof content === 'string') { + return API_ERROR_PREFIX_RE.test(content) || COSTRICT_API_ERROR_PREFIX_RE.test(content) + } + if (!Array.isArray(content)) return false + for (const block of content) { + if (!block || typeof block !== 'object') continue + const b = block as Record + if (b.type === 'text' && typeof b.text === 'string') { + const text = b.text as string + if (API_ERROR_PREFIX_RE.test(text) || COSTRICT_API_ERROR_PREFIX_RE.test(text)) return true + } + } + return false +} + +function buildErrorFromContent(content: unknown): { name: string; data: { message: string; statusCode?: number; isRetryable?: boolean } } { + let raw = '' + if (typeof content === 'string') { + raw = content.replace(API_ERROR_PREFIX_RE, '').replace(COSTRICT_API_ERROR_PREFIX_RE, '') + } else if (Array.isArray(content)) { + for (const block of content) { + if (!block || typeof block !== 'object') continue + const b = block as Record + if (b.type === 'text' && typeof b.text === 'string') { + raw = (b.text as string).replace(API_ERROR_PREFIX_RE, '').replace(COSTRICT_API_ERROR_PREFIX_RE, '') + break + } + } + } + const statusMatch = raw.match(/^(\d{3})\s+/) + const statusCode = statusMatch ? parseInt(statusMatch[1]!, 10) : undefined + const isRetryable = statusCode === 429 || statusCode === 503 || statusCode === 529 + let message = raw + try { + const jsonStart = raw.indexOf('{') + if (jsonStart !== -1) { + const parsed = JSON.parse(raw.slice(jsonStart)) + if (parsed?.error?.message) { + message = parsed.error.message + } else if (typeof parsed?.message === 'string') { + message = parsed.message + } + } + } catch {} + return { name: 'APIError', data: { message, statusCode, isRetryable } } +} + +function extractToolResultContent(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + const parts: string[] = [] + for (const block of content) { + if (!block || typeof block !== 'object') continue + const b = block as Record + if (b.type === 'text' && typeof b.text === 'string') { + parts.push(b.text) + } else if (b.type === 'image') { + const src = b.source as Record | undefined + if (typeof src?.data === 'string') { + parts.push(`[image: ${src.type}]`) + } + } + } + return parts.join('\n') +} + +function getActiveSubagentId(ctx: MessageRouterCtx): string | undefined { + const subs = ctx.getActiveSubagents() + if (subs.size === 0) return undefined + return subs.keys().next().value as string | undefined +} + +export type SubagentInfo = { + agentId: string + agentType?: string + description?: string + prompt?: string + toolUseId?: string +} + export type StdoutMessage = { type: string [key: string]: unknown @@ -27,6 +125,10 @@ export type MessageRouterCtx = { setLastMessageUuid(uuid: string): void setLastActiveAt(ts: number): void + getActiveSubagents(): Map + registerSubagent(info: SubagentInfo): void + unregisterSubagent(agentId: string): void + getControlChannel(): ControlChannel getPendingPermissions(): Map getPendingQuestions(): Map @@ -44,6 +146,7 @@ export type MessageRouterCtx = { emitMessage(msg: StdoutMessage): void writeStdin(data: string): void pushBufferMessage(msg: SessionMessage): void + addTombstonedUuid(uuid: string): void } export function routeMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void { @@ -86,12 +189,23 @@ export function routeMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void { break } case 'stream_event': { - ctx.setLastActiveAt(Date.now()) - ctx.emitEvent('stream_event', msg) + handleStreamEvent(msg, ctx) break } case 'system': { - ctx.emitEvent('message', msg) + handleSystemMessage(msg, ctx) + break + } + case 'attachment': { + handleAttachment(msg, ctx) + break + } + case 'progress': { + handleProgress(msg, ctx) + break + } + case 'tombstone': { + handleTombstone(msg, ctx) break } default: { @@ -113,10 +227,11 @@ function handleInitResponse(response: Record, ctx: MessageRoute ctx.setStatus('running') ctx.setLastActiveAt(Date.now()) ctx.resolveInit(initData) - ctx.emitEvent('ready', { + ctx.emitOpencodeEvent('session.updated', { + sessionID: ctx.sessionId, status: 'running', model: ctx.getModel(), - provider_id: ctx.getProviderId(), + providerID: ctx.getProviderId(), }) } @@ -126,6 +241,57 @@ function handleAssistantMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void if (msg.uuid && typeof msg.uuid === 'string') { ctx.setLastMessageUuid(msg.uuid) } + const rawContent = (msg.message as Record)?.content ?? msg.content + const isApiError = isApiErrorContent(rawContent) + const agentId = (msg.agent_id as string | undefined) || getActiveSubagentId(ctx) + const emitSessionID = agentId && ctx.getActiveSubagents().has(agentId) ? agentId : ctx.sessionId + const wasStreamed = hasActiveStream(emitSessionID) || consumeStreamedTurn(emitSessionID) + + if (msg.uuid) { + ctx.pushBufferMessage({ + uuid: msg.uuid as string, + type: 'assistant', + role: 'assistant', + content: isApiError ? [] : (msg.message ?? msg.content ?? ''), + timestamp: msg.timestamp ? new Date(msg.timestamp as string).getTime() : Date.now(), + parent_uuid: (msg.parentUuid as string) ?? null, + ...(isApiError ? { error: buildErrorFromContent(rawContent) } : {}), + }) + } + + if (wasStreamed) { + if (Array.isArray(rawContent)) { + for (const block of rawContent) { + const b = block as Record + if (b.type !== 'tool_result') continue + const toolUseID = b.tool_use_id as string + if (!toolUseID) continue + const toolInfo = getCompletedToolInfo(emitSessionID, toolUseID) + const output = extractToolResultContent(b.content) + const isError = b.is_error === true + ctx.emitOpencodeEvent('message.part.updated', { + sessionID: emitSessionID, + part: { + type: 'tool', + id: toolInfo?.partID ?? randomUUID(), + callID: toolUseID, + tool: toolInfo?.toolName ? normalizeToolName(toolInfo.toolName) : '', + messageID: ctx.getLastMessageUuid() ?? '', + sessionID: emitSessionID, + state: { + status: isError ? 'error' : 'completed', + input: toolInfo?.input ?? {}, + title: toolInfo?.title ?? toolInfo?.toolName ?? '', + ...(isError ? { error: output } : { output }), + time: { start: toolInfo?.startTime ?? Date.now(), end: Date.now() }, + }, + }, + }) + } + } + return + } + let enriched = msg if (!msg.model && ctx.getModel()) { enriched = { ...enriched, model: ctx.getModel() } @@ -133,24 +299,91 @@ function handleAssistantMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void if (!msg.provider_id && ctx.getProviderId()) { enriched = { ...enriched, provider_id: ctx.getProviderId() } } - ctx.emitEvent('message', enriched) - if (msg.uuid) { - ctx.pushBufferMessage({ - uuid: msg.uuid as string, - type: 'assistant', + const assistantMsgID = (msg.uuid as string) ?? randomUUID() + ctx.emitOpencodeEvent('message.updated', { + sessionID: emitSessionID, + info: { + id: assistantMsgID, role: 'assistant', - content: msg.message ?? msg.content ?? '', - timestamp: msg.timestamp ? new Date(msg.timestamp as string).getTime() : Date.now(), - parent_uuid: (msg.parentUuid as string) ?? null, - }) + modelID: enriched.model, + providerID: enriched.provider_id, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { + created: msg.timestamp + ? new Date(msg.timestamp as string).getTime() + : Date.now(), + }, + parentID: (msg.parentUuid as string) ?? null, + ...(isApiError ? { error: buildErrorFromContent(rawContent) } : {}), + }, + }) + + if (Array.isArray(rawContent)) { + for (const block of rawContent) { + const part = buildPartFromContentBlock(block as Record, assistantMsgID, emitSessionID) + if (part) { + if (isApiError && part.type === 'text') continue + ctx.emitOpencodeEvent('message.part.updated', { + sessionID: emitSessionID, + part, + }) + } + } } } function handleUserMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void { if (msg.isReplay) return + + const agentId = (msg.agent_id as string | undefined) || getActiveSubagentId(ctx) + const emitSessionID = agentId && ctx.getActiveSubagents().has(agentId) ? agentId : ctx.sessionId + + let rawContent = msg.message ?? msg.content + if (rawContent && typeof rawContent === 'object' && !Array.isArray(rawContent) && 'content' in (rawContent as Record)) { + rawContent = (rawContent as Record).content + } + if (Array.isArray(rawContent)) { + for (const block of rawContent) { + const b = block as Record + if (b.type !== 'tool_result') continue + const toolUseID = b.tool_use_id as string + if (!toolUseID) continue + const output = extractToolResultContent(b.content) + const isError = b.is_error === true + const toolInfo = getCompletedToolInfo(emitSessionID, toolUseID) + ctx.emitOpencodeEvent('message.part.updated', { + sessionID: emitSessionID, + part: { + type: 'tool', + id: toolInfo?.partID ?? randomUUID(), + callID: toolUseID, + tool: toolInfo?.toolName ? normalizeToolName(toolInfo.toolName) : '', + messageID: toolInfo?.messageID, + sessionID: emitSessionID, + state: { + status: isError ? 'error' : 'completed', + input: toolInfo?.input ?? {}, + title: toolInfo?.title ?? toolInfo?.toolName ?? '', + ...(isError ? { error: output } : { output }), + time: { start: toolInfo?.startTime, end: Date.now() }, + }, + }, + }) + } + } + const content = typeof msg.content === 'string' ? msg.content : '' if (content.includes('')) return - ctx.emitEvent('message', msg) + ctx.emitOpencodeEvent('message.updated', { + sessionID: emitSessionID, + info: { + id: (msg.uuid as string) ?? randomUUID(), + role: 'user', + time: { created: Date.now() }, + parentID: null, + }, + }) } function handleResultMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void { @@ -165,10 +398,119 @@ function handleResultMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void { if (usage?.output_tokens) ctx.addOutputTokens(usage.output_tokens) ctx.setBusyStatus({ type: 'idle' }) ctx.emitBusyStatus() - ctx.emitEvent('result', msg) + ctx.emitOpencodeEvent('session.result', { + sessionID: ctx.sessionId, + subtype: msg.subtype ?? 'success', + costUsd: msg.cost_usd, + usage: msg.usage, + stopReason: msg.stop_reason, + }) + + const subtype = msg.subtype as string | undefined + if (subtype && subtype !== 'success') { + const errorData = msg.errors as Array> | undefined + const errorMessage = errorData?.[0]?.message ?? msg.subtype ?? 'Unknown error' + ctx.emitOpencodeEvent('session.error', { + sessionID: ctx.sessionId, + error: { + subtype, + level: 'error', + message: typeof errorMessage === 'string' ? errorMessage : JSON.stringify(errorMessage), + }, + }) + } + ctx.resolvePrompt({ done: true }) } +function handleStreamEvent(msg: StdoutMessage, ctx: MessageRouterCtx): void { + ctx.setLastActiveAt(Date.now()) + const event = msg.event as Record | undefined + if (!event) return + + const agentId = msg.agent_id as string | undefined + const subagentActive = agentId && ctx.getActiveSubagents().has(agentId) + const emitSessionID = subagentActive ? agentId! : ctx.sessionId + const canonicalEvents = processStreamEvent(emitSessionID, event) + for (const ce of canonicalEvents) { + ctx.emitOpencodeEvent(ce.type, ce.properties) + } +} + +function handleSystemMessage(msg: StdoutMessage, ctx: MessageRouterCtx): void { + const subtype = msg.subtype as string + switch (subtype) { + case 'task_notification': + emitTaskCompleted(msg, ctx) + break + case 'task_started': + emitTaskStarted(msg, ctx) + break + case 'task_progress': + emitTaskProgress(msg, ctx) + break + case 'api_error': + case 'api_retry': + emitSessionError(msg, ctx) + break + case 'compact_boundary': + case 'microcompact_boundary': + emitCompactionEvent(msg, ctx) + break + case 'stop_hook_summary': + emitHookSummary(msg, ctx) + break + case 'turn_duration': + emitSessionMetrics(msg, ctx) + break + case 'cache_warning': + emitSessionWarning(msg, ctx) + break + case 'informational': + case 'post_turn_summary': + emitSessionInfo(msg, ctx) + break + case 'session_state_changed': + case 'status': + emitSessionStatus(msg, ctx) + break + default: + if (subtype) { + emitSessionInfo(msg, ctx) + } + break + } +} + +function handleAttachment(msg: StdoutMessage, ctx: MessageRouterCtx): void { + ctx.emitOpencodeEvent('message.attachment', { + sessionID: ctx.sessionId, + attachmentType: (msg.attachment as Record)?.type, + attachment: msg.attachment, + }) +} + +function handleProgress(msg: StdoutMessage, ctx: MessageRouterCtx): void { + ctx.emitOpencodeEvent('tool.progress', { + sessionID: ctx.sessionId, + toolUseID: msg.toolUseID, + parentToolUseID: msg.parentToolUseID, + data: msg.data, + }) +} + +function handleTombstone(msg: StdoutMessage, ctx: MessageRouterCtx): void { + const messageObj = msg.message as Record | undefined + const targetUuid = messageObj?.uuid as string | undefined + if (targetUuid) { + ctx.addTombstonedUuid(targetUuid) + ctx.emitOpencodeEvent('message.removed', { + sessionID: ctx.sessionId, + messageID: targetUuid, + }) + } +} + function handleControlRequest(msg: StdoutMessage, ctx: MessageRouterCtx): void { const request = msg.request as Record | undefined const requestId = msg.request_id as string @@ -305,6 +647,341 @@ function handleCancelRequest(msg: StdoutMessage, ctx: MessageRouterCtx): void { } } +function buildPartFromContentBlock(block: Record, messageID: string, sessionID: string): Record | null { + switch (block.type) { + case 'text': + return { type: 'text', id: randomUUID(), text: block.text as string, messageID, sessionID } + case 'thinking': + return { type: 'reasoning', id: randomUUID(), text: block.thinking as string, messageID, sessionID } + case 'redacted_thinking': + return { type: 'reasoning', id: randomUUID(), text: '', redacted: true, messageID, sessionID } + case 'tool_use': { + const toolInput = normalizeToolInput(block.input as Record) + return { + type: 'tool', + id: randomUUID(), + callID: block.id as string, + tool: normalizeToolName(block.name as string), + state: { + status: 'running', + input: toolInput, + title: (toolInput?.description as string) ?? (block.name as string), + time: { start: Date.now() }, + }, + messageID, + sessionID, + } + } + default: + return null + } +} + +function normalizeToolName(name: string): string { + return toPermissionKey(name) +} + +function emitTaskStarted(msg: StdoutMessage, ctx: MessageRouterCtx): void { + const agentId = (msg.agent_id ?? msg.task_id) as string + const taskType = msg.task_type as string | undefined + const description = msg.description as string + const prompt = msg.prompt as string | undefined + const toolUseId = msg.tool_use_id as string | undefined + const assistantMessageID = randomUUID() + + ctx.registerSubagent({ + agentId, + agentType: taskType, + description, + prompt, + toolUseId, + }) + + const toolUseIdForMain = toolUseId + subagentToolState.set(agentId, { + emittedToolCount: 0, + assistantMessageID, + toolPartIDs: new Map(), + mainPartID: '', + mainMessageID: '', + mainToolUseID: toolUseIdForMain ?? '', + progressLines: [], + }) + + ctx.emitOpencodeEvent('task.started', { + sessionID: ctx.sessionId, + taskID: msg.task_id as string, + toolUseID: toolUseId, + description, + taskType, + workflowName: msg.workflow_name as string | undefined, + prompt, + }) + + ctx.emitOpencodeEvent('session.created', { + sessionID: agentId, + info: { + id: agentId, + parentID: ctx.sessionId, + title: description || `Subagent ${agentId.slice(0, 8)}`, + agent: taskType ?? 'general-purpose', + createdAt: Date.now(), + status: 'running', + }, + }) + + ctx.emitOpencodeEvent('message.updated', { + sessionID: agentId, + info: { + id: assistantMessageID, + role: 'assistant', + modelID: 'subagent', + time: { created: Date.now() }, + parentID: null, + }, + }) + + ctx.emitOpencodeEvent('session.status', { + sessionID: agentId, + status: { type: 'busy' }, + }) + + if (toolUseId) { + registerAgentSession(ctx.sessionId, toolUseId, agentId) + } +} + +function emitTaskProgress(msg: StdoutMessage, ctx: MessageRouterCtx): void { + const agentId = (msg.agent_id ?? msg.task_id) as string + const usage = msg.usage as { duration_ms?: number; tool_uses?: number; total_tokens?: number } | undefined + const toolUses = usage?.tool_uses ?? 0 + const lastToolName = (msg.last_tool_name as string | undefined) ?? 'Tool' + const description = msg.description as string | undefined + + ctx.emitOpencodeEvent('task.progress', { + sessionID: ctx.sessionId, + taskID: msg.task_id as string, + description, + usage, + lastToolName, + summary: msg.summary as string | undefined, + workflowProgress: msg.workflow_progress, + }) + + const toolState = subagentToolState.get(agentId) + if (!toolState) return + + if (!toolState.mainPartID && toolState.mainToolUseID) { + const info = getCompletedToolInfo(ctx.sessionId, toolState.mainToolUseID) + if (info) { + toolState.mainPartID = info.partID + toolState.mainMessageID = info.messageID + } + } + + if (description && toolState.mainPartID) { + const lines = toolState.progressLines + lines.push(description) + if (lines.length > 3) lines.splice(0, lines.length - 3) + toolState.progressLines = lines + + const mainToolInfo = getCompletedToolInfo(ctx.sessionId, toolState.mainToolUseID) + ctx.emitOpencodeEvent('message.part.updated', { + sessionID: ctx.sessionId, + part: { + type: 'tool', + id: toolState.mainPartID, + callID: toolState.mainToolUseID, + tool: mainToolInfo?.toolName ? normalizeToolName(mainToolInfo.toolName) : 'task', + state: { + status: 'running', + input: mainToolInfo?.input ?? {}, + title: mainToolInfo?.title ?? '', + time: { start: mainToolInfo?.startTime ?? Date.now() }, + progress: toolState.progressLines, + }, + messageID: toolState.mainMessageID, + sessionID: ctx.sessionId, + }, + }) + } + + while (toolState.emittedToolCount < toolUses) { + toolState.emittedToolCount++ + const toolIndex = toolState.emittedToolCount + const isLast = toolIndex === toolUses + const toolName = toolIndex === toolUses ? lastToolName : 'Tool' + + let partID = toolState.toolPartIDs.get(toolIndex) + if (!partID) { + partID = randomUUID() + toolState.toolPartIDs.set(toolIndex, partID) + } + + ctx.emitOpencodeEvent('message.part.updated', { + sessionID: agentId, + part: { + type: 'tool', + id: partID, + callID: `subagent-tool-${toolIndex}`, + tool: normalizeToolName(toolName), + state: { + status: 'completed', + output: isLast && description ? description.replace(/^Running\s*/i, '') : '', + title: `${toolName} #${toolIndex}`, + time: { start: Date.now() - (usage?.duration_ms ?? 0), end: Date.now() }, + }, + messageID: toolState.assistantMessageID, + sessionID: agentId, + }, + }) + } +} + +function emitTaskCompleted(msg: StdoutMessage, ctx: MessageRouterCtx): void { + const agentId = (msg.agent_id ?? msg.task_id) as string + const status = msg.status as 'completed' | 'failed' | 'stopped' + const summary = msg.summary as string + const toolUseId = msg.tool_use_id as string | undefined + const toolState = subagentToolState.get(agentId) + + ctx.emitOpencodeEvent('task.completed', { + sessionID: ctx.sessionId, + taskID: msg.task_id as string, + toolUseID: toolUseId, + status, + summary, + outputFile: msg.output_file as string, + usage: msg.usage, + }) + + if (toolUseId) { + const agentToolInfo = getCompletedToolInfo(ctx.sessionId, toolUseId) + ctx.emitOpencodeEvent('message.part.updated', { + sessionID: ctx.sessionId, + part: { + type: 'tool', + id: agentToolInfo?.partID ?? randomUUID(), + callID: toolUseId, + tool: agentToolInfo?.toolName ? normalizeToolName(agentToolInfo.toolName) : undefined, + messageID: agentToolInfo?.messageID, + sessionID: ctx.sessionId, + state: { + status: status === 'failed' ? 'error' : 'completed', + input: agentToolInfo?.input ?? {}, + ...(status === 'failed' ? { error: summary || 'Task failed' } : { output: summary || '' }), + title: summary || '', + time: { start: agentToolInfo?.startTime, end: Date.now() }, + }, + }, + }) + } + + if (toolState) { + ctx.emitOpencodeEvent('message.updated', { + sessionID: agentId, + info: { + id: toolState.assistantMessageID, + role: 'assistant', + modelID: 'subagent', + time: { completed: Date.now() }, + }, + }) + subagentToolState.delete(agentId) + } + + const subagent = ctx.getActiveSubagents().get(agentId) + if (subagent || agentId) { + ctx.emitOpencodeEvent('session.updated', { + sessionID: agentId, + info: { + id: agentId, + status, + summary, + completedAt: Date.now(), + }, + }) + + ctx.emitOpencodeEvent('session.status', { + sessionID: agentId, + status: { type: 'idle' }, + }) + + ctx.emitOpencodeEvent('session.idle', { + sessionID: agentId, + }) + + ctx.unregisterSubagent(agentId) + } +} + +function emitSessionError(msg: StdoutMessage, ctx: MessageRouterCtx): void { + ctx.emitOpencodeEvent('session.error', { + sessionID: ctx.sessionId, + error: { + subtype: msg.subtype, + level: msg.level ?? 'error', + message: msg.content ?? (msg.error as Record)?.message, + retryInMs: msg.retry_in_ms ?? msg.retryInMs, + retryAttempt: msg.retry_attempt ?? msg.retryAttempt, + maxRetries: msg.max_retries ?? msg.maxRetries, + }, + }) +} + +function emitCompactionEvent(msg: StdoutMessage, ctx: MessageRouterCtx): void { + ctx.emitOpencodeEvent('message.part.updated', { + sessionID: ctx.sessionId, + part: { + type: 'compaction', + id: (msg.uuid as string) ?? randomUUID(), + auto: + msg.subtype === 'microcompact_boundary' || + ((msg.compact_metadata as Record)?.trigger === 'auto'), + }, + }) +} + +function emitHookSummary(msg: StdoutMessage, ctx: MessageRouterCtx): void { + ctx.emitOpencodeEvent('session.hook_summary', { + sessionID: ctx.sessionId, + hookLabel: msg.hook_label ?? msg.hookLabel, + hookCount: msg.hook_count ?? msg.hookCount, + hookErrors: msg.hook_errors ?? msg.hookErrors, + preventedContinuation: msg.prevented_continuation ?? msg.preventedContinuation, + totalDurationMs: msg.total_duration_ms ?? msg.totalDurationMs, + }) +} + +function emitSessionMetrics(msg: StdoutMessage, ctx: MessageRouterCtx): void { + ctx.emitOpencodeEvent('session.metrics', { + sessionID: ctx.sessionId, + turnDuration: msg.duration ?? msg.turn_duration, + }) +} + +function emitSessionWarning(msg: StdoutMessage, ctx: MessageRouterCtx): void { + ctx.emitOpencodeEvent('session.warning', { + sessionID: ctx.sessionId, + message: msg.content, + }) +} + +function emitSessionInfo(msg: StdoutMessage, ctx: MessageRouterCtx): void { + ctx.emitOpencodeEvent('session.info', { + sessionID: ctx.sessionId, + subtype: msg.subtype, + content: msg.content, + }) +} + +function emitSessionStatus(msg: StdoutMessage, ctx: MessageRouterCtx): void { + ctx.emitOpencodeEvent('session.status', { + sessionID: ctx.sessionId, + status: msg.status, + }) +} + function toPermissionKey(toolName: string): string { const map: Record = { Read: 'read', @@ -334,3 +1011,19 @@ function extractPatterns(input: Record): string[] { if (cmd) patterns.push(cmd) return patterns } + +function normalizeToolInput(input: Record): Record { + const renames: Record = { + file_path: 'filePath', + old_string: 'oldString', + new_string: 'newString', + replace_all: 'replaceAll', + } + for (const [oldKey, newKey] of Object.entries(renames)) { + if (oldKey in input) { + input[newKey] = input[oldKey] + delete input[oldKey] + } + } + return input +} diff --git a/src/server/streamStateTracker.ts b/src/server/streamStateTracker.ts new file mode 100644 index 000000000..ca19cbd9c --- /dev/null +++ b/src/server/streamStateTracker.ts @@ -0,0 +1,424 @@ +import { randomUUID } from 'crypto' + +type BlockState = { + type: string + partID: string + toolUseID?: string + toolName?: string + inputJson: string + startTime: number + text: string +} + +type SessionStreamState = { + messageID: string + parentID: string + modelID: string + activeBlocks: Map + stepStartPartID: string + assistantPartEmitted: boolean + usage: { + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + } + stopReason: string +} + +export type CanonicalEvent = { + type: string + properties: Record +} + +const states = new Map() +const streamedSessions = new Set() + +type CompletedToolInfo = { + toolName: string + partID: string + messageID: string + startTime: number + input: Record + title: string +} + +const completedTools = new Map>() + +function getOrCreate(sessionID: string): SessionStreamState { + let state = states.get(sessionID) + if (!state) { + state = { + messageID: randomUUID(), + parentID: '', + modelID: '', + activeBlocks: new Map(), + stepStartPartID: randomUUID(), + assistantPartEmitted: false, + usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, + stopReason: '', + } + states.set(sessionID, state) + } + return state +} + +export function resetStreamState(sessionID: string): void { + states.delete(sessionID) +} + +export function resetAllState(sessionID: string): void { + states.delete(sessionID) + streamedSessions.delete(sessionID) + completedTools.delete(sessionID) + pendingAgentSessions.delete(sessionID) +} + +export function hasActiveStream(sessionID: string): boolean { + return states.has(sessionID) +} + +export function consumeStreamedTurn(sessionID: string): boolean { + if (streamedSessions.has(sessionID)) { + streamedSessions.delete(sessionID) + return true + } + return false +} + +const pendingAgentSessions = new Map>() + +export function registerAgentSession(sessionID: string, toolUseID: string, agentSessionID: string): void { + let sessionMap = pendingAgentSessions.get(sessionID) + if (!sessionMap) { + sessionMap = new Map() + pendingAgentSessions.set(sessionID, sessionMap) + } + sessionMap.set(toolUseID, agentSessionID) +} + +export function consumeAgentSession(sessionID: string, toolUseID: string): string | undefined { + return pendingAgentSessions.get(sessionID)?.get(toolUseID) +} + +export function getCompletedToolInfo(sessionID: string, toolUseID: string): CompletedToolInfo | undefined { + return completedTools.get(sessionID)?.get(toolUseID) +} + +export function processStreamEvent( + sessionID: string, + event: Record, +): CanonicalEvent[] { + const results: CanonicalEvent[] = [] + const state = getOrCreate(sessionID) + const eventType = event.type as string + + switch (eventType) { + case 'message_start': { + const msg = (event as { message?: Record }).message + state.messageID = randomUUID() + state.modelID = (msg?.model as string) ?? '' + const msgUsage = msg?.usage as Record | undefined + state.usage.inputTokens = msgUsage?.input_tokens ?? 0 + state.usage.cacheReadTokens = msgUsage?.cache_read_input_tokens ?? 0 + state.usage.cacheWriteTokens = msgUsage?.cache_creation_input_tokens ?? 0 + state.activeBlocks.clear() + + if (!state.assistantPartEmitted) { + results.push({ + type: 'message.updated', + properties: { + sessionID, + info: { + id: state.messageID, + role: 'assistant', + modelID: state.modelID, + time: { created: Date.now() }, + }, + }, + }) + state.assistantPartEmitted = true + } + + state.stepStartPartID = randomUUID() + results.push({ + type: 'message.part.updated', + properties: { + sessionID, + part: { type: 'step-start', id: state.stepStartPartID, messageID: state.messageID, sessionID }, + }, + }) + break + } + + case 'content_block_start': { + const evt = event as { content_block?: Record; index?: number } + const block = evt.content_block + const index = evt.index as number + if (!block) break + const partID = randomUUID() + const blockState: BlockState = { + type: block.type as string, + partID, + inputJson: '', + startTime: Date.now(), + text: '', + } + + state.activeBlocks.set(index, blockState) + + if (block.type === 'text') { + results.push({ + type: 'message.part.updated', + properties: { + sessionID, + part: { type: 'text', id: partID, text: '', messageID: state.messageID, sessionID }, + }, + }) + } else if (block.type === 'thinking' || block.type === 'redacted_thinking') { + results.push({ + type: 'message.part.updated', + properties: { + sessionID, + part: { + type: 'reasoning', + id: partID, + text: '', + redacted: block.type === 'redacted_thinking', + messageID: state.messageID, sessionID, + }, + }, + }) + } else if (block.type === 'tool_use') { + blockState.toolUseID = block.id as string + blockState.toolName = block.name as string + results.push({ + type: 'message.part.updated', + properties: { + sessionID, + part: { + type: 'tool', + id: partID, + callID: block.id, + tool: normalizeToolName(block.name as string), + state: { status: 'pending', input: {} }, + messageID: state.messageID, sessionID, + }, + }, + }) + } + break + } + + case 'content_block_delta': { + const evt = event as { delta?: Record; index?: number } + const delta = evt.delta + const index = evt.index as number + const blockState = state.activeBlocks.get(index) + + if (!blockState || !delta) break + + if (delta.type === 'text_delta') { + blockState.text += (delta.text as string) ?? '' + results.push({ + type: 'message.part.delta', + properties: { + sessionID, + messageID: state.messageID, + partID: blockState.partID, + field: 'text', + delta: delta.text, + }, + }) + } else if (delta.type === 'thinking_delta') { + blockState.text += (delta.thinking as string) ?? '' + results.push({ + type: 'message.part.delta', + properties: { + sessionID, + messageID: state.messageID, + partID: blockState.partID, + field: 'text', + delta: delta.thinking, + }, + }) + } else if (delta.type === 'input_json_delta') { + blockState.inputJson += (delta.partial_json as string) ?? '' + results.push({ + type: 'message.part.delta', + properties: { + sessionID, + messageID: state.messageID, + partID: blockState.partID, + field: 'input', + delta: delta.partial_json, + }, + }) + } + break + } + + case 'content_block_stop': { + const index = (event as { index?: number }).index as number + const blockState = state.activeBlocks.get(index) + if (!blockState) break + const now = Date.now() + + if (blockState.type === 'tool_use') { + let parsedInput: Record = {} + try { + parsedInput = JSON.parse(blockState.inputJson || '{}') + } catch { + parsedInput = {} + } + parsedInput = normalizeToolInput(parsedInput) + const title = (parsedInput.description as string) ?? blockState.toolName ?? '' + const toolState: Record = { + status: 'running', + input: parsedInput, + title, + time: { start: blockState.startTime }, + } + results.push({ + type: 'message.part.updated', + properties: { + sessionID, + part: { + type: 'tool', + id: blockState.partID, + callID: blockState.toolUseID, + tool: normalizeToolName(blockState.toolName ?? ''), + state: toolState, + messageID: state.messageID, sessionID, + }, + }, + }) + if (blockState.toolUseID) { + let sessionTools = completedTools.get(sessionID) + if (!sessionTools) { + sessionTools = new Map() + completedTools.set(sessionID, sessionTools) + } + sessionTools.set(blockState.toolUseID, { + toolName: blockState.toolName ?? '', + partID: blockState.partID, + messageID: state.messageID, + startTime: blockState.startTime, + input: parsedInput, + title, + }) + } + } else if (blockState.type === 'text' || blockState.type === 'thinking' || blockState.type === 'redacted_thinking') { + const partType = blockState.type === 'text' ? 'text' : 'reasoning' + results.push({ + type: 'message.part.updated', + properties: { + sessionID, + part: { + type: partType, + id: blockState.partID, + text: blockState.text.trimEnd(), + messageID: state.messageID, sessionID, + time: { start: blockState.startTime, end: now }, + ...(blockState.type === 'redacted_thinking' ? { redacted: true } : {}), + }, + }, + }) + } + state.activeBlocks.delete(index) + break + } + + case 'message_delta': { + const evt = event as { + delta?: Record + usage?: Record + } + if (evt.delta?.stop_reason) { + state.stopReason = evt.delta.stop_reason as string + } + if (evt.usage?.output_tokens) { + state.usage.outputTokens = evt.usage.output_tokens + } + break + } + + case 'message_stop': { + results.push({ + type: 'message.part.updated', + properties: { + sessionID, + part: { + type: 'step-finish', + id: randomUUID(), + reason: state.stopReason || 'stop', + cost: 0, + tokens: { + input: state.usage.inputTokens, + output: state.usage.outputTokens, + reasoning: 0, + cache: { + read: state.usage.cacheReadTokens, + write: state.usage.cacheWriteTokens, + }, + }, + messageID: state.messageID, sessionID, + }, + }, + }) + results.push({ + type: 'message.updated', + properties: { + sessionID, + info: { + id: state.messageID, + role: 'assistant', + modelID: state.modelID, + time: { completed: Date.now() }, + }, + }, + }) + resetStreamState(sessionID) + streamedSessions.add(sessionID) + break + } + } + + return results +} + +function normalizeToolInput(input: Record): Record { + const renames: Record = { + file_path: 'filePath', + old_string: 'oldString', + new_string: 'newString', + replace_all: 'replaceAll', + } + for (const [oldKey, newKey] of Object.entries(renames)) { + if (oldKey in input) { + input[newKey] = input[oldKey] + delete input[oldKey] + } + } + return input +} + +function normalizeToolName(name: string): string { + const map: Record = { + Read: 'read', + Edit: 'edit', + Write: 'edit', + Glob: 'glob', + Grep: 'grep', + LS: 'list', + Bash: 'bash', + PowerShell: 'bash', + Agent: 'task', + Task: 'task', + WebFetch: 'webfetch', + WebSearch: 'websearch', + TodoRead: 'todoread', + TodoWrite: 'todowrite', + } + return map[name] ?? name.toLowerCase() +} diff --git a/src/server/transcriptReader.ts b/src/server/transcriptReader.ts index 6ecd7e48a..588e1089c 100644 --- a/src/server/transcriptReader.ts +++ b/src/server/transcriptReader.ts @@ -28,6 +28,25 @@ type TranscriptEntry = { [key: string]: unknown } +export type MessagePart = + | { type: 'text'; id: string; text: string } + | { type: 'reasoning'; id: string; text: string; redacted?: boolean } + | { + type: 'tool' + id: string + callID: string + tool: string + state: + | { status: 'pending'; input: Record } + | { status: 'running'; input: Record; title?: string; time: { start: number } } + | { status: 'completed'; input: Record; output: string; title: string; time: { start: number; end: number } } + | { status: 'error'; input: Record; error: string; time: { start: number; end: number } } + } + | { type: 'tool-result'; id: string; toolUseID: string; content: unknown } + | { type: 'step-start'; id: string } + | { type: 'step-finish'; id: string; reason: string; cost: number; tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } } } + | { type: 'compaction'; id: string; auto: boolean } + export type SessionMessage = { uuid: string type: string @@ -36,6 +55,15 @@ export type SessionMessage = { timestamp: number parent_uuid: string | null usage?: { input_tokens: number; output_tokens: number } + parts?: MessagePart[] + error?: { + name: string + data: { + message: string + statusCode?: number + isRetryable?: boolean + } + } } export type TodoItem = { @@ -45,6 +73,91 @@ export type TodoItem = { priority: string } +const TOOL_NAME_MAP: Record = { + Read: 'read', + Edit: 'edit', + Write: 'edit', + Glob: 'glob', + Grep: 'grep', + LS: 'list', + Bash: 'bash', + PowerShell: 'bash', + Agent: 'task', + WebFetch: 'webfetch', + WebSearch: 'websearch', + TodoRead: 'todoread', + TodoWrite: 'todowrite', +} + +function normalizeToolName(name: string): string { + return TOOL_NAME_MAP[name] ?? name.toLowerCase() +} + +function contentToParts( + content: unknown, + role: string, +): MessagePart[] { + const parts: MessagePart[] = [] + if (!Array.isArray(content)) return parts + + for (const block of content) { + if (!block || typeof block !== 'object') continue + const b = block as Record + switch (b.type) { + case 'text': + parts.push({ type: 'text', id: crypto.randomUUID(), text: (b.text as string) ?? '' }) + break + case 'thinking': + parts.push({ type: 'reasoning', id: crypto.randomUUID(), text: (b.thinking as string) ?? '' }) + break + case 'redacted_thinking': + parts.push({ type: 'reasoning', id: crypto.randomUUID(), text: '', redacted: true }) + break + case 'tool_use': + parts.push({ + type: 'tool', + id: crypto.randomUUID(), + callID: (b.id as string) ?? '', + tool: normalizeToolName((b.name as string) ?? ''), + state: { + status: 'running', + input: (b.input as Record) ?? {}, + time: { start: Date.now() }, + }, + }) + break + case 'tool_result': { + parts.push({ + type: 'tool-result', + id: crypto.randomUUID(), + toolUseID: (b.tool_use_id as string) ?? '', + content: b.content, + }) + break + } + } + } + return parts +} + +export function decomposeMessageToParts(msg: SessionMessage): SessionMessage { + if (msg.parts && msg.parts.length > 0) return msg + const content = msg.content + const role = msg.role + if (!content || !role) return msg + + if (role === 'assistant' || role === 'user') { + const parts = contentToParts(content, role) + const filtered = msg.error + ? parts.filter(p => p.type !== 'text') + : parts + if (filtered.length > 0) { + return { ...msg, parts: filtered } + } + } + return msg +} + const MESSAGE_TYPES = new Set(['user', 'assistant']) const SKIP_TYPES = new Set([ 'summary', @@ -128,20 +241,30 @@ function entryToSessionMessage( const role = entry.role ?? entry.message?.role ?? entry.type const content = entry.message?.content ?? entry.content ?? '' + + if (role === 'user' && isTaskNotificationContent(content)) return null + const timestamp = entry.timestamp ? new Date(entry.timestamp).getTime() : 0 + const errorRaw = extractApiErrorText(content) + const detail = errorRaw ? parseApiErrorDetail(errorRaw) : undefined + const error = detail + ? { name: 'APIError', data: detail } + : undefined + return { uuid: entry.uuid, type: entry.type, role: role ?? entry.type, - content, + content: error ? [] : content, timestamp, parent_uuid: entry.parentUuid ?? null, usage: entry.usage as | { input_tokens: number; output_tokens: number } | undefined, + error, } } @@ -149,11 +272,23 @@ function readMessagesFromLines( lines: string[], includeSystem: boolean, ): SessionMessage[] { + const tombstoned = new Set() + for (const line of lines) { + if (!line) continue + const entry = parseEntry(line) + if (!entry) continue + if (entry.type === 'tombstone') { + const targetUuid = (entry.message as Record | undefined)?.uuid + if (typeof targetUuid === 'string') tombstoned.add(targetUuid) + } + } + const messages: SessionMessage[] = [] for (const line of lines) { if (!line) continue const entry = parseEntry(line) if (!entry) continue + if (entry.uuid && tombstoned.has(entry.uuid)) continue const msg = entryToSessionMessage(entry, includeSystem) if (msg) messages.push(msg) } @@ -425,6 +560,147 @@ export async function readSessionDiff(opts: { } } +export type TaskInfo = { + taskID: string + status: 'running' | 'completed' | 'failed' | 'stopped' + description: string + taskType?: string + summary?: string + usage?: { total_tokens: number; tool_uses: number; duration_ms: number } + toolUseID?: string + startTime: number + endTime?: number +} + +const TASK_NOTIFICATION_RE = /^[\s\S]*<\/task-notification>\s*$/ +const TASK_ID_RE = /([^<]+)<\/task-id>/ +const API_ERROR_RE = /^API Error:\s*/ +const COSTRICT_API_ERROR_RE = /^CoStrict API Error:\s*/ + +function extractApiErrorText(content: unknown): string | null { + if (typeof content === 'string') { + if (API_ERROR_RE.test(content)) return content.replace(API_ERROR_RE, '') + if (COSTRICT_API_ERROR_RE.test(content)) return content.replace(COSTRICT_API_ERROR_RE, '') + } + if (!Array.isArray(content)) return null + for (const block of content) { + if (!block || typeof block !== 'object') continue + const b = block as Record + if (b.type === 'text' && typeof b.text === 'string') { + const text = b.text as string + if (API_ERROR_RE.test(text)) return text.replace(API_ERROR_RE, '') + if (COSTRICT_API_ERROR_RE.test(text)) return text.replace(COSTRICT_API_ERROR_RE, '') + } + } + return null +} + +function parseApiErrorDetail(raw: string): { + message: string + statusCode?: number + isRetryable?: boolean +} { + const statusMatch = raw.match(/^(\d{3})\s+/) + const statusCode = statusMatch ? parseInt(statusMatch[1]!, 10) : undefined + const isRetryable = statusCode === 429 || statusCode === 503 || statusCode === 529 + let message = raw + try { + const jsonStart = raw.indexOf('{') + if (jsonStart !== -1) { + const parsed = JSON.parse(raw.slice(jsonStart)) + if (parsed?.error?.message) { + message = parsed.error.message + } else if (typeof parsed?.message === 'string') { + message = parsed.message + } + } + } catch {} + return { message, statusCode, isRetryable } +} +const TOOL_USE_ID_RE = /([^<]+)<\/tool-use-id>/ +const TASK_STATUS_RE = /([^<]+)<\/status>/ +const TASK_SUMMARY_RE = /([^<]*)<\/summary>/ + +function isTaskNotificationContent(content: unknown): boolean { + return typeof content === 'string' && TASK_NOTIFICATION_RE.test(content) +} + +function parseTaskNotificationXml(xml: string): { + taskID: string + toolUseID?: string + status: 'completed' | 'failed' | 'stopped' + summary?: string +} | null { + const taskID = TASK_ID_RE.exec(xml)?.[1] + if (!taskID) return null + const rawStatus = TASK_STATUS_RE.exec(xml)?.[1] ?? 'completed' + const status: 'completed' | 'failed' | 'stopped' = + rawStatus === 'completed' || rawStatus === 'failed' || rawStatus === 'stopped' + ? rawStatus + : 'completed' + const toolUseID = TOOL_USE_ID_RE.exec(xml)?.[1] + const summary = TASK_SUMMARY_RE.exec(xml)?.[1] + return { taskID, toolUseID, status, summary } +} + +export async function readSessionTasks(opts: { + sessionId: string + cwd?: string +}): Promise { + const path = await resolveTranscriptPath(opts.sessionId, opts.cwd) + if (!path || !existsSync(path)) return [] + + const raw = await readFile(path, 'utf-8') + const lines = raw.split('\n').filter(Boolean) + + const tasks = new Map() + const descriptions = new Map() + + for (const line of lines) { + const entry = parseEntry(line) + if (!entry) continue + + if (entry.type === 'assistant' && entry.message?.content) { + const content = entry.message.content + if (!Array.isArray(content)) continue + for (const block of content) { + if (!block || typeof block !== 'object') continue + const b = block as Record + if (b.type === 'tool_use' && (b.name === 'Agent' || b.name === 'LocalMainSessionTask')) { + const input = b.input as Record | undefined + const callID = b.id as string | undefined + if (input?.description && callID) { + descriptions.set(callID, input.description as string) + } + } + } + } + + if (entry.type === 'user' && entry.message?.content) { + const content = entry.message.content + if (!isTaskNotificationContent(content)) continue + const parsed = parseTaskNotificationXml(content as string) + if (!parsed) continue + + const existing = tasks.get(parsed.taskID) + const endTime = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now() + const desc = existing?.description ?? descriptions.get(parsed.toolUseID ?? '') ?? '' + tasks.set(parsed.taskID, { + taskID: parsed.taskID, + status: parsed.status, + description: desc, + toolUseID: parsed.toolUseID ?? existing?.toolUseID, + summary: parsed.summary ?? existing?.summary, + usage: existing?.usage, + startTime: existing?.startTime ?? endTime, + endTime, + }) + } + } + + return [...tasks.values()] +} + export function clearPathCache(): void { pathCache.clear() }