import { afterEach, describe, expect, it, vi } from 'vitest'; import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService'; import type { ParsedMessage } from '../../../../src/main/types'; import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; import type { BoardTaskExactLogBundleCandidate } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes'; function makeRecord( id: string, timestamp: string, actor: BoardTaskActivityRecord['actor'], toolUseId?: string, ): BoardTaskActivityRecord { return { id, timestamp, task: { locator: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, resolution: 'resolved', }, linkKind: 'board_action', targetRole: 'subject', actor, actorContext: { relation: 'same_task' }, source: { filePath: '/tmp/task.jsonl', messageUuid: `${id}-msg`, ...(toolUseId ? { toolUseId } : {}), sourceOrder: 1, }, }; } function makeCandidate( id: string, timestamp: string, actor: BoardTaskActivityRecord['actor'], toolUseId?: string, ): BoardTaskExactLogBundleCandidate { const record = makeRecord(id, timestamp, actor, toolUseId); return { id, timestamp, actor, source: { filePath: '/tmp/task.jsonl', messageUuid: `${id}-msg`, ...(toolUseId ? { toolUseId } : {}), sourceOrder: 1, }, records: [record], anchor: toolUseId ? { kind: 'tool', filePath: '/tmp/task.jsonl', messageUuid: `${id}-msg`, toolUseId, } : { kind: 'message', filePath: '/tmp/task.jsonl', messageUuid: `${id}-msg`, }, actionLabel: 'Worked on task', linkKinds: ['board_action'], targetRoles: ['subject'], canLoadDetail: true, sourceGeneration: 'gen-1', }; } function makeMessage(uuid: string, timestamp: string, text: string): ParsedMessage { return { uuid, parentUuid: null, type: 'assistant', timestamp: new Date(timestamp), role: 'assistant', content: [{ type: 'text', text } as never], toolCalls: [], toolResults: [], isSidechain: true, isMeta: false, isCompactSummary: false, }; } describe('BoardTaskLogStreamService', () => { afterEach(() => { vi.unstubAllEnvs(); }); it('returns empty when the stream read flag is disabled', async () => { vi.stubEnv('CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED', 'false'); const recordSource = { getTaskRecords: vi.fn(async () => { throw new Error('should not be called'); }), }; const service = new BoardTaskLogStreamService(recordSource as never); await expect(service.getTaskLogStream('demo', 'task-a')).resolves.toEqual({ participants: [], defaultFilter: 'all', segments: [], }); expect(recordSource.getTaskRecords).not.toHaveBeenCalled(); }); it('falls back to OpenCode runtime stream when transcript slices are empty', async () => { const runtimeFallbackSource = { getTaskLogStream: vi.fn(async () => ({ participants: [ { key: 'member:alice', label: 'alice', role: 'member' as const, isLead: false, isSidechain: true, }, ], defaultFilter: 'member:alice', segments: [ { id: 'opencode:segment-1', participantKey: 'member:alice', actor: { memberName: 'alice', role: 'member' as const, sessionId: 'session-opencode', isSidechain: true, }, startTimestamp: '2026-04-21T10:00:00.000Z', endTimestamp: '2026-04-21T10:01:00.000Z', chunks: [{ id: 'chunk-1' }], }, ], source: 'opencode_runtime_fallback' as const, })), }; const service = new BoardTaskLogStreamService( { getTaskRecords: vi.fn(async () => []), } as never, undefined as never, undefined as never, undefined as never, undefined as never, undefined as never, undefined as never, runtimeFallbackSource as never ); const response = await service.getTaskLogStream('demo', 'task-a'); expect(response.source).toBe('opencode_runtime_fallback'); expect(response.segments).toHaveLength(1); expect(await service.getTaskLogStreamSummary('demo', 'task-a')).toEqual({ segmentCount: 0, }); expect(runtimeFallbackSource.getTaskLogStream).toHaveBeenCalledTimes(1); }); it('merges OpenCode runtime stream when board transcript slices mask member execution', async () => { const lead = { role: 'lead' as const, sessionId: 'session-lead', isSidechain: false, }; const candidate = { ...makeCandidate('c1', '2026-04-12T16:00:00.000Z', lead, 'tool-board'), actionCategory: 'comment' as const, canonicalToolName: 'task_add_comment', }; const runtimeFallbackSource = { getTaskLogStream: vi.fn(async () => ({ participants: [ { key: 'member:jack', label: 'jack', role: 'member' as const, isLead: false, isSidechain: true, }, ], defaultFilter: 'member:jack', segments: [ { id: 'opencode:demo:task-a:jack', participantKey: 'member:jack', actor: { memberName: 'jack', role: 'member' as const, sessionId: 'session-opencode', isSidechain: true, }, startTimestamp: '2026-04-12T16:01:00.000Z', endTimestamp: '2026-04-12T16:02:00.000Z', chunks: [{ id: 'chunk-bash' }], }, ], source: 'opencode_runtime_fallback' as const, runtimeProjection: { provider: 'opencode' as const, mode: 'heuristic' as const, attributionRecordCount: 0, projectedMessageCount: 2, fallbackReason: 'task_tool_markers' as const, }, })), }; const recordSource = { getTaskRecords: vi.fn(async () => candidate.records), }; const summarySelector = { selectSummaries: vi.fn(() => [candidate]), }; const strictParser = { parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), }; const detailSelector = { selectDetail: vi.fn(() => ({ id: 'c1', timestamp: '2026-04-12T16:00:00.000Z', actor: lead, source: candidate.source, records: candidate.records, filteredMessages: [makeMessage('c1', '2026-04-12T16:00:00.000Z', 'board update')], })), }; const taskReader = { getTasks: vi.fn(async () => [{ id: 'task-a', owner: 'jack' }]), getDeletedTasks: vi.fn(async () => []), }; const membersMetaStore = { getMembers: vi.fn(async () => [{ name: 'jack', providerId: 'opencode' }]), }; const configReader = { getConfig: vi.fn(async () => null), }; const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); const service = new BoardTaskLogStreamService( recordSource as never, summarySelector as never, strictParser as never, detailSelector as never, { buildBundleChunks } as never, taskReader as never, undefined as never, runtimeFallbackSource as never, membersMetaStore as never, configReader as never ); const response = await service.getTaskLogStream('demo', 'task-a'); expect(runtimeFallbackSource.getTaskLogStream).toHaveBeenCalledWith('demo', 'task-a'); expect(response.defaultFilter).toBe('member:jack'); expect(response.participants.map((participant) => participant.key)).toEqual([ 'member:jack', 'lead', ]); expect(response.segments.map((segment) => segment.id)).toEqual([ 'lead:c1:c1', 'opencode:demo:task-a:jack', ]); expect(response.runtimeProjection).toMatchObject({ provider: 'opencode', projectedMessageCount: 2, }); }); it('does not probe OpenCode runtime for non-OpenCode task owners', async () => { const lead = { role: 'lead' as const, sessionId: 'session-lead', isSidechain: false, }; const candidate = makeCandidate('c1', '2026-04-12T16:00:00.000Z', lead, 'tool-board'); const runtimeFallbackSource = { getTaskLogStream: vi.fn(async () => { throw new Error('should not be called'); }), }; const service = new BoardTaskLogStreamService( { getTaskRecords: vi.fn(async () => candidate.records), } as never, { selectSummaries: vi.fn(() => [candidate]), } as never, { parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), } as never, { selectDetail: vi.fn(() => ({ id: 'c1', timestamp: '2026-04-12T16:00:00.000Z', actor: lead, source: candidate.source, records: candidate.records, filteredMessages: [makeMessage('c1', '2026-04-12T16:00:00.000Z', 'board update')], })), } as never, { buildBundleChunks: vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]), } as never, { getTasks: vi.fn(async () => [{ id: 'task-a', owner: 'alice' }]), getDeletedTasks: vi.fn(async () => []), } as never, undefined as never, runtimeFallbackSource as never, { getMembers: vi.fn(async () => [{ name: 'alice', providerId: 'codex' }]), } as never, { getConfig: vi.fn(async () => null), } as never ); await service.getTaskLogStream('demo', 'task-a'); expect(runtimeFallbackSource.getTaskLogStream).not.toHaveBeenCalled(); }); it('groups contiguous slices into participant segments and excludes lead slices when member slices exist', async () => { const tom = { memberName: 'tom', role: 'member' as const, sessionId: 'session-tom', agentId: 'agent-tom', isSidechain: true, }; const alice = { memberName: 'alice', role: 'member' as const, sessionId: 'session-alice', agentId: 'agent-alice', isSidechain: true, }; const lead = { role: 'lead' as const, sessionId: 'session-lead', isSidechain: false, }; const candidates = [ makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), makeCandidate('c2', '2026-04-12T16:01:00.000Z', tom, 'tool-2'), makeCandidate('c3', '2026-04-12T16:02:00.000Z', alice, 'tool-3'), makeCandidate('c4', '2026-04-12T16:03:00.000Z', lead), makeCandidate('c5', '2026-04-12T16:04:00.000Z', tom, 'tool-4'), ]; const recordSource = { getTaskRecords: vi.fn(async () => candidates.flatMap((candidate) => candidate.records)), }; const summarySelector = { selectSummaries: vi.fn(() => candidates), }; const strictParser = { parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), }; const detailSelector = { selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => ({ id: candidate.id, timestamp: candidate.timestamp, actor: candidate.actor, source: candidate.source, records: candidate.records, filteredMessages: [makeMessage(candidate.id, candidate.timestamp, candidate.id)], })), }; const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); const service = new BoardTaskLogStreamService( recordSource as never, summarySelector as never, strictParser as never, detailSelector as never, { buildBundleChunks } as never, ); const response = await service.getTaskLogStream('demo', 'task-a'); expect(response.defaultFilter).toBe('all'); expect(response.participants.map((participant) => participant.key)).toEqual([ 'member:tom', 'member:alice', ]); expect(response.segments.map((segment) => segment.participantKey)).toEqual([ 'member:tom', 'member:alice', 'member:tom', ]); expect(buildBundleChunks).toHaveBeenCalledTimes(3); expect(buildBundleChunks.mock.calls[0]?.[0]).toHaveLength(2); }); it('returns lightweight segment count without building stream chunks', async () => { const tom = { memberName: 'tom', role: 'member' as const, sessionId: 'session-tom', agentId: 'agent-tom', isSidechain: true, }; const alice = { memberName: 'alice', role: 'member' as const, sessionId: 'session-alice', agentId: 'agent-alice', isSidechain: true, }; const candidates = [ makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), makeCandidate('c2', '2026-04-12T16:01:00.000Z', tom, 'tool-2'), makeCandidate('c3', '2026-04-12T16:02:00.000Z', alice, 'tool-3'), makeCandidate('c4', '2026-04-12T16:03:00.000Z', tom, 'tool-4'), ]; const recordSource = { getTaskRecords: vi.fn(async () => candidates.flatMap((candidate) => candidate.records)), }; const summarySelector = { selectSummaries: vi.fn(() => candidates), }; const strictParser = { parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), }; const detailSelector = { selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => ({ id: candidate.id, timestamp: candidate.timestamp, actor: candidate.actor, source: candidate.source, records: candidate.records, filteredMessages: [makeMessage(candidate.id, candidate.timestamp, candidate.id)], })), }; const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); const service = new BoardTaskLogStreamService( recordSource as never, summarySelector as never, strictParser as never, detailSelector as never, { buildBundleChunks } as never, ); await expect(service.getTaskLogStreamSummary('demo', 'task-a')).resolves.toEqual({ segmentCount: 3, }); expect(buildBundleChunks).not.toHaveBeenCalled(); }); it('shares concurrent summary and stream layout work', async () => { const tom = { memberName: 'tom', role: 'member' as const, sessionId: 'session-tom', agentId: 'agent-tom', isSidechain: true, }; const candidates = [ makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), makeCandidate('c2', '2026-04-12T16:01:00.000Z', tom, 'tool-2'), ]; const recordSource = { getTaskRecords: vi.fn(async () => { await Promise.resolve(); return candidates.flatMap((candidate) => candidate.records); }), }; const summarySelector = { selectSummaries: vi.fn(() => candidates), }; const strictParser = { parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), }; const detailSelector = { selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => ({ id: candidate.id, timestamp: candidate.timestamp, actor: candidate.actor, source: candidate.source, records: candidate.records, filteredMessages: [makeMessage(candidate.id, candidate.timestamp, candidate.id)], })), }; const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); const taskReader = { getTasks: vi.fn(async () => [ { id: 'task-a', displayId: 'abcd1234', owner: 'tom', status: 'in_progress', createdAt: '2026-04-12T15:59:00.000Z', updatedAt: '2026-04-12T16:05:00.000Z', }, ]), getDeletedTasks: vi.fn(async () => []), }; const transcriptSourceLocator = { getGeneration: vi.fn(() => 0), getContext: vi.fn(async () => ({ transcriptFiles: [], config: { members: [] }, })), }; const service = new BoardTaskLogStreamService( recordSource as never, summarySelector as never, strictParser as never, detailSelector as never, { buildBundleChunks } as never, taskReader as never, transcriptSourceLocator as never ); const [summary, response] = await Promise.all([ service.getTaskLogStreamSummary('demo', 'task-a'), service.getTaskLogStream('demo', 'task-a'), ]); expect(summary).toEqual({ segmentCount: 1 }); expect(response.segments).toHaveLength(1); expect(recordSource.getTaskRecords).toHaveBeenCalledTimes(1); expect(strictParser.parseFiles).toHaveBeenCalledTimes(1); expect(transcriptSourceLocator.getContext).toHaveBeenCalledTimes(1); }); it('does not cache a stream layout when transcript discovery changes during build', async () => { const tom = { memberName: 'tom', role: 'member' as const, sessionId: 'session-tom', agentId: 'agent-tom', isSidechain: true, }; const baseCandidate = makeCandidate( 'c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1' ); const executionRecord: BoardTaskActivityRecord = { ...baseCandidate.records[0]!, linkKind: 'execution', }; const candidate: BoardTaskExactLogBundleCandidate = { ...baseCandidate, records: [executionRecord], linkKinds: ['execution'], }; let generation = 0; let recordReadCount = 0; const recordSource = { getTaskRecords: vi.fn(async () => { recordReadCount += 1; if (recordReadCount === 1) { generation += 1; } return candidate.records; }), }; const summarySelector = { selectSummaries: vi.fn(() => [candidate]), }; const strictParser = { parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), }; const detailSelector = { selectDetail: vi.fn(() => ({ id: candidate.id, timestamp: candidate.timestamp, actor: candidate.actor, source: candidate.source, records: candidate.records, filteredMessages: [makeMessage(candidate.id, candidate.timestamp, 'native work')], })), }; const transcriptSourceLocator = { getGeneration: vi.fn(() => generation), getContext: vi.fn(async () => null), }; const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); const service = new BoardTaskLogStreamService( recordSource as never, summarySelector as never, strictParser as never, detailSelector as never, { buildBundleChunks } as never, undefined as never, transcriptSourceLocator as never ); await service.getTaskLogStream('demo', 'task-a'); await service.getTaskLogStream('demo', 'task-a'); await service.getTaskLogStream('demo', 'task-a'); expect(recordSource.getTaskRecords).toHaveBeenCalledTimes(2); expect(buildBundleChunks).toHaveBeenCalledTimes(3); }); it('merges duplicate message uuids inside one participant segment before chunk building', async () => { const tom = { memberName: 'tom', role: 'member' as const, sessionId: 'session-tom', agentId: 'agent-tom', isSidechain: true, }; const candidates = [ makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), makeCandidate('c2', '2026-04-12T16:00:10.000Z', tom, 'tool-2'), ]; const sharedMessage = { uuid: 'assistant-shared', parentUuid: null, type: 'assistant' as const, timestamp: new Date('2026-04-12T16:00:00.000Z'), role: 'assistant', toolCalls: [], toolResults: [], isSidechain: true, isMeta: false, isCompactSummary: false, }; const recordSource = { getTaskRecords: vi.fn(async () => candidates.flatMap((candidate) => candidate.records)), }; const summarySelector = { selectSummaries: vi.fn(() => candidates), }; const strictParser = { parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), }; const detailSelector = { selectDetail: vi .fn() .mockImplementationOnce(() => ({ id: 'c1', timestamp: '2026-04-12T16:00:00.000Z', actor: tom, source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-shared', sourceOrder: 1 }, records: candidates[0]!.records, filteredMessages: [ { ...sharedMessage, content: [{ type: 'tool_use', id: 'tool-1', name: 'task_get', input: {} } as never], }, ], })) .mockImplementationOnce(() => ({ id: 'c2', timestamp: '2026-04-12T16:00:10.000Z', actor: tom, source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-shared', sourceOrder: 2 }, records: candidates[1]!.records, filteredMessages: [ { ...sharedMessage, content: [{ type: 'text', text: 'task looked up' } as never], }, ], })), }; const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); const service = new BoardTaskLogStreamService( recordSource as never, summarySelector as never, strictParser as never, detailSelector as never, { buildBundleChunks } as never, ); await service.getTaskLogStream('demo', 'task-a'); expect(buildBundleChunks).toHaveBeenCalledTimes(1); const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; expect(mergedMessages).toHaveLength(1); expect(mergedMessages[0]?.toolCalls).toHaveLength(1); expect(Array.isArray(mergedMessages[0]?.content)).toBe(true); expect(mergedMessages[0]?.content).toHaveLength(2); }); it('drops tool-anchored assistant output-only messages to avoid noisy raw result blocks', async () => { const tom = { memberName: 'tom', role: 'member' as const, sessionId: 'session-tom', agentId: 'agent-tom', isSidechain: true, }; const candidate = makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'); const recordSource = { getTaskRecords: vi.fn(async () => candidate.records), }; const summarySelector = { selectSummaries: vi.fn(() => [candidate]), }; const strictParser = { parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), }; const detailSelector = { selectDetail: vi.fn(() => ({ id: 'c1', timestamp: '2026-04-12T16:00:00.000Z', actor: tom, source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-tool', toolUseId: 'tool-1', sourceOrder: 1 }, records: candidate.records, filteredMessages: [ { uuid: 'assistant-tool', parentUuid: null, type: 'assistant' as const, timestamp: new Date('2026-04-12T16:00:00.000Z'), role: 'assistant', content: [{ type: 'tool_use', id: 'tool-1', name: 'task_get', input: {} } as never], toolCalls: [], toolResults: [], isSidechain: true, isMeta: false, isCompactSummary: false, }, { uuid: 'assistant-output', parentUuid: 'assistant-tool', type: 'assistant' as const, timestamp: new Date('2026-04-12T16:00:01.000Z'), role: 'assistant', content: [{ type: 'text', text: '[{\"type\":\"text\",\"text\":\"{\\n \\\"id\\\": \\\"task-a\\\"\\n}\"}]' } as never], toolCalls: [], toolResults: [], sourceToolUseID: 'tool-1', sourceToolAssistantUUID: 'assistant-tool', isSidechain: true, isMeta: false, isCompactSummary: false, }, { uuid: 'user-result', parentUuid: 'assistant-tool', type: 'user' as const, timestamp: new Date('2026-04-12T16:00:02.000Z'), role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' } as never], toolCalls: [], toolResults: [], sourceToolUseID: 'tool-1', sourceToolAssistantUUID: 'assistant-tool', toolUseResult: { toolUseId: 'tool-1', content: 'ok' }, isSidechain: true, isMeta: false, isCompactSummary: false, }, ], })), }; const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); const service = new BoardTaskLogStreamService( recordSource as never, summarySelector as never, strictParser as never, detailSelector as never, { buildBundleChunks } as never, ); await service.getTaskLogStream('demo', 'task-a'); expect(buildBundleChunks).toHaveBeenCalledTimes(1); const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; expect(mergedMessages.map((message) => message.uuid)).toEqual(['assistant-tool', 'user-result']); }); it('defaults to the single named participant and excludes unnamed lead noise when named task logs exist', async () => { const tom = { memberName: 'tom', role: 'lead' as const, sessionId: 'session-tom', isSidechain: false, }; const unknownLead = { role: 'unknown' as const, sessionId: 'session-lead', isSidechain: false, }; const candidates = [ makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), makeCandidate('c2', '2026-04-12T16:01:00.000Z', unknownLead, 'tool-2'), ]; const recordSource = { getTaskRecords: vi.fn(async () => candidates.flatMap((candidate) => candidate.records)), }; const summarySelector = { selectSummaries: vi.fn(() => candidates), }; const strictParser = { parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), }; const detailSelector = { selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => ({ id: candidate.id, timestamp: candidate.timestamp, actor: candidate.actor, source: candidate.source, records: candidate.records, filteredMessages: [makeMessage(candidate.id, candidate.timestamp, candidate.id)], })), }; const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); const service = new BoardTaskLogStreamService( recordSource as never, summarySelector as never, strictParser as never, detailSelector as never, { buildBundleChunks } as never, ); const response = await service.getTaskLogStream('demo', 'task-a'); expect(response.participants.map((participant) => participant.key)).toEqual(['member:tom']); expect(response.defaultFilter).toBe('member:tom'); expect(response.segments.map((segment) => segment.participantKey)).toEqual(['member:tom']); }); it('drops empty json-like task_get tool result messages after sanitization', async () => { const tom = { memberName: 'tom', role: 'member' as const, sessionId: 'session-tom', agentId: 'agent-tom', isSidechain: true, }; const candidate = makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'); const recordSource = { getTaskRecords: vi.fn(async () => candidate.records), }; const summarySelector = { selectSummaries: vi.fn(() => [candidate]), }; const strictParser = { parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), }; const detailSelector = { selectDetail: vi.fn(() => ({ id: 'c1', timestamp: '2026-04-12T16:00:00.000Z', actor: tom, source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-tool', toolUseId: 'tool-1', sourceOrder: 1 }, records: candidate.records, filteredMessages: [ { uuid: 'assistant-tool', parentUuid: null, type: 'assistant' as const, timestamp: new Date('2026-04-12T16:00:00.000Z'), role: 'assistant', content: [{ type: 'tool_use', id: 'tool-1', name: 'task_get', input: {} } as never], toolCalls: [], toolResults: [], isSidechain: true, isMeta: false, isCompactSummary: false, }, { uuid: 'user-result', parentUuid: 'assistant-tool', type: 'user' as const, timestamp: new Date('2026-04-12T16:00:02.000Z'), role: 'user', content: [ { type: 'tool_result', tool_use_id: 'tool-1', content: [{ type: 'text', text: '{\n \"id\": \"task-a\"\n}' } as never], } as never, ], toolCalls: [], toolResults: [], sourceToolUseID: 'tool-1', sourceToolAssistantUUID: 'assistant-tool', toolUseResult: { toolUseId: 'tool-1', content: '{\n \"id\": \"task-a\"\n}' }, isSidechain: true, isMeta: false, isCompactSummary: false, }, ], })), }; const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); const service = new BoardTaskLogStreamService( recordSource as never, summarySelector as never, strictParser as never, detailSelector as never, { buildBundleChunks } as never, ); await service.getTaskLogStream('demo', 'task-a'); const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; const toolResultMessage = mergedMessages.find((message) => message.uuid === 'user-result'); expect(toolResultMessage).toBeUndefined(); expect(mergedMessages.map((message) => message.uuid)).toEqual(['assistant-tool']); }); it('drops read-only slices when the same participant has more meaningful task logs', async () => { const tom = { memberName: 'tom', role: 'lead' as const, sessionId: 'session-tom', isSidechain: false, }; const readCandidate = { ...makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), actionCategory: 'read' as const, canonicalToolName: 'task_get' }; const commentCandidate = { ...makeCandidate('c2', '2026-04-12T16:01:00.000Z', tom, 'tool-2'), actionCategory: 'comment' as const, canonicalToolName: 'task_add_comment' }; const recordSource = { getTaskRecords: vi.fn(async () => [...readCandidate.records, ...commentCandidate.records]), }; const summarySelector = { selectSummaries: vi.fn(() => [readCandidate, commentCandidate]), }; const strictParser = { parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), }; const detailSelector = { selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => ({ id: candidate.id, timestamp: candidate.timestamp, actor: candidate.actor, source: candidate.source, records: candidate.records, filteredMessages: [makeMessage(candidate.id, candidate.timestamp, candidate.id)], })), }; const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); const service = new BoardTaskLogStreamService( recordSource as never, summarySelector as never, strictParser as never, detailSelector as never, { buildBundleChunks } as never, ); const response = await service.getTaskLogStream('demo', 'task-a'); expect(response.segments).toHaveLength(1); expect(buildBundleChunks).toHaveBeenCalledTimes(1); const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; expect(mergedMessages.map((message) => message.uuid)).toEqual(['c2']); }); it('does not use read-only task readers as inferred execution participants', async () => { const alice = { memberName: 'alice', role: 'member' as const, sessionId: 'session-alice', isSidechain: false, }; const readRecord = { ...makeRecord('alice-read', '2026-04-12T16:00:00.000Z', alice, 'tool-read'), action: { canonicalToolName: 'task_get', toolUseId: 'tool-read', category: 'read' as const, }, }; const readCandidate: BoardTaskExactLogBundleCandidate = { ...makeCandidate('alice-read', '2026-04-12T16:00:00.000Z', alice, 'tool-read'), records: [readRecord], actionCategory: 'read', canonicalToolName: 'task_get', }; const aliceRuntimeMessage: ParsedMessage = { uuid: 'alice-bash', parentUuid: null, type: 'assistant', timestamp: new Date('2026-04-12T16:02:00.000Z'), role: 'assistant', content: [ { type: 'tool_use', id: 'tool-bash', name: 'Bash', input: { command: 'git diff' }, } as never, ], toolCalls: [ { id: 'tool-bash', name: 'Bash', input: { command: 'git diff' }, isTask: false, }, ], toolResults: [], sessionId: 'session-alice', agentName: 'alice', isSidechain: false, isMeta: false, isCompactSummary: false, }; const recordSource = { getTaskRecords: vi.fn(async () => [readRecord]), }; const summarySelector = { selectSummaries: vi.fn(() => [readCandidate]), }; const strictParser = { parseFiles: vi.fn(async (filePaths: string[]) => new Map( filePaths.map((filePath) => [ filePath, filePath === '/tmp/alice.jsonl' ? [aliceRuntimeMessage] : [], ]) ) ), }; const detailSelector = { selectDetail: vi.fn(() => ({ id: 'alice-read', timestamp: '2026-04-12T16:00:00.000Z', actor: alice, source: readCandidate.source, records: [readRecord], filteredMessages: [makeMessage('alice-read-detail', '2026-04-12T16:00:00.000Z', 'read')], })), }; const taskReader = { getTasks: vi.fn(async () => [ { id: 'task-a', displayId: 'abcd1234', owner: 'tom', status: 'in_progress', createdAt: '2026-04-12T15:59:00.000Z', updatedAt: '2026-04-12T16:05:00.000Z', }, ]), getDeletedTasks: vi.fn(async () => []), }; const transcriptSourceLocator = { getContext: vi.fn(async () => ({ transcriptFiles: ['/tmp/task.jsonl', '/tmp/alice.jsonl'], config: { members: [{ name: 'team-lead', agentType: 'team-lead' }] }, })), }; const runtimeFallbackSource = { getTaskLogStream: vi.fn(async () => null), }; const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); const service = new BoardTaskLogStreamService( recordSource as never, summarySelector as never, strictParser as never, detailSelector as never, { buildBundleChunks } as never, taskReader as never, transcriptSourceLocator as never, runtimeFallbackSource as never, { getMembers: vi.fn(async () => [{ name: 'tom', providerId: 'codex' }]) } as never, { getConfig: vi.fn(async () => null) } as never ); const response = await service.getTaskLogStream('demo', 'task-a'); expect(response.segments).toHaveLength(1); expect(response.segments[0]?.participantKey).toBe('member:alice'); const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; expect(mergedMessages.map((message) => message.uuid)).toEqual(['alice-read-detail']); }); it('does not recover task_get logs from nested task refs in result payloads', async () => { const taskReader = { getTasks: vi.fn(async () => [ { id: 'task-a', displayId: 'abcd1234', owner: 'tom', status: 'completed', createdAt: '2026-04-12T16:00:00.000Z', updatedAt: '2026-04-12T16:05:00.000Z', }, ]), getDeletedTasks: vi.fn(async () => []), }; const transcriptSourceLocator = { getContext: vi.fn(async () => ({ transcriptFiles: ['/tmp/lead.jsonl'], config: { members: [{ name: 'team-lead', agentType: 'team-lead' }], }, })), }; const strictParser = { parseFiles: vi.fn(async () => new Map([ [ '/tmp/lead.jsonl', [ { uuid: 'assistant-task-get', parentUuid: null, type: 'assistant' as const, timestamp: new Date('2026-04-12T16:01:00.000Z'), role: 'assistant', content: [ { type: 'tool_use', id: 'tool-task-get', name: 'task_get', input: { teamName: 'demo', taskId: 'parent-task' }, } as never, ], toolCalls: [ { id: 'tool-task-get', name: 'task_get', input: { teamName: 'demo', taskId: 'parent-task' }, isTask: false, }, ], toolResults: [], isSidechain: false, isMeta: false, isCompactSummary: false, }, { uuid: 'user-task-get-result', parentUuid: 'assistant-task-get', type: 'user' as const, timestamp: new Date('2026-04-12T16:01:01.000Z'), role: 'user', content: [ { type: 'tool_result', tool_use_id: 'tool-task-get', content: JSON.stringify({ id: 'parent-task', displayId: 'parent', blockedBy: ['task-a'], }), } as never, ], toolCalls: [], toolResults: [ { toolUseId: 'tool-task-get', content: JSON.stringify({ id: 'parent-task', displayId: 'parent', blockedBy: ['task-a'], }), isError: false, }, ], sourceToolUseID: 'tool-task-get', sourceToolAssistantUUID: 'assistant-task-get', toolUseResult: { toolUseId: 'tool-task-get', content: JSON.stringify({ id: 'parent-task', displayId: 'parent', blockedBy: ['task-a'], }), }, isSidechain: false, isMeta: false, isCompactSummary: false, }, ], ], ]) ), }; const summarySelector = { selectSummaries: vi.fn(() => { throw new Error('task_get result payload should not create recovered records'); }), }; const runtimeFallbackSource = { getTaskLogStream: vi.fn(async () => null), }; const service = new BoardTaskLogStreamService( { getTaskRecords: vi.fn(async () => []) } as never, summarySelector as never, strictParser as never, undefined as never, undefined as never, taskReader as never, transcriptSourceLocator as never, runtimeFallbackSource as never ); await expect(service.getTaskLogStream('demo', 'task-a')).resolves.toEqual({ participants: [], defaultFilter: 'all', segments: [], }); expect(summarySelector.selectSummaries).not.toHaveBeenCalled(); }); it('extracts task_add_comment text from json-like tool result payload', async () => { const tom = { memberName: 'tom', role: 'lead' as const, sessionId: 'session-tom', isSidechain: false, }; const candidate = { ...makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), actionCategory: 'comment' as const, canonicalToolName: 'task_add_comment', }; const recordSource = { getTaskRecords: vi.fn(async () => candidate.records), }; const summarySelector = { selectSummaries: vi.fn(() => [candidate]), }; const strictParser = { parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), }; const detailSelector = { selectDetail: vi.fn(() => ({ id: 'c1', timestamp: '2026-04-12T16:00:00.000Z', actor: tom, source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-tool', toolUseId: 'tool-1', sourceOrder: 1 }, records: candidate.records, filteredMessages: [ { uuid: 'assistant-tool', parentUuid: null, type: 'assistant' as const, timestamp: new Date('2026-04-12T16:00:00.000Z'), role: 'assistant', content: [{ type: 'tool_use', id: 'tool-1', name: 'task_add_comment', input: {} } as never], toolCalls: [], toolResults: [], isSidechain: false, isMeta: false, isCompactSummary: false, }, { uuid: 'user-result', parentUuid: 'assistant-tool', type: 'user' as const, timestamp: new Date('2026-04-12T16:00:02.000Z'), role: 'user', content: [ { type: 'tool_result', tool_use_id: 'tool-1', content: [{ type: 'text', text: '{\"comment\":{\"text\":\"useful comment\"}}' } as never], } as never, ], toolCalls: [], toolResults: [], sourceToolUseID: 'tool-1', sourceToolAssistantUUID: 'assistant-tool', toolUseResult: { toolUseId: 'tool-1', content: '{"comment":{"text":"useful comment"}}' }, isSidechain: false, isMeta: false, isCompactSummary: false, }, ], })), }; const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); const service = new BoardTaskLogStreamService( recordSource as never, summarySelector as never, strictParser as never, detailSelector as never, { buildBundleChunks } as never, ); await service.getTaskLogStream('demo', 'task-a'); const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; const toolResultMessage = mergedMessages.find((message) => message.uuid === 'user-result'); const content = Array.isArray(toolResultMessage?.content) ? toolResultMessage.content : []; expect(content[0]).toMatchObject({ type: 'tool_result', tool_use_id: 'tool-1', content: 'useful comment', }); expect(toolResultMessage?.toolUseResult).toEqual({ toolUseId: 'tool-1', content: 'useful comment' }); }); it('sanitizes SendMessage json payloads into a concise human-readable result', async () => { const bob = { memberName: 'bob', role: 'member' as const, sessionId: 'session-bob', agentId: 'agent-bob', isSidechain: true, }; const candidate = { ...makeCandidate('c1', '2026-04-12T16:00:00.000Z', bob, 'tool-send'), actionCategory: 'execution' as const, canonicalToolName: 'SendMessage', }; const recordSource = { getTaskRecords: vi.fn(async () => candidate.records), }; const summarySelector = { selectSummaries: vi.fn(() => [candidate]), }; const strictParser = { parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), }; const detailSelector = { selectDetail: vi.fn(() => ({ id: 'c1', timestamp: '2026-04-12T16:00:00.000Z', actor: bob, source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-send', toolUseId: 'tool-send', sourceOrder: 1, }, records: candidate.records, filteredMessages: [ { uuid: 'assistant-send', parentUuid: null, type: 'assistant' as const, timestamp: new Date('2026-04-12T16:00:00.000Z'), role: 'assistant', content: [ { type: 'tool_use', id: 'tool-send', name: 'SendMessage', input: { to: 'team-lead', summary: '#abc done' }, } as never, ], toolCalls: [], toolResults: [], isSidechain: false, isMeta: false, isCompactSummary: false, }, { uuid: 'user-send-result', parentUuid: 'assistant-send', type: 'user' as const, timestamp: new Date('2026-04-12T16:00:02.000Z'), role: 'user', content: [ { type: 'tool_result', tool_use_id: 'tool-send', content: [ { type: 'text', text: JSON.stringify({ success: true, message: "Message sent to team-lead's inbox", routing: { target: '@team-lead', summary: '#abc done', content: 'Detailed body that should not leak into the preview.', }, }), } as never, ], } as never, ], toolCalls: [], toolResults: [ { toolUseId: 'tool-send', content: [ { type: 'text', text: JSON.stringify({ success: true, message: "Message sent to team-lead's inbox", routing: { target: '@team-lead', summary: '#abc done', content: 'Detailed body that should not leak into the preview.', }, }), }, ], isError: false, }, ], sourceToolUseID: 'tool-send', sourceToolAssistantUUID: 'assistant-send', toolUseResult: { success: true, message: "Message sent to team-lead's inbox", routing: { target: '@team-lead', summary: '#abc done', content: 'Detailed body that should not leak into the preview.', }, }, isSidechain: false, isMeta: false, isCompactSummary: false, }, ], })), }; const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); const service = new BoardTaskLogStreamService( recordSource as never, summarySelector as never, strictParser as never, detailSelector as never, { buildBundleChunks } as never, ); await service.getTaskLogStream('demo', 'task-a'); const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; const toolResultMessage = mergedMessages.find((message) => message.uuid === 'user-send-result'); const content = Array.isArray(toolResultMessage?.content) ? toolResultMessage.content : []; expect(content[0]).toMatchObject({ type: 'tool_result', tool_use_id: 'tool-send', content: "Message sent to team-lead's inbox - #abc done", }); expect(toolResultMessage?.toolResults).toEqual([ { toolUseId: 'tool-send', content: "Message sent to team-lead's inbox - #abc done", isError: false, }, ]); }); it('sanitizes MCP task_complete and message_send json payloads into readable results', async () => { const tom = { memberName: 'tom', role: 'member' as const, sessionId: 'session-tom', isSidechain: false, }; const completeCandidate = { ...makeCandidate('c-complete', '2026-04-12T16:00:00.000Z', tom, 'tool-complete'), actionCategory: 'status' as const, canonicalToolName: 'task_complete', }; const sendCandidate = { ...makeCandidate('c-send', '2026-04-12T16:00:01.000Z', tom, 'tool-send'), actionCategory: 'other' as const, canonicalToolName: 'mcp__agent-teams__message_send', }; const recordSource = { getTaskRecords: vi.fn(async () => [ ...completeCandidate.records, ...sendCandidate.records, ]), }; const summarySelector = { selectSummaries: vi.fn(() => [completeCandidate, sendCandidate]), }; const strictParser = { parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), }; const detailSelector = { selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => { const isComplete = candidate.id === 'c-complete'; const toolUseId = isComplete ? 'tool-complete' : 'tool-send'; const toolName = isComplete ? 'task_complete' : 'mcp__agent-teams__message_send'; const payload = isComplete ? { id: 'task-a', displayId: 'abcd1234', status: 'completed' } : { deliveredToInbox: true, message: { from: 'tom', to: 'team-lead', text: 'Detailed body', summary: '#abcd1234 done', }, }; return { id: candidate.id, timestamp: candidate.timestamp, actor: tom, source: candidate.source, records: candidate.records, filteredMessages: [ { uuid: `${candidate.id}-assistant`, parentUuid: null, type: 'assistant' as const, timestamp: new Date(candidate.timestamp), role: 'assistant', content: [{ type: 'tool_use', id: toolUseId, name: toolName, input: {} } as never], toolCalls: [], toolResults: [], isSidechain: false, isMeta: false, isCompactSummary: false, }, { uuid: `${candidate.id}-result`, parentUuid: `${candidate.id}-assistant`, type: 'user' as const, timestamp: new Date(candidate.timestamp), role: 'user', content: [ { type: 'tool_result', tool_use_id: toolUseId, content: [{ type: 'text', text: JSON.stringify(payload) } as never], } as never, ], toolCalls: [], toolResults: [ { toolUseId, content: [{ type: 'text', text: JSON.stringify(payload) }], isError: false, }, ], sourceToolUseID: toolUseId, sourceToolAssistantUUID: `${candidate.id}-assistant`, toolUseResult: { toolUseId, content: JSON.stringify(payload), }, isSidechain: false, isMeta: false, isCompactSummary: false, }, ], }; }), }; const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); const service = new BoardTaskLogStreamService( recordSource as never, summarySelector as never, strictParser as never, detailSelector as never, { buildBundleChunks } as never, ); await service.getTaskLogStream('demo', 'task-a'); const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; const completeResult = mergedMessages.find((message) => message.uuid === 'c-complete-result'); const sendResult = mergedMessages.find((message) => message.uuid === 'c-send-result'); expect(completeResult?.toolResults).toEqual([ { toolUseId: 'tool-complete', content: 'Task abcd1234 completed', isError: false, }, ]); expect(sendResult?.toolResults).toEqual([ { toolUseId: 'tool-send', content: 'Message sent to team-lead - #abcd1234 done', isError: false, }, ]); }); });