From e01e099c6cf457284321d1ecef94a94274ebba02 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 23 Apr 2026 19:24:02 +0300 Subject: [PATCH] fix(opencode): harden bridge delivery and bump runtime --- runtime.lock.json | 12 +- .../services/team/TeamProvisioningService.ts | 21 +- .../runtime/OpenCodeTeamRuntimeAdapter.ts | 68 ++- .../team/OpenCodeTeamRuntimeAdapter.test.ts | 22 +- .../TeamAgentLaunchMatrix.safe-e2e.test.ts | 493 +++++++++++++++++- ...eamProvisioningServiceLiveMessages.test.ts | 11 +- 6 files changed, 590 insertions(+), 37 deletions(-) diff --git a/runtime.lock.json b/runtime.lock.json index 36853e08..901e08f9 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.4", - "sourceRef": "v0.0.4", + "version": "0.0.5", + "sourceRef": "v0.0.5", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/claude_agent_teams_ui", "releaseTag": "v1.2.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.4.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.5.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.4.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.5.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.4.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.5.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.4.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.5.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1d0751a0..3477715d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4694,25 +4694,20 @@ export class TeamProvisioningService { return Array.isArray(innerContent) ? (innerContent as Record[]) : []; } - private hasCapturedVisibleMessageToUser(content: Record[]): boolean { + private hasCapturedVisibleSendMessage(content: Record[]): boolean { return content.some((part) => { if (!part || typeof part !== 'object') return false; if (part.type !== 'tool_use' || typeof part.name !== 'string') return false; - // Only native SendMessage(to="user") is guaranteed to be materialized as a - // visible outbound message by captureSendMessages(). - // Keep this intentionally narrower than captureSendMessages(): if another tool path - // later starts creating its own user-visible row, expand this helper in lockstep. if (part.name !== 'SendMessage') return false; const input = part.input; if (!input || typeof input !== 'object') return false; const inp = input as Record; - const target = ( - typeof inp.recipient === 'string' ? inp.recipient : typeof inp.to === 'string' ? inp.to : '' - ).trim(); + const target = (typeof inp.recipient === 'string' ? inp.recipient : '').trim(); + const text = (typeof inp.content === 'string' ? inp.content : '').trim(); - return target.toLowerCase() === 'user'; + return target.length > 0 && text.length > 0; }); } @@ -14461,7 +14456,7 @@ export class TeamProvisioningService { if (msg.type === 'assistant') { const content = this.extractStreamContentBlocks(msg); - const hasCapturedVisibleMessageToUser = this.hasCapturedVisibleMessageToUser(content); + const hasCapturedVisibleSendMessage = this.hasCapturedVisibleSendMessage(content); const textParts = content .filter((part) => part.type === 'text' && typeof part.text === 'string') @@ -14503,13 +14498,13 @@ export class TeamProvisioningService { }, capture.idleMs); } else if (run.provisioningComplete) { // Push each assistant text block as a separate live message (per-message pattern). - // When the same assistant message includes a user-visible message send, skip text — + // When the same assistant message includes SendMessage, skip narration because // captureSendMessages() handles the visible outbound message separately. if ( !run.silentUserDmForward && !run.suppressPostCompactReminderOutput && !run.suppressGeminiPostLaunchHydrationOutput && - !hasCapturedVisibleMessageToUser + !hasCapturedVisibleSendMessage ) { const cleanText = stripAgentBlocks(text).trim(); if (cleanText.length > 0) { @@ -14524,7 +14519,7 @@ export class TeamProvisioningService { } else { // Pre-ready: keep showing provisioning narration in the banner, but also mirror it // into the live cache so Messages/Activity can show the earliest assistant output. - if (!run.silentUserDmForward && !hasCapturedVisibleMessageToUser) { + if (!run.silentUserDmForward && !hasCapturedVisibleSendMessage) { const cleanText = stripAgentBlocks(text).trim(); if (cleanText.length > 0) { this.pushLiveLeadTextMessage( diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 9e9bb0c7..ac3d2f95 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -204,7 +204,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { members: input.expectedMembers.map((member) => ({ name: member.name, role: member.role?.trim() || member.workflow?.trim() || 'teammate', - prompt: buildMemberBootstrapPrompt(input, member.name), + prompt: buildMemberBootstrapPrompt(input, member), })), leadPrompt: input.prompt?.trim() ?? '', expectedCapabilitySnapshotId: runtimeSnapshot?.capabilitySnapshotId ?? null, @@ -335,7 +335,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { teamName: input.teamName, projectPath: input.cwd, memberName: input.memberName, - text: input.text, + text: buildOpenCodeRuntimeMessageText(input), messageId: input.messageId, agent: 'teammate', }); @@ -587,12 +587,66 @@ function extractCheckpointNames(data: OpenCodeLaunchTeamCommandData): Set line !== null) + .join('\n'); +} + +function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput): string { + const replyRecipient = extractRequestedReplyRecipient(input.text); + const replyLine = replyRecipient + ? `For this message, if you reply, call agent-teams_message_send with to="${replyRecipient}" and from="${input.memberName}".` + : `If you reply, call agent-teams_message_send with the requested recipient and from="${input.memberName}".`; + + return [ + '', + 'You are running in OpenCode, not Claude Code or Codex native.', + 'If the incoming message below mentions SendMessage, treat that as a UI abstraction for other runtimes. Do not import, require, create, or run a SendMessage script.', + 'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).', + `Use teamName="${input.teamName}". ${replyLine}`, + 'Pass your human-readable reply as text and a short summary as summary. Do not answer only with plain assistant text when the tool is available.', + input.messageId + ? `The inbound app messageId is "${input.messageId}"; keep it only as context unless a tool explicitly asks for provenance.` + : null, + '', + '', + input.text, + ] + .filter((line): line is string => line !== null) + .join('\n'); +} + +function extractRequestedReplyRecipient(text: string): string | null { + const replyRecipientMatch = /reply back to recipient "([^"]+)"/i.exec(text); + if (replyRecipientMatch?.[1]?.trim()) { + return replyRecipientMatch[1].trim(); } - return `Join team "${input.teamName}" as "${memberName}" and wait for app MCP task delivery.`; + const destinationMatch = /destination must be exactly to="([^"]+)"/i.exec(text); + if (destinationMatch?.[1]?.trim()) { + return destinationMatch[1].trim(); + } + return null; } function validateOpenCodeRuntimeMembers( diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index bbdfff17..8f334b95 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -152,7 +152,9 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }); it('maps ready bridge launch data to successful runtime evidence only with required checkpoints', async () => { - const launchOpenCodeTeam = vi.fn( + const launchOpenCodeTeam = vi.fn< + NonNullable + >( async () => ({ runId: 'run-1', @@ -208,8 +210,17 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect.objectContaining({ expectedCapabilitySnapshotId: 'cap-1', manifestHighWatermark: null, + members: [ + expect.objectContaining({ + name: 'alice', + prompt: expect.stringContaining('agent-teams_member_briefing'), + }), + ], }) ); + const launchArg = launchOpenCodeTeam.mock.calls[0]?.[0]; + expect(launchArg?.members[0]?.prompt).toContain('Do NOT create local team files'); + expect(launchArg?.members[0]?.prompt).not.toContain('Join team "team-a"'); }); it('does not mark the lane clean_success when ready bridge data omits an expected member', async () => { @@ -309,7 +320,9 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }); it('sends direct teammate messages through the OpenCode message bridge', async () => { - const sendOpenCodeTeamMessage = vi.fn(async () => ({ + const sendOpenCodeTeamMessage = vi.fn< + NonNullable + >(async () => ({ accepted: true, sessionId: 'oc-session-bob', memberName: 'bob', @@ -347,10 +360,13 @@ describe('OpenCodeTeamRuntimeAdapter', () => { teamName: 'team-a', projectPath: '/repo', memberName: 'bob', - text: 'hello bob', + text: expect.stringContaining('agent-teams_message_send'), messageId: 'msg-1', agent: 'teammate', }); + const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? ''; + expect(sentText).toContain('hello bob'); + expect(sentText).toContain('Do not import, require, create, or run a SendMessage script'); }); it('keeps missing bridge members pending while reconcile is still launching', async () => { diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index db3e4feb..9f673b57 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -391,6 +391,120 @@ describe('Team agent launch matrix safe e2e', () => { }); }); + it('recovers mixed Gemini failure and split OpenCode lane truth after service restart', async () => { + const teamName = 'mixed-persisted-gemini-failure-opencode-split-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath, includeGeminiPrimary: true }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName, { includeGeminiPrimary: true }); + await writeMixedTeamLaunchState({ + teamName, + members: { + alice: mixedMemberState({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + reviewer: mixedMemberState({ + providerId: 'gemini', + model: 'gemini-2.5-flash', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'gemini', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Gemini pane exited before bootstrap', + }), + bob: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + tom: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'runtime_pending_permission', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + pendingPermissionRequestIds: ['perm-tom'], + }), + }, + }); + + const restartedService = new TeamProvisioningService(); + const statuses = await restartedService.getMemberSpawnStatuses(teamName); + + expect(statuses.expectedMembers).toEqual(['alice', 'reviewer', 'bob', 'tom']); + expect(statuses.teamLaunchState).toBe('partial_failure'); + expect(statuses.summary).toMatchObject({ + confirmedCount: 2, + pendingCount: 1, + failedCount: 1, + }); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + expect(statuses.statuses.reviewer).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + hardFailureReason: 'Gemini pane exited before bootstrap', + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'runtime_pending_permission', + hardFailure: false, + pendingPermissionRequestIds: ['perm-tom'], + }); + + const runtimeSnapshot = await restartedService.getTeamAgentRuntimeSnapshot(teamName); + expect(runtimeSnapshot.members.reviewer).toMatchObject({ + providerId: 'gemini', + laneKind: 'primary', + alive: false, + runtimeModel: 'gemini-2.5-flash', + }); + expect(runtimeSnapshot.members.bob).toMatchObject({ + providerId: 'opencode', + laneKind: 'secondary', + runtimeModel: 'opencode/minimax-m2.5-free', + }); + expect(runtimeSnapshot.members.tom).toMatchObject({ + providerId: 'opencode', + laneKind: 'secondary', + runtimeModel: 'opencode/nemotron-3-super-free', + }); + }); + it('exposes shared OpenCode side-lane runtime memory in the team runtime snapshot', async () => { const teamName = 'mixed-opencode-runtime-memory-safe-e2e'; const sharedHostPid = 24_242; @@ -457,6 +571,121 @@ describe('Team agent launch matrix safe e2e', () => { expect(runtimeSnapshot.members.bob.providerBackendId).toBeUndefined(); }); + it('keeps OpenCode side-lane pid and memory visible after mixed failure recovery', async () => { + const teamName = 'mixed-gemini-failure-opencode-memory-safe-e2e'; + const sharedHostPid = 31_313; + const sharedRssBytes = 211.4 * 1024 * 1024; + await writeMixedTeamConfig({ teamName, projectPath, includeGeminiPrimary: true }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName, { includeGeminiPrimary: true }); + await writeMixedTeamLaunchState({ + teamName, + members: { + alice: mixedMemberState({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + reviewer: mixedMemberState({ + providerId: 'gemini', + model: 'gemini-2.5-flash', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'gemini', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Gemini pane exited before bootstrap', + }), + bob: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + tom: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'runtime_pending_permission', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + pendingPermissionRequestIds: ['perm-tom'], + }), + }, + }); + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentRuntimeMetadata = async () => + new Map([ + [ + 'bob', + { + alive: true, + metricsPid: sharedHostPid, + model: 'opencode/minimax-m2.5-free', + }, + ], + [ + 'tom', + { + alive: true, + metricsPid: sharedHostPid, + model: 'opencode/nemotron-3-super-free', + }, + ], + ]); + (svc as any).readProcessRssBytesByPid = async () => new Map([[sharedHostPid, sharedRssBytes]]); + + const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); + + expect(runtimeSnapshot.members.reviewer).toMatchObject({ + providerId: 'gemini', + laneKind: 'primary', + alive: false, + runtimeModel: 'gemini-2.5-flash', + }); + expect(runtimeSnapshot.members.bob).toMatchObject({ + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + alive: true, + restartable: false, + pid: sharedHostPid, + runtimeModel: 'opencode/minimax-m2.5-free', + rssBytes: sharedRssBytes, + }); + expect(runtimeSnapshot.members.tom).toMatchObject({ + providerId: 'opencode', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + alive: true, + restartable: false, + pid: sharedHostPid, + runtimeModel: 'opencode/nemotron-3-super-free', + rssBytes: sharedRssBytes, + }); + }); + it('infers OpenCode runtime provider from model after restart when provider metadata is missing', async () => { const teamName = 'mixed-opencode-model-inference-safe-e2e'; const sharedHostPid = 24_243; @@ -803,6 +1032,138 @@ describe('Team agent launch matrix safe e2e', () => { }); }); + it('keeps mixed launch pending while Codex primary is still joining and OpenCode lanes are ready', async () => { + const teamName = 'mixed-codex-starting-opencode-ready-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'confirmed', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, run); + run.memberSpawnStatuses.set('alice', { + status: 'starting', + launchState: 'starting', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + updatedAt: '2026-04-23T10:00:00.000Z', + }); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => + run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') + ); + await waitForCondition(() => run.memberSpawnStatuses.get('bob')?.launchState === 'confirmed_alive'); + await waitForCondition(() => run.memberSpawnStatuses.get('tom')?.launchState === 'confirmed_alive'); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.teamLaunchState).toBe('partial_pending'); + expect(statuses.summary).toMatchObject({ + confirmedCount: 2, + pendingCount: 1, + failedCount: 0, + }); + expect(statuses.statuses.alice).toMatchObject({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + }); + }); + + it('keeps mixed launch partial when Gemini primary fails and OpenCode lanes split ready and pending', async () => { + const teamName = 'mixed-gemini-failed-opencode-split-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath, includeGeminiPrimary: true }); + const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'permission', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + const reviewer = { + name: 'reviewer', + role: 'Reviewer', + providerId: 'gemini', + model: 'gemini-2.5-flash', + }; + run.expectedMembers = ['alice', 'reviewer']; + run.effectiveMembers = [...run.effectiveMembers, reviewer]; + run.allEffectiveMembers = [ + ...run.effectiveMembers, + ...run.allEffectiveMembers.filter((member: { providerId?: string }) => member.providerId === 'opencode'), + ]; + run.memberSpawnStatuses.set('reviewer', { + status: 'error', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Gemini pane exited before bootstrap', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + updatedAt: '2026-04-23T10:00:00.000Z', + }); + trackLiveRun(svc, run); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => + run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') + ); + await waitForCondition(() => run.memberSpawnStatuses.get('bob')?.launchState === 'confirmed_alive'); + await waitForCondition(() => run.memberSpawnStatuses.get('tom')?.launchState === 'runtime_pending_permission'); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.teamLaunchState).toBe('partial_failure'); + expect(statuses.summary).toMatchObject({ + confirmedCount: 2, + pendingCount: 1, + failedCount: 1, + }); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + expect(statuses.statuses.reviewer).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + hardFailureReason: 'Gemini pane exited before bootstrap', + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'runtime_pending_permission', + hardFailure: false, + pendingPermissionRequestIds: ['perm-tom'], + }); + }); + it('keeps Codex primary online when a mixed OpenCode secondary lane fails', async () => { const teamName = 'mixed-live-secondary-failure-safe-e2e'; await writeMixedTeamConfig({ teamName, projectPath }); @@ -847,6 +1208,113 @@ describe('Team agent launch matrix safe e2e', () => { }); }); + it('keeps OpenCode secondary lanes online when the primary Codex member failed to spawn', async () => { + const teamName = 'mixed-primary-failure-opencode-ready-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'confirmed', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, run); + run.memberSpawnStatuses.set('alice', { + status: 'error', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Codex native runtime unavailable', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + updatedAt: '2026-04-23T10:00:00.000Z', + }); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => + run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') + ); + await waitForCondition(() => run.memberSpawnStatuses.get('bob')?.launchState === 'confirmed_alive'); + await waitForCondition(() => run.memberSpawnStatuses.get('tom')?.launchState === 'confirmed_alive'); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.teamLaunchState).toBe('partial_failure'); + expect(statuses.summary).toMatchObject({ + confirmedCount: 2, + failedCount: 1, + }); + expect(statuses.statuses.alice).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + hardFailureReason: 'Codex native runtime unavailable', + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + + const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); + expect(runtimeSnapshot.members.bob).toMatchObject({ + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + runtimeModel: 'opencode/minimax-m2.5-free', + }); + expect(runtimeSnapshot.members.tom).toMatchObject({ + providerId: 'opencode', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + runtimeModel: 'opencode/nemotron-3-super-free', + }); + }); + + it('fails mixed OpenCode secondary lanes clearly when the runtime adapter is not registered', async () => { + const teamName = 'mixed-missing-opencode-adapter-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + const svc = new TeamProvisioningService(); + const run = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, run); + + const snapshot = await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + + expect(snapshot).toMatchObject({ + teamName, + teamLaunchState: 'partial_failure', + }); + expect(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([ + 'finished', + 'finished', + ]); + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.teamLaunchState).toBe('partial_failure'); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + hardFailureReason: 'opencode_runtime_adapter_missing', + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + hardFailureReason: 'opencode_runtime_adapter_missing', + }); + }); + it('restarts one mixed OpenCode secondary lane without touching other live teammates', async () => { const teamName = 'mixed-opencode-manual-restart-safe-e2e'; await writeMixedTeamConfig({ teamName, projectPath }); @@ -1921,6 +2389,7 @@ async function writeOpenCodeTeamConfig(input: { async function writeMixedTeamConfig(input: { teamName: string; projectPath: string; + includeGeminiPrimary?: boolean; }): Promise { const teamDir = path.join(getTeamsBasePath(), input.teamName); await fs.mkdir(teamDir, { recursive: true }); @@ -1948,6 +2417,16 @@ async function writeMixedTeamConfig(input: { providerBackendId: 'codex-native', model: 'gpt-5.4-mini', }, + ...(input.includeGeminiPrimary + ? [ + { + name: 'reviewer', + role: 'Reviewer', + providerId: 'gemini', + model: 'gemini-2.5-flash', + }, + ] + : []), { name: 'bob', role: 'Developer', @@ -2069,7 +2548,10 @@ async function writeTeamMeta(teamName: string, projectPath: string): Promise { +async function writeMembersMeta( + teamName: string, + options: { includeGeminiPrimary?: boolean } = {} +): Promise { const teamDir = path.join(getTeamsBasePath(), teamName); await fs.mkdir(teamDir, { recursive: true }); await fs.writeFile( @@ -2085,6 +2567,15 @@ async function writeMembersMeta(teamName: string): Promise { providerBackendId: 'codex-native', model: 'gpt-5.4-mini', }, + ...(options.includeGeminiPrimary + ? [ + { + name: 'reviewer', + providerId: 'gemini', + model: 'gemini-2.5-flash', + }, + ] + : []), { name: 'bob', providerId: 'opencode', diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts index b0914870..d9b870af 100644 --- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts +++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts @@ -404,7 +404,7 @@ describe('TeamProvisioningService pre-ready live messages', () => { expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1); }); - it('keeps assistant thought text when SendMessage targets a teammate', () => { + it('suppresses duplicate assistant thought text when SendMessage targets a teammate', () => { const service = new TeamProvisioningService(); seedConfig('my-team'); const run = attachRun(service, 'my-team', { provisioningComplete: true }); @@ -427,13 +427,10 @@ describe('TeamProvisioningService pre-ready live messages', () => { }); const live = service.getLiveLeadProcessMessages('my-team'); - expect(live).toHaveLength(2); - expect(live[0].to).toBeUndefined(); - expect(live[0].text).toBe('Forwarding the clarification request now.'); + expect(live).toHaveLength(1); + expect(live[0].to).toBe('team-lead'); + expect(live[0].text).toBe('Need clarification on #abcd1234'); expect(live[0].source).toBe('lead_process'); - expect(live[1].to).toBe('team-lead'); - expect(live[1].text).toBe('Need clarification on #abcd1234'); - expect(live[1].source).toBe('lead_process'); // Non-user recipient → delivered to inbox, not sentMessages expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1); expect(hoisted.appendSentMessage).not.toHaveBeenCalled();