1110 lines
35 KiB
TypeScript
1110 lines
35 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { OpenCodeTaskLogStreamSource } from '../../../../src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource';
|
|
|
|
import type { TeamTask } from '../../../../src/shared/types';
|
|
import type { OpenCodeRuntimeTranscriptLogMessage } from '../../../../src/main/services/runtime/ClaudeMultimodelBridgeService';
|
|
import type { OpenCodeTaskLogAttributionRecord } from '../../../../src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore';
|
|
|
|
function createTask(overrides: Partial<TeamTask> = {}): TeamTask {
|
|
return {
|
|
id: 'task-a',
|
|
displayId: 'task-a',
|
|
subject: 'Investigate failing command',
|
|
owner: 'alice',
|
|
status: 'in_progress',
|
|
createdAt: '2026-04-21T09:59:00.000Z',
|
|
updatedAt: '2026-04-21T10:10:00.000Z',
|
|
workIntervals: [
|
|
{
|
|
startedAt: '2026-04-21T10:00:00.000Z',
|
|
completedAt: '2026-04-21T10:10:00.000Z',
|
|
},
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function textLogMessage(
|
|
overrides: Pick<OpenCodeRuntimeTranscriptLogMessage, 'uuid' | 'timestamp'> &
|
|
Partial<OpenCodeRuntimeTranscriptLogMessage>
|
|
): OpenCodeRuntimeTranscriptLogMessage {
|
|
const type = overrides.type ?? 'assistant';
|
|
return {
|
|
uuid: overrides.uuid,
|
|
parentUuid: overrides.parentUuid ?? null,
|
|
type,
|
|
timestamp: overrides.timestamp,
|
|
role: overrides.role ?? type,
|
|
content: overrides.content ?? [{ type: 'text', text: overrides.uuid }],
|
|
isMeta: overrides.isMeta ?? false,
|
|
sessionId: overrides.sessionId ?? 'session-opencode',
|
|
toolCalls: overrides.toolCalls ?? [],
|
|
toolResults: overrides.toolResults ?? [],
|
|
...(overrides.sourceToolUseID ? { sourceToolUseID: overrides.sourceToolUseID } : {}),
|
|
...(overrides.sourceToolAssistantUUID
|
|
? { sourceToolAssistantUUID: overrides.sourceToolAssistantUUID }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function taskMarkerLogMessage({
|
|
uuid,
|
|
parentUuid = null,
|
|
timestamp,
|
|
toolName,
|
|
input,
|
|
}: {
|
|
uuid: string;
|
|
parentUuid?: string | null;
|
|
timestamp: string;
|
|
toolName: string;
|
|
input: Record<string, unknown>;
|
|
}): OpenCodeRuntimeTranscriptLogMessage {
|
|
const toolId = `${uuid}-tool`;
|
|
return textLogMessage({
|
|
uuid,
|
|
parentUuid,
|
|
timestamp,
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
id: toolId,
|
|
name: toolName,
|
|
input,
|
|
},
|
|
],
|
|
toolCalls: [
|
|
{
|
|
id: toolId,
|
|
name: toolName,
|
|
input,
|
|
isTask: false,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
function toolResultLogMessage({
|
|
uuid,
|
|
parentUuid,
|
|
timestamp,
|
|
sourceToolAssistantUUID,
|
|
}: {
|
|
uuid: string;
|
|
parentUuid: string;
|
|
timestamp: string;
|
|
sourceToolAssistantUUID: string;
|
|
}): OpenCodeRuntimeTranscriptLogMessage {
|
|
const toolUseId = `${sourceToolAssistantUUID}-tool`;
|
|
return textLogMessage({
|
|
uuid,
|
|
parentUuid,
|
|
type: 'user',
|
|
role: 'user',
|
|
timestamp,
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: toolUseId,
|
|
content: 'ok',
|
|
},
|
|
],
|
|
isMeta: true,
|
|
toolResults: [
|
|
{
|
|
toolUseId,
|
|
content: 'ok',
|
|
isError: false,
|
|
},
|
|
],
|
|
sourceToolUseID: toolUseId,
|
|
sourceToolAssistantUUID,
|
|
});
|
|
}
|
|
|
|
describe('OpenCodeTaskLogStreamSource', () => {
|
|
it('projects OpenCode runtime logs into a task stream segment and caches repeated reads', async () => {
|
|
const bridge = {
|
|
getOpenCodeTranscript: vi.fn(async () => ({
|
|
sessionId: 'session-opencode',
|
|
logProjection: {
|
|
messages: [
|
|
{
|
|
uuid: 'assistant-1',
|
|
parentUuid: 'user-1',
|
|
type: 'assistant',
|
|
timestamp: '2026-04-21T10:05:00.000Z',
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'thinking', thinking: 'Inspecting repository', signature: 'opencode' },
|
|
{ type: 'text', text: 'Running the check now.' },
|
|
{
|
|
type: 'tool_use',
|
|
id: 'tool-1',
|
|
name: 'bash',
|
|
input: { command: 'npm test' },
|
|
},
|
|
],
|
|
isMeta: false,
|
|
sessionId: 'session-opencode',
|
|
toolCalls: [
|
|
{
|
|
id: 'tool-1',
|
|
name: 'bash',
|
|
input: { command: 'npm test' },
|
|
isTask: false,
|
|
},
|
|
],
|
|
toolResults: [],
|
|
},
|
|
{
|
|
uuid: 'assistant-1::tool_results',
|
|
parentUuid: 'assistant-1',
|
|
type: 'user',
|
|
timestamp: '2026-04-21T10:05:03.000Z',
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'tool-1',
|
|
content: 'ok',
|
|
},
|
|
],
|
|
isMeta: true,
|
|
sessionId: 'session-opencode',
|
|
toolCalls: [],
|
|
toolResults: [
|
|
{
|
|
toolUseId: 'tool-1',
|
|
content: 'ok',
|
|
isError: false,
|
|
},
|
|
],
|
|
sourceToolUseID: 'tool-1',
|
|
sourceToolAssistantUUID: 'assistant-1',
|
|
},
|
|
{
|
|
uuid: 'assistant-outside-window',
|
|
parentUuid: 'user-2',
|
|
type: 'assistant',
|
|
timestamp: '2026-04-21T08:00:00.000Z',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'Old task output' }],
|
|
isMeta: false,
|
|
sessionId: 'session-opencode',
|
|
toolCalls: [],
|
|
toolResults: [],
|
|
},
|
|
],
|
|
},
|
|
})),
|
|
};
|
|
|
|
const chunkBuilder = {
|
|
buildBundleChunks: vi.fn((messages) => [
|
|
{
|
|
id: 'chunk-1',
|
|
kind: 'assistant',
|
|
messages,
|
|
},
|
|
]),
|
|
};
|
|
|
|
const taskReader = {
|
|
getTasks: vi.fn(async () => [createTask()]),
|
|
getDeletedTasks: vi.fn(async () => []),
|
|
};
|
|
|
|
const source = new OpenCodeTaskLogStreamSource(
|
|
bridge as never,
|
|
{ resolve: async () => '/tmp/claude' },
|
|
taskReader as never,
|
|
chunkBuilder as never,
|
|
{ readTaskRecords: vi.fn(async () => []) }
|
|
);
|
|
|
|
const first = await source.getTaskLogStream('team-a', 'task-a');
|
|
const second = await source.getTaskLogStream('team-a', 'task-a');
|
|
|
|
expect(first?.source).toBe('opencode_runtime_fallback');
|
|
expect(first?.runtimeProjection).toEqual({
|
|
provider: 'opencode',
|
|
mode: 'heuristic',
|
|
attributionRecordCount: 0,
|
|
projectedMessageCount: 2,
|
|
fallbackReason: 'no_attribution_records',
|
|
});
|
|
expect(first?.participants).toEqual([
|
|
{
|
|
key: 'member:alice',
|
|
label: 'alice',
|
|
role: 'member',
|
|
isLead: false,
|
|
isSidechain: true,
|
|
},
|
|
]);
|
|
expect(first?.segments).toHaveLength(1);
|
|
expect(first?.segments[0]?.actor).toEqual({
|
|
memberName: 'alice',
|
|
role: 'member',
|
|
sessionId: 'session-opencode',
|
|
isSidechain: true,
|
|
});
|
|
expect(chunkBuilder.buildBundleChunks).toHaveBeenCalledTimes(1);
|
|
expect(chunkBuilder.buildBundleChunks.mock.calls[0]?.[0]).toHaveLength(2);
|
|
expect(
|
|
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid)
|
|
).toEqual(['assistant-1', 'assistant-1::tool_results']);
|
|
expect(bridge.getOpenCodeTranscript).toHaveBeenCalledTimes(1);
|
|
expect(second).toEqual(first);
|
|
});
|
|
|
|
it('returns null when the task has no owner', async () => {
|
|
const source = new OpenCodeTaskLogStreamSource(
|
|
{ getOpenCodeTranscript: vi.fn() } as never,
|
|
{ resolve: async () => '/tmp/claude' },
|
|
{
|
|
getTasks: async () => [createTask({ owner: undefined })],
|
|
getDeletedTasks: async () => [],
|
|
} as never,
|
|
{ buildBundleChunks: vi.fn() } as never,
|
|
{ readTaskRecords: vi.fn(async () => []) }
|
|
);
|
|
|
|
await expect(source.getTaskLogStream('team-a', 'task-a')).resolves.toBeNull();
|
|
});
|
|
|
|
it('narrows owner fallback to OpenCode task tool marker ranges when available', async () => {
|
|
const bridge = {
|
|
getOpenCodeTranscript: vi.fn(async () => ({
|
|
sessionId: 'session-opencode',
|
|
logProjection: {
|
|
messages: [
|
|
{
|
|
uuid: 'unrelated-before',
|
|
parentUuid: undefined,
|
|
type: 'assistant',
|
|
timestamp: '2026-04-21T10:01:00.000Z',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'Other work before the task marker' }],
|
|
isMeta: false,
|
|
sessionId: 'session-opencode',
|
|
toolCalls: [],
|
|
toolResults: [],
|
|
},
|
|
{
|
|
uuid: 'user-task-prompt',
|
|
parentUuid: undefined,
|
|
type: 'user',
|
|
timestamp: '2026-04-21T10:02:00.000Z',
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'Start task-a now' }],
|
|
isMeta: false,
|
|
sessionId: 'session-opencode',
|
|
toolCalls: [],
|
|
toolResults: [],
|
|
},
|
|
{
|
|
uuid: 'assistant-start',
|
|
parentUuid: 'user-task-prompt',
|
|
type: 'assistant',
|
|
timestamp: '2026-04-21T10:03:00.000Z',
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
id: 'tool-start',
|
|
name: 'mcp__agent-teams__task_start',
|
|
input: { teamName: 'team-a', taskId: 'task-a' },
|
|
},
|
|
],
|
|
isMeta: false,
|
|
sessionId: 'session-opencode',
|
|
toolCalls: [
|
|
{
|
|
id: 'tool-start',
|
|
name: 'mcp__agent-teams__task_start',
|
|
input: { teamName: 'team-a', taskId: 'task-a' },
|
|
isTask: false,
|
|
},
|
|
],
|
|
toolResults: [],
|
|
},
|
|
{
|
|
uuid: 'assistant-start::tool_results',
|
|
parentUuid: 'assistant-start',
|
|
type: 'user',
|
|
timestamp: '2026-04-21T10:03:01.000Z',
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'tool-start',
|
|
content: 'started',
|
|
},
|
|
],
|
|
isMeta: true,
|
|
sessionId: 'session-opencode',
|
|
toolCalls: [],
|
|
toolResults: [
|
|
{
|
|
toolUseId: 'tool-start',
|
|
content: 'started',
|
|
isError: false,
|
|
},
|
|
],
|
|
sourceToolUseID: 'tool-start',
|
|
sourceToolAssistantUUID: 'assistant-start',
|
|
},
|
|
{
|
|
uuid: 'assistant-work',
|
|
parentUuid: 'assistant-start::tool_results',
|
|
type: 'assistant',
|
|
timestamp: '2026-04-21T10:04:00.000Z',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'Doing the actual work' }],
|
|
isMeta: false,
|
|
sessionId: 'session-opencode',
|
|
toolCalls: [],
|
|
toolResults: [],
|
|
},
|
|
{
|
|
uuid: 'assistant-complete',
|
|
parentUuid: 'assistant-work',
|
|
type: 'assistant',
|
|
timestamp: '2026-04-21T10:06:00.000Z',
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
id: 'tool-complete',
|
|
name: 'mcp__agent-teams__task_complete',
|
|
input: { teamName: 'team-a', taskId: 'task-a' },
|
|
},
|
|
],
|
|
isMeta: false,
|
|
sessionId: 'session-opencode',
|
|
toolCalls: [
|
|
{
|
|
id: 'tool-complete',
|
|
name: 'mcp__agent-teams__task_complete',
|
|
input: { teamName: 'team-a', taskId: 'task-a' },
|
|
isTask: false,
|
|
},
|
|
],
|
|
toolResults: [],
|
|
},
|
|
{
|
|
uuid: 'assistant-complete::tool_results',
|
|
parentUuid: 'assistant-complete',
|
|
type: 'user',
|
|
timestamp: '2026-04-21T10:06:01.000Z',
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'tool-complete',
|
|
content: 'completed',
|
|
},
|
|
],
|
|
isMeta: true,
|
|
sessionId: 'session-opencode',
|
|
toolCalls: [],
|
|
toolResults: [
|
|
{
|
|
toolUseId: 'tool-complete',
|
|
content: 'completed',
|
|
isError: false,
|
|
},
|
|
],
|
|
sourceToolUseID: 'tool-complete',
|
|
sourceToolAssistantUUID: 'assistant-complete',
|
|
},
|
|
{
|
|
uuid: 'unrelated-after',
|
|
parentUuid: 'assistant-complete::tool_results',
|
|
type: 'assistant',
|
|
timestamp: '2026-04-21T10:07:00.000Z',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'Other work after task completion' }],
|
|
isMeta: false,
|
|
sessionId: 'session-opencode',
|
|
toolCalls: [],
|
|
toolResults: [],
|
|
},
|
|
],
|
|
},
|
|
})),
|
|
};
|
|
const chunkBuilder = {
|
|
buildBundleChunks: vi.fn((messages) => [
|
|
{
|
|
id: 'chunk-marker',
|
|
kind: 'assistant',
|
|
messages,
|
|
},
|
|
]),
|
|
};
|
|
const source = new OpenCodeTaskLogStreamSource(
|
|
bridge as never,
|
|
{ resolve: async () => '/tmp/claude' },
|
|
{
|
|
getTasks: async () => [createTask()],
|
|
getDeletedTasks: async () => [],
|
|
} as never,
|
|
chunkBuilder as never,
|
|
{ readTaskRecords: vi.fn(async () => []) }
|
|
);
|
|
|
|
const response = await source.getTaskLogStream('team-a', 'task-a');
|
|
|
|
expect(response?.source).toBe('opencode_runtime_fallback');
|
|
expect(response?.runtimeProjection).toEqual({
|
|
provider: 'opencode',
|
|
mode: 'heuristic',
|
|
attributionRecordCount: 0,
|
|
projectedMessageCount: 6,
|
|
fallbackReason: 'task_tool_markers',
|
|
markerMatchCount: 2,
|
|
markerSpanCount: 1,
|
|
});
|
|
expect(
|
|
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid)
|
|
).toEqual([
|
|
'user-task-prompt',
|
|
'assistant-start',
|
|
'assistant-start::tool_results',
|
|
'assistant-work',
|
|
'assistant-complete',
|
|
'assistant-complete::tool_results',
|
|
]);
|
|
});
|
|
|
|
it('ignores OpenCode task markers that explicitly belong to another team', async () => {
|
|
const bridge = {
|
|
getOpenCodeTranscript: vi.fn(async () => ({
|
|
sessionId: 'session-opencode',
|
|
logProjection: {
|
|
messages: [
|
|
textLogMessage({
|
|
uuid: 'other-team-prompt',
|
|
type: 'user',
|
|
role: 'user',
|
|
timestamp: '2026-04-21T10:01:00.000Z',
|
|
}),
|
|
taskMarkerLogMessage({
|
|
uuid: 'other-team-start',
|
|
parentUuid: 'other-team-prompt',
|
|
timestamp: '2026-04-21T10:02:00.000Z',
|
|
toolName: 'mcp__agent-teams__task_start',
|
|
input: { teamName: 'other-team', taskId: 'task-a' },
|
|
}),
|
|
toolResultLogMessage({
|
|
uuid: 'other-team-start::tool_results',
|
|
parentUuid: 'other-team-start',
|
|
timestamp: '2026-04-21T10:02:01.000Z',
|
|
sourceToolAssistantUUID: 'other-team-start',
|
|
}),
|
|
textLogMessage({
|
|
uuid: 'team-a-prompt',
|
|
type: 'user',
|
|
role: 'user',
|
|
timestamp: '2026-04-21T10:03:00.000Z',
|
|
}),
|
|
taskMarkerLogMessage({
|
|
uuid: 'team-a-start',
|
|
parentUuid: 'team-a-prompt',
|
|
timestamp: '2026-04-21T10:04:00.000Z',
|
|
toolName: 'mcp__agent-teams__task_start',
|
|
input: { teamName: 'team-a', taskId: 'task-a' },
|
|
}),
|
|
toolResultLogMessage({
|
|
uuid: 'team-a-start::tool_results',
|
|
parentUuid: 'team-a-start',
|
|
timestamp: '2026-04-21T10:04:01.000Z',
|
|
sourceToolAssistantUUID: 'team-a-start',
|
|
}),
|
|
],
|
|
},
|
|
})),
|
|
};
|
|
const chunkBuilder = {
|
|
buildBundleChunks: vi.fn((messages) => [
|
|
{
|
|
id: 'chunk-team-marker',
|
|
kind: 'assistant',
|
|
messages,
|
|
},
|
|
]),
|
|
};
|
|
const source = new OpenCodeTaskLogStreamSource(
|
|
bridge as never,
|
|
{ resolve: async () => '/tmp/claude' },
|
|
{
|
|
getTasks: async () => [createTask()],
|
|
getDeletedTasks: async () => [],
|
|
} as never,
|
|
chunkBuilder as never,
|
|
{ readTaskRecords: vi.fn(async () => []) }
|
|
);
|
|
|
|
const response = await source.getTaskLogStream('team-a', 'task-a');
|
|
|
|
expect(response?.runtimeProjection).toEqual({
|
|
provider: 'opencode',
|
|
mode: 'heuristic',
|
|
attributionRecordCount: 0,
|
|
projectedMessageCount: 3,
|
|
fallbackReason: 'task_tool_markers',
|
|
markerMatchCount: 1,
|
|
markerSpanCount: 1,
|
|
});
|
|
expect(
|
|
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid)
|
|
).toEqual(['team-a-prompt', 'team-a-start', 'team-a-start::tool_results']);
|
|
});
|
|
|
|
it('keeps separate marker spans for repeated task work cycles without including unrelated gaps', async () => {
|
|
const bridge = {
|
|
getOpenCodeTranscript: vi.fn(async () => ({
|
|
sessionId: 'session-opencode',
|
|
logProjection: {
|
|
messages: [
|
|
textLogMessage({
|
|
uuid: 'cycle-1-prompt',
|
|
type: 'user',
|
|
role: 'user',
|
|
timestamp: '2026-04-21T10:01:00.000Z',
|
|
}),
|
|
taskMarkerLogMessage({
|
|
uuid: 'cycle-1-start',
|
|
parentUuid: 'cycle-1-prompt',
|
|
timestamp: '2026-04-21T10:02:00.000Z',
|
|
toolName: 'mcp__agent-teams__task_start',
|
|
input: { teamName: 'team-a', taskId: 'task-a' },
|
|
}),
|
|
toolResultLogMessage({
|
|
uuid: 'cycle-1-start::tool_results',
|
|
parentUuid: 'cycle-1-start',
|
|
timestamp: '2026-04-21T10:02:01.000Z',
|
|
sourceToolAssistantUUID: 'cycle-1-start',
|
|
}),
|
|
textLogMessage({
|
|
uuid: 'cycle-1-work',
|
|
timestamp: '2026-04-21T10:04:00.000Z',
|
|
}),
|
|
taskMarkerLogMessage({
|
|
uuid: 'cycle-1-complete',
|
|
parentUuid: 'cycle-1-work',
|
|
timestamp: '2026-04-21T10:08:00.000Z',
|
|
toolName: 'mcp__agent-teams__task_complete',
|
|
input: { teamName: 'team-a', taskId: 'task-a' },
|
|
}),
|
|
toolResultLogMessage({
|
|
uuid: 'cycle-1-complete::tool_results',
|
|
parentUuid: 'cycle-1-complete',
|
|
timestamp: '2026-04-21T10:08:01.000Z',
|
|
sourceToolAssistantUUID: 'cycle-1-complete',
|
|
}),
|
|
textLogMessage({
|
|
uuid: 'unrelated-between-cycles',
|
|
timestamp: '2026-04-21T12:00:00.000Z',
|
|
}),
|
|
textLogMessage({
|
|
uuid: 'cycle-2-prompt',
|
|
type: 'user',
|
|
role: 'user',
|
|
timestamp: '2026-04-21T14:01:00.000Z',
|
|
}),
|
|
taskMarkerLogMessage({
|
|
uuid: 'cycle-2-start',
|
|
parentUuid: 'cycle-2-prompt',
|
|
timestamp: '2026-04-21T14:02:00.000Z',
|
|
toolName: 'mcp__agent-teams__task_start',
|
|
input: { teamName: 'team-a', taskId: 'task-a' },
|
|
}),
|
|
toolResultLogMessage({
|
|
uuid: 'cycle-2-start::tool_results',
|
|
parentUuid: 'cycle-2-start',
|
|
timestamp: '2026-04-21T14:02:01.000Z',
|
|
sourceToolAssistantUUID: 'cycle-2-start',
|
|
}),
|
|
textLogMessage({
|
|
uuid: 'cycle-2-work-after-open-marker',
|
|
timestamp: '2026-04-21T14:04:00.000Z',
|
|
}),
|
|
],
|
|
},
|
|
})),
|
|
};
|
|
const chunkBuilder = {
|
|
buildBundleChunks: vi.fn((messages) => [
|
|
{
|
|
id: 'chunk-marker-cycles',
|
|
kind: 'assistant',
|
|
messages,
|
|
},
|
|
]),
|
|
};
|
|
const source = new OpenCodeTaskLogStreamSource(
|
|
bridge as never,
|
|
{ resolve: async () => '/tmp/claude' },
|
|
{
|
|
getTasks: async () => [
|
|
createTask({
|
|
updatedAt: '2026-04-21T14:10:00.000Z',
|
|
workIntervals: [
|
|
{
|
|
startedAt: '2026-04-21T10:00:00.000Z',
|
|
completedAt: '2026-04-21T10:10:00.000Z',
|
|
},
|
|
{
|
|
startedAt: '2026-04-21T14:00:00.000Z',
|
|
completedAt: '2026-04-21T14:10:00.000Z',
|
|
},
|
|
],
|
|
}),
|
|
],
|
|
getDeletedTasks: async () => [],
|
|
} as never,
|
|
chunkBuilder as never,
|
|
{ readTaskRecords: vi.fn(async () => []) }
|
|
);
|
|
|
|
const response = await source.getTaskLogStream('team-a', 'task-a');
|
|
|
|
expect(response?.runtimeProjection).toEqual({
|
|
provider: 'opencode',
|
|
mode: 'heuristic',
|
|
attributionRecordCount: 0,
|
|
projectedMessageCount: 10,
|
|
fallbackReason: 'task_tool_markers',
|
|
markerMatchCount: 3,
|
|
markerSpanCount: 2,
|
|
});
|
|
expect(
|
|
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid)
|
|
).toEqual([
|
|
'cycle-1-prompt',
|
|
'cycle-1-start',
|
|
'cycle-1-start::tool_results',
|
|
'cycle-1-work',
|
|
'cycle-1-complete',
|
|
'cycle-1-complete::tool_results',
|
|
'cycle-2-prompt',
|
|
'cycle-2-start',
|
|
'cycle-2-start::tool_results',
|
|
'cycle-2-work-after-open-marker',
|
|
]);
|
|
});
|
|
|
|
it('ignores stale task markers outside current task windows before falling back to time-window logs', async () => {
|
|
const bridge = {
|
|
getOpenCodeTranscript: vi.fn(async () => ({
|
|
sessionId: 'session-opencode',
|
|
logProjection: {
|
|
messages: [
|
|
taskMarkerLogMessage({
|
|
uuid: 'stale-start-marker',
|
|
timestamp: '2026-04-21T08:00:00.000Z',
|
|
toolName: 'mcp__agent-teams__task_start',
|
|
input: { teamName: 'team-a', taskId: 'task-a' },
|
|
}),
|
|
textLogMessage({
|
|
uuid: 'current-window-work',
|
|
timestamp: '2026-04-21T10:05:00.000Z',
|
|
}),
|
|
],
|
|
},
|
|
})),
|
|
};
|
|
const chunkBuilder = {
|
|
buildBundleChunks: vi.fn((messages) => [
|
|
{
|
|
id: 'chunk-stale-marker',
|
|
kind: 'assistant',
|
|
messages,
|
|
},
|
|
]),
|
|
};
|
|
const source = new OpenCodeTaskLogStreamSource(
|
|
bridge as never,
|
|
{ resolve: async () => '/tmp/claude' },
|
|
{
|
|
getTasks: async () => [createTask()],
|
|
getDeletedTasks: async () => [],
|
|
} as never,
|
|
chunkBuilder as never,
|
|
{ readTaskRecords: vi.fn(async () => []) }
|
|
);
|
|
|
|
const response = await source.getTaskLogStream('team-a', 'task-a');
|
|
|
|
expect(response?.runtimeProjection).toEqual({
|
|
provider: 'opencode',
|
|
mode: 'heuristic',
|
|
attributionRecordCount: 0,
|
|
projectedMessageCount: 1,
|
|
fallbackReason: 'no_attribution_records',
|
|
});
|
|
expect(
|
|
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid)
|
|
).toEqual(['current-window-work']);
|
|
});
|
|
|
|
it('matches hash-prefixed display task refs in OpenCode task tool markers', async () => {
|
|
const bridge = {
|
|
getOpenCodeTranscript: vi.fn(async () => ({
|
|
sessionId: 'session-opencode',
|
|
logProjection: {
|
|
messages: [
|
|
taskMarkerLogMessage({
|
|
uuid: 'display-ref-start',
|
|
timestamp: '2026-04-21T10:03:00.000Z',
|
|
toolName: 'mcp__agent-teams__task_start',
|
|
input: { teamName: 'team-a', taskId: '#short123' },
|
|
}),
|
|
toolResultLogMessage({
|
|
uuid: 'display-ref-start::tool_results',
|
|
parentUuid: 'display-ref-start',
|
|
timestamp: '2026-04-21T10:03:01.000Z',
|
|
sourceToolAssistantUUID: 'display-ref-start',
|
|
}),
|
|
],
|
|
},
|
|
})),
|
|
};
|
|
const chunkBuilder = {
|
|
buildBundleChunks: vi.fn((messages) => [
|
|
{
|
|
id: 'chunk-display-ref-marker',
|
|
kind: 'assistant',
|
|
messages,
|
|
},
|
|
]),
|
|
};
|
|
const source = new OpenCodeTaskLogStreamSource(
|
|
bridge as never,
|
|
{ resolve: async () => '/tmp/claude' },
|
|
{
|
|
getTasks: async () => [createTask({ id: 'task-canonical', displayId: 'short123' })],
|
|
getDeletedTasks: async () => [],
|
|
} as never,
|
|
chunkBuilder as never,
|
|
{ readTaskRecords: vi.fn(async () => []) }
|
|
);
|
|
|
|
const response = await source.getTaskLogStream('team-a', 'task-canonical');
|
|
|
|
expect(response?.runtimeProjection).toEqual({
|
|
provider: 'opencode',
|
|
mode: 'heuristic',
|
|
attributionRecordCount: 0,
|
|
projectedMessageCount: 2,
|
|
fallbackReason: 'task_tool_markers',
|
|
markerMatchCount: 1,
|
|
markerSpanCount: 1,
|
|
});
|
|
expect(
|
|
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid)
|
|
).toEqual(['display-ref-start', 'display-ref-start::tool_results']);
|
|
});
|
|
|
|
it('prefers explicit OpenCode attribution over owner/time-window heuristic', async () => {
|
|
const bridge = {
|
|
getOpenCodeTranscript: vi.fn(async (_binaryPath, params: { memberName: string }) => {
|
|
if (params.memberName !== 'bob') {
|
|
throw new Error(`unexpected member ${params.memberName}`);
|
|
}
|
|
return {
|
|
sessionId: 'session-bob',
|
|
logProjection: {
|
|
messages: [
|
|
{
|
|
uuid: 'bob-outside',
|
|
parentUuid: undefined,
|
|
type: 'assistant',
|
|
timestamp: '2026-04-21T11:50:00.000Z',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'Before attributed window' }],
|
|
isMeta: false,
|
|
sessionId: 'session-bob',
|
|
toolCalls: [],
|
|
toolResults: [],
|
|
},
|
|
{
|
|
uuid: 'bob-inside',
|
|
parentUuid: undefined,
|
|
type: 'assistant',
|
|
timestamp: '2026-04-21T12:05:00.000Z',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'Explicitly attributed OpenCode work' }],
|
|
isMeta: false,
|
|
sessionId: 'session-bob',
|
|
toolCalls: [],
|
|
toolResults: [],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}),
|
|
};
|
|
const chunkBuilder = {
|
|
buildBundleChunks: vi.fn((messages) => [
|
|
{
|
|
id: 'chunk-attributed',
|
|
kind: 'assistant',
|
|
messages,
|
|
},
|
|
]),
|
|
};
|
|
const taskReader = {
|
|
getTasks: vi.fn(async () => [createTask()]),
|
|
getDeletedTasks: vi.fn(async () => []),
|
|
};
|
|
const attributionRecords: OpenCodeTaskLogAttributionRecord[] = [
|
|
{
|
|
taskId: 'task-a',
|
|
memberName: 'bob',
|
|
scope: 'member_session_window',
|
|
sessionId: 'session-bob',
|
|
since: '2026-04-21T12:00:00.000Z',
|
|
until: '2026-04-21T12:10:00.000Z',
|
|
source: 'launch_runtime',
|
|
},
|
|
];
|
|
|
|
const source = new OpenCodeTaskLogStreamSource(
|
|
bridge as never,
|
|
{ resolve: async () => '/tmp/claude' },
|
|
taskReader as never,
|
|
chunkBuilder as never,
|
|
{ readTaskRecords: vi.fn(async () => attributionRecords) }
|
|
);
|
|
|
|
const response = await source.getTaskLogStream('team-a', 'task-a');
|
|
|
|
expect(response?.source).toBe('opencode_runtime_attribution');
|
|
expect(response?.runtimeProjection).toEqual({
|
|
provider: 'opencode',
|
|
mode: 'attribution',
|
|
attributionRecordCount: 1,
|
|
projectedMessageCount: 1,
|
|
});
|
|
expect(response?.participants).toEqual([
|
|
{
|
|
key: 'member:bob',
|
|
label: 'bob',
|
|
role: 'member',
|
|
isLead: false,
|
|
isSidechain: true,
|
|
},
|
|
]);
|
|
expect(response?.defaultFilter).toBe('member:bob');
|
|
expect(response?.segments[0]?.actor).toEqual({
|
|
memberName: 'bob',
|
|
role: 'member',
|
|
sessionId: 'session-bob',
|
|
isSidechain: true,
|
|
});
|
|
expect(
|
|
chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid)
|
|
).toEqual(['bob-inside']);
|
|
expect(bridge.getOpenCodeTranscript).toHaveBeenCalledWith('/tmp/claude', {
|
|
teamId: 'team-a',
|
|
memberName: 'bob',
|
|
limit: 500,
|
|
});
|
|
});
|
|
|
|
it('falls back to owner heuristic when explicit attribution is stale', async () => {
|
|
const bridge = {
|
|
getOpenCodeTranscript: vi.fn(async (_binaryPath, params: { memberName: string }) => {
|
|
if (params.memberName === 'bob') {
|
|
return {
|
|
sessionId: 'stale-session',
|
|
logProjection: {
|
|
messages: [
|
|
{
|
|
uuid: 'stale-bob',
|
|
parentUuid: undefined,
|
|
type: 'assistant',
|
|
timestamp: '2026-04-21T10:05:00.000Z',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'Wrong session' }],
|
|
isMeta: false,
|
|
sessionId: 'stale-session',
|
|
toolCalls: [],
|
|
toolResults: [],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
sessionId: 'session-alice',
|
|
logProjection: {
|
|
messages: [
|
|
{
|
|
uuid: 'alice-inside',
|
|
parentUuid: undefined,
|
|
type: 'assistant',
|
|
timestamp: '2026-04-21T10:05:00.000Z',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: 'Heuristic owner work' }],
|
|
isMeta: false,
|
|
sessionId: 'session-alice',
|
|
toolCalls: [],
|
|
toolResults: [],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}),
|
|
};
|
|
const chunkBuilder = {
|
|
buildBundleChunks: vi.fn((messages) => [
|
|
{
|
|
id: 'chunk-heuristic',
|
|
kind: 'assistant',
|
|
messages,
|
|
},
|
|
]),
|
|
};
|
|
const attributionRecords: OpenCodeTaskLogAttributionRecord[] = [
|
|
{
|
|
taskId: 'task-a',
|
|
memberName: 'bob',
|
|
scope: 'member_session_window',
|
|
sessionId: 'session-bob',
|
|
since: '2026-04-21T10:00:00.000Z',
|
|
until: '2026-04-21T10:10:00.000Z',
|
|
},
|
|
];
|
|
const source = new OpenCodeTaskLogStreamSource(
|
|
bridge as never,
|
|
{ resolve: async () => '/tmp/claude' },
|
|
{
|
|
getTasks: async () => [createTask()],
|
|
getDeletedTasks: async () => [],
|
|
} as never,
|
|
chunkBuilder as never,
|
|
{ readTaskRecords: vi.fn(async () => attributionRecords) }
|
|
);
|
|
|
|
const response = await source.getTaskLogStream('team-a', 'task-a');
|
|
|
|
expect(response?.source).toBe('opencode_runtime_fallback');
|
|
expect(response?.runtimeProjection).toEqual({
|
|
provider: 'opencode',
|
|
mode: 'heuristic',
|
|
attributionRecordCount: 1,
|
|
projectedMessageCount: 1,
|
|
fallbackReason: 'attribution_no_projected_messages',
|
|
});
|
|
expect(response?.participants[0]?.label).toBe('alice');
|
|
expect(
|
|
chunkBuilder.buildBundleChunks.mock.calls.at(-1)?.[0].map((message: { uuid: string }) => message.uuid)
|
|
).toEqual(['alice-inside']);
|
|
expect(bridge.getOpenCodeTranscript).toHaveBeenNthCalledWith(1, '/tmp/claude', {
|
|
teamId: 'team-a',
|
|
memberName: 'bob',
|
|
limit: 500,
|
|
});
|
|
expect(bridge.getOpenCodeTranscript).toHaveBeenNthCalledWith(2, '/tmp/claude', {
|
|
teamId: 'team-a',
|
|
memberName: 'alice',
|
|
limit: 200,
|
|
});
|
|
});
|
|
|
|
it('keys the short cache by attribution records so new explicit attribution is visible immediately', async () => {
|
|
const attributionRecords: OpenCodeTaskLogAttributionRecord[] = [
|
|
{
|
|
taskId: 'task-a',
|
|
memberName: 'bob',
|
|
scope: 'member_session_window',
|
|
sessionId: 'session-bob',
|
|
since: '2026-04-21T12:00:00.000Z',
|
|
until: '2026-04-21T12:10:00.000Z',
|
|
},
|
|
];
|
|
const attributionStore = {
|
|
readTaskRecords: vi
|
|
.fn<() => Promise<OpenCodeTaskLogAttributionRecord[]>>()
|
|
.mockResolvedValueOnce([])
|
|
.mockResolvedValueOnce(attributionRecords),
|
|
};
|
|
const bridge = {
|
|
getOpenCodeTranscript: vi.fn(async (_binaryPath, params: { memberName: string }) => {
|
|
const isBob = params.memberName === 'bob';
|
|
return {
|
|
sessionId: isBob ? 'session-bob' : 'session-alice',
|
|
logProjection: {
|
|
messages: [
|
|
{
|
|
uuid: isBob ? 'bob-new-attribution' : 'alice-old-heuristic',
|
|
parentUuid: undefined,
|
|
type: 'assistant',
|
|
timestamp: isBob
|
|
? '2026-04-21T12:05:00.000Z'
|
|
: '2026-04-21T10:05:00.000Z',
|
|
role: 'assistant',
|
|
content: [{ type: 'text', text: isBob ? 'new attribution' : 'old heuristic' }],
|
|
isMeta: false,
|
|
sessionId: isBob ? 'session-bob' : 'session-alice',
|
|
toolCalls: [],
|
|
toolResults: [],
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}),
|
|
};
|
|
const chunkBuilder = {
|
|
buildBundleChunks: vi.fn((messages) => [
|
|
{
|
|
id: 'chunk',
|
|
kind: 'assistant',
|
|
messages,
|
|
},
|
|
]),
|
|
};
|
|
const source = new OpenCodeTaskLogStreamSource(
|
|
bridge as never,
|
|
{ resolve: async () => '/tmp/claude' },
|
|
{
|
|
getTasks: async () => [createTask()],
|
|
getDeletedTasks: async () => [],
|
|
} as never,
|
|
chunkBuilder as never,
|
|
attributionStore
|
|
);
|
|
|
|
const first = await source.getTaskLogStream('team-a', 'task-a');
|
|
const second = await source.getTaskLogStream('team-a', 'task-a');
|
|
|
|
expect(first?.source).toBe('opencode_runtime_fallback');
|
|
expect(second?.source).toBe('opencode_runtime_attribution');
|
|
expect(second?.runtimeProjection).toMatchObject({
|
|
provider: 'opencode',
|
|
mode: 'attribution',
|
|
attributionRecordCount: 1,
|
|
projectedMessageCount: 1,
|
|
});
|
|
expect(bridge.getOpenCodeTranscript).toHaveBeenNthCalledWith(1, '/tmp/claude', {
|
|
teamId: 'team-a',
|
|
memberName: 'alice',
|
|
limit: 200,
|
|
});
|
|
expect(bridge.getOpenCodeTranscript).toHaveBeenNthCalledWith(2, '/tmp/claude', {
|
|
teamId: 'team-a',
|
|
memberName: 'bob',
|
|
limit: 500,
|
|
});
|
|
expect(
|
|
chunkBuilder.buildBundleChunks.mock.calls.at(-1)?.[0].map((message: { uuid: string }) => message.uuid)
|
|
).toEqual(['bob-new-attribution']);
|
|
});
|
|
});
|