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",
|
"version": "0.0.5",
|
||||||
"sourceRef": "v0.0.4",
|
"sourceRef": "v0.0.5",
|
||||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||||
"releaseTag": "v1.2.0",
|
"releaseTag": "v1.2.0",
|
||||||
"assets": {
|
"assets": {
|
||||||
"darwin-arm64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"darwin-x64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"linux-x64": {
|
"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",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"win32-x64": {
|
"win32-x64": {
|
||||||
"file": "agent-teams-runtime-win32-x64-v0.0.4.zip",
|
"file": "agent-teams-runtime-win32-x64-v0.0.5.zip",
|
||||||
"archiveKind": "zip",
|
"archiveKind": "zip",
|
||||||
"binaryName": "claude-multimodel.exe"
|
"binaryName": "claude-multimodel.exe"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4694,25 +4694,20 @@ export class TeamProvisioningService {
|
||||||
return Array.isArray(innerContent) ? (innerContent as Record<string, unknown>[]) : [];
|
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) => {
|
return content.some((part) => {
|
||||||
if (!part || typeof part !== 'object') return false;
|
if (!part || typeof part !== 'object') return false;
|
||||||
if (part.type !== 'tool_use' || typeof part.name !== 'string') 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;
|
if (part.name !== 'SendMessage') return false;
|
||||||
|
|
||||||
const input = part.input;
|
const input = part.input;
|
||||||
if (!input || typeof input !== 'object') return false;
|
if (!input || typeof input !== 'object') return false;
|
||||||
const inp = input as Record<string, unknown>;
|
const inp = input as Record<string, unknown>;
|
||||||
const target = (
|
const target = (typeof inp.recipient === 'string' ? inp.recipient : '').trim();
|
||||||
typeof inp.recipient === 'string' ? inp.recipient : typeof inp.to === 'string' ? inp.to : ''
|
const text = (typeof inp.content === 'string' ? inp.content : '').trim();
|
||||||
).trim();
|
|
||||||
|
|
||||||
return target.toLowerCase() === 'user';
|
return target.length > 0 && text.length > 0;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -14461,7 +14456,7 @@ export class TeamProvisioningService {
|
||||||
if (msg.type === 'assistant') {
|
if (msg.type === 'assistant') {
|
||||||
const content = this.extractStreamContentBlocks(msg);
|
const content = this.extractStreamContentBlocks(msg);
|
||||||
|
|
||||||
const hasCapturedVisibleMessageToUser = this.hasCapturedVisibleMessageToUser(content);
|
const hasCapturedVisibleSendMessage = this.hasCapturedVisibleSendMessage(content);
|
||||||
|
|
||||||
const textParts = content
|
const textParts = content
|
||||||
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
||||||
|
|
@ -14503,13 +14498,13 @@ export class TeamProvisioningService {
|
||||||
}, capture.idleMs);
|
}, capture.idleMs);
|
||||||
} else if (run.provisioningComplete) {
|
} else if (run.provisioningComplete) {
|
||||||
// Push each assistant text block as a separate live message (per-message pattern).
|
// 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.
|
// captureSendMessages() handles the visible outbound message separately.
|
||||||
if (
|
if (
|
||||||
!run.silentUserDmForward &&
|
!run.silentUserDmForward &&
|
||||||
!run.suppressPostCompactReminderOutput &&
|
!run.suppressPostCompactReminderOutput &&
|
||||||
!run.suppressGeminiPostLaunchHydrationOutput &&
|
!run.suppressGeminiPostLaunchHydrationOutput &&
|
||||||
!hasCapturedVisibleMessageToUser
|
!hasCapturedVisibleSendMessage
|
||||||
) {
|
) {
|
||||||
const cleanText = stripAgentBlocks(text).trim();
|
const cleanText = stripAgentBlocks(text).trim();
|
||||||
if (cleanText.length > 0) {
|
if (cleanText.length > 0) {
|
||||||
|
|
@ -14524,7 +14519,7 @@ export class TeamProvisioningService {
|
||||||
} else {
|
} else {
|
||||||
// Pre-ready: keep showing provisioning narration in the banner, but also mirror it
|
// 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.
|
// 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();
|
const cleanText = stripAgentBlocks(text).trim();
|
||||||
if (cleanText.length > 0) {
|
if (cleanText.length > 0) {
|
||||||
this.pushLiveLeadTextMessage(
|
this.pushLiveLeadTextMessage(
|
||||||
|
|
|
||||||
|
|
@ -204,7 +204,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||||
members: input.expectedMembers.map((member) => ({
|
members: input.expectedMembers.map((member) => ({
|
||||||
name: member.name,
|
name: member.name,
|
||||||
role: member.role?.trim() || member.workflow?.trim() || 'teammate',
|
role: member.role?.trim() || member.workflow?.trim() || 'teammate',
|
||||||
prompt: buildMemberBootstrapPrompt(input, member.name),
|
prompt: buildMemberBootstrapPrompt(input, member),
|
||||||
})),
|
})),
|
||||||
leadPrompt: input.prompt?.trim() ?? '',
|
leadPrompt: input.prompt?.trim() ?? '',
|
||||||
expectedCapabilitySnapshotId: runtimeSnapshot?.capabilitySnapshotId ?? null,
|
expectedCapabilitySnapshotId: runtimeSnapshot?.capabilitySnapshotId ?? null,
|
||||||
|
|
@ -335,7 +335,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
projectPath: input.cwd,
|
projectPath: input.cwd,
|
||||||
memberName: input.memberName,
|
memberName: input.memberName,
|
||||||
text: input.text,
|
text: buildOpenCodeRuntimeMessageText(input),
|
||||||
messageId: input.messageId,
|
messageId: input.messageId,
|
||||||
agent: 'teammate',
|
agent: 'teammate',
|
||||||
});
|
});
|
||||||
|
|
@ -587,12 +587,66 @@ function extractCheckpointNames(data: OpenCodeLaunchTeamCommandData): Set<string
|
||||||
return names;
|
return names;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMemberBootstrapPrompt(input: TeamRuntimeLaunchInput, memberName: string): string {
|
function buildMemberBootstrapPrompt(
|
||||||
const shared = input.prompt?.trim();
|
input: TeamRuntimeLaunchInput,
|
||||||
if (shared) {
|
member: TeamRuntimeLaunchInput['expectedMembers'][number]
|
||||||
return shared;
|
): 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(
|
function validateOpenCodeRuntimeMembers(
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,9 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps ready bridge launch data to successful runtime evidence only with required checkpoints', async () => {
|
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 () =>
|
async () =>
|
||||||
({
|
({
|
||||||
runId: 'run-1',
|
runId: 'run-1',
|
||||||
|
|
@ -208,8 +210,17 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
expectedCapabilitySnapshotId: 'cap-1',
|
expectedCapabilitySnapshotId: 'cap-1',
|
||||||
manifestHighWatermark: null,
|
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 () => {
|
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 () => {
|
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,
|
accepted: true,
|
||||||
sessionId: 'oc-session-bob',
|
sessionId: 'oc-session-bob',
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
|
|
@ -347,10 +360,13 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
teamName: 'team-a',
|
teamName: 'team-a',
|
||||||
projectPath: '/repo',
|
projectPath: '/repo',
|
||||||
memberName: 'bob',
|
memberName: 'bob',
|
||||||
text: 'hello bob',
|
text: expect.stringContaining('agent-teams_message_send'),
|
||||||
messageId: 'msg-1',
|
messageId: 'msg-1',
|
||||||
agent: 'teammate',
|
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 () => {
|
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 () => {
|
it('exposes shared OpenCode side-lane runtime memory in the team runtime snapshot', async () => {
|
||||||
const teamName = 'mixed-opencode-runtime-memory-safe-e2e';
|
const teamName = 'mixed-opencode-runtime-memory-safe-e2e';
|
||||||
const sharedHostPid = 24_242;
|
const sharedHostPid = 24_242;
|
||||||
|
|
@ -457,6 +571,121 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
expect(runtimeSnapshot.members.bob.providerBackendId).toBeUndefined();
|
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 () => {
|
it('infers OpenCode runtime provider from model after restart when provider metadata is missing', async () => {
|
||||||
const teamName = 'mixed-opencode-model-inference-safe-e2e';
|
const teamName = 'mixed-opencode-model-inference-safe-e2e';
|
||||||
const sharedHostPid = 24_243;
|
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 () => {
|
it('keeps Codex primary online when a mixed OpenCode secondary lane fails', async () => {
|
||||||
const teamName = 'mixed-live-secondary-failure-safe-e2e';
|
const teamName = 'mixed-live-secondary-failure-safe-e2e';
|
||||||
await writeMixedTeamConfig({ teamName, projectPath });
|
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 () => {
|
it('restarts one mixed OpenCode secondary lane without touching other live teammates', async () => {
|
||||||
const teamName = 'mixed-opencode-manual-restart-safe-e2e';
|
const teamName = 'mixed-opencode-manual-restart-safe-e2e';
|
||||||
await writeMixedTeamConfig({ teamName, projectPath });
|
await writeMixedTeamConfig({ teamName, projectPath });
|
||||||
|
|
@ -1921,6 +2389,7 @@ async function writeOpenCodeTeamConfig(input: {
|
||||||
async function writeMixedTeamConfig(input: {
|
async function writeMixedTeamConfig(input: {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
|
includeGeminiPrimary?: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const teamDir = path.join(getTeamsBasePath(), input.teamName);
|
const teamDir = path.join(getTeamsBasePath(), input.teamName);
|
||||||
await fs.mkdir(teamDir, { recursive: true });
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
|
|
@ -1948,6 +2417,16 @@ async function writeMixedTeamConfig(input: {
|
||||||
providerBackendId: 'codex-native',
|
providerBackendId: 'codex-native',
|
||||||
model: 'gpt-5.4-mini',
|
model: 'gpt-5.4-mini',
|
||||||
},
|
},
|
||||||
|
...(input.includeGeminiPrimary
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'reviewer',
|
||||||
|
role: 'Reviewer',
|
||||||
|
providerId: 'gemini',
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
name: 'bob',
|
name: 'bob',
|
||||||
role: 'Developer',
|
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);
|
const teamDir = path.join(getTeamsBasePath(), teamName);
|
||||||
await fs.mkdir(teamDir, { recursive: true });
|
await fs.mkdir(teamDir, { recursive: true });
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
|
|
@ -2085,6 +2567,15 @@ async function writeMembersMeta(teamName: string): Promise<void> {
|
||||||
providerBackendId: 'codex-native',
|
providerBackendId: 'codex-native',
|
||||||
model: 'gpt-5.4-mini',
|
model: 'gpt-5.4-mini',
|
||||||
},
|
},
|
||||||
|
...(options.includeGeminiPrimary
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: 'reviewer',
|
||||||
|
providerId: 'gemini',
|
||||||
|
model: 'gemini-2.5-flash',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
name: 'bob',
|
name: 'bob',
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
|
|
|
||||||
|
|
@ -404,7 +404,7 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
||||||
expect(hoisted.appendSentMessage).toHaveBeenCalledTimes(1);
|
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();
|
const service = new TeamProvisioningService();
|
||||||
seedConfig('my-team');
|
seedConfig('my-team');
|
||||||
const run = attachRun(service, 'my-team', { provisioningComplete: true });
|
const run = attachRun(service, 'my-team', { provisioningComplete: true });
|
||||||
|
|
@ -427,13 +427,10 @@ describe('TeamProvisioningService pre-ready live messages', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const live = service.getLiveLeadProcessMessages('my-team');
|
const live = service.getLiveLeadProcessMessages('my-team');
|
||||||
expect(live).toHaveLength(2);
|
expect(live).toHaveLength(1);
|
||||||
expect(live[0].to).toBeUndefined();
|
expect(live[0].to).toBe('team-lead');
|
||||||
expect(live[0].text).toBe('Forwarding the clarification request now.');
|
expect(live[0].text).toBe('Need clarification on #abcd1234');
|
||||||
expect(live[0].source).toBe('lead_process');
|
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
|
// Non-user recipient → delivered to inbox, not sentMessages
|
||||||
expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1);
|
expect(hoisted.sendInboxMessage).toHaveBeenCalledTimes(1);
|
||||||
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
|
expect(hoisted.appendSentMessage).not.toHaveBeenCalled();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue