fix(opencode): harden bridge delivery and bump runtime

This commit is contained in:
777genius 2026-04-23 19:24:02 +03:00
parent 9ebc4368d0
commit e01e099c6c
6 changed files with 590 additions and 37 deletions

View file

@ -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"
}

View file

@ -4694,25 +4694,20 @@ export class TeamProvisioningService {
return Array.isArray(innerContent) ? (innerContent as Record<string, unknown>[]) : [];
}
private hasCapturedVisibleMessageToUser(content: Record<string, unknown>[]): boolean {
private hasCapturedVisibleSendMessage(content: Record<string, unknown>[]): 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<string, unknown>;
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(

View file

@ -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<string
return names;
}
function buildMemberBootstrapPrompt(input: TeamRuntimeLaunchInput, memberName: string): string {
const shared = input.prompt?.trim();
if (shared) {
return shared;
function buildMemberBootstrapPrompt(
input: TeamRuntimeLaunchInput,
member: TeamRuntimeLaunchInput['expectedMembers'][number]
): string {
const teamPrompt = input.prompt?.trim();
const role = member.role?.trim() || member.workflow?.trim() || 'teammate';
const workflow = member.workflow?.trim();
return [
`You are ${member.name}, a ${role} on team "${input.teamName}".`,
teamPrompt ? `Team launch context:\n${teamPrompt}` : null,
workflow ? `Workflow:\n${workflow}` : null,
'',
'This OpenCode session is already attached by the desktop app. Do NOT create local team files, run join scripts, or search the project for a fake team registry.',
'Use the app MCP tools exposed by the "agent-teams" server for team communication and task state.',
'If available, your first app-team action is to call MCP tool agent-teams_member_briefing (or mcp__agent-teams__member_briefing if that is the exposed name) with:',
`{ "teamName": "${input.teamName}", "memberName": "${member.name}" }`,
'If that tool is not available, stay idle and wait for app-delivered instructions. Do not improvise a replacement workflow.',
'',
'When you need to message the human user, team lead, or another teammate, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send) with teamName, to, from, text, and optional summary.',
`Always set from="${member.name}" when sending a team message from this OpenCode teammate.`,
'Do not answer team/app messages only as plain assistant text when agent-teams_message_send is available.',
]
.filter((line): line is string => 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 [
'<opencode_app_message_delivery>',
'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,
'</opencode_app_message_delivery>',
'',
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(

View file

@ -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<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
>(
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<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']>
>(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 () => {

View file

@ -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<void> {
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<voi
);
}
async function writeMembersMeta(teamName: string): Promise<void> {
async function writeMembersMeta(
teamName: string,
options: { includeGeminiPrimary?: boolean } = {}
): Promise<void> {
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<void> {
providerBackendId: 'codex-native',
model: 'gpt-5.4-mini',
},
...(options.includeGeminiPrimary
? [
{
name: 'reviewer',
providerId: 'gemini',
model: 'gemini-2.5-flash',
},
]
: []),
{
name: 'bob',
providerId: 'opencode',

View file

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