fix(opencode): harden runtime projection delivery
This commit is contained in:
parent
dec0eaba18
commit
2f37be4bd0
13 changed files with 3631 additions and 37 deletions
|
|
@ -15,8 +15,13 @@ export const agentBlocks = controllerModule.agentBlocks;
|
||||||
|
|
||||||
export function getController(teamName: string, claudeDir?: string) {
|
export function getController(teamName: string, claudeDir?: string) {
|
||||||
const forcedClaudeDir = process.env[FORCED_CLAUDE_DIR_ENV]?.trim();
|
const forcedClaudeDir = process.env[FORCED_CLAUDE_DIR_ENV]?.trim();
|
||||||
|
let resolvedClaudeDir = claudeDir;
|
||||||
|
if (forcedClaudeDir) {
|
||||||
|
resolvedClaudeDir = forcedClaudeDir;
|
||||||
|
}
|
||||||
|
|
||||||
return createController({
|
return createController({
|
||||||
teamName,
|
teamName,
|
||||||
...(forcedClaudeDir ? { claudeDir: forcedClaudeDir } : claudeDir ? { claudeDir } : {}),
|
...(resolvedClaudeDir ? { claudeDir: resolvedClaudeDir } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import {
|
||||||
createLegacyRuntimeFallbackCliExtensionCapabilities,
|
createLegacyRuntimeFallbackCliExtensionCapabilities,
|
||||||
} from '@shared/utils/providerExtensionCapabilities';
|
} from '@shared/utils/providerExtensionCapabilities';
|
||||||
import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility';
|
import { filterVisibleProviderRuntimeModels } from '@shared/utils/providerModelVisibility';
|
||||||
|
import { mkdtemp, readFile, rm } from 'fs/promises';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
import { resolveGeminiRuntimeAuth } from './geminiRuntimeAuth';
|
||||||
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
|
import { buildProviderAwareCliEnv } from './providerAwareCliEnv';
|
||||||
|
|
@ -843,17 +846,26 @@ export class ClaudeMultimodelBridgeService {
|
||||||
params.teamId,
|
params.teamId,
|
||||||
'--member',
|
'--member',
|
||||||
params.memberName,
|
params.memberName,
|
||||||
|
'--projection-only',
|
||||||
];
|
];
|
||||||
if (typeof params.limit === 'number') {
|
if (typeof params.limit === 'number') {
|
||||||
args.push('--limit', String(params.limit));
|
args.push('--limit', String(params.limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stdout } = await execCli(binaryPath, args, {
|
const outputDir = await mkdtemp(path.join(tmpdir(), 'opencode-transcript-'));
|
||||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
const outputPath = path.join(outputDir, 'transcript.json');
|
||||||
env,
|
try {
|
||||||
});
|
await execCli(binaryPath, [...args, '--output', outputPath], {
|
||||||
const parsed = extractJsonObject<OpenCodeRuntimeTranscriptResponse>(stdout);
|
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||||
return parsed.providerId === 'opencode' ? (parsed.transcript ?? null) : null;
|
env,
|
||||||
|
});
|
||||||
|
const parsed = extractJsonObject<OpenCodeRuntimeTranscriptResponse>(
|
||||||
|
await readFile(outputPath, 'utf8')
|
||||||
|
);
|
||||||
|
return parsed.providerId === 'opencode' ? (parsed.transcript ?? null) : null;
|
||||||
|
} finally {
|
||||||
|
await rm(outputDir, { recursive: true, force: true }).catch(() => undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async verifyOpenCodeModel(
|
private async verifyOpenCodeModel(
|
||||||
|
|
|
||||||
|
|
@ -290,6 +290,7 @@ import type {
|
||||||
PersistedTeamLaunchSnapshot,
|
PersistedTeamLaunchSnapshot,
|
||||||
PersistedTeamLaunchSummary,
|
PersistedTeamLaunchSummary,
|
||||||
ProviderModelLaunchIdentity,
|
ProviderModelLaunchIdentity,
|
||||||
|
TaskRef,
|
||||||
TeamAgentRuntimeBackendType,
|
TeamAgentRuntimeBackendType,
|
||||||
TeamAgentRuntimeDiagnosticSeverity,
|
TeamAgentRuntimeDiagnosticSeverity,
|
||||||
TeamAgentRuntimeEntry,
|
TeamAgentRuntimeEntry,
|
||||||
|
|
@ -314,7 +315,6 @@ import type {
|
||||||
TeamProvisioningState,
|
TeamProvisioningState,
|
||||||
TeamRuntimeState,
|
TeamRuntimeState,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TaskRef,
|
|
||||||
ToolActivityEventPayload,
|
ToolActivityEventPayload,
|
||||||
ToolApprovalAutoResolved,
|
ToolApprovalAutoResolved,
|
||||||
ToolApprovalEvent,
|
ToolApprovalEvent,
|
||||||
|
|
@ -2035,6 +2035,12 @@ function normalizeTeamMemberProviderId(providerId: unknown): TeamProviderId | un
|
||||||
return normalizeOptionalTeamProviderId(providerId);
|
return normalizeOptionalTeamProviderId(providerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTeamProviderLike(providerId: unknown): TeamProviderId | undefined {
|
||||||
|
return normalizeOptionalTeamProviderId(
|
||||||
|
typeof providerId === 'string' ? providerId.trim().toLowerCase() : providerId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildEffectiveTeamMemberSpec(
|
function buildEffectiveTeamMemberSpec(
|
||||||
member: TeamMemberInput,
|
member: TeamMemberInput,
|
||||||
defaults: {
|
defaults: {
|
||||||
|
|
@ -4508,15 +4514,11 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
const configProvider = (configMember as { provider?: unknown } | undefined)?.provider;
|
const configProvider = (configMember as { provider?: unknown } | undefined)?.provider;
|
||||||
const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider;
|
const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider;
|
||||||
const normalizeProviderLike = (value: unknown) =>
|
|
||||||
normalizeOptionalTeamProviderId(
|
|
||||||
typeof value === 'string' ? value.trim().toLowerCase() : value
|
|
||||||
);
|
|
||||||
const providerId =
|
const providerId =
|
||||||
normalizeProviderLike(metaMember?.providerId) ??
|
normalizeTeamProviderLike(metaMember?.providerId) ??
|
||||||
normalizeProviderLike(metaProvider) ??
|
normalizeTeamProviderLike(metaProvider) ??
|
||||||
normalizeProviderLike(configMember?.providerId) ??
|
normalizeTeamProviderLike(configMember?.providerId) ??
|
||||||
normalizeProviderLike(configProvider) ??
|
normalizeTeamProviderLike(configProvider) ??
|
||||||
inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model);
|
inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model);
|
||||||
return providerId === 'opencode';
|
return providerId === 'opencode';
|
||||||
}
|
}
|
||||||
|
|
@ -4551,15 +4553,11 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
const configProvider = (configMember as { provider?: unknown } | undefined)?.provider;
|
const configProvider = (configMember as { provider?: unknown } | undefined)?.provider;
|
||||||
const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider;
|
const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider;
|
||||||
const normalizeProviderLike = (value: unknown) =>
|
|
||||||
normalizeOptionalTeamProviderId(
|
|
||||||
typeof value === 'string' ? value.trim().toLowerCase() : value
|
|
||||||
);
|
|
||||||
const providerId =
|
const providerId =
|
||||||
normalizeProviderLike(metaMember?.providerId) ??
|
normalizeTeamProviderLike(metaMember?.providerId) ??
|
||||||
normalizeProviderLike(metaProvider) ??
|
normalizeTeamProviderLike(metaProvider) ??
|
||||||
normalizeProviderLike(configMember?.providerId) ??
|
normalizeTeamProviderLike(configMember?.providerId) ??
|
||||||
normalizeProviderLike(configProvider) ??
|
normalizeTeamProviderLike(configProvider) ??
|
||||||
inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model);
|
inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model);
|
||||||
if (providerId !== 'opencode') {
|
if (providerId !== 'opencode') {
|
||||||
return { delivered: false, reason: 'recipient_is_not_opencode' };
|
return { delivered: false, reason: 'recipient_is_not_opencode' };
|
||||||
|
|
@ -4605,17 +4603,25 @@ export class TeamProvisioningService {
|
||||||
const trackedRunId = this.resolveDeliverableTrackedRuntimeRunId(teamName);
|
const trackedRunId = this.resolveDeliverableTrackedRuntimeRunId(teamName);
|
||||||
const trackedRun = trackedRunId ? this.runs.get(trackedRunId) : null;
|
const trackedRun = trackedRunId ? this.runs.get(trackedRunId) : null;
|
||||||
let liveSecondaryLaneRunId: string | null = null;
|
let liveSecondaryLaneRunId: string | null = null;
|
||||||
|
let trackedSecondaryLanePresent = false;
|
||||||
|
let trackedSecondaryLaneSnapshotKnown = false;
|
||||||
if (
|
if (
|
||||||
trackedRun &&
|
trackedRun &&
|
||||||
laneIdentity.laneKind === 'secondary' &&
|
laneIdentity.laneKind === 'secondary' &&
|
||||||
laneIdentity.laneOwnerProviderId === 'opencode'
|
laneIdentity.laneOwnerProviderId === 'opencode'
|
||||||
) {
|
) {
|
||||||
const liveLane = trackedRun.mixedSecondaryLanes.find(
|
const secondaryLanes = trackedRun.mixedSecondaryLanes;
|
||||||
|
trackedSecondaryLaneSnapshotKnown = secondaryLanes.length > 0;
|
||||||
|
const liveLane = secondaryLanes.find(
|
||||||
(lane) =>
|
(lane) =>
|
||||||
lane.laneId === laneIdentity.laneId ||
|
lane.laneId === laneIdentity.laneId ||
|
||||||
lane.member.name.trim().toLowerCase() === normalizedMemberName.toLowerCase()
|
lane.member.name.trim().toLowerCase() === normalizedMemberName.toLowerCase()
|
||||||
);
|
);
|
||||||
liveSecondaryLaneRunId = liveLane?.runId?.trim() || null;
|
trackedSecondaryLanePresent = liveLane != null;
|
||||||
|
liveSecondaryLaneRunId = liveLane ? trackedRunId : null;
|
||||||
|
if (!liveLane && trackedSecondaryLaneSnapshotKnown) {
|
||||||
|
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const runtimeRunId =
|
const runtimeRunId =
|
||||||
laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode'
|
laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode'
|
||||||
|
|
@ -4623,7 +4629,20 @@ export class TeamProvisioningService {
|
||||||
(await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId)))
|
(await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId)))
|
||||||
: (trackedRunId ??
|
: (trackedRunId ??
|
||||||
(await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId)));
|
(await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId)));
|
||||||
if (!runtimeRunId) {
|
let runtimeActive = Boolean(runtimeRunId);
|
||||||
|
if (!runtimeActive) {
|
||||||
|
if (
|
||||||
|
trackedRun &&
|
||||||
|
laneIdentity.laneKind === 'secondary' &&
|
||||||
|
laneIdentity.laneOwnerProviderId === 'opencode' &&
|
||||||
|
!trackedSecondaryLanePresent &&
|
||||||
|
trackedSecondaryLaneSnapshotKnown
|
||||||
|
) {
|
||||||
|
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
||||||
|
}
|
||||||
|
runtimeActive = await this.isOpenCodeRuntimeLaneIndexActive(teamName, laneIdentity.laneId);
|
||||||
|
}
|
||||||
|
if (!runtimeActive) {
|
||||||
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4840,6 +4859,16 @@ export class TeamProvisioningService {
|
||||||
return durableRunId || null;
|
return durableRunId || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async isOpenCodeRuntimeLaneIndexActive(
|
||||||
|
teamName: string,
|
||||||
|
laneId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
return laneIndex?.lanes[laneId]?.state === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveOpenCodeRuntimeLaneId(params: {
|
private async resolveOpenCodeRuntimeLaneId(params: {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
runId: string;
|
runId: string;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
import type { AgentActionMode, TaskRef } from '@shared/types/team';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
OpenCodeBridgeRuntimeSnapshot,
|
OpenCodeBridgeRuntimeSnapshot,
|
||||||
OpenCodeLaunchTeamCommandBody,
|
OpenCodeLaunchTeamCommandBody,
|
||||||
|
|
@ -26,6 +24,7 @@ import type {
|
||||||
TeamRuntimeStopInput,
|
TeamRuntimeStopInput,
|
||||||
TeamRuntimeStopResult,
|
TeamRuntimeStopResult,
|
||||||
} from './TeamRuntimeAdapter';
|
} from './TeamRuntimeAdapter';
|
||||||
|
import type { AgentActionMode, TaskRef } from '@shared/types/team';
|
||||||
|
|
||||||
export interface OpenCodeTeamRuntimeBridgePort {
|
export interface OpenCodeTeamRuntimeBridgePort {
|
||||||
checkOpenCodeTeamLaunchReadiness(input: {
|
checkOpenCodeTeamLaunchReadiness(input: {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction
|
||||||
import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection';
|
import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection';
|
||||||
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
|
|
||||||
|
import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames';
|
||||||
import { TeamTaskReader } from '../../TeamTaskReader';
|
import { TeamTaskReader } from '../../TeamTaskReader';
|
||||||
import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource';
|
import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource';
|
||||||
import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator';
|
import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator';
|
||||||
|
|
@ -11,7 +12,6 @@ import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictP
|
||||||
import { BoardTaskExactLogSummarySelector } from '../exact/BoardTaskExactLogSummarySelector';
|
import { BoardTaskExactLogSummarySelector } from '../exact/BoardTaskExactLogSummarySelector';
|
||||||
import { isBoardTaskExactLogsReadEnabled } from '../exact/featureGates';
|
import { isBoardTaskExactLogsReadEnabled } from '../exact/featureGates';
|
||||||
import { getBoardTaskExactLogFileVersions } from '../exact/fileVersions';
|
import { getBoardTaskExactLogFileVersions } from '../exact/fileVersions';
|
||||||
import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames';
|
|
||||||
|
|
||||||
import { OpenCodeTaskLogStreamSource } from './OpenCodeTaskLogStreamSource';
|
import { OpenCodeTaskLogStreamSource } from './OpenCodeTaskLogStreamSource';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,55 @@ function collectTaskMarkerCalls(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markerInputReferencesTaskInDifferentExplicitTeam(
|
||||||
|
input: unknown,
|
||||||
|
teamName: string,
|
||||||
|
taskRefs: Set<string>
|
||||||
|
): boolean {
|
||||||
|
if (taskRefs.size === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedTeamName = normalizeTaskRef(teamName);
|
||||||
|
const explicitTeamRefs = collectExplicitRefsForKeys(input, TEAM_REFERENCE_KEYS);
|
||||||
|
if (
|
||||||
|
!normalizedTeamName ||
|
||||||
|
explicitTeamRefs.size === 0 ||
|
||||||
|
explicitTeamRefs.has(normalizedTeamName)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const explicitTaskRefs = collectExplicitRefsForKeys(input, TASK_REFERENCE_KEYS);
|
||||||
|
return explicitTaskRefs.size > 0
|
||||||
|
? refsIntersect(explicitTaskRefs, taskRefs)
|
||||||
|
: valueReferencesTask(input, taskRefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasForeignTeamTaskMarker(
|
||||||
|
projectedMessages: OpenCodeRuntimeTranscriptLogMessage[],
|
||||||
|
teamName: string,
|
||||||
|
task: TeamTask
|
||||||
|
): boolean {
|
||||||
|
const taskRefs = buildTaskRefSet(task);
|
||||||
|
if (taskRefs.size === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectedMessages
|
||||||
|
.map(toParsedMessage)
|
||||||
|
.filter((message): message is ParsedMessage => message !== null)
|
||||||
|
.some((message) =>
|
||||||
|
message.toolCalls.some((toolCall) => {
|
||||||
|
const toolName = canonicalizeAgentTeamsToolName(toolCall.name ?? '').toLowerCase();
|
||||||
|
return (
|
||||||
|
TASK_LOG_MARKER_TOOL_NAMES.has(toolName) &&
|
||||||
|
markerInputReferencesTaskInDifferentExplicitTeam(toolCall.input, teamName, taskRefs)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function isTerminalTaskMarkerCall(markerCall: TaskMarkerCall): boolean {
|
function isTerminalTaskMarkerCall(markerCall: TaskMarkerCall): boolean {
|
||||||
if (TERMINAL_TASK_MARKER_TOOL_NAMES.has(markerCall.toolName)) {
|
if (TERMINAL_TASK_MARKER_TOOL_NAMES.has(markerCall.toolName)) {
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -923,6 +972,9 @@ export class OpenCodeTaskLogStreamSource {
|
||||||
}
|
}
|
||||||
|
|
||||||
const markerProjection = buildTaskMarkerProjection(projectedMessages, teamName, task);
|
const markerProjection = buildTaskMarkerProjection(projectedMessages, teamName, task);
|
||||||
|
if (!markerProjection && hasForeignTeamTaskMarker(projectedMessages, teamName, task)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const timeWindows = markerProjection ? [] : buildTaskTimeWindows(task);
|
const timeWindows = markerProjection ? [] : buildTaskTimeWindows(task);
|
||||||
const projectionReason: HeuristicFallbackReason = markerProjection
|
const projectionReason: HeuristicFallbackReason = markerProjection
|
||||||
? 'task_tool_markers'
|
? 'task_tool_markers'
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ import {
|
||||||
hasMemberLaunchDiagnosticsError,
|
hasMemberLaunchDiagnosticsError,
|
||||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||||
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
|
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
|
||||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Ban,
|
Ban,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
* Backward compatibility:
|
* Backward compatibility:
|
||||||
* - legacy fenced blocks: ```info_for_agent ... ```
|
* - legacy fenced blocks: ```info_for_agent ... ```
|
||||||
* - legacy xml-like blocks: <agent-block> ... </agent-block>
|
* - legacy xml-like blocks: <agent-block> ... </agent-block>
|
||||||
|
* - OpenCode runtime-only delivery blocks
|
||||||
*/
|
*/
|
||||||
export const AGENT_BLOCK_TAG = 'info_for_agent';
|
export const AGENT_BLOCK_TAG = 'info_for_agent';
|
||||||
export const AGENT_BLOCK_OPEN = `<${AGENT_BLOCK_TAG}>`;
|
export const AGENT_BLOCK_OPEN = `<${AGENT_BLOCK_TAG}>`;
|
||||||
|
|
@ -22,7 +23,11 @@ export const AGENT_BLOCK_CLOSE = `</${AGENT_BLOCK_TAG}>`;
|
||||||
const CURRENT_AGENT_BLOCK_PATTERN = '\\n?<info_for_agent>\\n?[\\s\\S]*?\\n?<\\/info_for_agent>\\n?';
|
const CURRENT_AGENT_BLOCK_PATTERN = '\\n?<info_for_agent>\\n?[\\s\\S]*?\\n?<\\/info_for_agent>\\n?';
|
||||||
const LEGACY_FENCED_AGENT_BLOCK_PATTERN = '\\n?```' + AGENT_BLOCK_TAG + '\\n[\\s\\S]*?\\n```\\n?';
|
const LEGACY_FENCED_AGENT_BLOCK_PATTERN = '\\n?```' + AGENT_BLOCK_TAG + '\\n[\\s\\S]*?\\n```\\n?';
|
||||||
const LEGACY_XML_AGENT_BLOCK_PATTERN = '\\n?<agent-block>\\n?[\\s\\S]*?\\n?<\\/agent-block>\\n?';
|
const LEGACY_XML_AGENT_BLOCK_PATTERN = '\\n?<agent-block>\\n?[\\s\\S]*?\\n?<\\/agent-block>\\n?';
|
||||||
const AGENT_BLOCK_PATTERN = `(?:${CURRENT_AGENT_BLOCK_PATTERN}|${LEGACY_FENCED_AGENT_BLOCK_PATTERN}|${LEGACY_XML_AGENT_BLOCK_PATTERN})`;
|
const OPENCODE_RUNTIME_IDENTITY_BLOCK_PATTERN =
|
||||||
|
'\\n?<opencode_runtime_identity>\\n?[\\s\\S]*?\\n?<\\/opencode_runtime_identity>\\n?';
|
||||||
|
const OPENCODE_APP_MESSAGE_DELIVERY_BLOCK_PATTERN =
|
||||||
|
'\\n?<opencode_app_message_delivery>\\n?[\\s\\S]*?\\n?<\\/opencode_app_message_delivery>\\n?';
|
||||||
|
const AGENT_BLOCK_PATTERN = `(?:${CURRENT_AGENT_BLOCK_PATTERN}|${LEGACY_FENCED_AGENT_BLOCK_PATTERN}|${LEGACY_XML_AGENT_BLOCK_PATTERN}|${OPENCODE_RUNTIME_IDENTITY_BLOCK_PATTERN}|${OPENCODE_APP_MESSAGE_DELIVERY_BLOCK_PATTERN})`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new RegExp for matching agent blocks.
|
* Creates a new RegExp for matching agent blocks.
|
||||||
|
|
@ -63,6 +68,18 @@ export function unwrapAgentBlock(block: string): string {
|
||||||
return trimmed.slice(legacyXmlOpen.length, -legacyXmlClose.length).trim();
|
return trimmed.slice(legacyXmlOpen.length, -legacyXmlClose.length).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const opencodeRuntimeOpen = '<opencode_runtime_identity>';
|
||||||
|
const opencodeRuntimeClose = '</opencode_runtime_identity>';
|
||||||
|
if (trimmed.startsWith(opencodeRuntimeOpen) && trimmed.endsWith(opencodeRuntimeClose)) {
|
||||||
|
return trimmed.slice(opencodeRuntimeOpen.length, -opencodeRuntimeClose.length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const opencodeDeliveryOpen = '<opencode_app_message_delivery>';
|
||||||
|
const opencodeDeliveryClose = '</opencode_app_message_delivery>';
|
||||||
|
if (trimmed.startsWith(opencodeDeliveryOpen) && trimmed.endsWith(opencodeDeliveryClose)) {
|
||||||
|
return trimmed.slice(opencodeDeliveryOpen.length, -opencodeDeliveryClose.length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
2873
test/fixtures/team/opencode/relay-works-10-jack-projection-transcript.json
vendored
Normal file
2873
test/fixtures/team/opencode/relay-works-10-jack-projection-transcript.json
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1,5 +1,6 @@
|
||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import type { PathLike } from 'fs';
|
import type { PathLike } from 'fs';
|
||||||
|
import { readFile as readFileFixture, writeFile } from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
|
@ -810,15 +811,20 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads projected OpenCode transcript data through the runtime transcript command', async () => {
|
it('loads projected OpenCode transcript data through the runtime transcript command', async () => {
|
||||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
execCliMock.mockImplementation(async (_binaryPath, args) => {
|
||||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||||
|
|
||||||
if (
|
if (
|
||||||
normalizedArgs
|
normalizedArgs.startsWith(
|
||||||
=== 'runtime transcript --json --provider opencode --team team-a --member alice --limit 20'
|
'runtime transcript --json --provider opencode --team team-a --member alice --projection-only --limit 20 --output '
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
return Promise.resolve({
|
const outputIndex = Array.isArray(args) ? args.indexOf('--output') : -1;
|
||||||
stdout: JSON.stringify({
|
const outputPath =
|
||||||
|
outputIndex >= 0 && Array.isArray(args) ? String(args[outputIndex + 1] ?? '') : '';
|
||||||
|
await writeFile(
|
||||||
|
outputPath,
|
||||||
|
JSON.stringify({
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
transcript: {
|
transcript: {
|
||||||
|
|
@ -856,6 +862,10 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
return Promise.resolve({
|
||||||
|
stdout: '',
|
||||||
stderr: '',
|
stderr: '',
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
});
|
});
|
||||||
|
|
@ -896,6 +906,71 @@ describe('ClaudeMultimodelBridgeService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads a large real OpenCode projection fixture through output-file transcript delivery', async () => {
|
||||||
|
const fixturePath = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'test/fixtures/team/opencode/relay-works-10-jack-projection-transcript.json'
|
||||||
|
);
|
||||||
|
const fixtureRaw = await readFileFixture(fixturePath, 'utf8');
|
||||||
|
|
||||||
|
execCliMock.mockImplementation(async (_binaryPath, args) => {
|
||||||
|
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedArgs.startsWith(
|
||||||
|
'runtime transcript --json --provider opencode --team relay-works-10 --member jack --projection-only --limit 200 --output '
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const outputIndex = Array.isArray(args) ? args.indexOf('--output') : -1;
|
||||||
|
const outputPath =
|
||||||
|
outputIndex >= 0 && Array.isArray(args) ? String(args[outputIndex + 1] ?? '') : '';
|
||||||
|
await writeFile(outputPath, fixtureRaw, 'utf8');
|
||||||
|
return Promise.resolve({
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const { ClaudeMultimodelBridgeService } =
|
||||||
|
await import('@main/services/runtime/ClaudeMultimodelBridgeService');
|
||||||
|
const service = new ClaudeMultimodelBridgeService();
|
||||||
|
|
||||||
|
const transcript = await service.getOpenCodeTranscript('/mock/agent_teams_orchestrator', {
|
||||||
|
teamId: 'relay-works-10',
|
||||||
|
memberName: 'jack',
|
||||||
|
limit: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectedMessages = transcript?.logProjection?.messages ?? [];
|
||||||
|
const toolNames = projectedMessages.flatMap((message) =>
|
||||||
|
message.toolCalls.map((toolCall) => toolCall.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fixtureRaw.length).toBeGreaterThan(64_000);
|
||||||
|
expect(transcript?.sessionId).toBe('ses_23edf9243ffeSNYPWObDloBJyQ');
|
||||||
|
expect(transcript?.messageCount).toBe(65);
|
||||||
|
expect(transcript?.toolCallCount).toBe(36);
|
||||||
|
expect(transcript?.messages).toEqual([]);
|
||||||
|
expect(projectedMessages).toHaveLength(101);
|
||||||
|
expect(toolNames).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'agent-teams_runtime_bootstrap_checkin',
|
||||||
|
'agent-teams_member_briefing',
|
||||||
|
'agent-teams_message_send',
|
||||||
|
'agent-teams_task_start',
|
||||||
|
'agent-teams_task_add_comment',
|
||||||
|
'agent-teams_task_complete',
|
||||||
|
'bash',
|
||||||
|
'read',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(toolNames).not.toContain('SendMessage');
|
||||||
|
});
|
||||||
|
|
||||||
it('verifies OpenCode models through execution-grade runtime probes', async () => {
|
it('verifies OpenCode models through execution-grade runtime probes', async () => {
|
||||||
execCliMock.mockImplementation((_binaryPath, args) => {
|
execCliMock.mockImplementation((_binaryPath, args) => {
|
||||||
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
const normalizedArgs = Array.isArray(args) ? args.join(' ') : '';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,373 @@
|
||||||
|
// @vitest-environment node
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { OpenCodeTaskLogStreamSource } from '../../../../src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource';
|
||||||
|
import { BoardTaskExactLogChunkBuilder } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder';
|
||||||
|
|
||||||
|
import type { OpenCodeRuntimeTranscriptResponse } from '../../../../src/main/services/runtime/ClaudeMultimodelBridgeService';
|
||||||
|
import type { OpenCodeTaskLogAttributionRecord } from '../../../../src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore';
|
||||||
|
import type { ParsedMessage } from '../../../../src/main/types';
|
||||||
|
import type { BoardTaskLogStreamResponse, TeamTask } from '../../../../src/shared/types';
|
||||||
|
|
||||||
|
const FIXTURE_PATH = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'test/fixtures/team/opencode/relay-works-10-jack-projection-transcript.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
const RELAY_WORKS_10_TASK: TeamTask = {
|
||||||
|
id: '0b3a0624-5d66-4067-848e-5a74a1720c0d',
|
||||||
|
displayId: '0b3a0624',
|
||||||
|
subject: 'Define calculator arithmetic behavior',
|
||||||
|
owner: 'jack',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: '2026-04-24T20:29:03.133Z',
|
||||||
|
updatedAt: '2026-04-24T20:29:34.157Z',
|
||||||
|
workIntervals: [
|
||||||
|
{
|
||||||
|
startedAt: '2026-04-24T20:29:03.133Z',
|
||||||
|
completedAt: '2026-04-24T20:29:34.157Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadFixtureTranscript(): Promise<
|
||||||
|
NonNullable<OpenCodeRuntimeTranscriptResponse['transcript']>
|
||||||
|
> {
|
||||||
|
const raw = await readFile(FIXTURE_PATH, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as OpenCodeRuntimeTranscriptResponse;
|
||||||
|
if (parsed.providerId !== 'opencode' || !parsed.transcript) {
|
||||||
|
throw new Error('Invalid OpenCode transcript fixture');
|
||||||
|
}
|
||||||
|
return parsed.transcript;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenRawMessages(response: BoardTaskLogStreamResponse): ParsedMessage[] {
|
||||||
|
return response.segments.flatMap((segment) =>
|
||||||
|
segment.chunks.flatMap((chunk) => chunk.rawMessages)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeContent(message: ParsedMessage): string {
|
||||||
|
return typeof message.content === 'string' ? message.content : JSON.stringify(message.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeProjectedContent(
|
||||||
|
transcript: NonNullable<OpenCodeRuntimeTranscriptResponse['transcript']>
|
||||||
|
): string {
|
||||||
|
return JSON.stringify(transcript.logProjection?.messages ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerTaskIds(messages: ParsedMessage[], markerNames: Set<string>): Set<string> {
|
||||||
|
const taskIds = new Set<string>();
|
||||||
|
for (const message of messages) {
|
||||||
|
for (const toolCall of message.toolCalls) {
|
||||||
|
if (!markerNames.has(toolCall.name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const input = toolCall.input;
|
||||||
|
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
||||||
|
const taskId = (input as Record<string, unknown>).taskId;
|
||||||
|
if (typeof taskId === 'string') {
|
||||||
|
taskIds.add(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return taskIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSource(params: {
|
||||||
|
transcript: NonNullable<OpenCodeRuntimeTranscriptResponse['transcript']>;
|
||||||
|
activeTasks?: TeamTask[];
|
||||||
|
deletedTasks?: TeamTask[];
|
||||||
|
attributionRecords?: OpenCodeTaskLogAttributionRecord[];
|
||||||
|
}) {
|
||||||
|
const bridge = {
|
||||||
|
getOpenCodeTranscript: vi.fn(async () => params.transcript),
|
||||||
|
};
|
||||||
|
const taskReader = {
|
||||||
|
getTasks: vi.fn(async () => params.activeTasks ?? [RELAY_WORKS_10_TASK]),
|
||||||
|
getDeletedTasks: vi.fn(async () => params.deletedTasks ?? []),
|
||||||
|
};
|
||||||
|
const attributionStore = {
|
||||||
|
readTaskRecords: vi.fn(async () => params.attributionRecords ?? []),
|
||||||
|
};
|
||||||
|
const source = new OpenCodeTaskLogStreamSource(
|
||||||
|
bridge as never,
|
||||||
|
{ resolve: async () => '/tmp/agent_teams_orchestrator' },
|
||||||
|
taskReader as never,
|
||||||
|
new BoardTaskExactLogChunkBuilder(),
|
||||||
|
attributionStore
|
||||||
|
);
|
||||||
|
|
||||||
|
return { source, bridge, taskReader, attributionStore };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OpenCodeTaskLogStreamSource real OpenCode fixture e2e', () => {
|
||||||
|
it('builds a task log stream from real OpenCode MCP task markers without leaking unrelated tasks', async () => {
|
||||||
|
const transcript = await loadFixtureTranscript();
|
||||||
|
const { source, bridge } = createSource({ transcript });
|
||||||
|
|
||||||
|
const response = await source.getTaskLogStream('relay-works-10', RELAY_WORKS_10_TASK.id);
|
||||||
|
|
||||||
|
expect(response).not.toBeNull();
|
||||||
|
expect(response?.source).toBe('opencode_runtime_fallback');
|
||||||
|
expect(response?.runtimeProjection).toMatchObject({
|
||||||
|
provider: 'opencode',
|
||||||
|
mode: 'heuristic',
|
||||||
|
attributionRecordCount: 0,
|
||||||
|
fallbackReason: 'task_tool_markers',
|
||||||
|
});
|
||||||
|
expect(response?.runtimeProjection?.projectedMessageCount).toBeGreaterThanOrEqual(10);
|
||||||
|
expect(response?.runtimeProjection?.markerMatchCount).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(response?.runtimeProjection?.markerSpanCount).toBe(1);
|
||||||
|
expect(response?.participants).toEqual([
|
||||||
|
{
|
||||||
|
key: 'member:jack',
|
||||||
|
label: 'jack',
|
||||||
|
role: 'member',
|
||||||
|
isLead: false,
|
||||||
|
isSidechain: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(response?.segments).toHaveLength(1);
|
||||||
|
|
||||||
|
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([
|
||||||
|
'agent-teams_task_start',
|
||||||
|
'agent-teams_task_add_comment',
|
||||||
|
'agent-teams_task_complete',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(toolNames).not.toContain('SendMessage');
|
||||||
|
expect(serialized).toContain('Calculator behavior: digits 0-9 append to display');
|
||||||
|
expect(serialized).toContain('Noted');
|
||||||
|
expect(serialized).toContain('Confirmed');
|
||||||
|
expect(serialized).not.toContain('Keyboard handlers added');
|
||||||
|
expect(serialized).not.toContain('Logic smoke check');
|
||||||
|
expect(serialized).not.toContain('#00000000');
|
||||||
|
expect(
|
||||||
|
markerTaskIds(
|
||||||
|
rawMessages,
|
||||||
|
new Set([
|
||||||
|
'agent-teams_task_start',
|
||||||
|
'agent-teams_task_add_comment',
|
||||||
|
'agent-teams_task_complete',
|
||||||
|
])
|
||||||
|
)
|
||||||
|
).toEqual(new Set([RELAY_WORKS_10_TASK.id]));
|
||||||
|
expect(bridge.getOpenCodeTranscript).toHaveBeenCalledWith('/tmp/agent_teams_orchestrator', {
|
||||||
|
teamId: 'relay-works-10',
|
||||||
|
memberName: 'jack',
|
||||||
|
limit: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses real attribution UUID bounds before heuristic fallback', async () => {
|
||||||
|
const transcript = await loadFixtureTranscript();
|
||||||
|
const { source, bridge, attributionStore } = createSource({
|
||||||
|
transcript,
|
||||||
|
attributionRecords: [
|
||||||
|
{
|
||||||
|
taskId: RELAY_WORKS_10_TASK.id,
|
||||||
|
memberName: 'jack',
|
||||||
|
scope: 'member_session_window',
|
||||||
|
sessionId: 'ses_23edf9243ffeSNYPWObDloBJyQ',
|
||||||
|
startMessageUuid: 'msg_dc12eb246001iUrCHiLxsvZ3mN',
|
||||||
|
endMessageUuid: 'msg_dc12ed5ec001OIh5Bh9emN2Utj',
|
||||||
|
source: 'launch_runtime',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await source.getTaskLogStream('relay-works-10', RELAY_WORKS_10_TASK.id);
|
||||||
|
|
||||||
|
expect(response?.source).toBe('opencode_runtime_attribution');
|
||||||
|
expect(response?.runtimeProjection).toEqual({
|
||||||
|
provider: 'opencode',
|
||||||
|
mode: 'attribution',
|
||||||
|
attributionRecordCount: 1,
|
||||||
|
projectedMessageCount: 10,
|
||||||
|
});
|
||||||
|
expect(response?.defaultFilter).toBe('member:jack');
|
||||||
|
expect(response?.segments).toHaveLength(1);
|
||||||
|
|
||||||
|
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(rawMessages.map((message) => message.uuid)).toEqual([
|
||||||
|
'msg_dc12eb246001iUrCHiLxsvZ3mN',
|
||||||
|
'msg_dc12eb261001b8MzfjP5WZGwA1',
|
||||||
|
'msg_dc12eb261001b8MzfjP5WZGwA1::tool_results',
|
||||||
|
'msg_dc12ebe27001UFPOASv4SiAr51',
|
||||||
|
'msg_dc12ebe27001UFPOASv4SiAr51::tool_results',
|
||||||
|
'msg_dc12ec768001m7G1qMVTexxl2s',
|
||||||
|
'msg_dc12ec768001m7G1qMVTexxl2s::tool_results',
|
||||||
|
'msg_dc12ece54001bDAaT7Rt1m6OmN',
|
||||||
|
'msg_dc12ece54001bDAaT7Rt1m6OmN::tool_results',
|
||||||
|
'msg_dc12ed5ec001OIh5Bh9emN2Utj',
|
||||||
|
]);
|
||||||
|
expect(toolNames).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'agent-teams_task_start',
|
||||||
|
'agent-teams_task_add_comment',
|
||||||
|
'agent-teams_task_complete',
|
||||||
|
'agent-teams_message_send',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(serialized).toContain('Calculator behavior: digits 0-9 append to display');
|
||||||
|
expect(serialized).toContain('Задача #0b3a0624 завершена');
|
||||||
|
expect(serialized).not.toContain('Noted');
|
||||||
|
expect(serialized).not.toContain('Keyboard handlers added');
|
||||||
|
expect(attributionStore.readTaskRecords).toHaveBeenCalledWith(
|
||||||
|
'relay-works-10',
|
||||||
|
RELAY_WORKS_10_TASK.id
|
||||||
|
);
|
||||||
|
expect(bridge.getOpenCodeTranscript).toHaveBeenCalledWith('/tmp/agent_teams_orchestrator', {
|
||||||
|
teamId: 'relay-works-10',
|
||||||
|
memberName: 'jack',
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can recover a deleted task stream from real OpenCode markers', async () => {
|
||||||
|
const transcript = await loadFixtureTranscript();
|
||||||
|
const deletedTask = {
|
||||||
|
...RELAY_WORKS_10_TASK,
|
||||||
|
status: 'deleted',
|
||||||
|
} satisfies TeamTask;
|
||||||
|
const { source } = createSource({
|
||||||
|
transcript,
|
||||||
|
activeTasks: [],
|
||||||
|
deletedTasks: [deletedTask],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await source.getTaskLogStream('relay-works-10', deletedTask.id);
|
||||||
|
|
||||||
|
expect(response?.source).toBe('opencode_runtime_fallback');
|
||||||
|
expect(response?.participants[0]?.label).toBe('jack');
|
||||||
|
expect(response?.runtimeProjection?.fallbackReason).toBe('task_tool_markers');
|
||||||
|
expect(flattenRawMessages(response as BoardTaskLogStreamResponse).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak a real OpenCode task stream across explicit team boundaries', async () => {
|
||||||
|
const transcript = await loadFixtureTranscript();
|
||||||
|
const { source, bridge } = createSource({ transcript });
|
||||||
|
|
||||||
|
const response = await source.getTaskLogStream('other-team', RELAY_WORKS_10_TASK.id);
|
||||||
|
|
||||||
|
expect(response).toBeNull();
|
||||||
|
expect(bridge.getOpenCodeTranscript).toHaveBeenCalledWith('/tmp/agent_teams_orchestrator', {
|
||||||
|
teamId: 'other-team',
|
||||||
|
memberName: 'jack',
|
||||||
|
limit: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to real marker projection when stale attribution does not match the session', async () => {
|
||||||
|
const transcript = await loadFixtureTranscript();
|
||||||
|
const { source, bridge } = createSource({
|
||||||
|
transcript,
|
||||||
|
attributionRecords: [
|
||||||
|
{
|
||||||
|
taskId: RELAY_WORKS_10_TASK.id,
|
||||||
|
memberName: 'jack',
|
||||||
|
scope: 'task_session',
|
||||||
|
sessionId: 'stale-session-id',
|
||||||
|
startMessageUuid: 'msg_dc12eb246001iUrCHiLxsvZ3mN',
|
||||||
|
endMessageUuid: 'msg_dc12ed5ec001OIh5Bh9emN2Utj',
|
||||||
|
source: 'reconcile',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await source.getTaskLogStream('relay-works-10', RELAY_WORKS_10_TASK.id);
|
||||||
|
|
||||||
|
expect(response?.source).toBe('opencode_runtime_fallback');
|
||||||
|
expect(response?.runtimeProjection).toMatchObject({
|
||||||
|
provider: 'opencode',
|
||||||
|
mode: 'heuristic',
|
||||||
|
attributionRecordCount: 1,
|
||||||
|
fallbackReason: 'task_tool_markers',
|
||||||
|
});
|
||||||
|
expect(bridge.getOpenCodeTranscript).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'/tmp/agent_teams_orchestrator',
|
||||||
|
{
|
||||||
|
teamId: 'relay-works-10',
|
||||||
|
memberName: 'jack',
|
||||||
|
limit: 500,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(bridge.getOpenCodeTranscript).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'/tmp/agent_teams_orchestrator',
|
||||||
|
{
|
||||||
|
teamId: 'relay-works-10',
|
||||||
|
memberName: 'jack',
|
||||||
|
limit: 200,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('captures the OpenCode runtime identity and MCP messaging contract from the real fixture', async () => {
|
||||||
|
const transcript = await loadFixtureTranscript();
|
||||||
|
const serialized = serializeProjectedContent(transcript);
|
||||||
|
const toolNames = (transcript.logProjection?.messages ?? []).flatMap((message) =>
|
||||||
|
message.toolCalls.map((toolCall) => toolCall.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(serialized).toContain('<opencode_runtime_identity>');
|
||||||
|
expect(serialized).toContain('runtimeProvider');
|
||||||
|
expect(serialized).toContain('opencode');
|
||||||
|
expect(serialized).toContain('agent-teams_runtime_bootstrap_checkin');
|
||||||
|
expect(serialized).toContain('agent-teams_member_briefing');
|
||||||
|
expect(serialized).toContain('agent-teams_message_send');
|
||||||
|
expect(serialized).toContain('Do not use SendMessage');
|
||||||
|
expect(serialized).toContain('Do not use runtime_deliver_message for ordinary visible replies');
|
||||||
|
expect(toolNames).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'agent-teams_runtime_bootstrap_checkin',
|
||||||
|
'agent-teams_member_briefing',
|
||||||
|
'agent-teams_message_send',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(toolNames).not.toContain('SendMessage');
|
||||||
|
expect(toolNames).not.toContain('runtime_deliver_message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps real OpenCode projected tool results bounded and linked to assistant tool calls', async () => {
|
||||||
|
const transcript = await loadFixtureTranscript();
|
||||||
|
const projectedMessages = transcript.logProjection?.messages ?? [];
|
||||||
|
const assistantToolIds = new Set(
|
||||||
|
projectedMessages.flatMap((message) => message.toolCalls.map((toolCall) => toolCall.id))
|
||||||
|
);
|
||||||
|
const toolResultMessages = projectedMessages.filter((message) => message.toolResults.length > 0);
|
||||||
|
|
||||||
|
expect(projectedMessages).toHaveLength(101);
|
||||||
|
expect(toolResultMessages.length).toBeGreaterThan(20);
|
||||||
|
for (const message of toolResultMessages) {
|
||||||
|
expect(message.isMeta).toBe(true);
|
||||||
|
expect(message.sourceToolAssistantUUID).toBeTruthy();
|
||||||
|
for (const toolResult of message.toolResults) {
|
||||||
|
expect(assistantToolIds.has(toolResult.toolUseId)).toBe(true);
|
||||||
|
const serializedContent =
|
||||||
|
typeof toolResult.content === 'string'
|
||||||
|
? toolResult.content
|
||||||
|
: JSON.stringify(toolResult.content);
|
||||||
|
expect(serializedContent.length).toBeLessThanOrEqual(8_200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { readFile } from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { OpenCodeTaskLogStreamSource } from '../../../../../src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource';
|
||||||
|
import { BoardTaskExactLogChunkBuilder } from '../../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder';
|
||||||
|
import { TooltipProvider } from '../../../../../src/renderer/components/ui/tooltip';
|
||||||
|
|
||||||
|
import type { OpenCodeRuntimeTranscriptResponse } from '../../../../../src/main/services/runtime/ClaudeMultimodelBridgeService';
|
||||||
|
import type { BoardTaskLogStreamResponse, TeamTask } from '../../../../../src/shared/types';
|
||||||
|
|
||||||
|
const TEAM_NAME = 'relay-works-10';
|
||||||
|
const TASK_ID = '0b3a0624-5d66-4067-848e-5a74a1720c0d';
|
||||||
|
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>
|
||||||
|
>(),
|
||||||
|
onTeamChange: vi.fn<(callback: (event: unknown, data: unknown) => void) => () => void>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@renderer/api', () => ({
|
||||||
|
api: {
|
||||||
|
teams: {
|
||||||
|
getTaskLogStream: (...args: Parameters<typeof apiState.getTaskLogStream>) =>
|
||||||
|
apiState.getTaskLogStream(...args),
|
||||||
|
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
|
||||||
|
apiState.onTeamChange(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TaskLogStreamSection } from '@renderer/components/team/taskLogs/TaskLogStreamSection';
|
||||||
|
|
||||||
|
const RELAY_WORKS_10_TASK: TeamTask = {
|
||||||
|
id: TASK_ID,
|
||||||
|
displayId: '0b3a0624',
|
||||||
|
subject: 'Define calculator arithmetic behavior',
|
||||||
|
owner: 'jack',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: '2026-04-24T20:29:03.133Z',
|
||||||
|
updatedAt: '2026-04-24T20:29:34.157Z',
|
||||||
|
workIntervals: [
|
||||||
|
{
|
||||||
|
startedAt: '2026-04-24T20:29:03.133Z',
|
||||||
|
completedAt: '2026-04-24T20:29:34.157Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadFixtureTranscript(): Promise<
|
||||||
|
NonNullable<OpenCodeRuntimeTranscriptResponse['transcript']>
|
||||||
|
> {
|
||||||
|
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> {
|
||||||
|
const transcript = await loadFixtureTranscript();
|
||||||
|
const source = new OpenCodeTaskLogStreamSource(
|
||||||
|
{
|
||||||
|
getOpenCodeTranscript: vi.fn(async () => transcript),
|
||||||
|
} as never,
|
||||||
|
{ resolve: async () => '/tmp/agent_teams_orchestrator' },
|
||||||
|
{
|
||||||
|
getTasks: vi.fn(async () => [RELAY_WORKS_10_TASK]),
|
||||||
|
getDeletedTasks: vi.fn(async () => []),
|
||||||
|
} as never,
|
||||||
|
new BoardTaskExactLogChunkBuilder(),
|
||||||
|
{ readTaskRecords: vi.fn(async () => []) }
|
||||||
|
);
|
||||||
|
const stream = await source.getTaskLogStream(TEAM_NAME, TASK_ID);
|
||||||
|
if (!stream) {
|
||||||
|
throw new Error('Expected OpenCode fixture stream');
|
||||||
|
}
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushMicrotasks(): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TaskLogStreamSection OpenCode real fixture e2e', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
apiState.getTaskLogStream.mockReset();
|
||||||
|
apiState.onTeamChange.mockReset();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders real OpenCode task activity through the UI log stream', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
apiState.onTeamChange.mockImplementation(() => () => undefined);
|
||||||
|
apiState.getTaskLogStream.mockResolvedValueOnce(await buildFixtureStream());
|
||||||
|
|
||||||
|
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: TASK_ID,
|
||||||
|
liveEnabled: false,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = host.textContent ?? '';
|
||||||
|
expect(text).toContain('Task Log Stream');
|
||||||
|
expect(text).toContain('matched task tool markers');
|
||||||
|
expect(text).toContain('Agent');
|
||||||
|
expect(text).toContain('Calculator behavior');
|
||||||
|
expect(text).toContain('Задача #0b3a0624 завершена');
|
||||||
|
expect(text).not.toContain('Keyboard handlers added');
|
||||||
|
expect(text).not.toContain('Logic smoke check');
|
||||||
|
expect(text).not.toContain('#00000000');
|
||||||
|
expect(text).not.toContain('SendMessage');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -29,4 +29,22 @@ describe('agentBlocks', () => {
|
||||||
expect(unwrapAgentBlock('```info_for_agent\ninside\n```')).toBe('inside');
|
expect(unwrapAgentBlock('```info_for_agent\ninside\n```')).toBe('inside');
|
||||||
expect(unwrapAgentBlock('<agent-block>\ninside\n</agent-block>')).toBe('inside');
|
expect(unwrapAgentBlock('<agent-block>\ninside\n</agent-block>')).toBe('inside');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('strips OpenCode runtime-only delivery blocks from display text', () => {
|
||||||
|
const text = [
|
||||||
|
'<opencode_runtime_identity>',
|
||||||
|
'Do not use SendMessage.',
|
||||||
|
'</opencode_runtime_identity>',
|
||||||
|
'<opencode_app_message_delivery>',
|
||||||
|
'Use agent-teams_message_send.',
|
||||||
|
'</opencode_app_message_delivery>',
|
||||||
|
'Human-visible task text',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
expect(stripAgentBlocks(text)).toBe('Human-visible task text');
|
||||||
|
expect(extractAgentBlockContents(text)).toEqual([
|
||||||
|
'Do not use SendMessage.',
|
||||||
|
'Use agent-teams_message_send.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue