From 531a10e34f957918e43d4c73f60f8c0a3027ba2a Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 27 Apr 2026 20:49:44 +0300 Subject: [PATCH] fix: improve opencode runtime task logs --- .../services/team/TeamProvisioningService.ts | 16 + .../team/TeamRuntimeLivenessResolver.ts | 35 ++- .../runtime/OpenCodeTeamRuntimeAdapter.ts | 9 + .../team/runtime/TeamRuntimeAdapter.ts | 2 + .../stallMonitor/TeamTaskStallNotifier.ts | 7 +- .../stream/OpenCodeTaskLogStreamSource.ts | 236 ++++++++++++++- src/shared/types/team.ts | 2 + ...odeTaskLogStreamSource.fixture-e2e.test.ts | 58 +++- .../team/OpenCodeTaskLogStreamSource.test.ts | 276 +++++++++++++++++- .../team/TeamProvisioningService.test.ts | 43 +-- .../team/TeamRuntimeLivenessResolver.test.ts | 32 +- .../TeamTaskStallNotifier.test.ts | 25 ++ ...treamSection.opencode-fixture-e2e.test.tsx | 73 ++++- 13 files changed, 757 insertions(+), 57 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0e02a44d..6545aaa1 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -16072,6 +16072,11 @@ export class TeamProvisioningService { hardFailureReason: evidenceEntry.hardFailureReason, pendingPermissionRequestIds: evidenceEntry.pendingPermissionRequestIds, runtimePid: evidenceEntry.runtimePid, + sessionId: evidenceEntry.sessionId, + livenessKind: evidenceEntry.livenessKind, + pidSource: evidenceEntry.pidSource, + runtimeDiagnostic: evidenceEntry.runtimeDiagnostic, + runtimeDiagnosticSeverity: evidenceEntry.runtimeDiagnosticSeverity, diagnostics: evidenceEntry.diagnostics, } : finishedWithoutRuntimeEvidence @@ -16638,6 +16643,12 @@ export class TeamProvisioningService { hardFailureReason?: string; pendingPermissionRequestIds?: string[]; runtimePid?: number; + sessionId?: string; + runtimeSessionId?: string; + livenessKind?: TeamAgentRuntimeLivenessKind; + pidSource?: TeamAgentRuntimePidSource; + runtimeDiagnostic?: string; + runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; diagnostics?: string[]; }; pendingReason?: string; @@ -16685,6 +16696,11 @@ export class TeamProvisioningService { hardFailureReason: runtimeEvidence.hardFailureReason, pendingPermissionRequestIds: runtimeEvidence.pendingPermissionRequestIds, runtimePid: runtimeEvidence.runtimePid, + sessionId: runtimeEvidence.sessionId, + livenessKind: runtimeEvidence.livenessKind, + pidSource: runtimeEvidence.pidSource, + runtimeDiagnostic: runtimeEvidence.runtimeDiagnostic, + runtimeDiagnosticSeverity: runtimeEvidence.runtimeDiagnosticSeverity, diagnostics: runtimeEvidence.diagnostics, }, }); diff --git a/src/main/services/team/TeamRuntimeLivenessResolver.ts b/src/main/services/team/TeamRuntimeLivenessResolver.ts index 804f9e75..2ce967c6 100644 --- a/src/main/services/team/TeamRuntimeLivenessResolver.ts +++ b/src/main/services/team/TeamRuntimeLivenessResolver.ts @@ -185,6 +185,8 @@ export function resolveTeamMemberRuntimeLiveness( ): ResolvedTeamMemberRuntimeLiveness { const tracked = input.trackedSpawnStatus; const runtimeSessionId = input.runtimeSessionId ?? input.persistedRuntimeSessionId; + const hasConfirmedBootstrap = + tracked?.bootstrapConfirmed === true || tracked?.launchState === 'confirmed_alive'; const diagnostics: string[] = []; if (!input.processTableAvailable) { diagnostics.push('process table unavailable'); @@ -230,15 +232,38 @@ export function resolveTeamMemberRuntimeLiveness( if (runtimePidRow && input.providerId === 'opencode') { const processCommand = sanitizeProcessCommandForDiagnostics(runtimePidRow.command); if (isOpenCodeRuntimeProcess(runtimePidRow.command)) { + if (hasConfirmedBootstrap) { + return result({ + alive: true, + livenessKind: 'runtime_process', + pidSource: 'opencode_bridge', + pid: runtimePidRow.pid, + runtimeSessionId, + processCommand, + runtimeLastSeenAt: tracked?.lastHeartbeatAt ?? tracked?.updatedAt, + runtimeDiagnostic: 'OpenCode runtime process detected after bootstrap confirmation', + diagnostics: [ + ...diagnostics, + 'matched OpenCode runtime pid and process identity', + 'bootstrap confirmed', + ], + }); + } return result({ - alive: true, - livenessKind: 'runtime_process', + alive: false, + livenessKind: 'runtime_process_candidate', pidSource: 'opencode_bridge', pid: runtimePidRow.pid, runtimeSessionId, processCommand, - runtimeDiagnostic: 'OpenCode runtime process detected', - diagnostics: [...diagnostics, 'matched OpenCode runtime pid and process identity'], + runtimeDiagnostic: + 'OpenCode runtime process detected, but teammate bootstrap is not confirmed', + runtimeDiagnosticSeverity: 'warning', + diagnostics: [ + ...diagnostics, + 'matched OpenCode runtime pid and process identity', + 'waiting for teammate bootstrap confirmation', + ], }); } return result({ @@ -257,7 +282,7 @@ export function resolveTeamMemberRuntimeLiveness( }); } - if (tracked?.bootstrapConfirmed === true || tracked?.launchState === 'confirmed_alive') { + if (hasConfirmedBootstrap) { return result({ alive: true, livenessKind: 'confirmed_bootstrap', diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 267b452b..d1d7f1ad 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -556,6 +556,14 @@ function mapBridgeMemberToRuntimeEvidence( : runtimeMaterialized || sessionId ? 'OpenCode session exists without verified runtime pid' : undefined; + const runtimeDiagnosticSeverity = failed + ? 'error' + : pendingRuntimeObserved || + launchState === 'permission_blocked' || + runtimeMaterialized || + sessionId + ? 'warning' + : undefined; return { memberName, providerId: 'opencode', @@ -585,6 +593,7 @@ function mapBridgeMemberToRuntimeEvidence( livenessKind, ...(hasRuntimePid ? { pidSource: 'opencode_bridge' as const } : {}), ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}), + ...(runtimeDiagnosticSeverity ? { runtimeDiagnosticSeverity } : {}), diagnostics, }; } diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index 1fd06f8e..1ce62ce3 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -4,6 +4,7 @@ import type { PersistedTeamLaunchPhase, PersistedTeamLaunchSnapshot, TeamAgentRuntimeBackendType, + TeamAgentRuntimeDiagnosticSeverity, TeamAgentRuntimeLivenessKind, TeamAgentRuntimePidSource, TeamLaunchAggregateState, @@ -78,6 +79,7 @@ export interface TeamRuntimeMemberLaunchEvidence { livenessKind?: TeamAgentRuntimeLivenessKind; pidSource?: TeamAgentRuntimePidSource; runtimeDiagnostic?: string; + runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; diagnostics: string[]; } diff --git a/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts b/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts index 730ad1bf..dae2dfd1 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts @@ -44,13 +44,16 @@ function isOpenCodeDeliveryAccepted(delivery: OpenCodeTaskStallDelivery): boolea if (delivery.queuedBehindMessageId) { return false; } + if (delivery.responsePending === true) { + return false; + } if (delivery.accepted === true) { return true; } - if (delivery.delivered === true && delivery.responsePending !== true) { + if (delivery.delivered === true) { return true; } - return Boolean(delivery.responsePending === true && delivery.ledgerRecordId); + return false; } export class TeamTaskStallNotifier { diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts index ce4205c6..31d11d87 100644 --- a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts @@ -36,6 +36,15 @@ const WINDOW_GRACE_AFTER_MS = 15_000; const ATTRIBUTION_WINDOW_GRACE_MS = 1_000; const TASK_MARKER_CONTEXT_BEFORE_MESSAGES = 1; const TASK_MARKER_CONTEXT_MAX_MS = 5 * 60_000; +const NATIVE_TOOL_CONTEXT_BEFORE_MS = 5 * 60_000; +const NATIVE_TOOL_CONTEXT_AFTER_MS = 5 * 60_000; + +const AGENT_TEAMS_TOOL_PREFIXES = [ + 'mcp__agent-teams__', + 'mcp__agent_teams__', + 'agent-teams_', + 'agent_teams_', +] as const; const TASK_LOG_MARKER_TOOL_NAMES = new Set([ 'task_start', @@ -52,6 +61,22 @@ const TASK_LOG_MARKER_TOOL_NAMES = new Set([ 'review_request_changes', ]); +const BOARD_MCP_TOOL_NAMES = new Set([ + ...TASK_LOG_MARKER_TOOL_NAMES, + 'runtime_bootstrap_checkin', + 'member_briefing', + 'message_send', + 'cross_team_send', + 'task_create', + 'task_create_from_message', + 'task_get', + 'task_get_comment', + 'task_list', + 'task_update', + 'task_delete', + 'process_list', +]); + const TERMINAL_TASK_MARKER_TOOL_NAMES = new Set([ 'task_complete', 'review_approve', @@ -104,6 +129,13 @@ interface TaskMarkerProjection { messages: ParsedMessage[]; markerMatchCount: number; markerSpanCount: number; + boardMcpToolCount: number; + nativeToolCount: number; +} + +interface ProjectionToolCounts { + boardMcpToolCount: number; + nativeToolCount: number; } type HeuristicFallbackReason = @@ -284,6 +316,44 @@ function refsIntersect(left: Set, right: Set): boolean { return false; } +function isBoardMcpToolName(rawName: string): boolean { + const normalizedRawName = rawName + .trim() + .replace(/^proxy_/, '') + .toLowerCase(); + const canonicalName = canonicalizeAgentTeamsToolName(rawName).trim().toLowerCase(); + return ( + AGENT_TEAMS_TOOL_PREFIXES.some((prefix) => normalizedRawName.startsWith(prefix)) || + BOARD_MCP_TOOL_NAMES.has(canonicalName) + ); +} + +function isNativeOpenCodeToolName(rawName: string): boolean { + const normalizedName = rawName.trim(); + return normalizedName.length > 0 && !isBoardMcpToolName(normalizedName); +} + +function messageHasNativeOpenCodeToolCall(message: ParsedMessage): boolean { + return message.toolCalls.some((toolCall) => isNativeOpenCodeToolName(toolCall.name ?? '')); +} + +function countProjectionToolCalls(messages: ParsedMessage[]): ProjectionToolCounts { + let boardMcpToolCount = 0; + let nativeToolCount = 0; + + for (const message of messages) { + for (const toolCall of message.toolCalls) { + if (isNativeOpenCodeToolName(toolCall.name ?? '')) { + nativeToolCount += 1; + } else if (isBoardMcpToolName(toolCall.name ?? '')) { + boardMcpToolCount += 1; + } + } + } + + return { boardMcpToolCount, nativeToolCount }; +} + function markerInputReferencesTaskInTeam( input: unknown, teamName: string, @@ -504,11 +574,16 @@ function resolveMarkerSpanStart(messages: ParsedMessage[], markerIndex: number): function findLastMessageIndexInWindow( messages: ParsedMessage[], startIndex: number, - window: TimeWindow + window: TimeWindow, + maxEndMs = Number.POSITIVE_INFINITY ): number { let endIndex = startIndex; for (let index = startIndex + 1; index < messages.length; index += 1) { - if (!isWithinSingleTimeWindow(messages[index].timestamp, window)) { + const message = messages[index]; + if (!message || message.timestamp.getTime() > maxEndMs) { + break; + } + if (!isWithinSingleTimeWindow(message.timestamp, window)) { break; } endIndex = index; @@ -562,7 +637,11 @@ function buildMarkerSpan( lastMarker.windowIndex === null ? undefined : (windows[lastMarker.windowIndex] ?? undefined); if (!isTerminalTaskMarkerMatch(lastMarker) && window) { - endIndex = findLastMessageIndexInWindow(messages, lastMarker.index, window); + const maxEndMs = + window.endMs === null + ? messages[lastMarker.index].timestamp.getTime() + TASK_MARKER_CONTEXT_MAX_MS + : Number.POSITIVE_INFINITY; + endIndex = findLastMessageIndexInWindow(messages, lastMarker.index, window, maxEndMs); } return { @@ -571,6 +650,107 @@ function buildMarkerSpan( }; } +function clampWindowToTaskWindow( + window: TimeWindow, + taskWindow: TimeWindow | undefined +): TimeWindow { + if (!taskWindow) { + return window; + } + + const taskEndMs = taskWindow.endMs ?? Date.now(); + return { + startMs: Math.max(window.startMs, taskWindow.startMs), + endMs: Math.min(window.endMs ?? taskEndMs, taskEndMs), + }; +} + +function buildNativeToolWindowForMarkerGroup( + messages: ParsedMessage[], + markerGroup: TaskMarkerMatch[], + span: { startIndex: number; endIndex: number }, + taskWindows: TimeWindow[] +): TimeWindow | null { + const firstMarker = markerGroup[0]; + const lastMarker = markerGroup[markerGroup.length - 1]; + if (!firstMarker || !lastMarker) { + return null; + } + + const groupHasStartMarker = markerGroup.some((match) => + match.markerCalls.some((markerCall) => markerCall.toolName === 'task_start') + ); + const spanStartMessage = messages[span.startIndex]; + const lastMarkerMessage = messages[lastMarker.index]; + if (!spanStartMessage || !lastMarkerMessage) { + return null; + } + + const startMs = groupHasStartMarker + ? spanStartMessage.timestamp.getTime() + : lastMarkerMessage.timestamp.getTime() - NATIVE_TOOL_CONTEXT_BEFORE_MS; + const taskWindow = + lastMarker.windowIndex === null + ? undefined + : (taskWindows[lastMarker.windowIndex] ?? undefined); + const endMs = isTerminalTaskMarkerMatch(lastMarker) + ? Math.max( + messages[span.endIndex]?.timestamp.getTime() ?? lastMarkerMessage.timestamp.getTime(), + lastMarkerMessage.timestamp.getTime() + ) + : (taskWindow?.endMs ?? lastMarkerMessage.timestamp.getTime() + NATIVE_TOOL_CONTEXT_AFTER_MS); + const clamped = clampWindowToTaskWindow({ startMs, endMs }, taskWindow); + return clamped.startMs <= (clamped.endMs ?? Date.now()) ? clamped : null; +} + +function addNativeToolIndexesInWindows( + includedIndexes: Set, + messages: ParsedMessage[], + windows: TimeWindow[] +): void { + if (windows.length === 0) { + return; + } + + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if (!messageHasNativeOpenCodeToolCall(message)) { + continue; + } + if (isWithinTimeWindows(message.timestamp, windows)) { + includedIndexes.add(index); + } + } +} + +function addToolResultIndexesForIncludedAssistants( + includedIndexes: Set, + messages: ParsedMessage[] +): void { + const includedAssistantUuids = new Set(); + for (const index of includedIndexes) { + const message = messages[index]; + if (message?.type === 'assistant') { + includedAssistantUuids.add(message.uuid); + } + } + + if (includedAssistantUuids.size === 0) { + return; + } + + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if ( + message?.isMeta && + message.sourceToolAssistantUUID && + includedAssistantUuids.has(message.sourceToolAssistantUUID) + ) { + includedIndexes.add(index); + } + } +} + function buildTaskMarkerProjection( projectedMessages: OpenCodeRuntimeTranscriptLogMessage[], teamName: string, @@ -595,15 +775,36 @@ function buildTaskMarkerProjection( return null; } - const spans = groupMarkerMatches(markerMatches, taskWindows) - .map((group) => buildMarkerSpan(parsedMessages, group, taskWindows)) - .filter((span): span is { startIndex: number; endIndex: number } => span !== null); + const markerGroups = groupMarkerMatches(markerMatches, taskWindows); + const spansWithGroups = markerGroups + .map((group) => { + const span = buildMarkerSpan(parsedMessages, group, taskWindows); + return span ? { group, span } : null; + }) + .filter( + ( + item + ): item is { group: TaskMarkerMatch[]; span: { startIndex: number; endIndex: number } } => + item !== null + ); const includedIndexes = new Set(); - for (const span of spans) { + const nativeToolWindows: TimeWindow[] = []; + for (const { group, span } of spansWithGroups) { for (let index = span.startIndex; index <= span.endIndex; index += 1) { includedIndexes.add(index); } + const nativeToolWindow = buildNativeToolWindowForMarkerGroup( + parsedMessages, + group, + span, + taskWindows + ); + if (nativeToolWindow) { + nativeToolWindows.push(nativeToolWindow); + } } + addNativeToolIndexesInWindows(includedIndexes, parsedMessages, nativeToolWindows); + addToolResultIndexesForIncludedAssistants(includedIndexes, parsedMessages); const messages = [...includedIndexes] .sort((left, right) => left - right) @@ -618,7 +819,8 @@ function buildTaskMarkerProjection( ? { messages, markerMatchCount, - markerSpanCount: spans.length, + markerSpanCount: spansWithGroups.length, + ...countProjectionToolCalls(messages), } : null; } @@ -989,6 +1191,12 @@ export class OpenCodeTaskLogStreamSource { .filter((message): message is ParsedMessage => message !== null) .filter((message) => isWithinTimeWindows(message.timestamp, timeWindows)) .sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()); + const toolCounts = markerProjection + ? { + boardMcpToolCount: markerProjection.boardMcpToolCount, + nativeToolCount: markerProjection.nativeToolCount, + } + : countProjectionToolCalls(filteredMessages); if (filteredMessages.length === 0) { return null; @@ -1017,7 +1225,7 @@ export class OpenCodeTaskLogStreamSource { }; logger.debug( - `[${teamName}/${task.id}] using OpenCode runtime fallback for task log stream (${filteredMessages.length} messages, owner=${ownerName})` + `[${teamName}/${task.id}] using OpenCode runtime fallback for task log stream (${filteredMessages.length} messages, owner=${ownerName}, boardMcpTools=${toolCounts.boardMcpToolCount}, nativeTools=${toolCounts.nativeToolCount})` ); return { @@ -1030,6 +1238,7 @@ export class OpenCodeTaskLogStreamSource { mode: 'heuristic', attributionRecordCount: projectionContext.attributionRecordCount, projectedMessageCount: filteredMessages.length, + ...toolCounts, fallbackReason: projectionReason, ...(markerProjection ? { @@ -1122,6 +1331,8 @@ export class OpenCodeTaskLogStreamSource { const participants: BoardTaskLogParticipant[] = []; const segments: BoardTaskLogSegment[] = []; let projectedMessageCount = 0; + let boardMcpToolCount = 0; + let nativeToolCount = 0; for (const member of members.sort((left, right) => { const leftStart = left.messages[0]?.timestamp.getTime() ?? 0; const rightStart = right.messages[0]?.timestamp.getTime() ?? 0; @@ -1142,7 +1353,10 @@ export class OpenCodeTaskLogStreamSource { } const participant = buildParticipant(member.memberName); + const memberToolCounts = countProjectionToolCalls(member.messages); projectedMessageCount += member.messages.length; + boardMcpToolCount += memberToolCounts.boardMcpToolCount; + nativeToolCount += memberToolCounts.nativeToolCount; participants.push(participant); segments.push({ id: `opencode-attributed:${teamName}:${task.id}:${normalizeMemberName(member.memberName)}`, @@ -1159,7 +1373,7 @@ export class OpenCodeTaskLogStreamSource { } logger.debug( - `[${teamName}/${task.id}] using OpenCode task-log attribution (${segments.length} segment(s), ${attributionRecords.length} record(s))` + `[${teamName}/${task.id}] using OpenCode task-log attribution (${segments.length} segment(s), ${attributionRecords.length} record(s), boardMcpTools=${boardMcpToolCount}, nativeTools=${nativeToolCount})` ); return { @@ -1172,6 +1386,8 @@ export class OpenCodeTaskLogStreamSource { mode: 'attribution', attributionRecordCount: attributionRecords.length, projectedMessageCount, + boardMcpToolCount, + nativeToolCount, }, }; } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 909fd93b..d209c61a 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -353,6 +353,8 @@ export interface BoardTaskLogStreamRuntimeProjection { mode: 'attribution' | 'heuristic'; attributionRecordCount: number; projectedMessageCount: number; + boardMcpToolCount?: number; + nativeToolCount?: number; fallbackReason?: | 'no_attribution_records' | 'attribution_no_projected_messages' diff --git a/test/main/services/team/OpenCodeTaskLogStreamSource.fixture-e2e.test.ts b/test/main/services/team/OpenCodeTaskLogStreamSource.fixture-e2e.test.ts index e8cc20e9..7c7cd846 100644 --- a/test/main/services/team/OpenCodeTaskLogStreamSource.fixture-e2e.test.ts +++ b/test/main/services/team/OpenCodeTaskLogStreamSource.fixture-e2e.test.ts @@ -32,6 +32,21 @@ const RELAY_WORKS_10_TASK: TeamTask = { ], }; +const RELAY_WORKS_10_COORDINATION_TASK: TeamTask = { + id: 'b5534868-0901-4c9e-9296-2b6e2059a08f', + displayId: 'b5534868', + subject: 'Split calculator implementation work', + owner: 'jack', + status: 'in_progress', + createdAt: '2026-04-24T20:28:58.000Z', + updatedAt: '2026-04-24T20:31:21.876Z', + workIntervals: [ + { + startedAt: '2026-04-24T20:28:58.000Z', + }, + ], +}; + async function loadFixtureTranscript(): Promise< NonNullable > { @@ -171,6 +186,43 @@ describe('OpenCodeTaskLogStreamSource real OpenCode fixture e2e', () => { }); }); + it('includes real native OpenCode read/bash tools from a task-scoped runtime projection', async () => { + const transcript = await loadFixtureTranscript(); + const { source } = createSource({ + transcript, + activeTasks: [RELAY_WORKS_10_COORDINATION_TASK], + }); + + const response = await source.getTaskLogStream( + 'relay-works-10', + RELAY_WORKS_10_COORDINATION_TASK.id + ); + + expect(response).not.toBeNull(); + expect(response?.source).toBe('opencode_runtime_fallback'); + expect(response?.runtimeProjection).toMatchObject({ + provider: 'opencode', + mode: 'heuristic', + fallbackReason: 'task_tool_markers', + }); + expect(response?.runtimeProjection?.boardMcpToolCount).toBeGreaterThan(0); + expect(response?.runtimeProjection?.nativeToolCount).toBeGreaterThanOrEqual(2); + + const rawMessages = flattenRawMessages(response as BoardTaskLogStreamResponse); + const toolNames = rawMessages.flatMap((message) => + message.toolCalls.map((toolCall) => toolCall.name) + ); + const serialized = rawMessages.map(serializeContent).join('\n'); + + expect(toolNames).toEqual(expect.arrayContaining(['read', 'bash'])); + expect(toolNames).toEqual( + expect.arrayContaining(['agent-teams_task_start', 'agent-teams_task_add_comment']) + ); + expect(serialized).toContain('package.json'); + expect(serialized).toContain('Разбил работу на мелкие задачи'); + expect(toolNames).not.toContain('SendMessage'); + }); + it('uses real attribution UUID bounds before heuristic fallback', async () => { const transcript = await loadFixtureTranscript(); const { source, bridge, attributionStore } = createSource({ @@ -196,6 +248,8 @@ describe('OpenCodeTaskLogStreamSource real OpenCode fixture e2e', () => { mode: 'attribution', attributionRecordCount: 1, projectedMessageCount: 10, + boardMcpToolCount: 4, + nativeToolCount: 0, }); expect(response?.defaultFilter).toBe('member:jack'); expect(response?.segments).toHaveLength(1); @@ -353,7 +407,9 @@ describe('OpenCodeTaskLogStreamSource real OpenCode fixture e2e', () => { const assistantToolIds = new Set( projectedMessages.flatMap((message) => message.toolCalls.map((toolCall) => toolCall.id)) ); - const toolResultMessages = projectedMessages.filter((message) => message.toolResults.length > 0); + const toolResultMessages = projectedMessages.filter( + (message) => message.toolResults.length > 0 + ); expect(projectedMessages).toHaveLength(101); expect(toolResultMessages.length).toBeGreaterThan(20); diff --git a/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts b/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts index 98f98b45..52c2ac3c 100644 --- a/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts +++ b/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts @@ -233,6 +233,8 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'heuristic', attributionRecordCount: 0, projectedMessageCount: 2, + boardMcpToolCount: 0, + nativeToolCount: 1, fallbackReason: 'no_attribution_records', }); expect(first?.participants).toEqual([ @@ -254,7 +256,9 @@ describe('OpenCodeTaskLogStreamSource', () => { 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) + 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); @@ -529,12 +533,16 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'heuristic', attributionRecordCount: 0, projectedMessageCount: 6, + boardMcpToolCount: 2, + nativeToolCount: 0, fallbackReason: 'task_tool_markers', markerMatchCount: 2, markerSpanCount: 1, }); expect( - chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) ).toEqual([ 'user-task-prompt', 'assistant-start', @@ -545,6 +553,226 @@ describe('OpenCodeTaskLogStreamSource', () => { ]); }); + it('keeps native OpenCode tools near task markers in the task stream', async () => { + const bridge = { + getOpenCodeTranscript: vi.fn(async () => ({ + sessionId: 'session-opencode', + logProjection: { + messages: [ + taskMarkerLogMessage({ + uuid: 'native-before-task', + timestamp: '2026-04-21T09:50:00.000Z', + toolName: 'read', + input: { filePath: '/tmp/unrelated.ts' }, + }), + textLogMessage({ + uuid: 'user-task-prompt', + type: 'user', + role: 'user', + timestamp: '2026-04-21T10:01:00.000Z', + content: [{ type: 'text', text: 'Start task-a now' }], + }), + taskMarkerLogMessage({ + uuid: 'assistant-start', + parentUuid: 'user-task-prompt', + timestamp: '2026-04-21T10:02:00.000Z', + toolName: 'mcp__agent-teams__task_start', + input: { teamName: 'team-a', taskId: 'task-a' }, + }), + toolResultLogMessage({ + uuid: 'assistant-start::tool_results', + parentUuid: 'assistant-start', + timestamp: '2026-04-21T10:02:01.000Z', + sourceToolAssistantUUID: 'assistant-start', + }), + taskMarkerLogMessage({ + uuid: 'native-read', + parentUuid: 'assistant-start::tool_results', + timestamp: '2026-04-21T10:03:00.000Z', + toolName: 'read', + input: { filePath: '/tmp/app.ts' }, + }), + toolResultLogMessage({ + uuid: 'native-read::tool_results', + parentUuid: 'native-read', + timestamp: '2026-04-21T10:03:01.000Z', + sourceToolAssistantUUID: 'native-read', + }), + taskMarkerLogMessage({ + uuid: 'native-bash', + parentUuid: 'native-read::tool_results', + timestamp: '2026-04-21T10:04:00.000Z', + toolName: 'bash', + input: { command: 'pnpm test' }, + }), + toolResultLogMessage({ + uuid: 'native-bash::tool_results', + parentUuid: 'native-bash', + timestamp: '2026-04-21T10:04:01.000Z', + sourceToolAssistantUUID: 'native-bash', + }), + taskMarkerLogMessage({ + uuid: 'assistant-comment', + parentUuid: 'native-bash::tool_results', + timestamp: '2026-04-21T10:05:00.000Z', + toolName: 'mcp__agent-teams__task_add_comment', + input: { teamName: 'team-a', taskId: 'task-a', text: 'Tests passed' }, + }), + toolResultLogMessage({ + uuid: 'assistant-comment::tool_results', + parentUuid: 'assistant-comment', + timestamp: '2026-04-21T10:05:01.000Z', + sourceToolAssistantUUID: 'assistant-comment', + }), + taskMarkerLogMessage({ + uuid: 'native-after-task', + timestamp: '2026-04-21T10:20:00.000Z', + toolName: 'bash', + input: { command: 'echo unrelated' }, + }), + ], + }, + })), + }; + const chunkBuilder = { + buildBundleChunks: vi.fn((messages) => [ + { + id: 'chunk-native-tools', + kind: 'assistant', + messages, + }, + ]), + }; + const source = new OpenCodeTaskLogStreamSource( + bridge as never, + { resolve: async () => '/tmp/claude' }, + { + getTasks: async () => [ + createTask({ + workIntervals: [ + { + startedAt: '2026-04-21T10:00:00.000Z', + completedAt: '2026-04-21T10:06: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: 9, + boardMcpToolCount: 2, + nativeToolCount: 2, + 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', + 'native-read', + 'native-read::tool_results', + 'native-bash', + 'native-bash::tool_results', + 'assistant-comment', + 'assistant-comment::tool_results', + ]); + }); + + it('can include native OpenCode work shortly before a comment-only task marker', async () => { + const bridge = { + getOpenCodeTranscript: vi.fn(async () => ({ + sessionId: 'session-opencode', + logProjection: { + messages: [ + taskMarkerLogMessage({ + uuid: 'native-read', + timestamp: '2026-04-21T10:02:00.000Z', + toolName: 'read', + input: { filePath: '/tmp/app.ts' }, + }), + toolResultLogMessage({ + uuid: 'native-read::tool_results', + parentUuid: 'native-read', + timestamp: '2026-04-21T10:02:01.000Z', + sourceToolAssistantUUID: 'native-read', + }), + taskMarkerLogMessage({ + uuid: 'assistant-comment', + parentUuid: 'native-read::tool_results', + timestamp: '2026-04-21T10:04:00.000Z', + toolName: 'mcp__agent-teams__task_add_comment', + input: { teamName: 'team-a', taskId: 'task-a', text: 'Found the issue' }, + }), + toolResultLogMessage({ + uuid: 'assistant-comment::tool_results', + parentUuid: 'assistant-comment', + timestamp: '2026-04-21T10:04:01.000Z', + sourceToolAssistantUUID: 'assistant-comment', + }), + ], + }, + })), + }; + const chunkBuilder = { + buildBundleChunks: vi.fn((messages) => [ + { + id: 'chunk-comment-only-native', + 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: 4, + boardMcpToolCount: 1, + nativeToolCount: 1, + fallbackReason: 'task_tool_markers', + markerMatchCount: 1, + markerSpanCount: 1, + }); + expect( + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) + ).toEqual([ + 'native-read', + 'native-read::tool_results', + 'assistant-comment', + 'assistant-comment::tool_results', + ]); + }); + it('ignores OpenCode task markers that explicitly belong to another team', async () => { const bridge = { getOpenCodeTranscript: vi.fn(async () => ({ @@ -620,12 +848,16 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'heuristic', attributionRecordCount: 0, projectedMessageCount: 3, + boardMcpToolCount: 1, + nativeToolCount: 0, fallbackReason: 'task_tool_markers', markerMatchCount: 1, markerSpanCount: 1, }); expect( - chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) ).toEqual(['team-a-prompt', 'team-a-start', 'team-a-start::tool_results']); }); @@ -743,12 +975,16 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'heuristic', attributionRecordCount: 0, projectedMessageCount: 10, + boardMcpToolCount: 3, + nativeToolCount: 0, fallbackReason: 'task_tool_markers', markerMatchCount: 3, markerSpanCount: 2, }); expect( - chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) ).toEqual([ 'cycle-1-prompt', 'cycle-1-start', @@ -810,10 +1046,14 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'heuristic', attributionRecordCount: 0, projectedMessageCount: 1, + boardMcpToolCount: 0, + nativeToolCount: 0, fallbackReason: 'no_attribution_records', }); expect( - chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) ).toEqual(['current-window-work']); }); @@ -866,12 +1106,16 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'heuristic', attributionRecordCount: 0, projectedMessageCount: 2, + boardMcpToolCount: 1, + nativeToolCount: 0, fallbackReason: 'task_tool_markers', markerMatchCount: 1, markerSpanCount: 1, }); expect( - chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) ).toEqual(['display-ref-start', 'display-ref-start::tool_results']); }); @@ -955,6 +1199,8 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'attribution', attributionRecordCount: 1, projectedMessageCount: 1, + boardMcpToolCount: 0, + nativeToolCount: 0, }); expect(response?.participants).toEqual([ { @@ -973,7 +1219,9 @@ describe('OpenCodeTaskLogStreamSource', () => { isSidechain: true, }); expect( - chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) ).toEqual(['bob-inside']); expect(bridge.getOpenCodeTranscript).toHaveBeenCalledWith('/tmp/claude', { teamId: 'team-a', @@ -1065,11 +1313,15 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'heuristic', attributionRecordCount: 1, projectedMessageCount: 1, + boardMcpToolCount: 0, + nativeToolCount: 0, 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) + 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', @@ -1111,9 +1363,7 @@ describe('OpenCodeTaskLogStreamSource', () => { 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', + 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, @@ -1168,7 +1418,9 @@ describe('OpenCodeTaskLogStreamSource', () => { limit: 500, }); expect( - chunkBuilder.buildBundleChunks.mock.calls.at(-1)?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls + .at(-1)?.[0] + .map((message: { uuid: string }) => message.uuid) ).toEqual(['bob-new-attribution']); }); }); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 5730af8b..4d2f82fc 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -1222,7 +1222,7 @@ describe('TeamProvisioningService', () => { }); }); - it('shows RSS for OpenCode secondary lanes through the shared runtime host without exposing a member pid', async () => { + it('shows RSS for OpenCode secondary lane host pids without treating pre-bootstrap runtime as alive', async () => { const svc = new TeamProvisioningService(); (svc as any).configReader = { getConfig: vi.fn(async () => ({ @@ -1325,15 +1325,16 @@ describe('TeamProvisioningService', () => { expect(pidusage).toHaveBeenCalledWith(333, { maxage: 0 }); expect(snapshot.members.bob).toMatchObject({ memberName: 'bob', - alive: true, + alive: false, restartable: false, pid: 333, runtimeModel: 'opencode/minimax-m2.5-free', rssBytes: 456_000_000, + livenessKind: 'runtime_process_candidate', }); }); - it('shows RSS for persisted OpenCode secondary lane runtime pids after the launch run is gone', async () => { + it('shows RSS for persisted OpenCode secondary lane host pids without treating historical bootstrap as live', async () => { const svc = new TeamProvisioningService(); (svc as any).configReader = { getConfig: vi.fn(async () => ({ @@ -1399,12 +1400,14 @@ describe('TeamProvisioningService', () => { expect(pidusage).toHaveBeenCalledWith([333], { maxage: 0 }); expect(snapshot.members.bob).toMatchObject({ memberName: 'bob', - alive: true, + alive: false, restartable: false, pid: 333, providerId: 'opencode', runtimeModel: 'opencode/minimax-m2.5-free', rssBytes: 456_000_000, + historicalBootstrapConfirmed: true, + livenessKind: 'runtime_process_candidate', }); }); }); @@ -9892,7 +9895,7 @@ describe('TeamProvisioningService', () => { }); }); - it('clears stale OpenCode bridge launch failure when the runtime process is verified alive', async () => { + it('does not clear OpenCode bridge launch failure from process-only liveness', async () => { const svc = new TeamProvisioningService(); (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( async () => @@ -9900,12 +9903,13 @@ describe('TeamProvisioningService', () => { [ 'bob', { - alive: true, + alive: false, model: 'openrouter/google/gemini-2.5-flash', - livenessKind: 'runtime_process', + livenessKind: 'runtime_process_candidate', providerId: 'opencode', - runtimeDiagnostic: 'OpenCode runtime process detected', - runtimeDiagnosticSeverity: 'info', + runtimeDiagnostic: + 'OpenCode runtime process detected, but teammate bootstrap is not confirmed', + runtimeDiagnosticSeverity: 'warning', }, ], ]) @@ -9922,17 +9926,18 @@ describe('TeamProvisioningService', () => { }); expect(result.bob).toMatchObject({ - status: 'online', - launchState: 'runtime_pending_bootstrap', - runtimeAlive: true, - hardFailure: false, - hardFailureReason: undefined, - error: undefined, + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + hardFailure: true, + hardFailureReason: 'OpenCode bridge reported member launch failure', + error: 'OpenCode bridge reported member launch failure', runtimeModel: 'openrouter/google/gemini-2.5-flash', - livenessKind: 'runtime_process', - runtimeDiagnostic: 'OpenCode runtime process detected', - runtimeDiagnosticSeverity: 'info', - livenessSource: 'process', + livenessKind: 'runtime_process_candidate', + runtimeDiagnostic: + 'OpenCode runtime process detected, but teammate bootstrap is not confirmed', + runtimeDiagnosticSeverity: 'warning', + livenessSource: undefined, }); }); diff --git a/test/main/services/team/TeamRuntimeLivenessResolver.test.ts b/test/main/services/team/TeamRuntimeLivenessResolver.test.ts index adaf4956..73c91c20 100644 --- a/test/main/services/team/TeamRuntimeLivenessResolver.test.ts +++ b/test/main/services/team/TeamRuntimeLivenessResolver.test.ts @@ -104,7 +104,7 @@ describe('resolveTeamMemberRuntimeLiveness', () => { expect(result.pid).toBe(301); }); - it('promotes a live OpenCode runtime pid only when process identity matches', () => { + it('keeps a live OpenCode runtime pid as candidate until bootstrap is confirmed', () => { const result = resolveTeamMemberRuntimeLiveness({ teamName: 'demo', memberName: 'bob', @@ -116,6 +116,36 @@ describe('resolveTeamMemberRuntimeLiveness', () => { nowIso: NOW, }); + expect(result.alive).toBe(false); + expect(result.livenessKind).toBe('runtime_process_candidate'); + expect(result.pidSource).toBe('opencode_bridge'); + expect(result.pid).toBe(404); + expect(result.runtimeDiagnostic).toBe( + 'OpenCode runtime process detected, but teammate bootstrap is not confirmed' + ); + }); + + it('promotes a live OpenCode runtime pid after bootstrap confirmation', () => { + const result = resolveTeamMemberRuntimeLiveness({ + teamName: 'demo', + memberName: 'bob', + providerId: 'opencode', + persistedRuntimePid: 404, + persistedRuntimeSessionId: 'session-bob', + trackedSpawnStatus: { + status: 'online', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + updatedAt: NOW, + }, + processRows: [{ pid: 404, ppid: 1, command: 'opencode runtime host' }], + processTableAvailable: true, + nowIso: NOW, + }); + expect(result.alive).toBe(true); expect(result.livenessKind).toBe('runtime_process'); expect(result.pidSource).toBe('opencode_bridge'); diff --git a/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts index a9b622aa..7c506e62 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts @@ -170,6 +170,31 @@ describe('TeamTaskStallNotifier', () => { await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]); }); + it('does not mark response-pending delivery as remediated even after runtime acceptance', async () => { + const relay = vi.fn(async () => ({ + relayed: 1, + attempted: 1, + delivered: 1, + failed: 0, + lastDelivery: { + delivered: true, + accepted: true, + responsePending: true, + ledgerRecordId: 'active-ledger-record', + reason: 'opencode_delivery_response_pending', + }, + })); + const notifier = new TeamTaskStallNotifier( + { sendSystemNotificationToLead: vi.fn(async () => undefined) } as never, + { relayOpenCodeMemberInboxMessages: relay } as never, + { getMessagesFor: vi.fn(async () => []) } as never, + { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never + ); + + await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]); + expect(relay).toHaveBeenCalledTimes(1); + }); + it('does not deliver runtime nudge when inbox write fails', async () => { const relay = vi.fn(async () => ({ lastDelivery: { delivered: true } })); const notifier = new TeamTaskStallNotifier( diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx b/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx index cbcf0e10..8351f672 100644 --- a/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx @@ -13,15 +13,15 @@ import type { BoardTaskLogStreamResponse, TeamTask } from '../../../../../src/sh const TEAM_NAME = 'relay-works-10'; const TASK_ID = '0b3a0624-5d66-4067-848e-5a74a1720c0d'; +const COORDINATION_TASK_ID = 'b5534868-0901-4c9e-9296-2b6e2059a08f'; const FIXTURE_PATH = path.resolve( process.cwd(), 'test/fixtures/team/opencode/relay-works-10-jack-projection-transcript.json' ); const apiState = { - getTaskLogStream: vi.fn< - (teamName: string, taskId: string) => Promise - >(), + getTaskLogStream: + vi.fn<(teamName: string, taskId: string) => Promise>(), onTeamChange: vi.fn<(callback: (event: unknown, data: unknown) => void) => () => void>(), }; @@ -54,17 +54,36 @@ const RELAY_WORKS_10_TASK: TeamTask = { ], }; +const RELAY_WORKS_10_COORDINATION_TASK: TeamTask = { + id: COORDINATION_TASK_ID, + displayId: 'b5534868', + subject: 'Split calculator implementation work', + owner: 'jack', + status: 'in_progress', + createdAt: '2026-04-24T20:28:58.000Z', + updatedAt: '2026-04-24T20:31:21.876Z', + workIntervals: [ + { + startedAt: '2026-04-24T20:28:58.000Z', + }, + ], +}; + async function loadFixtureTranscript(): Promise< NonNullable > { - const parsed = JSON.parse(await readFile(FIXTURE_PATH, 'utf8')) as OpenCodeRuntimeTranscriptResponse; + const parsed = JSON.parse( + await readFile(FIXTURE_PATH, 'utf8') + ) as OpenCodeRuntimeTranscriptResponse; if (parsed.providerId !== 'opencode' || !parsed.transcript) { throw new Error('Invalid OpenCode transcript fixture'); } return parsed.transcript; } -async function buildFixtureStream(): Promise { +async function buildFixtureStream( + task: TeamTask = RELAY_WORKS_10_TASK +): Promise { const transcript = await loadFixtureTranscript(); const source = new OpenCodeTaskLogStreamSource( { @@ -72,13 +91,13 @@ async function buildFixtureStream(): Promise { } as never, { resolve: async () => '/tmp/agent_teams_orchestrator' }, { - getTasks: vi.fn(async () => [RELAY_WORKS_10_TASK]), + getTasks: vi.fn(async () => [task]), getDeletedTasks: vi.fn(async () => []), } as never, new BoardTaskExactLogChunkBuilder(), { readTaskRecords: vi.fn(async () => []) } ); - const stream = await source.getTaskLogStream(TEAM_NAME, TASK_ID); + const stream = await source.getTaskLogStream(TEAM_NAME, task.id); if (!stream) { throw new Error('Expected OpenCode fixture stream'); } @@ -138,4 +157,44 @@ describe('TaskLogStreamSection OpenCode real fixture e2e', () => { await flushMicrotasks(); }); }); + + it('renders real OpenCode native tool rows when the task stream includes them', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.onTeamChange.mockImplementation(() => () => undefined); + apiState.getTaskLogStream.mockResolvedValueOnce( + await buildFixtureStream(RELAY_WORKS_10_COORDINATION_TASK) + ); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement( + TooltipProvider, + null, + React.createElement(TaskLogStreamSection, { + teamName: TEAM_NAME, + taskId: COORDINATION_TASK_ID, + liveEnabled: false, + }) + ) + ); + await flushMicrotasks(); + await flushMicrotasks(); + }); + + const text = host.textContent ?? ''; + expect(text).toContain('Task Log Stream'); + expect(text).toContain('read'); + expect(text).toContain('bash'); + expect(text).toContain('Разбил работу на мелкие задачи'); + expect(text).not.toContain('SendMessage'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); });