fix: improve opencode runtime task logs

This commit is contained in:
777genius 2026-04-27 20:49:44 +03:00
parent 376480b84f
commit 531a10e34f
13 changed files with 757 additions and 57 deletions

View file

@ -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,
},
});

View file

@ -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',

View file

@ -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,
};
}

View file

@ -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[];
}

View file

@ -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 {

View file

@ -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<string>([
'task_start',
@ -52,6 +61,22 @@ const TASK_LOG_MARKER_TOOL_NAMES = new Set<string>([
'review_request_changes',
]);
const BOARD_MCP_TOOL_NAMES = new Set<string>([
...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<string>([
'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<string>, right: Set<string>): 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<number>,
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<number>,
messages: ParsedMessage[]
): void {
const includedAssistantUuids = new Set<string>();
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<number>();
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,
},
};
}

View file

@ -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'

View file

@ -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<OpenCodeRuntimeTranscriptResponse['transcript']>
> {
@ -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);

View file

@ -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']);
});
});

View file

@ -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,
});
});

View file

@ -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');

View file

@ -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(

View file

@ -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<BoardTaskLogStreamResponse>
>(),
getTaskLogStream:
vi.fn<(teamName: string, taskId: string) => Promise<BoardTaskLogStreamResponse>>(),
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<OpenCodeRuntimeTranscriptResponse['transcript']>
> {
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<BoardTaskLogStreamResponse> {
async function buildFixtureStream(
task: TeamTask = RELAY_WORKS_10_TASK
): Promise<BoardTaskLogStreamResponse> {
const transcript = await loadFixtureTranscript();
const source = new OpenCodeTaskLogStreamSource(
{
@ -72,13 +91,13 @@ async function buildFixtureStream(): Promise<BoardTaskLogStreamResponse> {
} 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();
});
});
});