2056 lines
70 KiB
TypeScript
2056 lines
70 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
|
import {
|
|
OpenCodeTeamRuntimeAdapter,
|
|
type OpenCodeTeamRuntimeBridgePort,
|
|
type TeamRuntimeLaunchInput,
|
|
} from '../../../../src/main/services/team/runtime';
|
|
|
|
import type { OpenCodeLaunchTeamCommandData } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
|
import type { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness';
|
|
import type { PersistedTeamLaunchSnapshot } from '../../../../src/shared/types';
|
|
|
|
describe('OpenCodeTeamRuntimeAdapter', () => {
|
|
it('maps readiness failures to a structured prepare block', async () => {
|
|
const bridge = bridgePort(
|
|
readiness({
|
|
state: 'mcp_unavailable',
|
|
launchAllowed: false,
|
|
missing: ['runtime_deliver_message'],
|
|
diagnostics: ['OpenCode missing canonical app MCP tool id'],
|
|
})
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
|
|
|
await expect(adapter.prepare(launchInput())).resolves.toEqual({
|
|
ok: false,
|
|
providerId: 'opencode',
|
|
reason: 'mcp_unavailable',
|
|
retryable: true,
|
|
diagnostics: ['OpenCode missing canonical app MCP tool id', 'runtime_deliver_message'],
|
|
warnings: [],
|
|
});
|
|
expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledWith({
|
|
projectPath: '/repo',
|
|
selectedModel: 'openai/gpt-5.4-mini',
|
|
requireExecutionProbe: true,
|
|
});
|
|
expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('uses runtime-only readiness for model-less preflight checks', async () => {
|
|
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true, modelId: null }));
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
|
|
|
await expect(
|
|
adapter.prepare(launchInput({ model: undefined, runtimeOnly: true }))
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
providerId: 'opencode',
|
|
modelId: null,
|
|
});
|
|
|
|
expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledWith({
|
|
projectPath: '/repo',
|
|
selectedModel: null,
|
|
requireExecutionProbe: false,
|
|
});
|
|
});
|
|
|
|
it('surfaces unknown readiness failures with the concrete bridge diagnostic on launch', async () => {
|
|
const bridge = bridgePort(
|
|
readiness({
|
|
state: 'unknown_error',
|
|
launchAllowed: false,
|
|
diagnostics: [
|
|
'OpenCode readiness bridge failed: timeout: OpenCode bridge command timed out',
|
|
],
|
|
missing: ['OpenCode bridge command timed out'],
|
|
})
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
|
|
|
await expect(adapter.launch(launchInput())).resolves.toMatchObject({
|
|
teamLaunchState: 'partial_failure',
|
|
members: {
|
|
alice: {
|
|
launchState: 'failed_to_start',
|
|
hardFailureReason:
|
|
'OpenCode readiness bridge failed: timeout: OpenCode bridge command timed out',
|
|
diagnostics: [
|
|
'OpenCode readiness bridge failed: timeout: OpenCode bridge command timed out',
|
|
'OpenCode bridge command timed out',
|
|
],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('still runs readiness when a legacy caller asks to skip OpenCode preflight', async () => {
|
|
const launchOpenCodeTeam = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
|
>(
|
|
async () =>
|
|
({
|
|
runId: 'run-1',
|
|
teamLaunchState: 'ready',
|
|
members: {
|
|
alice: {
|
|
sessionId: 'oc-session-1',
|
|
launchState: 'confirmed_alive',
|
|
runtimePid: 123,
|
|
model: 'openai/gpt-5.4-mini',
|
|
evidence: [
|
|
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
],
|
|
},
|
|
},
|
|
warnings: [],
|
|
diagnostics: [
|
|
{
|
|
code: 'opencode_launch_total_timing',
|
|
severity: 'info',
|
|
message: 'total=12ms provisioningProbe=3ms members=1',
|
|
},
|
|
{
|
|
code: 'member_reconcile',
|
|
severity: 'warning',
|
|
message: 'alice: sample reconcile diagnostic',
|
|
},
|
|
],
|
|
}) satisfies OpenCodeLaunchTeamCommandData
|
|
);
|
|
const bridge = bridgePort(
|
|
readiness({
|
|
state: 'ready',
|
|
launchAllowed: true,
|
|
diagnostics: ['readiness was required'],
|
|
}),
|
|
{
|
|
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot('cap-1')),
|
|
launchOpenCodeTeam,
|
|
}
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
|
|
|
const result = await adapter.launch(launchInput({ skipReadinessPreflight: true }));
|
|
|
|
expect(result.teamLaunchState).toBe('clean_success');
|
|
expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledTimes(1);
|
|
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
selectedModel: 'openai/gpt-5.4-mini',
|
|
skipPermissions: true,
|
|
expectedCapabilitySnapshotId: 'cap-1',
|
|
})
|
|
);
|
|
expect(result.diagnostics).toEqual(
|
|
expect.arrayContaining([
|
|
'info:opencode_launch_total_timing: total=12ms provisioningProbe=3ms members=1',
|
|
])
|
|
);
|
|
expect(result.members.alice?.diagnostics).not.toContain(
|
|
'info:opencode_launch_total_timing: total=12ms provisioningProbe=3ms members=1'
|
|
);
|
|
expect(result.members.alice?.diagnostics).toContain(
|
|
'warning:member_reconcile: alice: sample reconcile diagnostic'
|
|
);
|
|
});
|
|
|
|
it('launches isolated worktrees with the member worktree as the OpenCode project path', async () => {
|
|
const worktreePath = '/tmp/generated-worktrees/alice';
|
|
const launchOpenCodeTeam = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
|
>(async () => successfulOpenCodeLaunchData());
|
|
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot('cap-worktree')),
|
|
launchOpenCodeTeam,
|
|
});
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
|
|
|
const result = await adapter.launch(
|
|
launchInput({
|
|
cwd: worktreePath,
|
|
expectedMembers: [
|
|
{
|
|
name: 'alice',
|
|
providerId: 'opencode',
|
|
model: 'openai/gpt-5.4-mini',
|
|
cwd: worktreePath,
|
|
isolation: 'worktree',
|
|
},
|
|
],
|
|
})
|
|
);
|
|
|
|
expect(result.teamLaunchState).toBe('clean_success');
|
|
expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledWith({
|
|
projectPath: worktreePath,
|
|
selectedModel: 'openai/gpt-5.4-mini',
|
|
requireExecutionProbe: true,
|
|
});
|
|
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
projectPath: worktreePath,
|
|
expectedCapabilitySnapshotId: 'cap-worktree',
|
|
members: [expect.objectContaining({ name: 'alice' })],
|
|
})
|
|
);
|
|
});
|
|
|
|
it('retries transient MCP readiness transport failures before prepare succeeds', async () => {
|
|
const firstReadiness = readiness({
|
|
state: 'mcp_unavailable',
|
|
launchAllowed: false,
|
|
diagnostics: [
|
|
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?',
|
|
],
|
|
missing: ['runtime_deliver_message'],
|
|
});
|
|
const finalReadiness = readiness({
|
|
state: 'ready',
|
|
launchAllowed: true,
|
|
diagnostics: ['OpenCode readiness recovered'],
|
|
});
|
|
const checkReadiness = vi
|
|
.fn<OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']>()
|
|
.mockResolvedValueOnce(firstReadiness)
|
|
.mockResolvedValueOnce(finalReadiness);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter({
|
|
checkOpenCodeTeamLaunchReadiness: checkReadiness,
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
try {
|
|
const resultPromise = adapter.prepare(launchInput());
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(750);
|
|
|
|
await expect(resultPromise).resolves.toEqual({
|
|
ok: true,
|
|
providerId: 'opencode',
|
|
modelId: 'openai/gpt-5.4-mini',
|
|
diagnostics: ['OpenCode readiness recovered'],
|
|
warnings: [],
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
|
|
expect(checkReadiness).toHaveBeenCalledTimes(2);
|
|
expect(adapter.getLastOpenCodeTeamLaunchReadiness('/repo')).toBe(finalReadiness);
|
|
});
|
|
|
|
it('retries unknown readiness failures only when diagnostics show transport failure', async () => {
|
|
const checkReadiness = vi
|
|
.fn<OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']>()
|
|
.mockResolvedValueOnce(
|
|
readiness({
|
|
state: 'unknown_error',
|
|
launchAllowed: false,
|
|
diagnostics: ['OpenCode readiness bridge failed: fetch failed'],
|
|
})
|
|
)
|
|
.mockResolvedValueOnce(readiness({ state: 'ready', launchAllowed: true }));
|
|
const adapter = new OpenCodeTeamRuntimeAdapter({
|
|
checkOpenCodeTeamLaunchReadiness: checkReadiness,
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
try {
|
|
const resultPromise = adapter.prepare(launchInput());
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(750);
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({
|
|
ok: true,
|
|
providerId: 'opencode',
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
|
|
expect(checkReadiness).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('returns the final readiness failure after transient retries are exhausted', async () => {
|
|
const finalReadiness = readiness({
|
|
state: 'mcp_unavailable',
|
|
launchAllowed: false,
|
|
diagnostics: ['Final OpenCode /experimental/tool/ids unavailable - ECONNRESET'],
|
|
missing: ['final transport missing'],
|
|
});
|
|
const checkReadiness = vi
|
|
.fn<OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']>()
|
|
.mockResolvedValueOnce(
|
|
readiness({
|
|
state: 'mcp_unavailable',
|
|
launchAllowed: false,
|
|
diagnostics: ['First OpenCode /experimental/tool/ids unavailable - Unable to connect'],
|
|
})
|
|
)
|
|
.mockResolvedValueOnce(
|
|
readiness({
|
|
state: 'unknown_error',
|
|
launchAllowed: false,
|
|
diagnostics: ['Second OpenCode readiness bridge failed: socket hang up'],
|
|
})
|
|
)
|
|
.mockResolvedValueOnce(finalReadiness);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter({
|
|
checkOpenCodeTeamLaunchReadiness: checkReadiness,
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
try {
|
|
const resultPromise = adapter.prepare(launchInput());
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(750);
|
|
await vi.advanceTimersByTimeAsync(2_000);
|
|
|
|
await expect(resultPromise).resolves.toEqual({
|
|
ok: false,
|
|
providerId: 'opencode',
|
|
reason: 'mcp_unavailable',
|
|
retryable: true,
|
|
diagnostics: [
|
|
'Final OpenCode /experimental/tool/ids unavailable - ECONNRESET',
|
|
'final transport missing',
|
|
],
|
|
warnings: [],
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
|
|
expect(checkReadiness).toHaveBeenCalledTimes(3);
|
|
expect(adapter.getLastOpenCodeTeamLaunchReadiness('/repo')).toBe(finalReadiness);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
state: 'not_authenticated' as const,
|
|
diagnostics: ['OpenCode provider returned 401 unauthorized'],
|
|
},
|
|
{
|
|
state: 'not_installed' as const,
|
|
diagnostics: ['OpenCode runtime binary is not installed'],
|
|
},
|
|
{
|
|
state: 'model_unavailable' as const,
|
|
diagnostics: ['Selected model is unavailable'],
|
|
},
|
|
{
|
|
state: 'mcp_unavailable' as const,
|
|
diagnostics: ['OpenCode /experimental/tool/ids unavailable - HTTP 403 forbidden'],
|
|
},
|
|
{
|
|
state: 'mcp_unavailable' as const,
|
|
diagnostics: ['OpenCode /experimental/tool/ids unavailable - HTTP 404 Not Found'],
|
|
},
|
|
{
|
|
state: 'mcp_unavailable' as const,
|
|
diagnostics: [
|
|
'OpenCode /experimental/tool/ids unavailable - fetch failed',
|
|
'App MCP tool missing: runtime_deliver_message',
|
|
],
|
|
},
|
|
{
|
|
state: 'unknown_error' as const,
|
|
diagnostics: ['OpenCode bridge contract violation: schema mismatch'],
|
|
},
|
|
])('does not retry $state readiness failures', async ({ state, diagnostics }) => {
|
|
const checkReadiness = vi
|
|
.fn<OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']>()
|
|
.mockResolvedValue(readiness({ state, launchAllowed: false, diagnostics }));
|
|
const adapter = new OpenCodeTeamRuntimeAdapter({
|
|
checkOpenCodeTeamLaunchReadiness: checkReadiness,
|
|
});
|
|
|
|
await expect(adapter.prepare(launchInput())).resolves.toMatchObject({
|
|
ok: false,
|
|
reason: state,
|
|
});
|
|
expect(checkReadiness).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('launch retries readiness before bridge launch and uses the fresh runtime snapshot', async () => {
|
|
const checkReadiness = vi
|
|
.fn<OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']>()
|
|
.mockResolvedValueOnce(
|
|
readiness({
|
|
state: 'mcp_unavailable',
|
|
launchAllowed: false,
|
|
diagnostics: ['OpenCode /experimental/tool/ids unavailable - Unable to connect'],
|
|
})
|
|
)
|
|
.mockResolvedValueOnce(readiness({ state: 'ready', launchAllowed: true }));
|
|
const getLastOpenCodeRuntimeSnapshot = vi.fn(() => runtimeSnapshot('cap-fresh'));
|
|
const launchOpenCodeTeam = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
|
>(() => Promise.resolve(successfulOpenCodeLaunchData()));
|
|
const adapter = new OpenCodeTeamRuntimeAdapter({
|
|
checkOpenCodeTeamLaunchReadiness: checkReadiness,
|
|
getLastOpenCodeRuntimeSnapshot,
|
|
launchOpenCodeTeam,
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
try {
|
|
const resultPromise = adapter.launch(launchInput());
|
|
await Promise.resolve();
|
|
await vi.advanceTimersByTimeAsync(750);
|
|
|
|
await expect(resultPromise).resolves.toMatchObject({
|
|
teamLaunchState: 'clean_success',
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
|
|
expect(checkReadiness).toHaveBeenCalledTimes(2);
|
|
expect(getLastOpenCodeRuntimeSnapshot).toHaveBeenCalledWith('/repo');
|
|
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
expectedCapabilitySnapshotId: 'cap-fresh',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('passes manual tool approval intent with a fresh capability precondition', async () => {
|
|
const launchOpenCodeTeam = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
|
>(() => Promise.resolve(successfulOpenCodeLaunchData()));
|
|
const adapter = new OpenCodeTeamRuntimeAdapter({
|
|
checkOpenCodeTeamLaunchReadiness: vi.fn(async () =>
|
|
readiness({ state: 'ready', launchAllowed: true })
|
|
),
|
|
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot('cap-manual')),
|
|
launchOpenCodeTeam,
|
|
});
|
|
|
|
await expect(
|
|
adapter.launch(launchInput({ skipPermissions: false }))
|
|
).resolves.toMatchObject({
|
|
teamLaunchState: 'clean_success',
|
|
});
|
|
|
|
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
skipPermissions: false,
|
|
expectedCapabilitySnapshotId: 'cap-manual',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('launches model-less Default selections with the readiness-resolved model', async () => {
|
|
const launchOpenCodeTeam = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
|
>(async () => successfulOpenCodeLaunchData({ model: 'opencode/big-pickle' }));
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(
|
|
readiness({
|
|
state: 'ready',
|
|
launchAllowed: true,
|
|
modelId: 'opencode/big-pickle',
|
|
availableModels: ['opencode/big-pickle'],
|
|
}),
|
|
{ launchOpenCodeTeam }
|
|
)
|
|
);
|
|
|
|
const result = await adapter.launch(
|
|
launchInput({
|
|
model: undefined,
|
|
expectedMembers: [
|
|
{
|
|
name: 'alice',
|
|
providerId: 'opencode',
|
|
cwd: '/repo',
|
|
},
|
|
],
|
|
})
|
|
);
|
|
|
|
expect(result.teamLaunchState).toBe('clean_success');
|
|
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
selectedModel: 'opencode/big-pickle',
|
|
})
|
|
);
|
|
expect(result.members.alice?.model).toBe('opencode/big-pickle');
|
|
});
|
|
|
|
it('uses concrete member diagnostics as failed OpenCode hard failure reasons', async () => {
|
|
const concreteReason =
|
|
'Latest assistant message msg_123 failed with APIError - Insufficient credits. Add more using https://openrouter.ai/settings/credits';
|
|
const launchOpenCodeTeam = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
|
>(
|
|
async () =>
|
|
({
|
|
runId: 'run-1',
|
|
teamLaunchState: 'failed',
|
|
members: {
|
|
alice: {
|
|
sessionId: 'oc-session-1',
|
|
launchState: 'failed',
|
|
model: 'openai/gpt-5.4-mini',
|
|
diagnostics: ['OpenCode bridge reported member launch failure', concreteReason],
|
|
evidence: [],
|
|
},
|
|
},
|
|
warnings: [],
|
|
diagnostics: [],
|
|
}) satisfies OpenCodeLaunchTeamCommandData
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), { launchOpenCodeTeam })
|
|
);
|
|
|
|
const result = await adapter.launch(launchInput());
|
|
|
|
expect(result.members.alice).toMatchObject({
|
|
launchState: 'failed_to_start',
|
|
hardFailureReason: concreteReason,
|
|
});
|
|
});
|
|
|
|
it('falls back to bridge error diagnostics when member failure details are generic', async () => {
|
|
const bridgeError = 'Provider runtime returned a concrete launch error';
|
|
const launchOpenCodeTeam = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
|
>(
|
|
async () =>
|
|
({
|
|
runId: 'run-1',
|
|
teamLaunchState: 'failed',
|
|
members: {
|
|
alice: {
|
|
sessionId: 'oc-session-1',
|
|
launchState: 'failed',
|
|
model: 'openai/gpt-5.4-mini',
|
|
diagnostics: ['OpenCode bridge reported member launch failure'],
|
|
evidence: [],
|
|
},
|
|
},
|
|
warnings: [],
|
|
diagnostics: [{ code: 'provider_error', severity: 'error', message: bridgeError }],
|
|
}) satisfies OpenCodeLaunchTeamCommandData
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), { launchOpenCodeTeam })
|
|
);
|
|
|
|
const result = await adapter.launch(launchInput());
|
|
|
|
expect(result.members.alice?.hardFailureReason).toBe(bridgeError);
|
|
});
|
|
|
|
it('redacts secret-like values in selected OpenCode failure reasons', async () => {
|
|
const launchOpenCodeTeam = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
|
>(
|
|
async () =>
|
|
({
|
|
runId: 'run-1',
|
|
teamLaunchState: 'failed',
|
|
members: {
|
|
alice: {
|
|
sessionId: 'oc-session-1',
|
|
launchState: 'failed',
|
|
model: 'openai/gpt-5.4-mini',
|
|
diagnostics: [
|
|
'Provider failed with --api-key sk-openroutersecret000000000000 and Bearer abc.def.ghi',
|
|
],
|
|
evidence: [],
|
|
},
|
|
},
|
|
warnings: [],
|
|
diagnostics: [],
|
|
}) satisfies OpenCodeLaunchTeamCommandData
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), { launchOpenCodeTeam })
|
|
);
|
|
|
|
const result = await adapter.launch(launchInput());
|
|
|
|
expect(result.members.alice?.hardFailureReason).toBe(
|
|
'Provider failed with --api-key [redacted] and Bearer [redacted]'
|
|
);
|
|
});
|
|
|
|
it('rejects non-OpenCode members before readiness or launch bridge dispatch', async () => {
|
|
const launchOpenCodeTeam = vi.fn();
|
|
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
launchOpenCodeTeam,
|
|
});
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
|
|
|
const result = await adapter.launch(
|
|
launchInput({
|
|
expectedMembers: [
|
|
{
|
|
name: 'bob',
|
|
providerId: 'codex',
|
|
model: 'gpt-5.4-mini',
|
|
cwd: '/repo',
|
|
},
|
|
],
|
|
})
|
|
);
|
|
|
|
expect(result.teamLaunchState).toBe('partial_failure');
|
|
expect(result.members.bob).toMatchObject({
|
|
launchState: 'failed_to_start',
|
|
hardFailure: true,
|
|
hardFailureReason: 'opencode_invalid_expected_members',
|
|
diagnostics: [
|
|
'OpenCode runtime adapter received non-OpenCode member "bob" with provider "codex".',
|
|
],
|
|
});
|
|
expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled();
|
|
expect(launchOpenCodeTeam).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects empty OpenCode rosters before readiness or launch bridge dispatch', async () => {
|
|
const launchOpenCodeTeam = vi.fn();
|
|
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
launchOpenCodeTeam,
|
|
});
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
|
|
|
const result = await adapter.launch(launchInput({ expectedMembers: [] }));
|
|
|
|
expect(result.teamLaunchState).toBe('partial_failure');
|
|
expect(result.members).toEqual({});
|
|
expect(result.diagnostics).toEqual([
|
|
'OpenCode runtime adapter requires at least one expected OpenCode member.',
|
|
]);
|
|
expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled();
|
|
expect(launchOpenCodeTeam).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('maps ready bridge launch data to successful runtime evidence only with required checkpoints', async () => {
|
|
const launchOpenCodeTeam = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
|
>(
|
|
async () =>
|
|
({
|
|
runId: 'run-1',
|
|
teamLaunchState: 'ready',
|
|
members: {
|
|
alice: {
|
|
sessionId: 'oc-session-1',
|
|
launchState: 'confirmed_alive',
|
|
runtimePid: 123,
|
|
model: 'openai/gpt-5.4-mini',
|
|
evidence: [
|
|
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
],
|
|
},
|
|
},
|
|
warnings: [],
|
|
diagnostics: [],
|
|
}) satisfies OpenCodeLaunchTeamCommandData
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
getLastOpenCodeRuntimeSnapshot: vi.fn(() => ({
|
|
providerId: 'opencode' as const,
|
|
binaryPath: '/opt/homebrew/bin/opencode',
|
|
binaryFingerprint: 'version:1.14.19',
|
|
version: '1.14.19',
|
|
capabilitySnapshotId: 'cap-1',
|
|
})),
|
|
launchOpenCodeTeam,
|
|
})
|
|
);
|
|
|
|
await expect(adapter.launch(launchInput())).resolves.toMatchObject({
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
launchPhase: 'finished',
|
|
teamLaunchState: 'clean_success',
|
|
members: {
|
|
alice: {
|
|
providerId: 'opencode',
|
|
launchState: 'confirmed_alive',
|
|
sessionId: 'oc-session-1',
|
|
runtimePid: 123,
|
|
hardFailure: false,
|
|
},
|
|
},
|
|
});
|
|
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
expectedCapabilitySnapshotId: 'cap-1',
|
|
manifestHighWatermark: null,
|
|
members: [
|
|
expect.objectContaining({
|
|
name: 'alice',
|
|
prompt: expect.stringContaining('AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1'),
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
const launchArg = launchOpenCodeTeam.mock.calls[0]?.[0];
|
|
expect(launchArg?.members[0]?.prompt).toContain('Do NOT create local team files');
|
|
expect(launchArg?.members[0]?.prompt).toContain(
|
|
'That bootstrap restriction is only about team registry/startup files'
|
|
);
|
|
expect(launchArg?.members[0]?.prompt).toContain(
|
|
'you may inspect, read/search, and edit files in the project working directory as your available tools allow'
|
|
);
|
|
expect(launchArg?.members[0]?.prompt).toContain('Launch bootstrap is a silent attach');
|
|
expect(launchArg?.members[0]?.prompt).toContain('stay idle silently');
|
|
expect(launchArg?.members[0]?.prompt).not.toContain('agent-teams_member_briefing');
|
|
expect(launchArg?.members[0]?.prompt).not.toContain('Join team "team-a"');
|
|
});
|
|
|
|
it('refreshes readiness and retries once when the launch handshake sees a newer capability snapshot', async () => {
|
|
const { result, checkReadiness, launchOpenCodeTeam } =
|
|
await launchWithStaleCapabilitySnapshotRecovery('Bridge server capability snapshot mismatch');
|
|
|
|
expect(result.teamLaunchState).toBe('clean_success');
|
|
expect(result.warnings).toContain(
|
|
'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried launch.'
|
|
);
|
|
expect(checkReadiness).toHaveBeenCalledTimes(2);
|
|
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(2);
|
|
expect(launchOpenCodeTeam.mock.calls[0]?.[0].expectedCapabilitySnapshotId).toBe('cap-old');
|
|
expect(launchOpenCodeTeam.mock.calls[1]?.[0].expectedCapabilitySnapshotId).toBe('cap-new');
|
|
expect(launchOpenCodeTeam.mock.calls[0]?.[0].capabilitySnapshotRecoveryAttemptId).toBe(
|
|
undefined
|
|
);
|
|
expect(launchOpenCodeTeam.mock.calls[1]?.[0].capabilitySnapshotRecoveryAttemptId).toMatch(
|
|
/^opencode-capability-recovery-/
|
|
);
|
|
});
|
|
|
|
it('refreshes readiness and retries once when the launch command sees a newer capability snapshot', async () => {
|
|
const { result, checkReadiness, launchOpenCodeTeam } =
|
|
await launchWithStaleCapabilitySnapshotRecovery(
|
|
'OpenCode bridge capability snapshot precondition mismatch'
|
|
);
|
|
|
|
expect(result.teamLaunchState).toBe('clean_success');
|
|
expect(checkReadiness).toHaveBeenCalledTimes(2);
|
|
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(2);
|
|
expect(launchOpenCodeTeam.mock.calls[0]?.[0].expectedCapabilitySnapshotId).toBe('cap-old');
|
|
expect(launchOpenCodeTeam.mock.calls[1]?.[0].expectedCapabilitySnapshotId).toBe('cap-new');
|
|
expect(launchOpenCodeTeam.mock.calls[1]?.[0].capabilitySnapshotRecoveryAttemptId).toMatch(
|
|
/^opencode-capability-recovery-/
|
|
);
|
|
});
|
|
|
|
it('keeps refreshing bounded capability snapshot churn until launch observes the current snapshot', async () => {
|
|
let readinessCalls = 0;
|
|
const capabilitySnapshots = ['cap-1', 'cap-2', 'cap-3', 'cap-4'];
|
|
const checkReadiness = vi.fn<
|
|
OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']
|
|
>(() => {
|
|
readinessCalls += 1;
|
|
return Promise.resolve(readiness({ state: 'ready', launchAllowed: true }));
|
|
});
|
|
const launchOpenCodeTeam = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
|
>((input) =>
|
|
Promise.resolve(
|
|
input.expectedCapabilitySnapshotId === 'cap-3'
|
|
? successfulOpenCodeLaunchData()
|
|
: failedCapabilitySnapshotLaunchData('Bridge server capability snapshot mismatch')
|
|
)
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter({
|
|
checkOpenCodeTeamLaunchReadiness: checkReadiness,
|
|
getLastOpenCodeRuntimeSnapshot: vi.fn(
|
|
() => runtimeSnapshot(capabilitySnapshots[Math.max(0, Math.min(readinessCalls - 1, 3))] ?? 'cap-4')
|
|
),
|
|
launchOpenCodeTeam,
|
|
});
|
|
|
|
const result = await adapter.launch(launchInput());
|
|
|
|
expect(result.teamLaunchState).toBe('clean_success');
|
|
expect(result.warnings).toContain(
|
|
'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried launch.'
|
|
);
|
|
expect(checkReadiness).toHaveBeenCalledTimes(3);
|
|
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(3);
|
|
expect(launchOpenCodeTeam.mock.calls.map((call) => call[0].expectedCapabilitySnapshotId)).toEqual(
|
|
['cap-1', 'cap-2', 'cap-3']
|
|
);
|
|
expect(
|
|
launchOpenCodeTeam.mock.calls.slice(1).every((call) =>
|
|
/^opencode-capability-recovery-/.test(call[0].capabilitySnapshotRecoveryAttemptId ?? '')
|
|
)
|
|
).toBe(true);
|
|
});
|
|
|
|
it('uses a fresh recovery attempt id when capability refresh returns the same snapshot', async () => {
|
|
let readinessCalls = 0;
|
|
const checkReadiness = vi.fn<
|
|
OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']
|
|
>(() => {
|
|
readinessCalls += 1;
|
|
return Promise.resolve(readiness({ state: 'ready', launchAllowed: true }));
|
|
});
|
|
const launchOpenCodeTeam = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
|
>((input) =>
|
|
Promise.resolve(
|
|
input.capabilitySnapshotRecoveryAttemptId
|
|
? successfulOpenCodeLaunchData()
|
|
: failedCapabilitySnapshotLaunchData(
|
|
'OpenCode bridge capability snapshot precondition mismatch'
|
|
)
|
|
)
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter({
|
|
checkOpenCodeTeamLaunchReadiness: checkReadiness,
|
|
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot('cap-1')),
|
|
launchOpenCodeTeam,
|
|
});
|
|
|
|
const result = await adapter.launch(launchInput());
|
|
|
|
expect(result.teamLaunchState).toBe('clean_success');
|
|
expect(readinessCalls).toBe(2);
|
|
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(2);
|
|
expect(launchOpenCodeTeam.mock.calls[0]?.[0].expectedCapabilitySnapshotId).toBe('cap-1');
|
|
expect(launchOpenCodeTeam.mock.calls[1]?.[0].expectedCapabilitySnapshotId).toBe('cap-1');
|
|
expect(launchOpenCodeTeam.mock.calls[0]?.[0].capabilitySnapshotRecoveryAttemptId).toBe(
|
|
undefined
|
|
);
|
|
expect(launchOpenCodeTeam.mock.calls[1]?.[0].capabilitySnapshotRecoveryAttemptId).toMatch(
|
|
/^opencode-capability-recovery-/
|
|
);
|
|
});
|
|
|
|
it('retries pre-launch capability mismatch reported in member diagnostics', async () => {
|
|
const checkReadiness = vi.fn<
|
|
OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']
|
|
>(() => Promise.resolve(readiness({ state: 'ready', launchAllowed: true })));
|
|
const launchOpenCodeTeam = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
|
>((input) =>
|
|
Promise.resolve(
|
|
input.capabilitySnapshotRecoveryAttemptId
|
|
? successfulOpenCodeLaunchData()
|
|
: failedMemberCapabilitySnapshotLaunchData(
|
|
'OpenCode bridge capability snapshot precondition mismatch'
|
|
)
|
|
)
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter({
|
|
checkOpenCodeTeamLaunchReadiness: checkReadiness,
|
|
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot('cap-1')),
|
|
launchOpenCodeTeam,
|
|
});
|
|
|
|
const result = await adapter.launch(launchInput());
|
|
|
|
expect(result.teamLaunchState).toBe('clean_success');
|
|
expect(checkReadiness).toHaveBeenCalledTimes(2);
|
|
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(2);
|
|
expect(launchOpenCodeTeam.mock.calls[1]?.[0].capabilitySnapshotRecoveryAttemptId).toMatch(
|
|
/^opencode-capability-recovery-/
|
|
);
|
|
});
|
|
|
|
it('does not retry a successful launch just because stale diagnostics mention pre-launch mismatch', async () => {
|
|
const checkReadiness = vi.fn<
|
|
OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']
|
|
>(() => Promise.resolve(readiness({ state: 'ready', launchAllowed: true })));
|
|
const launchOpenCodeTeam = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
|
>(() =>
|
|
Promise.resolve({
|
|
...successfulOpenCodeLaunchData(),
|
|
diagnostics: [
|
|
{
|
|
code: 'stale_note',
|
|
severity: 'warning',
|
|
message: 'OpenCode bridge capability snapshot precondition mismatch',
|
|
},
|
|
],
|
|
})
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter({
|
|
checkOpenCodeTeamLaunchReadiness: checkReadiness,
|
|
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot('cap-1')),
|
|
launchOpenCodeTeam,
|
|
});
|
|
|
|
const result = await adapter.launch(launchInput());
|
|
|
|
expect(result.teamLaunchState).toBe('clean_success');
|
|
expect(checkReadiness).toHaveBeenCalledTimes(1);
|
|
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not retry post-result capability snapshot mismatch', async () => {
|
|
const { result, checkReadiness, launchOpenCodeTeam } =
|
|
await launchWithStaleCapabilitySnapshotRecovery(
|
|
'OpenCode bridge capability snapshot mismatch'
|
|
);
|
|
|
|
expect(result.teamLaunchState).toBe('partial_failure');
|
|
expect(checkReadiness).toHaveBeenCalledTimes(1);
|
|
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(1);
|
|
expect(result.diagnostics).toContain(
|
|
'error:opencode_bridge: OpenCode bridge failed: OpenCode bridge capability snapshot mismatch'
|
|
);
|
|
});
|
|
|
|
it('keeps the original precondition mismatch when the recovery retry also fails', async () => {
|
|
const checkReadiness = vi.fn<
|
|
OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']
|
|
>(() => Promise.resolve(readiness({ state: 'ready', launchAllowed: true })));
|
|
const launchOpenCodeTeam = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
|
>(() =>
|
|
Promise.resolve(
|
|
failedCapabilitySnapshotLaunchData(
|
|
'OpenCode bridge capability snapshot precondition mismatch'
|
|
)
|
|
)
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter({
|
|
checkOpenCodeTeamLaunchReadiness: checkReadiness,
|
|
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot('cap-1')),
|
|
launchOpenCodeTeam,
|
|
});
|
|
|
|
const result = await adapter.launch(launchInput());
|
|
|
|
expect(result.teamLaunchState).toBe('partial_failure');
|
|
expect(checkReadiness).toHaveBeenCalledTimes(4);
|
|
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(4);
|
|
expect(result.diagnostics).toContain(
|
|
'error:opencode_bridge: OpenCode bridge failed: OpenCode bridge capability snapshot precondition mismatch'
|
|
);
|
|
expect(result.diagnostics.join('\n')).not.toContain(
|
|
'OpenCode bridge command cannot be retried from status failed'
|
|
);
|
|
});
|
|
|
|
it('does not mark the lane clean_success when ready bridge data omits an expected member', async () => {
|
|
const launchOpenCodeTeam = vi.fn(
|
|
async () =>
|
|
({
|
|
runId: 'run-1',
|
|
teamLaunchState: 'ready',
|
|
members: {
|
|
alice: {
|
|
sessionId: 'oc-session-1',
|
|
launchState: 'confirmed_alive',
|
|
runtimePid: 123,
|
|
model: 'openai/gpt-5.4-mini',
|
|
evidence: [
|
|
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
],
|
|
},
|
|
},
|
|
warnings: [],
|
|
diagnostics: [],
|
|
durableCheckpoints: [
|
|
{ name: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ name: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ name: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
],
|
|
manifestHighWatermark: null,
|
|
runtimeStoreManifestHighWatermark: null,
|
|
}) satisfies OpenCodeLaunchTeamCommandData
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
launchOpenCodeTeam,
|
|
})
|
|
);
|
|
|
|
const result = await adapter.launch({
|
|
...launchInput(),
|
|
expectedMembers: [
|
|
...launchInput().expectedMembers,
|
|
{
|
|
name: 'bob',
|
|
providerId: 'opencode',
|
|
model: 'openai/gpt-5.4-mini',
|
|
cwd: '/repo',
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result.teamLaunchState).toBe('partial_pending');
|
|
expect(result.launchPhase).toBe('active');
|
|
expect(result.members.alice?.launchState).toBe('confirmed_alive');
|
|
expect(result.members.bob).toMatchObject({
|
|
launchState: 'runtime_pending_bootstrap',
|
|
runtimeAlive: false,
|
|
hardFailure: false,
|
|
});
|
|
expect(result.members.bob?.diagnostics).toContain(
|
|
'OpenCode bridge response did not include bob; keeping the member pending until lane state materializes.'
|
|
);
|
|
});
|
|
|
|
it('reconciles from existing persisted launch snapshot without treating OpenCode as truth', async () => {
|
|
const snapshot = launchSnapshot();
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'adapter_disabled', launchAllowed: false }))
|
|
);
|
|
|
|
await expect(
|
|
adapter.reconcile({
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
providerId: 'opencode',
|
|
expectedMembers: launchInput().expectedMembers,
|
|
previousLaunchState: snapshot,
|
|
reason: 'startup_recovery',
|
|
})
|
|
).resolves.toMatchObject({
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
launchPhase: 'active',
|
|
teamLaunchState: 'partial_pending',
|
|
members: {
|
|
alice: {
|
|
providerId: 'opencode',
|
|
launchState: 'runtime_pending_bootstrap',
|
|
runtimeAlive: false,
|
|
bootstrapConfirmed: false,
|
|
},
|
|
},
|
|
snapshot,
|
|
});
|
|
});
|
|
|
|
it('sends direct teammate messages through the OpenCode message bridge', async () => {
|
|
const sendOpenCodeTeamMessage = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']>
|
|
>(async () => ({
|
|
accepted: true,
|
|
sessionId: 'oc-session-bob',
|
|
memberName: 'bob',
|
|
runtimePid: 456,
|
|
runtimePromptMessageId: 'msg_prompt_1',
|
|
diagnostics: [],
|
|
}));
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
sendOpenCodeTeamMessage,
|
|
})
|
|
);
|
|
|
|
await expect(
|
|
adapter.sendMessageToMember({
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
laneId: 'secondary:opencode:bob',
|
|
memberName: 'bob',
|
|
cwd: '/repo',
|
|
text: 'hello bob',
|
|
messageId: 'msg-1',
|
|
replyRecipient: 'alice',
|
|
actionMode: 'delegate',
|
|
forceSessionRefreshReason: 'opencode_app_mcp_transport_changed:old->new',
|
|
taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }],
|
|
})
|
|
).resolves.toEqual({
|
|
ok: true,
|
|
providerId: 'opencode',
|
|
memberName: 'bob',
|
|
sessionId: 'oc-session-bob',
|
|
runtimePid: 456,
|
|
runtimePromptMessageId: 'msg_prompt_1',
|
|
diagnostics: [],
|
|
});
|
|
expect(sendOpenCodeTeamMessage).toHaveBeenCalledWith({
|
|
runId: 'run-1',
|
|
laneId: 'secondary:opencode:bob',
|
|
teamId: 'team-a',
|
|
teamName: 'team-a',
|
|
projectPath: '/repo',
|
|
memberName: 'bob',
|
|
text: expect.stringContaining('agent-teams_message_send'),
|
|
messageId: 'msg-1',
|
|
settlementMode: 'acceptance',
|
|
actionMode: 'delegate',
|
|
forceSessionRefreshReason: 'opencode_app_mcp_transport_changed:old->new',
|
|
taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }],
|
|
agent: 'teammate',
|
|
});
|
|
const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? '';
|
|
expect(sentText).toContain('hello bob');
|
|
expect(sentText).toContain('Use teamName="team-a", to="alice", from="bob", text, and summary.');
|
|
expect(sentText).toContain('Include source="runtime_delivery"');
|
|
expect(sentText).toContain('Include relayOfMessageId="msg-1"');
|
|
expect(sentText).toContain('Action mode for this message: delegate.');
|
|
expect(sentText).toContain('Action mode DELEGATE is orchestration-only');
|
|
expect(sentText).not.toContain('If this delivered message assigns implementation');
|
|
expect(sentText).toContain('You must not end this turn empty.');
|
|
expect(sentText).toContain('<opencode_delivery_context>');
|
|
expect(sentText).toContain('"kind":"opencode-delivery-context"');
|
|
expect(sentText).toContain('"inboundMessageId":"msg-1"');
|
|
expect(sentText).toContain('include taskRefs exactly as provided');
|
|
expect(sentText).not.toContain('The inbound app messageId is');
|
|
expect(sentText).toContain('Do not use SendMessage or runtime_deliver_message');
|
|
expect(sentText).toContain('never use #00000000');
|
|
});
|
|
|
|
it('uses observed settlement for member-work-sync nudges so turn-settled can drive reconcile', async () => {
|
|
const sendOpenCodeTeamMessage = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']>
|
|
>(async () => ({
|
|
accepted: true,
|
|
sessionId: 'oc-session-bob',
|
|
memberName: 'bob',
|
|
runtimePid: 456,
|
|
runtimePromptMessageId: 'msg_prompt_1',
|
|
diagnostics: [],
|
|
}));
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
sendOpenCodeTeamMessage,
|
|
})
|
|
);
|
|
|
|
await expect(
|
|
adapter.sendMessageToMember({
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
laneId: 'secondary:opencode:bob',
|
|
memberName: 'bob',
|
|
cwd: '/repo',
|
|
text: 'sync your current work state',
|
|
messageId: 'sync-1',
|
|
messageKind: 'member_work_sync_nudge',
|
|
taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }],
|
|
})
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
runtimePromptMessageId: 'msg_prompt_1',
|
|
});
|
|
|
|
expect(sendOpenCodeTeamMessage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
messageId: 'sync-1',
|
|
messageKind: 'member_work_sync_nudge',
|
|
settlementMode: 'observed',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('observes direct teammate messages by exact accepted runtime prompt id', async () => {
|
|
const observeOpenCodeTeamMessageDelivery = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['observeOpenCodeTeamMessageDelivery']>
|
|
>(async () => ({
|
|
observed: true,
|
|
sessionId: 'oc-session-bob',
|
|
memberName: 'bob',
|
|
runtimePid: 456,
|
|
runtimePromptMessageId: 'msg_prompt_1',
|
|
responseObservation: {
|
|
state: 'responded_plain_text',
|
|
deliveredUserMessageId: 'msg_prompt_1',
|
|
assistantMessageId: 'oc-assistant-1',
|
|
toolCallNames: [],
|
|
visibleMessageToolCallId: null,
|
|
visibleReplyMessageId: null,
|
|
visibleReplyCorrelation: null,
|
|
latestAssistantPreview: 'done',
|
|
reason: null,
|
|
},
|
|
diagnostics: [],
|
|
}));
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
observeOpenCodeTeamMessageDelivery,
|
|
})
|
|
);
|
|
|
|
await expect(
|
|
adapter.observeMessageDelivery({
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
laneId: 'secondary:opencode:bob',
|
|
memberName: 'bob',
|
|
cwd: '/repo',
|
|
text: 'hello bob',
|
|
messageId: 'msg-1',
|
|
sessionId: 'oc-session-bob',
|
|
runtimePromptMessageId: 'msg_prompt_1',
|
|
prePromptCursor: 'cursor-before',
|
|
})
|
|
).resolves.toMatchObject({
|
|
ok: true,
|
|
sessionId: 'oc-session-bob',
|
|
runtimePromptMessageId: 'msg_prompt_1',
|
|
responseObservation: {
|
|
deliveredUserMessageId: 'msg_prompt_1',
|
|
},
|
|
});
|
|
|
|
expect(observeOpenCodeTeamMessageDelivery).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
sessionId: 'oc-session-bob',
|
|
runtimePromptMessageId: 'msg_prompt_1',
|
|
prePromptCursor: 'cursor-before',
|
|
})
|
|
);
|
|
});
|
|
|
|
it('sends member work sync nudges with report-oriented response instructions', async () => {
|
|
const sendOpenCodeTeamMessage = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']>
|
|
>(async () => ({
|
|
accepted: true,
|
|
sessionId: 'oc-session-bob',
|
|
memberName: 'bob',
|
|
diagnostics: [],
|
|
}));
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
sendOpenCodeTeamMessage,
|
|
})
|
|
);
|
|
|
|
await adapter.sendMessageToMember({
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
laneId: 'secondary:opencode:bob',
|
|
memberName: 'bob',
|
|
cwd: '/repo',
|
|
text: 'Work sync check',
|
|
messageId: 'msg-work-sync',
|
|
replyRecipient: 'team-lead',
|
|
actionMode: 'do',
|
|
messageKind: 'member_work_sync_nudge',
|
|
controlUrl: 'http://127.0.0.1:43123',
|
|
taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }],
|
|
});
|
|
|
|
expect(sendOpenCodeTeamMessage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
messageKind: 'member_work_sync_nudge',
|
|
actionMode: 'do',
|
|
})
|
|
);
|
|
const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? '';
|
|
expect(sentText).toContain('"messageKind":"member_work_sync_nudge"');
|
|
expect(sentText).toContain('This delivered app message is a member-work-sync nudge.');
|
|
expect(sentText).toContain('agent-teams_member_work_sync_status');
|
|
expect(sentText).toContain('agent-teams_member_work_sync_report');
|
|
expect(sentText).toContain('mcp__agent-teams__member_work_sync_report');
|
|
expect(sentText).toContain('For agenda sync, only agent-teams_member_work_sync_report');
|
|
expect(sentText).not.toContain('Concrete task progress');
|
|
expect(sentText).toContain('If this delivered message assigns implementation');
|
|
expect(sentText).toContain(
|
|
'you may inspect, read/search, and edit files in the project working directory as your available tools allow'
|
|
);
|
|
expect(sentText).toContain('A status-only tool call is incomplete');
|
|
expect(sentText).toContain('teamName="team-a"');
|
|
expect(sentText).toContain('memberName="bob"');
|
|
expect(sentText).toContain('controlUrl="http://127.0.0.1:43123"');
|
|
expect(sentText).toContain('taskIds: "task-1"');
|
|
expect(sentText).toContain(
|
|
'Do not use provider names, runtime names, or team names as memberName'
|
|
);
|
|
expect(sentText).not.toContain('Include relayOfMessageId="msg-work-sync"');
|
|
expect(sentText).not.toContain('You must not end this turn empty.');
|
|
});
|
|
|
|
it('sends review pickup work sync nudges with review-oriented response instructions', async () => {
|
|
const sendOpenCodeTeamMessage = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']>
|
|
>(async () => ({
|
|
accepted: true,
|
|
sessionId: 'oc-session-bob',
|
|
memberName: 'bob',
|
|
diagnostics: [],
|
|
}));
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
sendOpenCodeTeamMessage,
|
|
})
|
|
);
|
|
|
|
await adapter.sendMessageToMember({
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
laneId: 'secondary:opencode:bob',
|
|
memberName: 'bob',
|
|
cwd: '/repo',
|
|
text: 'Review pickup required',
|
|
messageId: 'msg-review-pickup',
|
|
replyRecipient: 'team-lead',
|
|
actionMode: 'do',
|
|
messageKind: 'member_work_sync_nudge',
|
|
workSyncIntent: 'review_pickup',
|
|
workSyncReviewRequestEventIds: ['evt-review-request'],
|
|
taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }],
|
|
});
|
|
|
|
const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? '';
|
|
expect(sentText).toContain('"workSyncIntent":"review_pickup"');
|
|
expect(sentText).toContain('"workSyncReviewRequestEventIds":["evt-review-request"]');
|
|
expect(sentText).toContain('targeted member-work-sync review pickup nudge');
|
|
expect(sentText).toContain('review workflow tools');
|
|
expect(sentText).toContain('Review workflow tool usage');
|
|
expect(sentText).not.toContain('Concrete review progress');
|
|
expect(sentText).toContain('Do not mark the review complete from this prompt alone.');
|
|
expect(sentText).toContain('agent-teams_member_work_sync_report');
|
|
expect(sentText).toContain('A status-only tool call is incomplete');
|
|
expect(sentText).not.toContain('This delivered app message is a member-work-sync nudge.');
|
|
});
|
|
|
|
it('does not parse legacy native SendMessage wording to infer OpenCode reply recipient', async () => {
|
|
const sendOpenCodeTeamMessage = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']>
|
|
>(async () => ({
|
|
accepted: true,
|
|
sessionId: 'oc-session-bob',
|
|
memberName: 'bob',
|
|
diagnostics: [],
|
|
}));
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
sendOpenCodeTeamMessage,
|
|
})
|
|
);
|
|
|
|
await adapter.sendMessageToMember({
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
laneId: 'secondary:opencode:bob',
|
|
memberName: 'bob',
|
|
cwd: '/repo',
|
|
text: 'CRITICAL: The destination must be exactly to="alice". Please reply back to recipient "alice".',
|
|
messageId: 'msg-legacy-native',
|
|
});
|
|
|
|
const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? '';
|
|
expect(sentText).toContain('Use teamName="team-a", to="user", from="bob", text, and summary.');
|
|
expect(sentText).not.toContain(
|
|
'Use teamName="team-a", to="alice", from="bob", text, and summary.'
|
|
);
|
|
});
|
|
|
|
it('keeps missing bridge members pending while reconcile is still launching', async () => {
|
|
const reconcileOpenCodeTeam = vi.fn(
|
|
async () =>
|
|
({
|
|
runId: 'run-1',
|
|
teamLaunchState: 'launching',
|
|
members: {
|
|
alice: {
|
|
sessionId: 'oc-session-1',
|
|
launchState: 'confirmed_alive',
|
|
model: 'openai/gpt-5.4-mini',
|
|
evidence: [{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' }],
|
|
},
|
|
},
|
|
warnings: [],
|
|
diagnostics: [],
|
|
durableCheckpoints: [],
|
|
manifestHighWatermark: null,
|
|
runtimeStoreManifestHighWatermark: null,
|
|
}) satisfies OpenCodeLaunchTeamCommandData
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
reconcileOpenCodeTeam,
|
|
})
|
|
);
|
|
|
|
const result = await adapter.reconcile({
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
providerId: 'opencode',
|
|
expectedMembers: [
|
|
...launchInput().expectedMembers,
|
|
{
|
|
name: 'bob',
|
|
providerId: 'opencode',
|
|
model: 'openai/gpt-5.4-mini',
|
|
cwd: '/repo',
|
|
},
|
|
],
|
|
previousLaunchState: launchSnapshot(),
|
|
reason: 'startup_recovery',
|
|
});
|
|
|
|
expect(result.teamLaunchState).toBe('partial_pending');
|
|
expect(result.members.alice?.launchState).toBe('confirmed_alive');
|
|
expect(result.members.bob).toMatchObject({
|
|
providerId: 'opencode',
|
|
launchState: 'runtime_pending_bootstrap',
|
|
runtimeAlive: false,
|
|
agentToolAccepted: false,
|
|
bootstrapConfirmed: false,
|
|
hardFailure: false,
|
|
});
|
|
expect(result.members.bob?.diagnostics).toContain(
|
|
'OpenCode bridge response did not include bob; keeping the member pending until lane state materializes.'
|
|
);
|
|
});
|
|
|
|
it('acknowledges stop without mutating live OpenCode ownership in the adapter shell', async () => {
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'adapter_disabled', launchAllowed: false }))
|
|
);
|
|
|
|
await expect(
|
|
adapter.stop({
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
providerId: 'opencode',
|
|
reason: 'user_requested',
|
|
previousLaunchState: launchSnapshot(),
|
|
})
|
|
).resolves.toMatchObject({
|
|
stopped: true,
|
|
members: {
|
|
alice: {
|
|
providerId: 'opencode',
|
|
stopped: true,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('maps permission-blocked bridge members to runtime_pending_permission instead of bootstrap pending', async () => {
|
|
const launchOpenCodeTeam = vi.fn(
|
|
async () =>
|
|
({
|
|
runId: 'run-1',
|
|
teamLaunchState: 'permission_blocked',
|
|
members: {
|
|
alice: {
|
|
sessionId: 'oc-session-1',
|
|
launchState: 'permission_blocked',
|
|
pendingPermissionRequestIds: ['perm-1', 'perm-1', 'perm-2'],
|
|
pendingPermissions: [
|
|
{
|
|
requestId: 'perm-1',
|
|
sessionId: 'oc-session-1',
|
|
tool: 'bash',
|
|
title: 'Run git status',
|
|
kind: 'tool',
|
|
raw: {
|
|
requestID: 'perm-1',
|
|
sessionID: 'oc-session-1',
|
|
tool: 'bash',
|
|
title: 'Run git status',
|
|
kind: 'tool',
|
|
patterns: ['git status'],
|
|
},
|
|
},
|
|
],
|
|
diagnostics: ['waiting for permission approval'],
|
|
runtimePid: 123,
|
|
model: 'openai/gpt-5.4-mini',
|
|
evidence: [
|
|
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'permission_blocked', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
],
|
|
},
|
|
},
|
|
warnings: [],
|
|
diagnostics: [],
|
|
}) satisfies OpenCodeLaunchTeamCommandData
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
getLastOpenCodeRuntimeSnapshot: vi.fn(() => ({
|
|
providerId: 'opencode' as const,
|
|
binaryPath: '/opt/homebrew/bin/opencode',
|
|
binaryFingerprint: 'version:1.14.19',
|
|
version: '1.14.19',
|
|
capabilitySnapshotId: 'cap-1',
|
|
})),
|
|
launchOpenCodeTeam,
|
|
})
|
|
);
|
|
|
|
const result = await adapter.launch(launchInput());
|
|
|
|
expect(result).toMatchObject({
|
|
teamLaunchState: 'partial_pending',
|
|
members: {
|
|
alice: {
|
|
providerId: 'opencode',
|
|
launchState: 'runtime_pending_permission',
|
|
pendingPermissionRequestIds: ['perm-1', 'perm-2'],
|
|
pendingPermissions: [
|
|
{
|
|
providerId: 'opencode',
|
|
requestId: 'perm-1',
|
|
sessionId: 'oc-session-1',
|
|
tool: 'bash',
|
|
title: 'Run git status',
|
|
},
|
|
],
|
|
runtimeAlive: false,
|
|
agentToolAccepted: true,
|
|
livenessKind: 'permission_blocked',
|
|
bootstrapConfirmed: false,
|
|
hardFailure: false,
|
|
},
|
|
},
|
|
});
|
|
expect(result).toMatchObject({
|
|
members: {
|
|
alice: {
|
|
diagnostics: expect.arrayContaining(['waiting for permission approval']),
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('answers OpenCode runtime permissions through the bridge and remaps the lane state', async () => {
|
|
const answerOpenCodeRuntimePermission = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['answerOpenCodeRuntimePermission']>
|
|
>(async () => ({
|
|
runId: 'run-1',
|
|
teamLaunchState: 'ready',
|
|
members: {
|
|
alice: {
|
|
sessionId: 'oc-session-1',
|
|
launchState: 'confirmed_alive',
|
|
runtimePid: 123,
|
|
model: 'openai/gpt-5.4-mini',
|
|
evidence: [
|
|
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
],
|
|
},
|
|
},
|
|
warnings: [],
|
|
diagnostics: [],
|
|
}));
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
answerOpenCodeRuntimePermission,
|
|
})
|
|
);
|
|
|
|
const result = await adapter.answerRuntimePermission({
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
laneId: 'primary',
|
|
cwd: '/repo',
|
|
providerId: 'opencode',
|
|
memberName: 'alice',
|
|
requestId: 'perm-1',
|
|
decision: 'allow',
|
|
expectedMembers: launchInput().expectedMembers,
|
|
previousLaunchState: null,
|
|
});
|
|
|
|
expect(answerOpenCodeRuntimePermission).toHaveBeenCalledWith({
|
|
runId: 'run-1',
|
|
laneId: 'primary',
|
|
teamId: 'team-a',
|
|
teamName: 'team-a',
|
|
projectPath: '/repo',
|
|
memberName: 'alice',
|
|
requestId: 'perm-1',
|
|
decision: 'allow',
|
|
expectedCapabilitySnapshotId: null,
|
|
manifestHighWatermark: null,
|
|
});
|
|
expect(result.teamLaunchState).toBe('clean_success');
|
|
expect(result.members.alice?.launchState).toBe('confirmed_alive');
|
|
});
|
|
|
|
it('fails runtime permission answers when the OpenCode answer bridge is unavailable', async () => {
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }))
|
|
);
|
|
|
|
await expect(
|
|
adapter.answerRuntimePermission({
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
laneId: 'primary',
|
|
cwd: '/repo',
|
|
providerId: 'opencode',
|
|
memberName: 'alice',
|
|
requestId: 'perm-1',
|
|
decision: 'allow',
|
|
expectedMembers: launchInput().expectedMembers,
|
|
previousLaunchState: null,
|
|
})
|
|
).rejects.toThrow('OpenCode permission answer bridge is not registered.');
|
|
});
|
|
|
|
it('lists OpenCode runtime permissions through the bridge', async () => {
|
|
const listOpenCodeRuntimePermissions = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['listOpenCodeRuntimePermissions']>
|
|
>(async () => ({
|
|
permissions: [
|
|
{
|
|
requestId: 'perm-1',
|
|
sessionId: 'session-alice',
|
|
tool: 'bash',
|
|
title: 'Run git status',
|
|
kind: 'tool',
|
|
raw: { patterns: ['git status'] },
|
|
},
|
|
{
|
|
requestId: 'perm-1',
|
|
sessionId: 'session-alice',
|
|
tool: 'bash',
|
|
title: 'Duplicate',
|
|
kind: 'tool',
|
|
},
|
|
{
|
|
requestId: ' ',
|
|
sessionId: null,
|
|
tool: null,
|
|
title: null,
|
|
kind: null,
|
|
},
|
|
],
|
|
diagnostics: ['permission list recovered from bridge warning'],
|
|
}));
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
listOpenCodeRuntimePermissions,
|
|
})
|
|
);
|
|
|
|
await expect(
|
|
adapter.listRuntimePermissions({
|
|
teamName: 'team-a',
|
|
laneId: 'secondary:opencode:alice',
|
|
memberName: 'alice',
|
|
sessionId: 'session-alice',
|
|
cwd: '/repo',
|
|
})
|
|
).resolves.toEqual({
|
|
permissions: [
|
|
{
|
|
providerId: 'opencode',
|
|
requestId: 'perm-1',
|
|
sessionId: 'session-alice',
|
|
tool: 'bash',
|
|
title: 'Run git status',
|
|
kind: 'tool',
|
|
raw: { patterns: ['git status'] },
|
|
},
|
|
],
|
|
diagnostics: ['permission list recovered from bridge warning'],
|
|
});
|
|
expect(listOpenCodeRuntimePermissions).toHaveBeenCalledWith({
|
|
teamId: 'team-a',
|
|
teamName: 'team-a',
|
|
laneId: 'secondary:opencode:alice',
|
|
memberName: 'alice',
|
|
sessionId: 'session-alice',
|
|
projectPath: '/repo',
|
|
});
|
|
});
|
|
|
|
it('returns a diagnostic when the OpenCode runtime permission list bridge is unavailable', async () => {
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }))
|
|
);
|
|
|
|
await expect(
|
|
adapter.listRuntimePermissions({
|
|
teamName: 'team-a',
|
|
laneId: 'primary',
|
|
cwd: '/repo',
|
|
})
|
|
).resolves.toEqual({
|
|
permissions: [],
|
|
diagnostics: ['OpenCode runtime permission list bridge is not registered.'],
|
|
});
|
|
});
|
|
|
|
it('does not mark created bridge members without runtimePid as runtimeAlive', async () => {
|
|
const launchOpenCodeTeam = vi.fn(
|
|
async () =>
|
|
({
|
|
runId: 'run-1',
|
|
teamLaunchState: 'launching',
|
|
members: {
|
|
alice: {
|
|
sessionId: 'oc-session-1',
|
|
launchState: 'created',
|
|
model: 'openai/gpt-5.4-mini',
|
|
evidence: [],
|
|
},
|
|
},
|
|
warnings: [],
|
|
diagnostics: [],
|
|
}) satisfies OpenCodeLaunchTeamCommandData
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
launchOpenCodeTeam,
|
|
})
|
|
);
|
|
|
|
const result = await adapter.launch(launchInput());
|
|
|
|
expect(result.members.alice).toMatchObject({
|
|
launchState: 'runtime_pending_bootstrap',
|
|
agentToolAccepted: true,
|
|
runtimeAlive: false,
|
|
livenessKind: 'runtime_process_candidate',
|
|
runtimeDiagnostic: 'OpenCode session exists without verified runtime pid',
|
|
});
|
|
});
|
|
|
|
it('keeps created bridge runtimePid provisional until local process verification', async () => {
|
|
const launchOpenCodeTeam = vi.fn(
|
|
async () =>
|
|
({
|
|
runId: 'run-1',
|
|
teamLaunchState: 'launching',
|
|
members: {
|
|
alice: {
|
|
sessionId: 'oc-session-1',
|
|
launchState: 'created',
|
|
runtimePid: 123,
|
|
model: 'openai/gpt-5.4-mini',
|
|
evidence: [],
|
|
},
|
|
},
|
|
warnings: [],
|
|
diagnostics: [],
|
|
}) satisfies OpenCodeLaunchTeamCommandData
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
launchOpenCodeTeam,
|
|
})
|
|
);
|
|
|
|
const result = await adapter.launch(launchInput());
|
|
|
|
expect(result.members.alice).toMatchObject({
|
|
launchState: 'runtime_pending_bootstrap',
|
|
agentToolAccepted: true,
|
|
runtimeAlive: false,
|
|
livenessKind: 'runtime_process_candidate',
|
|
runtimePid: 123,
|
|
runtimeDiagnostic:
|
|
'OpenCode runtime pid reported by bridge without local process verification',
|
|
});
|
|
});
|
|
|
|
it('does not treat bridge members without session or pid as runtime candidates', async () => {
|
|
const launchOpenCodeTeam = vi.fn(
|
|
async () =>
|
|
({
|
|
runId: 'run-1',
|
|
teamLaunchState: 'launching',
|
|
members: {
|
|
alice: {
|
|
sessionId: '',
|
|
launchState: 'created',
|
|
model: 'openai/gpt-5.4-mini',
|
|
evidence: [],
|
|
},
|
|
},
|
|
warnings: [],
|
|
diagnostics: [],
|
|
}) satisfies OpenCodeLaunchTeamCommandData
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
launchOpenCodeTeam,
|
|
})
|
|
);
|
|
|
|
const result = await adapter.launch(launchInput());
|
|
|
|
expect(result.members.alice).toMatchObject({
|
|
launchState: 'runtime_pending_bootstrap',
|
|
agentToolAccepted: false,
|
|
runtimeAlive: false,
|
|
livenessKind: 'registered_only',
|
|
runtimeDiagnostic: 'OpenCode bridge did not report a runtime session or pid for this member',
|
|
});
|
|
});
|
|
|
|
it('keeps missing bridge members in bootstrap pending even when another member blocks on permission', async () => {
|
|
const launchOpenCodeTeam = vi.fn(
|
|
async () =>
|
|
({
|
|
runId: 'run-1',
|
|
teamLaunchState: 'permission_blocked',
|
|
members: {
|
|
alice: {
|
|
sessionId: 'oc-session-1',
|
|
launchState: 'permission_blocked',
|
|
pendingPermissionRequestIds: ['perm-1'],
|
|
diagnostics: ['waiting for permission approval'],
|
|
runtimePid: 123,
|
|
model: 'openai/gpt-5.4-mini',
|
|
evidence: [
|
|
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'permission_blocked', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
],
|
|
},
|
|
},
|
|
warnings: [],
|
|
diagnostics: [],
|
|
}) satisfies OpenCodeLaunchTeamCommandData
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
|
getLastOpenCodeRuntimeSnapshot: vi.fn(() => ({
|
|
providerId: 'opencode' as const,
|
|
binaryPath: '/opt/homebrew/bin/opencode',
|
|
binaryFingerprint: 'version:1.14.19',
|
|
version: '1.14.19',
|
|
capabilitySnapshotId: 'cap-1',
|
|
})),
|
|
launchOpenCodeTeam,
|
|
})
|
|
);
|
|
|
|
const result = await adapter.launch(
|
|
launchInput({
|
|
expectedMembers: [
|
|
...launchInput().expectedMembers,
|
|
{
|
|
name: 'bob',
|
|
providerId: 'opencode',
|
|
model: 'openai/gpt-5.4-mini',
|
|
cwd: '/repo',
|
|
},
|
|
],
|
|
})
|
|
);
|
|
|
|
expect(result.teamLaunchState).toBe('partial_pending');
|
|
expect(result.members.alice?.launchState).toBe('runtime_pending_permission');
|
|
expect(result.members.bob).toMatchObject({
|
|
providerId: 'opencode',
|
|
launchState: 'runtime_pending_bootstrap',
|
|
runtimeAlive: false,
|
|
agentToolAccepted: false,
|
|
bootstrapConfirmed: false,
|
|
hardFailure: false,
|
|
pendingPermissionRequestIds: undefined,
|
|
});
|
|
expect(result.members.bob?.diagnostics).toContain(
|
|
'OpenCode bridge response did not include bob; keeping the member pending until lane state materializes.'
|
|
);
|
|
});
|
|
});
|
|
|
|
async function launchWithStaleCapabilitySnapshotRecovery(message: string) {
|
|
let readinessCalls = 0;
|
|
let capabilitySnapshotId = 'cap-old';
|
|
const checkReadiness = vi.fn<OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']>(
|
|
() => {
|
|
readinessCalls += 1;
|
|
capabilitySnapshotId = readinessCalls === 1 ? 'cap-old' : 'cap-new';
|
|
return Promise.resolve(readiness({ state: 'ready', launchAllowed: true }));
|
|
}
|
|
);
|
|
const launchOpenCodeTeam = vi.fn<
|
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
|
>((input) =>
|
|
Promise.resolve(
|
|
input.expectedCapabilitySnapshotId === 'cap-old'
|
|
? failedCapabilitySnapshotLaunchData(message)
|
|
: successfulOpenCodeLaunchData()
|
|
)
|
|
);
|
|
const adapter = new OpenCodeTeamRuntimeAdapter({
|
|
checkOpenCodeTeamLaunchReadiness: checkReadiness,
|
|
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot(capabilitySnapshotId)),
|
|
launchOpenCodeTeam,
|
|
});
|
|
|
|
return {
|
|
result: await adapter.launch(launchInput()),
|
|
checkReadiness,
|
|
launchOpenCodeTeam,
|
|
};
|
|
}
|
|
|
|
function runtimeSnapshot(capabilitySnapshotId: string) {
|
|
return {
|
|
providerId: 'opencode' as const,
|
|
binaryPath: '/opt/homebrew/bin/opencode',
|
|
binaryFingerprint: 'version:1.14.19',
|
|
version: '1.14.19',
|
|
capabilitySnapshotId,
|
|
};
|
|
}
|
|
|
|
function successfulOpenCodeLaunchData(
|
|
overrides: { model?: string } = {}
|
|
): OpenCodeLaunchTeamCommandData {
|
|
return {
|
|
runId: 'run-1',
|
|
teamLaunchState: 'ready',
|
|
members: {
|
|
alice: {
|
|
sessionId: 'oc-session-1',
|
|
launchState: 'confirmed_alive',
|
|
runtimePid: 123,
|
|
model: overrides.model ?? 'openai/gpt-5.4-mini',
|
|
evidence: [
|
|
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
{ kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
|
],
|
|
},
|
|
},
|
|
warnings: [],
|
|
diagnostics: [],
|
|
};
|
|
}
|
|
|
|
function failedCapabilitySnapshotLaunchData(message: string): OpenCodeLaunchTeamCommandData {
|
|
return {
|
|
runId: 'run-1',
|
|
teamLaunchState: 'failed',
|
|
members: {},
|
|
warnings: [],
|
|
diagnostics: [
|
|
{
|
|
code: 'opencode_bridge',
|
|
severity: 'error',
|
|
message: `OpenCode bridge failed: ${message}`,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function failedMemberCapabilitySnapshotLaunchData(message: string): OpenCodeLaunchTeamCommandData {
|
|
return {
|
|
runId: 'run-1',
|
|
teamLaunchState: 'failed',
|
|
members: {
|
|
alice: {
|
|
sessionId: 'oc-session-1',
|
|
launchState: 'failed',
|
|
runtimePid: 123,
|
|
model: 'openai/gpt-5.4-mini',
|
|
evidence: [],
|
|
diagnostics: [message],
|
|
},
|
|
},
|
|
warnings: [],
|
|
diagnostics: [],
|
|
};
|
|
}
|
|
|
|
function bridgePort(
|
|
readinessResult: OpenCodeTeamLaunchReadiness,
|
|
overrides: Partial<OpenCodeTeamRuntimeBridgePort> = {}
|
|
): OpenCodeTeamRuntimeBridgePort {
|
|
return {
|
|
checkOpenCodeTeamLaunchReadiness: vi.fn(async () => readinessResult),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function launchInput(overrides: Partial<TeamRuntimeLaunchInput> = {}): TeamRuntimeLaunchInput {
|
|
return {
|
|
runId: 'run-1',
|
|
teamName: 'team-a',
|
|
cwd: '/repo',
|
|
providerId: 'opencode',
|
|
model: 'openai/gpt-5.4-mini',
|
|
skipPermissions: true,
|
|
expectedMembers: [
|
|
{
|
|
name: 'alice',
|
|
providerId: 'opencode',
|
|
model: 'openai/gpt-5.4-mini',
|
|
cwd: '/repo',
|
|
},
|
|
],
|
|
previousLaunchState: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function readiness(
|
|
overrides: Partial<OpenCodeTeamLaunchReadiness> = {}
|
|
): OpenCodeTeamLaunchReadiness {
|
|
return {
|
|
state: 'adapter_disabled',
|
|
launchAllowed: false,
|
|
modelId: 'openai/gpt-5.4-mini',
|
|
availableModels: ['openai/gpt-5.4-mini'],
|
|
opencodeVersion: '1.14.19',
|
|
installMethod: 'brew',
|
|
binaryPath: '/opt/homebrew/bin/opencode',
|
|
hostHealthy: true,
|
|
appMcpConnected: true,
|
|
requiredToolsPresent: true,
|
|
permissionBridgeReady: true,
|
|
runtimeStoresReady: true,
|
|
supportLevel: 'production_supported',
|
|
missing: [],
|
|
diagnostics: [],
|
|
evidence: {
|
|
capabilitiesReady: true,
|
|
mcpToolProofRoute: '/experimental/tool/ids',
|
|
observedMcpTools: [...REQUIRED_AGENT_TEAMS_APP_TOOL_IDS],
|
|
runtimeStoreReadinessReason: 'runtime_store_manifest_valid',
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function launchSnapshot(): PersistedTeamLaunchSnapshot {
|
|
return {
|
|
version: 2,
|
|
teamName: 'team-a',
|
|
updatedAt: '2026-04-21T00:00:00.000Z',
|
|
launchPhase: 'active',
|
|
expectedMembers: ['alice'],
|
|
teamLaunchState: 'partial_pending',
|
|
summary: {
|
|
confirmedCount: 0,
|
|
pendingCount: 1,
|
|
failedCount: 0,
|
|
runtimeAlivePendingCount: 1,
|
|
},
|
|
members: {
|
|
alice: {
|
|
name: 'alice',
|
|
launchState: 'runtime_pending_bootstrap',
|
|
agentToolAccepted: true,
|
|
runtimeAlive: true,
|
|
bootstrapConfirmed: false,
|
|
hardFailure: false,
|
|
lastEvaluatedAt: '2026-04-21T00:00:00.000Z',
|
|
diagnostics: ['waiting for teammate check-in'],
|
|
},
|
|
},
|
|
};
|
|
}
|