fix(opencode): harden bridge delivery and bump runtime
This commit is contained in:
parent
9ebc4368d0
commit
e01e099c6c
6 changed files with 590 additions and 37 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue