2046 lines
66 KiB
TypeScript
2046 lines
66 KiB
TypeScript
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: 1,
|
|
});
|
|
expect(runtimeFallbackSource.getTaskLogStream).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
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 suppress exact OpenCode fallback because of unrelated execution records', async () => {
|
|
const lead = {
|
|
role: 'lead' as const,
|
|
sessionId: 'session-lead',
|
|
isSidechain: false,
|
|
};
|
|
const baseCandidate = makeCandidate(
|
|
'c1',
|
|
'2026-04-12T16:00:00.000Z',
|
|
lead,
|
|
'tool-board'
|
|
);
|
|
const executionRecord: BoardTaskActivityRecord = {
|
|
...baseCandidate.records[0]!,
|
|
linkKind: 'execution',
|
|
};
|
|
const candidate: BoardTaskExactLogBundleCandidate = {
|
|
...baseCandidate,
|
|
records: [executionRecord],
|
|
linkKinds: ['execution'],
|
|
};
|
|
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:session-opencode',
|
|
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-exact-opencode' }],
|
|
},
|
|
],
|
|
source: 'opencode_runtime_attribution' as const,
|
|
runtimeProjection: {
|
|
provider: 'opencode' as const,
|
|
mode: 'attribution' as const,
|
|
attributionRecordCount: 1,
|
|
projectedMessageCount: 2,
|
|
},
|
|
})),
|
|
};
|
|
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', 'lead execution')],
|
|
})),
|
|
} as never,
|
|
{
|
|
buildBundleChunks: vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]),
|
|
} as never,
|
|
{
|
|
getTasks: vi.fn(async () => [{ id: 'task-a', owner: 'jack' }]),
|
|
getDeletedTasks: vi.fn(async () => []),
|
|
} as never,
|
|
undefined as never,
|
|
runtimeFallbackSource as never,
|
|
{
|
|
getMembers: vi.fn(async () => [{ name: 'jack', providerId: 'opencode' }]),
|
|
} as never,
|
|
{
|
|
getConfig: vi.fn(async () => null),
|
|
} as never
|
|
);
|
|
|
|
const response = await service.getTaskLogStream('demo', 'task-a');
|
|
|
|
expect(runtimeFallbackSource.getTaskLogStream).toHaveBeenCalledWith('demo', 'task-a');
|
|
expect(response.source).toBe('mixed_transcript_opencode_runtime');
|
|
expect(response.segments.map((segment) => segment.id)).toEqual([
|
|
'lead:c1:c1',
|
|
'opencode:demo:task-a:jack:session-opencode',
|
|
]);
|
|
});
|
|
|
|
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']);
|
|
expect(strictParser.parseFiles).toHaveBeenCalledTimes(1);
|
|
expect(strictParser.parseFiles.mock.calls.flatMap((call) => call[0] as string[])).not.toContain(
|
|
'/tmp/alice.jsonl'
|
|
);
|
|
});
|
|
|
|
it('limits inferred native parsing to direct and same-session transcript candidates', async () => {
|
|
const projectDir = '/tmp/task-log-project';
|
|
const rootFile = `${projectDir}/session-alice.jsonl`;
|
|
const subagentFile = `${projectDir}/session-alice/subagents/agent-worker.jsonl`;
|
|
const unrelatedFiles = Array.from(
|
|
{ length: 300 },
|
|
(_, index) => `${projectDir}/session-unrelated-${index}.jsonl`
|
|
);
|
|
const alice = {
|
|
memberName: 'alice',
|
|
role: 'member' as const,
|
|
sessionId: 'session-alice',
|
|
isSidechain: false,
|
|
};
|
|
const baseRecord = makeRecord(
|
|
'alice-comment',
|
|
'2026-04-12T16:00:00.000Z',
|
|
alice,
|
|
'tool-comment'
|
|
);
|
|
const commentRecord: BoardTaskActivityRecord = {
|
|
...baseRecord,
|
|
action: {
|
|
canonicalToolName: 'task_add_comment',
|
|
toolUseId: 'tool-comment',
|
|
category: 'comment',
|
|
},
|
|
source: {
|
|
...baseRecord.source,
|
|
filePath: rootFile,
|
|
},
|
|
};
|
|
const candidate: BoardTaskExactLogBundleCandidate = {
|
|
...makeCandidate('alice-comment', '2026-04-12T16:00:00.000Z', alice, 'tool-comment'),
|
|
source: commentRecord.source,
|
|
records: [commentRecord],
|
|
actionCategory: 'comment',
|
|
canonicalToolName: 'task_add_comment',
|
|
};
|
|
const nativeMessage: ParsedMessage = {
|
|
uuid: 'alice-bash',
|
|
parentUuid: null,
|
|
type: 'assistant',
|
|
timestamp: new Date('2026-04-12T16:01:00.000Z'),
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
id: 'tool-bash',
|
|
name: 'Bash',
|
|
input: { command: 'npm test' },
|
|
} as never,
|
|
],
|
|
toolCalls: [
|
|
{
|
|
id: 'tool-bash',
|
|
name: 'Bash',
|
|
input: { command: 'npm test' },
|
|
isTask: false,
|
|
},
|
|
],
|
|
toolResults: [],
|
|
sessionId: 'session-alice',
|
|
agentName: 'alice',
|
|
isSidechain: false,
|
|
isMeta: false,
|
|
isCompactSummary: false,
|
|
};
|
|
const recordSource = {
|
|
getTaskRecords: vi.fn(async () => [commentRecord]),
|
|
};
|
|
const summarySelector = {
|
|
selectSummaries: vi.fn(() => [candidate]),
|
|
};
|
|
const strictParser = {
|
|
parseFiles: vi.fn(async (filePaths: string[]) =>
|
|
new Map(
|
|
filePaths.map((filePath) => [
|
|
filePath,
|
|
filePath === subagentFile ? [nativeMessage] : [],
|
|
])
|
|
)
|
|
),
|
|
};
|
|
const detailSelector = {
|
|
selectDetail: vi.fn(() => ({
|
|
id: 'alice-comment',
|
|
timestamp: '2026-04-12T16:00:00.000Z',
|
|
actor: alice,
|
|
source: candidate.source,
|
|
records: [commentRecord],
|
|
filteredMessages: [
|
|
makeMessage('alice-comment-detail', '2026-04-12T16:00:00.000Z', 'comment'),
|
|
],
|
|
})),
|
|
};
|
|
const taskReader = {
|
|
getTasks: vi.fn(async () => [
|
|
{
|
|
id: 'task-a',
|
|
displayId: 'abcd1234',
|
|
owner: 'alice',
|
|
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 () => ({
|
|
projectDir,
|
|
transcriptFiles: [rootFile, subagentFile, ...unrelatedFiles],
|
|
config: { members: [{ name: 'team-lead', agentType: 'team-lead' }] },
|
|
})),
|
|
};
|
|
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,
|
|
{ getTaskLogStream: vi.fn(async () => null) } as never,
|
|
{ getMembers: vi.fn(async () => [{ name: 'alice', providerId: 'codex' }]) } as never,
|
|
{ getConfig: vi.fn(async () => null) } as never,
|
|
{ getTaskLogStream: vi.fn(async () => null) } as never
|
|
);
|
|
|
|
await service.getTaskLogStream('demo', 'task-a');
|
|
|
|
expect(strictParser.parseFiles.mock.calls.map((call) => call[0])).toEqual([
|
|
[rootFile],
|
|
[subagentFile],
|
|
]);
|
|
const parsedFiles = strictParser.parseFiles.mock.calls.flatMap((call) => call[0] as string[]);
|
|
expect(parsedFiles).not.toEqual(expect.arrayContaining(unrelatedFiles));
|
|
expect(buildBundleChunks.mock.calls[0]?.[0].map((message: ParsedMessage) => message.uuid)).toEqual([
|
|
'alice-comment-detail',
|
|
'alice-bash',
|
|
]);
|
|
});
|
|
|
|
it('limits historical board MCP recovery parsing to raw-probe candidate files', async () => {
|
|
const hitFile = '/tmp/historical-hit.jsonl';
|
|
const unrelatedFile = '/tmp/historical-unrelated.jsonl';
|
|
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: [hitFile, unrelatedFile],
|
|
config: {
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
|
},
|
|
})),
|
|
};
|
|
const strictParser = {
|
|
parseFiles: vi.fn(async () => new Map<string, ParsedMessage[]>([[hitFile, []]])),
|
|
};
|
|
const summarySelector = {
|
|
selectSummaries: vi.fn(() => {
|
|
throw new Error('empty parsed historical candidate should not create records');
|
|
}),
|
|
};
|
|
const rawProbe = {
|
|
findCandidateFiles: vi.fn(async () => ({
|
|
filePaths: [hitFile],
|
|
scannedFileCount: 2,
|
|
hitCount: 1,
|
|
elapsedMs: 0,
|
|
})),
|
|
};
|
|
|
|
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,
|
|
{ getTaskLogStream: vi.fn(async () => null) } as never,
|
|
undefined as never,
|
|
undefined as never,
|
|
{ getTaskLogStream: vi.fn(async () => null) } as never,
|
|
undefined as never,
|
|
rawProbe as never
|
|
);
|
|
|
|
await expect(service.getTaskLogStream('demo', 'task-a')).resolves.toEqual({
|
|
participants: [],
|
|
defaultFilter: 'all',
|
|
segments: [],
|
|
});
|
|
expect(rawProbe.findCandidateFiles).toHaveBeenCalledWith({
|
|
task: expect.objectContaining({ id: 'task-a' }),
|
|
transcriptFiles: [hitFile, unrelatedFile],
|
|
});
|
|
expect(strictParser.parseFiles).toHaveBeenCalledWith([hitFile]);
|
|
});
|
|
|
|
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<string, ParsedMessage[]>([
|
|
[
|
|
'/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,
|
|
},
|
|
]);
|
|
});
|
|
it('merges Codex native trace fallback even when primary transcript has MCP execution records', async () => {
|
|
const atlas = {
|
|
memberName: 'atlas',
|
|
role: 'member' as const,
|
|
sessionId: 'session-atlas',
|
|
agentId: 'agent-atlas',
|
|
isSidechain: true,
|
|
};
|
|
const baseCandidate = makeCandidate(
|
|
'c1',
|
|
'2026-05-01T17:10:00.000Z',
|
|
atlas,
|
|
'mcp-tool-1'
|
|
);
|
|
const executionRecord: BoardTaskActivityRecord = {
|
|
...baseCandidate.records[0]!,
|
|
linkKind: 'execution',
|
|
};
|
|
const candidate: BoardTaskExactLogBundleCandidate = {
|
|
...baseCandidate,
|
|
records: [executionRecord],
|
|
linkKinds: ['execution'],
|
|
};
|
|
const recordSource = {
|
|
getTaskRecords: vi.fn(async () => candidate.records),
|
|
};
|
|
const summarySelector = {
|
|
selectSummaries: vi.fn(() => [candidate]),
|
|
};
|
|
const strictParser = {
|
|
parseFiles: vi.fn(async () => new Map([['/tmp/codex-task.jsonl', []]])),
|
|
};
|
|
const detailSelector = {
|
|
selectDetail: vi.fn(() => ({
|
|
id: candidate.id,
|
|
timestamp: candidate.timestamp,
|
|
actor: atlas,
|
|
source: candidate.source,
|
|
records: candidate.records,
|
|
filteredMessages: [makeMessage('mcp-message', '2026-05-01T17:10:00.000Z', 'mcp task_start')],
|
|
})),
|
|
};
|
|
const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]);
|
|
const openCodeRuntimeFallbackSource = {
|
|
getTaskLogStream: vi.fn(async () => {
|
|
throw new Error('OpenCode fallback should stay behind OpenCode-only conditions');
|
|
}),
|
|
};
|
|
const membersMetaStore = {
|
|
getMembers: vi.fn(async () => [{ name: 'atlas', providerId: 'codex' }]),
|
|
};
|
|
const configReader = {
|
|
getConfig: vi.fn(async () => null),
|
|
};
|
|
const codexNativeTraceFallbackSource = {
|
|
getTaskLogStream: vi.fn(async () => ({
|
|
participants: [
|
|
{
|
|
key: 'member:atlas',
|
|
label: 'atlas',
|
|
role: 'member' as const,
|
|
isLead: false,
|
|
isSidechain: true,
|
|
},
|
|
],
|
|
defaultFilter: 'member:atlas',
|
|
segments: [
|
|
{
|
|
id: 'codex-native:demo:task-a:atlas',
|
|
participantKey: 'member:atlas',
|
|
actor: atlas,
|
|
startTimestamp: '2026-05-01T17:10:02.000Z',
|
|
endTimestamp: '2026-05-01T17:10:05.000Z',
|
|
chunks: [{ id: 'bash-chunk' }],
|
|
},
|
|
],
|
|
source: 'codex_native_trace_fallback' as const,
|
|
runtimeProjection: {
|
|
provider: 'codex_native' as const,
|
|
mode: 'trace' as const,
|
|
attributionRecordCount: 0,
|
|
projectedMessageCount: 2,
|
|
nativeToolCount: 1,
|
|
fallbackReason: 'codex_native_trace' as const,
|
|
traceFileCount: 1,
|
|
traceRunCount: 1,
|
|
dedupedNativeToolCount: 0,
|
|
},
|
|
})),
|
|
};
|
|
|
|
const service = new BoardTaskLogStreamService(
|
|
recordSource as never,
|
|
summarySelector as never,
|
|
strictParser as never,
|
|
detailSelector as never,
|
|
{ buildBundleChunks } as never,
|
|
undefined as never,
|
|
undefined as never,
|
|
openCodeRuntimeFallbackSource as never,
|
|
membersMetaStore as never,
|
|
configReader as never,
|
|
codexNativeTraceFallbackSource as never
|
|
);
|
|
|
|
const response = await service.getTaskLogStream('demo', 'task-a');
|
|
|
|
expect(openCodeRuntimeFallbackSource.getTaskLogStream).not.toHaveBeenCalled();
|
|
expect(codexNativeTraceFallbackSource.getTaskLogStream).toHaveBeenCalledWith(
|
|
'demo',
|
|
'task-a',
|
|
{ excludeNativeToolSignatures: expect.any(Set) }
|
|
);
|
|
expect(response.source).toBe('mixed_transcript_codex_native_trace');
|
|
expect(response.participants.map((participant) => participant.key)).toEqual(['member:atlas']);
|
|
expect(response.segments.map((segment) => segment.id)).toEqual([
|
|
'member:atlas:c1:c1',
|
|
'codex-native:demo:task-a:atlas',
|
|
]);
|
|
expect(response.runtimeProjection).toMatchObject({
|
|
provider: 'codex_native',
|
|
nativeToolCount: 1,
|
|
});
|
|
});
|
|
|
|
});
|