From 1f7f31f3ee957ebcb1867492f52a67ad05447be8 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 28 Apr 2026 15:00:13 +0300 Subject: [PATCH] fix(team): preserve task log owners across codex transcripts --- .../taskLogs/TranscriptSessionActorContext.ts | 108 +++++ .../BoardTaskActivityRecordBuilder.ts | 20 +- .../BoardTaskActivityTranscriptReader.ts | 23 +- .../BoardTaskLogDiagnosticsService.ts | 8 +- .../exact/BoardTaskExactLogDetailSelector.ts | 169 +++++++- .../exact/BoardTaskExactLogStrictParser.ts | 7 +- .../stream/BoardTaskLogStreamService.ts | 169 ++++++-- .../stream/TaskLogRuntimeStreamSource.ts | 5 + .../BoardTaskActivityEntryBuilder.test.ts | 45 ++ .../BoardTaskActivityTranscriptReader.test.ts | 53 +++ .../BoardTaskExactLogDetailSelector.test.ts | 128 ++++++ .../BoardTaskExactLogStrictParser.test.ts | 59 +++ .../BoardTaskLogDiagnosticsService.test.ts | 111 +++++ .../team/BoardTaskLogStreamService.test.ts | 386 ++++++++++++++++++ 14 files changed, 1224 insertions(+), 67 deletions(-) create mode 100644 src/main/services/team/taskLogs/TranscriptSessionActorContext.ts create mode 100644 src/main/services/team/taskLogs/stream/TaskLogRuntimeStreamSource.ts diff --git a/src/main/services/team/taskLogs/TranscriptSessionActorContext.ts b/src/main/services/team/taskLogs/TranscriptSessionActorContext.ts new file mode 100644 index 00000000..1ef5a5e7 --- /dev/null +++ b/src/main/services/team/taskLogs/TranscriptSessionActorContext.ts @@ -0,0 +1,108 @@ +interface SessionActorContextState { + agentId?: string; + agentName?: string; + isSidechain?: boolean; + agentIdAmbiguous: boolean; + agentNameAmbiguous: boolean; + isSidechainAmbiguous: boolean; +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function hasBoolean(value: unknown): value is boolean { + return typeof value === 'boolean'; +} + +function cloneWithContext>( + record: T, + updates: Record +): T { + return { + ...record, + ...updates, + }; +} + +export class TranscriptSessionActorContextTracker { + private readonly contextsBySessionId = new Map(); + + remember(record: Record): void { + const sessionId = readNonEmptyString(record.sessionId); + if (!sessionId) { + return; + } + + const agentId = readNonEmptyString(record.agentId); + const agentName = readNonEmptyString(record.agentName); + const isSidechain = hasBoolean(record.isSidechain) ? record.isSidechain : undefined; + if (!agentId && !agentName && isSidechain === undefined) { + return; + } + + const current = this.contextsBySessionId.get(sessionId) ?? { + agentIdAmbiguous: false, + agentNameAmbiguous: false, + isSidechainAmbiguous: false, + }; + + const next: SessionActorContextState = { ...current }; + if (agentId) { + if (current.agentId && current.agentId !== agentId) { + next.agentIdAmbiguous = true; + } else { + next.agentId = agentId; + } + } + + if (agentName) { + if (current.agentName && current.agentName !== agentName) { + next.agentNameAmbiguous = true; + } else { + next.agentName = agentName; + } + } + + if (isSidechain !== undefined) { + if (current.isSidechain !== undefined && current.isSidechain !== isSidechain) { + next.isSidechainAmbiguous = true; + } else { + next.isSidechain = isSidechain; + } + } + + this.contextsBySessionId.set(sessionId, next); + } + + apply>(record: T): T { + const sessionId = readNonEmptyString(record.sessionId); + if (!sessionId) { + return record; + } + + const context = this.contextsBySessionId.get(sessionId); + if (!context) { + return record; + } + + const updates: Record = {}; + if (!readNonEmptyString(record.agentId) && context.agentId && !context.agentIdAmbiguous) { + updates.agentId = context.agentId; + } + + if (!readNonEmptyString(record.agentName) && context.agentName && !context.agentNameAmbiguous) { + updates.agentName = context.agentName; + } + + if ( + !hasBoolean(record.isSidechain) && + context.isSidechain !== undefined && + !context.isSidechainAmbiguous + ) { + updates.isSidechain = context.isSidechain; + } + + return Object.keys(updates).length > 0 ? cloneWithContext(record, updates) : record; + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts index 01d780a3..93891bde 100644 --- a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts @@ -51,6 +51,11 @@ function normalizeDisplayRef(value: string): string { return value.trim().toLowerCase(); } +function isConventionalLeadName(value: string): boolean { + const normalized = normalizeDisplayRef(value); + return normalized === 'team-lead' || normalized === 'lead'; +} + function looksLikeCanonicalTaskId(value: string): boolean { return CANONICAL_TASK_ID_PATTERN.test(value.trim()); } @@ -245,16 +250,17 @@ function resolveActivityActor(message: RawTaskActivityMessage): BoardTaskActivit typeof message.agentName === 'string' && message.agentName.trim().length > 0 ? message.agentName.trim() : undefined; + const role: BoardTaskActivityActor['role'] = memberName + ? isConventionalLeadName(memberName) + ? 'lead' + : 'member' + : message.isSidechain + ? 'member' + : 'unknown'; return { ...(memberName ? { memberName } : {}), - role: memberName - ? message.isSidechain - ? 'member' - : 'lead' - : message.isSidechain - ? 'member' - : 'unknown', + role, sessionId: message.sessionId, ...(message.agentId ? { agentId: message.agentId } : {}), isSidechain: message.isSidechain, diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts index 02e7b1a0..e2391082 100644 --- a/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts @@ -10,6 +10,7 @@ import { type ParsedBoardTaskLink, type ParsedBoardTaskToolAction, } from '../contract/BoardTaskTranscriptContract'; +import { TranscriptSessionActorContextTracker } from '../TranscriptSessionActorContext'; import { BoardTaskActivityParseCache } from './BoardTaskActivityParseCache'; @@ -56,6 +57,12 @@ function asRecord(value: unknown): Record | null { return value && typeof value === 'object' ? (value as Record) : null; } +function lineMayContainTaskActivityOrActorContext(line: string): boolean { + return ( + line.includes('"boardTaskLinks"') || line.includes('"agentName"') || line.includes('"agentId"') + ); +} + export class BoardTaskActivityTranscriptReader { private readonly cache = new BoardTaskActivityParseCache(); @@ -112,6 +119,7 @@ export class BoardTaskActivityTranscriptReader { private async parseFile(filePath: string): Promise { const results: RawTaskActivityMessage[] = []; + const actorContextTracker = new TranscriptSessionActorContextTracker(); const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, @@ -123,7 +131,7 @@ export class BoardTaskActivityTranscriptReader { for await (const line of rl) { if (!line.trim()) continue; lineCount += 1; - if (!line.includes('"boardTaskLinks"')) { + if (!lineMayContainTaskActivityOrActorContext(line)) { if (lineCount % 500 === 0) { await yieldToEventLoop(); } @@ -134,6 +142,11 @@ export class BoardTaskActivityTranscriptReader { const parsed = JSON.parse(line) as unknown; const record = asRecord(parsed); if (!record) continue; + actorContextTracker.remember(record); + + if (!line.includes('"boardTaskLinks"')) { + continue; + } const uuid = typeof record.uuid === 'string' ? record.uuid : ''; const sessionId = typeof record.sessionId === 'string' ? record.sessionId : ''; @@ -142,6 +155,7 @@ export class BoardTaskActivityTranscriptReader { const boardTaskLinks = parseBoardTaskLinks(record.boardTaskLinks); if (boardTaskLinks.length === 0) continue; + const contextRecord = actorContextTracker.apply(record); sourceOrder += 1; results.push({ @@ -149,9 +163,10 @@ export class BoardTaskActivityTranscriptReader { uuid, timestamp, sessionId, - agentId: typeof record.agentId === 'string' ? record.agentId : undefined, - agentName: typeof record.agentName === 'string' ? record.agentName : undefined, - isSidechain: record.isSidechain === true, + agentId: typeof contextRecord.agentId === 'string' ? contextRecord.agentId : undefined, + agentName: + typeof contextRecord.agentName === 'string' ? contextRecord.agentName : undefined, + isSidechain: contextRecord.isSidechain === true, boardTaskLinks, boardTaskToolActions: parseBoardTaskToolActions(record.boardTaskToolActions), sourceOrder, diff --git a/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts b/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts index 4df44881..dd4813e8 100644 --- a/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts +++ b/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts @@ -170,6 +170,12 @@ function isEmptyToolPayload(value: unknown): boolean { return false; } +function asObjectRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + function collectEmptyPayloadExamples( stream: Awaited> ): BoardTaskLogDiagnosticExample[] { @@ -194,7 +200,7 @@ function collectEmptyPayloadExamples( }); } - const toolUseResult = message.toolUseResult; + const toolUseResult = asObjectRecord(message.toolUseResult); if (!toolUseResult) { continue; } diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts index c64f71f1..ff5513b5 100644 --- a/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts @@ -16,11 +16,150 @@ interface TentativeFilteredMessage { matchedToolUseId?: string; } -function isToolAnchoredOutputMessage( - message: ParsedMessage, - toolUseId: string | undefined -): boolean { - return Boolean(toolUseId && message.sourceToolUseID === toolUseId); +interface ToolAnchorScope { + toolUseId?: string; + assistantUuids: Set; + outputMessageUuids: Set; +} + +function messageHasToolUse(message: ParsedMessage, toolUseId: string | undefined): boolean { + if (!toolUseId || message.type !== 'assistant' || typeof message.content === 'string') { + return false; + } + return message.content.some((block) => block.type === 'tool_use' && block.id === toolUseId); +} + +function messageHasToolResult(message: ParsedMessage, toolUseId: string | undefined): boolean { + if (!toolUseId || typeof message.content === 'string') { + return false; + } + return message.content.some( + (block) => block.type === 'tool_result' && block.tool_use_id === toolUseId + ); +} + +function buildToolAnchorScope(args: { + candidate: BoardTaskExactLogBundleCandidate; + parsedMessages: ParsedMessage[]; + explicitMessageIds: Set; +}): ToolAnchorScope { + const toolUseId = + args.candidate.anchor.kind === 'tool' ? args.candidate.anchor.toolUseId : undefined; + const assistantUuids = new Set(); + const outputMessageUuids = new Set(); + if (!toolUseId) { + return { assistantUuids, outputMessageUuids }; + } + + const messagesByUuid = new Map(args.parsedMessages.map((message) => [message.uuid, message])); + const messageIndexByUuid = new Map( + args.parsedMessages.map((message, index) => [message.uuid, index]) + ); + + const addMatchingAssistant = (uuid: string | null | undefined): void => { + if (!uuid) { + return; + } + const message = messagesByUuid.get(uuid); + if (message && messageHasToolUse(message, toolUseId)) { + assistantUuids.add(message.uuid); + } + }; + + const addNearestPreviousMatchingAssistant = (message: ParsedMessage): void => { + const startIndex = messageIndexByUuid.get(message.uuid); + if (startIndex === undefined) { + return; + } + + for (let index = startIndex - 1; index >= 0; index -= 1) { + const candidate = args.parsedMessages[index]; + if (!candidate) { + continue; + } + if (candidate.type !== 'assistant') { + continue; + } + if (messageHasToolUse(candidate, toolUseId)) { + assistantUuids.add(candidate.uuid); + } + return; + } + }; + + addMatchingAssistant(args.candidate.anchor.messageUuid); + for (const explicitMessageId of args.explicitMessageIds) { + const message = messagesByUuid.get(explicitMessageId); + if (!message) { + continue; + } + addMatchingAssistant(message.uuid); + addMatchingAssistant(message.sourceToolAssistantUUID); + addMatchingAssistant(message.parentUuid); + if (message.type === 'user' && messageHasToolResult(message, toolUseId)) { + addNearestPreviousMatchingAssistant(message); + } + } + + let previousAssistantUuid: string | undefined; + for (const message of args.parsedMessages) { + const referencesTool = + message.sourceToolUseID === toolUseId || messageHasToolResult(message, toolUseId); + if ( + referencesTool && + ((message.sourceToolAssistantUUID !== undefined && + assistantUuids.has(message.sourceToolAssistantUUID)) || + (message.parentUuid !== null && + message.parentUuid !== undefined && + assistantUuids.has(message.parentUuid)) || + (message.sourceToolAssistantUUID === undefined && + (message.parentUuid === null || message.parentUuid === undefined) && + previousAssistantUuid !== undefined && + assistantUuids.has(previousAssistantUuid))) + ) { + outputMessageUuids.add(message.uuid); + } + + if (message.type === 'assistant') { + previousAssistantUuid = message.uuid; + } + } + + return { toolUseId, assistantUuids, outputMessageUuids }; +} + +function isToolLinkedMessage(message: ParsedMessage, scope: ToolAnchorScope): boolean { + const { toolUseId } = scope; + if (!toolUseId) { + return false; + } + + const hasScopedAssistant = scope.assistantUuids.size > 0; + if (scope.outputMessageUuids.has(message.uuid)) { + return true; + } + + if (message.type === 'assistant' && messageHasToolUse(message, toolUseId)) { + return !hasScopedAssistant || scope.assistantUuids.has(message.uuid); + } + + const referencesTool = + message.sourceToolUseID === toolUseId || messageHasToolResult(message, toolUseId); + if (!referencesTool) { + return false; + } + + if (!hasScopedAssistant) { + return true; + } + + return ( + (message.sourceToolAssistantUUID !== undefined && + scope.assistantUuids.has(message.sourceToolAssistantUUID)) || + (message.parentUuid !== null && + message.parentUuid !== undefined && + scope.assistantUuids.has(message.parentUuid)) + ); } function noteExactDiagnostic( @@ -120,16 +259,18 @@ function filterMessageForCandidate(args: { message: ParsedMessage; candidate: BoardTaskExactLogBundleCandidate; explicitMessageIds: Set; + toolAnchorScope: ToolAnchorScope; }): TentativeFilteredMessage | null { - const { message, candidate, explicitMessageIds } = args; + const { message, candidate, explicitMessageIds, toolAnchorScope } = args; const explicitMessageLinked = explicitMessageIds.has(message.uuid); const toolUseId = candidate.anchor.kind === 'tool' ? candidate.anchor.toolUseId : undefined; - const anchoredOutputLinked = isToolAnchoredOutputMessage(message, toolUseId); + const toolLinked = isToolLinkedMessage(message, toolAnchorScope); + + if (!explicitMessageLinked && !toolLinked) { + return null; + } if (typeof message.content === 'string') { - if (!explicitMessageLinked && !anchoredOutputLinked) { - return null; - } return { original: message, filteredContent: message.content, @@ -142,7 +283,7 @@ function filterMessageForCandidate(args: { filteredBlocks = filterAssistantContent( message.content, toolUseId, - explicitMessageLinked || anchoredOutputLinked + explicitMessageLinked || toolLinked ); } else if (message.type === 'user') { filteredBlocks = filterUserArrayContent(message.content, toolUseId, explicitMessageLinked); @@ -309,6 +450,11 @@ export class BoardTaskExactLogDetailSelector { } const explicitMessageIds = new Set(relevantRecords.map((record) => record.source.messageUuid)); + const toolAnchorScope = buildToolAnchorScope({ + candidate, + parsedMessages, + explicitMessageIds, + }); const tentative: TentativeFilteredMessage[] = []; for (const message of parsedMessages) { @@ -316,6 +462,7 @@ export class BoardTaskExactLogDetailSelector { message, candidate, explicitMessageIds, + toolAnchorScope, }); if (filtered) { tentative.push(filtered); diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts index 1eed2e49..d3799d2b 100644 --- a/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts @@ -5,6 +5,8 @@ import { createReadStream } from 'fs'; import * as fs from 'fs/promises'; import * as readline from 'readline'; +import { TranscriptSessionActorContextTracker } from '../TranscriptSessionActorContext'; + import { BoardTaskExactLogsParseCache } from './BoardTaskExactLogsParseCache'; import type { ParsedMessage } from '@main/types'; @@ -106,6 +108,7 @@ export class BoardTaskExactLogStrictParser { private async readStrictFile(filePath: string): Promise { const results: ParsedMessage[] = []; + const actorContextTracker = new TranscriptSessionActorContextTracker(); const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, @@ -124,7 +127,9 @@ export class BoardTaskExactLogStrictParser { continue; } - const parsed = parseJsonlEntry(record as unknown as ChatHistoryEntry); + actorContextTracker.remember(record); + const contextRecord = actorContextTracker.apply(record); + const parsed = parseJsonlEntry(contextRecord as unknown as ChatHistoryEntry); if (parsed) { results.push(parsed); } diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 92f9a998..ef20647f 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -20,6 +20,7 @@ import { OpenCodeTaskLogStreamSource } from './OpenCodeTaskLogStreamSource'; import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; import type { BoardTaskExactLogDetailCandidate } from '../exact/BoardTaskExactLogTypes'; +import type { TaskLogRuntimeStreamSource } from './TaskLogRuntimeStreamSource'; import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; import type { BoardTaskActivityCategory, @@ -59,7 +60,7 @@ interface TimeWindow { interface StreamLayout { participants: BoardTaskLogParticipant[]; visibleSlices: StreamSlice[]; - shouldMergeOpenCodeRuntimeFallback?: boolean; + shouldMergeRuntimeFallback?: boolean; } const logger = createLogger('Service:BoardTaskLogStreamService'); @@ -90,6 +91,7 @@ const HISTORICAL_BOARD_ACTION_TOOL_NAMES = new Set([ 'task_set_owner', 'task_unlink', ]); +const READ_ONLY_BOARD_TOOL_NAMES = new Set(['task_get', 'task_get_comment']); const TASK_REFERENCE_KEYS = new Set(['task', 'taskid', 'id', 'displayid', 'targetid']); function emptyResponse(): BoardTaskLogStreamResponse { @@ -269,6 +271,24 @@ function inferHistoricalActionCategory(canonicalToolName: string): BoardTaskActi } } +function historicalBoardToolReferencesTask(args: { + canonicalToolName: string; + input: Record; + resultPayload: unknown; + taskRefs: Set; +}): boolean { + const { canonicalToolName, input, resultPayload, taskRefs } = args; + if (valueReferencesTask(input, taskRefs)) { + return true; + } + + if (canonicalToolName === 'task_get' || canonicalToolName === 'task_get_comment') { + return false; + } + + return valueReferencesTask(resultPayload, taskRefs); +} + function asObjectRecord(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) @@ -519,6 +539,74 @@ function parseJsonLikeString(value: string): unknown { } } +function formatTaskStatusPayload(payload: Record): string | null { + const displayId = + typeof payload.displayId === 'string' && payload.displayId.trim().length > 0 + ? payload.displayId.trim() + : typeof payload.id === 'string' && payload.id.trim().length > 0 + ? payload.id.trim() + : null; + const status = + typeof payload.status === 'string' && payload.status.trim().length > 0 + ? payload.status.trim() + : null; + if (!displayId || !status) { + return null; + } + return `Task ${displayId} ${status}`; +} + +function formatMessageSendPayload(payload: Record): string | null { + const routing = payload.routing as Record | undefined; + const messageRecord = + typeof payload.message === 'object' && payload.message !== null + ? (payload.message as Record) + : undefined; + const deliveryMessage = + typeof payload.message === 'string' && payload.message.trim().length > 0 + ? payload.message.trim() + : null; + const summary = + typeof messageRecord?.summary === 'string' && messageRecord.summary.trim().length > 0 + ? messageRecord.summary.trim() + : typeof routing?.summary === 'string' && routing.summary.trim().length > 0 + ? routing.summary.trim() + : null; + const target = + typeof messageRecord?.to === 'string' && messageRecord.to.trim().length > 0 + ? messageRecord.to.trim() + : typeof routing?.target === 'string' && routing.target.trim().length > 0 + ? routing.target.trim() + : null; + const messageText = + typeof messageRecord?.text === 'string' && messageRecord.text.trim().length > 0 + ? messageRecord.text.trim() + : null; + + if (deliveryMessage && summary) { + return `${deliveryMessage} - ${summary}`; + } + if (summary && target) { + return `Message sent to ${target} - ${summary}`; + } + if (summary) { + return summary; + } + if (deliveryMessage) { + return deliveryMessage; + } + if (messageText && target) { + return `Message sent to ${target} - ${messageText}`; + } + if (messageText) { + return messageText; + } + if (target) { + return `Message sent to ${target}`; + } + return null; +} + function extractBoardToolOutputText( toolName: string | undefined, parsedPayload: unknown @@ -527,7 +615,7 @@ function extractBoardToolOutputText( return null; } - const normalizedToolName = toolName.trim().toLowerCase(); + const normalizedToolName = canonicalizeBoardToolName(toolName) ?? toolName.trim().toLowerCase(); const payload = unwrapAgentTeamsResponsePayload(parsedPayload as Record); if (normalizedToolName === 'task_add_comment' || normalizedToolName === 'task_get_comment') { const comment = payload.comment as Record | undefined; @@ -536,36 +624,20 @@ function extractBoardToolOutputText( } } - if (normalizedToolName === 'sendmessage') { - const routing = payload.routing as Record | undefined; - const deliveryMessage = - typeof payload.message === 'string' && payload.message.trim().length > 0 - ? payload.message.trim() - : null; - const summary = - typeof routing?.summary === 'string' && routing.summary.trim().length > 0 - ? routing.summary.trim() - : null; - const target = - typeof routing?.target === 'string' && routing.target.trim().length > 0 - ? routing.target.trim() - : null; + if (normalizedToolName === 'task_complete') { + return formatTaskStatusPayload(payload) ?? 'Task completed'; + } - if (deliveryMessage && summary) { - return `${deliveryMessage} - ${summary}`; - } - if (summary && target) { - return `Message sent to ${target} - ${summary}`; - } - if (summary) { - return summary; - } - if (deliveryMessage) { - return deliveryMessage; - } - if (target) { - return `Message sent to ${target}`; - } + if (normalizedToolName === 'sendmessage' || normalizedToolName === 'message_send') { + return formatMessageSendPayload(payload); + } + + if (payload.message) { + return formatMessageSendPayload(payload); + } + + if (payload.status) { + return formatTaskStatusPayload(payload); } return null; @@ -1303,7 +1375,10 @@ function collectExplicitToolUseIds( function collectAllowedMemberNames( task: TeamTask, - records: { actor: { memberName?: string } }[] + records: { + actor: { memberName?: string }; + action?: { category?: BoardTaskActivityCategory; canonicalToolName?: string }; + }[] ): Set { const allowedNames = new Set(); @@ -1312,6 +1387,14 @@ function collectAllowedMemberNames( } for (const record of records) { + const canonicalToolName = canonicalizeBoardToolName(record.action?.canonicalToolName); + if ( + record.action?.category === 'read' || + (canonicalToolName !== null && READ_ONLY_BOARD_TOOL_NAMES.has(canonicalToolName)) + ) { + continue; + } + if (typeof record.actor.memberName === 'string' && record.actor.memberName.trim().length > 0) { allowedNames.add(normalizeMemberName(record.actor.memberName)); } @@ -1523,7 +1606,7 @@ export class BoardTaskLogStreamService { private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(), private readonly taskReader: TeamTaskReader = new TeamTaskReader(), private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), - private readonly runtimeFallbackSource: OpenCodeTaskLogStreamSource = new OpenCodeTaskLogStreamSource(), + private readonly runtimeFallbackSource: TaskLogRuntimeStreamSource = new OpenCodeTaskLogStreamSource(), private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), private readonly configReader: TeamConfigReader = new TeamConfigReader() ) {} @@ -1792,8 +1875,12 @@ export class BoardTaskLogStreamService { const resultPayload = resolveToolResultPayload(message, toolResult); if ( - !valueReferencesTask(toolCall.input, taskRefs) && - !valueReferencesTask(resultPayload, taskRefs) + !historicalBoardToolReferencesTask({ + canonicalToolName: toolCall.canonicalToolName, + input: toolCall.input, + resultPayload, + taskRefs, + }) ) { continue; } @@ -1983,15 +2070,11 @@ export class BoardTaskLogStreamService { return { participants: buildOrderedParticipants(visibleSlices), visibleSlices, - shouldMergeOpenCodeRuntimeFallback: await this.shouldMergeOpenCodeRuntimeFallback( - teamName, - taskId, - records - ), + shouldMergeRuntimeFallback: await this.shouldMergeRuntimeFallback(teamName, taskId, records), }; } - private async shouldMergeOpenCodeRuntimeFallback( + private async shouldMergeRuntimeFallback( teamName: string, taskId: string, records: BoardTaskActivityRecord[] @@ -2032,7 +2115,7 @@ export class BoardTaskLogStreamService { const elapsedMs = Date.now() - startedAt; if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) { logger.warn( - `Slow OpenCode task-log runtime fallback: team=${teamName} task=${taskId} hit=${Boolean( + `Slow task-log runtime fallback: team=${teamName} task=${taskId} hit=${Boolean( fallback )} elapsedMs=${elapsedMs}` ); @@ -2121,7 +2204,7 @@ export class BoardTaskLogStreamService { source: 'transcript', }; - if (!layout.shouldMergeOpenCodeRuntimeFallback) { + if (!layout.shouldMergeRuntimeFallback) { return primaryResponse; } diff --git a/src/main/services/team/taskLogs/stream/TaskLogRuntimeStreamSource.ts b/src/main/services/team/taskLogs/stream/TaskLogRuntimeStreamSource.ts new file mode 100644 index 00000000..abf8cf85 --- /dev/null +++ b/src/main/services/team/taskLogs/stream/TaskLogRuntimeStreamSource.ts @@ -0,0 +1,5 @@ +import type { BoardTaskLogStreamResponse } from '@shared/types'; + +export interface TaskLogRuntimeStreamSource { + getTaskLogStream(teamName: string, taskId: string): Promise; +} diff --git a/test/main/services/team/BoardTaskActivityEntryBuilder.test.ts b/test/main/services/team/BoardTaskActivityEntryBuilder.test.ts index edc2faae..40c7fdd5 100644 --- a/test/main/services/team/BoardTaskActivityEntryBuilder.test.ts +++ b/test/main/services/team/BoardTaskActivityEntryBuilder.test.ts @@ -300,6 +300,51 @@ describe('BoardTaskActivityEntryBuilder', () => { expect(entries[0]?.actor.role).toBe('unknown'); }); + it('keeps named main-session teammates as members instead of forcing lead role', () => { + const taskA = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174000', + displayId: 'abcd1234', + subject: 'Task A', + status: 'in_progress', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/main-member.jsonl', + uuid: 'msg-main-member', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + agentName: 'tom', + isSidechain: false, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { relation: 'same_task' }, + }, + ], + boardTaskToolActions: [], + }, + ]; + + const entries = new BoardTaskActivityEntryBuilder().buildForTask({ + teamName: 'demo', + targetTask: taskA, + tasks: [taskA], + messages, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.actor).toMatchObject({ + memberName: 'tom', + role: 'member', + isSidechain: false, + }); + }); + it('never joins action payloads onto execution rows', () => { const taskA = makeTask({ id: '123e4567-e89b-12d3-a456-426614174000', diff --git a/test/main/services/team/BoardTaskActivityTranscriptReader.test.ts b/test/main/services/team/BoardTaskActivityTranscriptReader.test.ts index 0ae91ecd..d3ae9540 100644 --- a/test/main/services/team/BoardTaskActivityTranscriptReader.test.ts +++ b/test/main/services/team/BoardTaskActivityTranscriptReader.test.ts @@ -94,4 +94,57 @@ describe('BoardTaskActivityTranscriptReader', () => { ], }); }); + + it('inherits stable session actor context for task-linked Codex projection rows', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'activity-transcript-reader-actor-')); + tempDirs.push(tempDir); + + const filePath = path.join(tempDir, 'codex-session.jsonl'); + await fs.writeFile( + filePath, + [ + JSON.stringify({ + uuid: 'session-context', + sessionId: 'session-codex', + timestamp: '2026-04-20T12:00:00.000Z', + agentName: 'tom', + isSidechain: false, + message: { role: 'assistant', content: 'Starting task' }, + }), + JSON.stringify({ + uuid: 'linked-without-agent-name', + sessionId: 'session-codex', + timestamp: '2026-04-20T12:01:00.000Z', + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: '12345678', refKind: 'display', canonicalId: 'task-a' }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { relation: 'same_task' }, + toolUseId: 'toolu_task_comment', + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'toolu_task_comment', + canonicalToolName: 'task_add_comment', + }, + ], + }), + ].join('\n'), + 'utf8' + ); + + const rows = await new BoardTaskActivityTranscriptReader().readFiles([filePath]); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + uuid: 'linked-without-agent-name', + sessionId: 'session-codex', + agentName: 'tom', + isSidechain: false, + }); + }); }); diff --git a/test/main/services/team/BoardTaskExactLogDetailSelector.test.ts b/test/main/services/team/BoardTaskExactLogDetailSelector.test.ts index a9c0b1a9..cf0b585a 100644 --- a/test/main/services/team/BoardTaskExactLogDetailSelector.test.ts +++ b/test/main/services/team/BoardTaskExactLogDetailSelector.test.ts @@ -166,6 +166,134 @@ describe('BoardTaskExactLogDetailSelector', () => { expect(detail?.filteredMessages[2]?.sourceToolUseID).toBe('tool-1'); }); + it('scopes repeated tool use ids to the explicit tool-result parent assistant', () => { + const record = { + ...makeRecord(), + id: 'record-reused-tool-id', + action: { + canonicalToolName: 'task_add_comment', + toolUseId: 'item_24', + category: 'comment' as const, + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'user-target', + toolUseId: 'item_24', + sourceOrder: 1, + }, + } satisfies BoardTaskActivityRecord; + const candidate: BoardTaskExactLogBundleCandidate = { + id: 'tool:/tmp/task.jsonl:item_24', + timestamp: '2026-04-12T16:00:00.000Z', + actor: record.actor, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'user-target', + toolUseId: 'item_24', + sourceOrder: 1, + }, + records: [record], + anchor: { + kind: 'tool', + filePath: '/tmp/task.jsonl', + messageUuid: 'user-target', + toolUseId: 'item_24', + }, + actionLabel: 'Added a comment', + actionCategory: 'comment', + canonicalToolName: 'task_add_comment', + linkKinds: ['board_action'], + targetRoles: ['subject'], + canLoadDetail: true, + sourceGeneration: 'gen-reused-tool-id', + }; + const parsedMessagesByFile = new Map([ + [ + '/tmp/task.jsonl', + [ + { + uuid: 'assistant-target', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T16:00:00.000Z'), + role: 'assistant', + content: [ + { type: 'tool_use', id: 'item_24', name: 'task_add_comment', input: { taskId: 'task-a' } } as never, + ], + toolCalls: [], + toolResults: [], + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-target', + parentUuid: 'assistant-target', + type: 'user', + timestamp: new Date('2026-04-12T16:00:01.000Z'), + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'item_24', content: 'target result' } as never, + ], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'item_24', + sourceToolAssistantUUID: 'assistant-target', + toolUseResult: { toolUseId: 'item_24', content: 'target result' }, + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'assistant-other', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T16:10:00.000Z'), + role: 'assistant', + content: [ + { type: 'tool_use', id: 'item_24', name: 'task_complete', input: { taskId: 'other-task' } } as never, + ], + toolCalls: [], + toolResults: [], + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-other', + parentUuid: 'assistant-other', + type: 'user', + timestamp: new Date('2026-04-12T16:10:01.000Z'), + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'item_24', content: 'other result' } as never, + ], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'item_24', + sourceToolAssistantUUID: 'assistant-other', + toolUseResult: { toolUseId: 'item_24', content: 'other result' }, + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + ], + ], + ]); + + const detail = new BoardTaskExactLogDetailSelector().selectDetail({ + candidate, + records: [record], + parsedMessagesByFile, + }); + + expect(detail).not.toBeNull(); + expect(detail?.filteredMessages.map((message) => message.uuid)).toEqual([ + 'assistant-target', + 'user-target', + ]); + }); + it('drops stale derived tool metadata when a message-linked row survives filtering', () => { const record = { ...makeRecord(), diff --git a/test/main/services/team/BoardTaskExactLogStrictParser.test.ts b/test/main/services/team/BoardTaskExactLogStrictParser.test.ts index 0b4054c7..7303ec71 100644 --- a/test/main/services/team/BoardTaskExactLogStrictParser.test.ts +++ b/test/main/services/team/BoardTaskExactLogStrictParser.test.ts @@ -142,4 +142,63 @@ describe('BoardTaskExactLogStrictParser', () => { }, ]); }); + + it('inherits stable session actor context for Codex-native rows without agentName', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'exact-log-parser-actor-')); + tempDirs.push(tempDir); + + const filePath = path.join(tempDir, 'native-session.jsonl'); + await fs.writeFile( + filePath, + [ + JSON.stringify({ + parentUuid: null, + isSidechain: false, + userType: 'external', + cwd: '/tmp/project', + sessionId: 'session-codex', + version: '1.0.0', + gitBranch: 'main', + agentName: 'tom', + type: 'system', + uuid: 'session-context', + timestamp: '2026-04-19T10:00:00.000Z', + subtype: 'init', + level: 'info', + isMeta: false, + content: 'started', + }), + JSON.stringify({ + parentUuid: 'session-context', + userType: 'external', + cwd: '/tmp/project', + sessionId: 'session-codex', + version: '1.0.0', + gitBranch: 'main', + type: 'assistant', + uuid: 'assistant-without-agent', + timestamp: '2026-04-19T10:00:01.000Z', + requestId: 'req-1', + message: { + role: 'assistant', + id: 'msg-1', + type: 'message', + model: 'codex', + content: [{ type: 'text', text: 'working' }], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }), + ].join('\n'), + 'utf8' + ); + + const parsed = await new BoardTaskExactLogStrictParser().parseFiles([filePath]); + + expect(parsed.get(filePath)?.map((message) => [message.uuid, message.agentName])).toEqual([ + ['session-context', 'tom'], + ['assistant-without-agent', 'tom'], + ]); + }); }); diff --git a/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts b/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts index 88b08357..3e97c5af 100644 --- a/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts +++ b/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts @@ -354,4 +354,115 @@ describe('BoardTaskLogDiagnosticsService', () => { expect(report.stream.visibleToolNames).toContain('mcp__agent-teams__task_complete'); expect(report.diagnosis.join(' ')).not.toContain('Only board MCP actions are explicit'); }); + + it('ignores non-record toolUseResult values when checking empty stream payloads', async () => { + const task = createTask(); + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + listTranscriptFiles: async () => [] as string[], + }; + const recordSource = { + getTaskRecords: async () => [], + }; + const strictParser = { + parseFiles: async () => new Map(), + }; + const streamService = { + getTaskLogStream: async () => ({ + participants: [], + defaultFilter: 'all' as const, + segments: [ + { + id: 'segment-1', + participantKey: 'member:tom', + actor: { + memberName: 'tom', + role: 'member' as const, + sessionId: 'session-tom', + isSidechain: false, + }, + startTimestamp: '2026-04-12T15:36:00.000Z', + endTimestamp: '2026-04-12T15:36:00.000Z', + chunks: [ + { + id: 'chunk-1', + rawMessages: [ + { + uuid: 'assistant-1', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T15:36:00.000Z'), + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-1', + name: 'mcp__agent-teams__task_add_comment', + input: {}, + }, + ], + toolCalls: [ + { + id: 'tool-1', + name: 'mcp__agent-teams__task_add_comment', + input: {}, + isTask: false, + }, + ], + toolResults: [], + isSidechain: false, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-1', + parentUuid: 'assistant-1', + type: 'user', + timestamp: new Date('2026-04-12T15:36:01.000Z'), + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-1', + content: 'validation failed', + is_error: true, + }, + ], + toolCalls: [], + toolResults: [ + { + toolUseId: 'tool-1', + content: 'validation failed', + isError: true, + }, + ], + sourceToolUseID: 'tool-1', + toolUseResult: new Error('validation failed'), + isSidechain: false, + isMeta: false, + isCompactSummary: false, + }, + ], + }, + ], + }, + ], + }), + }; + const diagnosticsService = new BoardTaskLogDiagnosticsService( + taskReader as never, + transcriptSourceLocator as never, + recordSource as never, + strictParser as never, + streamService as never, + ); + + const report = await diagnosticsService.diagnose(TEAM_NAME, TASK_ID); + + expect(report.stream.emptyPayloadExamples).toEqual([]); + expect(report.stream.visibleToolNames).toEqual(['mcp__agent-teams__task_add_comment']); + }); }); diff --git a/test/main/services/team/BoardTaskLogStreamService.test.ts b/test/main/services/team/BoardTaskLogStreamService.test.ts index 4ccf5540..3863ff04 100644 --- a/test/main/services/team/BoardTaskLogStreamService.test.ts +++ b/test/main/services/team/BoardTaskLogStreamService.test.ts @@ -872,6 +872,259 @@ describe('BoardTaskLogStreamService', () => { 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', @@ -1112,4 +1365,137 @@ describe('BoardTaskLogStreamService', () => { }, ]); }); + + 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, + }, + ]); + }); });