fix(opencode): harden local provider launches
This commit is contained in:
parent
187a2697f7
commit
fd50f736b8
6 changed files with 868 additions and 11 deletions
|
|
@ -102,11 +102,12 @@ const SECRET_FLAG_PATTERN =
|
|||
const BEARER_TOKEN_PATTERN = /\bBearer\s+\S+/gi;
|
||||
const SECRET_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}\b/g;
|
||||
const OPEN_CODE_CAPABILITY_SNAPSHOT_REFRESH_RETRY_WARNING =
|
||||
'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried once.';
|
||||
'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried launch.';
|
||||
const OPEN_CODE_CAPABILITY_SNAPSHOT_PRELAUNCH_MISMATCH_MARKERS = [
|
||||
'Bridge server capability snapshot mismatch',
|
||||
'OpenCode bridge capability snapshot precondition mismatch',
|
||||
];
|
||||
const OPEN_CODE_CAPABILITY_SNAPSHOT_REFRESH_RETRY_LIMIT = 3;
|
||||
const OPEN_CODE_READINESS_RETRY_DELAYS_MS = [750, 2_000] as const;
|
||||
|
||||
type OpenCodeTeamLaunchReadinessInput = Parameters<
|
||||
|
|
@ -303,7 +304,13 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
let data = await this.bridge.launchOpenCodeTeam(
|
||||
buildLaunchCommand(runtimeSnapshot, selectedModel)
|
||||
);
|
||||
if (!skipReadinessPreflight && isOpenCodePreLaunchCapabilitySnapshotMismatchData(data)) {
|
||||
let capabilitySnapshotRefreshAttempts = 0;
|
||||
while (
|
||||
!skipReadinessPreflight &&
|
||||
isOpenCodePreLaunchCapabilitySnapshotMismatchData(data) &&
|
||||
capabilitySnapshotRefreshAttempts < OPEN_CODE_CAPABILITY_SNAPSHOT_REFRESH_RETRY_LIMIT
|
||||
) {
|
||||
capabilitySnapshotRefreshAttempts += 1;
|
||||
const refreshed = await this.prepare(input);
|
||||
if (!refreshed.ok) {
|
||||
return blockedLaunchResult(
|
||||
|
|
@ -335,6 +342,8 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
`opencode-capability-recovery-${randomUUID()}`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -689,6 +698,7 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
|||
member.name,
|
||||
fallbackLaunchState,
|
||||
bridgeMember?.sessionId,
|
||||
bridgeMember?.model,
|
||||
bridgeMember?.runtimePid,
|
||||
bridgeMember?.pendingPermissionRequestIds,
|
||||
bridgeMember != null,
|
||||
|
|
@ -792,6 +802,7 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
memberName: string,
|
||||
launchState: OpenCodeTeamMemberLaunchBridgeState,
|
||||
sessionId: string | undefined,
|
||||
model: string | undefined,
|
||||
runtimePid: number | undefined,
|
||||
pendingPermissionRequestIds: string[] | undefined,
|
||||
runtimeMaterialized: boolean,
|
||||
|
|
@ -855,6 +866,7 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
return {
|
||||
memberName,
|
||||
providerId: 'opencode',
|
||||
...(isNonEmptyString(model) ? { model: model.trim() } : {}),
|
||||
launchState: failed
|
||||
? 'failed_to_start'
|
||||
: confirmed
|
||||
|
|
@ -1074,6 +1086,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
|||
'Do not mark the review complete from this prompt alone.',
|
||||
'A visible agent-teams_message_send reply is optional. Concrete review progress, review tool usage, or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.',
|
||||
`If you cannot pick up the review now, call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with ${workSyncToolArgs}, then report state "blocked" or "still_working" only for the real current state.`,
|
||||
'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.',
|
||||
taskIds.length ? `Relevant taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.` : null,
|
||||
`Do not use provider names, runtime names, or team names as memberName; use exactly "${input.memberName}".`,
|
||||
'Do not reply only with acknowledgement.',
|
||||
|
|
@ -1084,6 +1097,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
|||
'A visible agent-teams_message_send reply is optional. Concrete task progress or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.',
|
||||
`Call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with ${workSyncToolArgs}.`,
|
||||
`Then call agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) with ${workSyncToolArgs}, the returned agendaFingerprint/reportToken, and state "still_working" or "blocked".`,
|
||||
'Do not stop after member_work_sync_status. A status-only tool call is incomplete; member_work_sync_report is the required proof.',
|
||||
taskIds.length
|
||||
? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.`
|
||||
: null,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
|
|
@ -17,6 +16,7 @@ import {
|
|||
getTeamsBasePath,
|
||||
setClaudeBasePathOverride,
|
||||
} from '../../../../src/main/utils/pathDecoder';
|
||||
|
||||
import {
|
||||
formatMemberWorkSyncDiagnostics,
|
||||
formatProgressDump,
|
||||
|
|
@ -25,9 +25,9 @@ import {
|
|||
} from './memberWorkSyncLiveHarness';
|
||||
import {
|
||||
createOpenCodeLiveHarness,
|
||||
type OpenCodeLiveHarness,
|
||||
readInboxMessages,
|
||||
waitForOpenCodeLanesStopped,
|
||||
type OpenCodeLiveHarness,
|
||||
} from './openCodeLiveTestHarness';
|
||||
|
||||
import type { TeamChangeEvent, TeamProvisioningProgress } from '../../../../src/shared/types';
|
||||
|
|
@ -69,7 +69,7 @@ liveDescribe('Member work sync OpenCode live e2e', () => {
|
|||
} else {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}, 90_000);
|
||||
|
||||
it(
|
||||
'delivers a work-sync nudge to a real OpenCode member and accepts its still-working report',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
import { execFile } from 'child_process';
|
||||
import { constants as fsConstants, promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { promisify } from 'util';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||
|
||||
const liveDescribe =
|
||||
process.env.OPENCODE_DEFAULT_MODEL_RESOLUTION_LIVE_E2E === '1' ? describe : describe.skip;
|
||||
|
||||
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface ProviderModelListResponse {
|
||||
providers?: {
|
||||
opencode?: {
|
||||
defaultModel?: string;
|
||||
models?: Array<{ id?: string }>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface RuntimeStatusResponse {
|
||||
providers?: {
|
||||
opencode?: {
|
||||
models?: string[];
|
||||
modelCatalog?: {
|
||||
defaultLaunchModel?: string | null;
|
||||
models?: Array<{ id?: string; launchModel?: string; displayName?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type DefaultModelResolver = {
|
||||
resolveProviderDefaultModel: (
|
||||
claudePath: string,
|
||||
cwd: string,
|
||||
providerId: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerArgs: string[],
|
||||
limitContext: boolean
|
||||
) => Promise<string | null>;
|
||||
materializeEffectiveTeamMemberSpecs: (params: {
|
||||
claudePath: string;
|
||||
cwd: string;
|
||||
members: Array<{ name: string; providerId: 'opencode'; model?: string }>;
|
||||
defaults: { providerId: 'anthropic' };
|
||||
primaryProviderId: 'opencode';
|
||||
primaryEnv: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
authSource: string;
|
||||
providerArgs: string[];
|
||||
geminiRuntimeAuth: null;
|
||||
};
|
||||
providerArgsResolver: () => string[];
|
||||
limitContext: boolean;
|
||||
}) => Promise<Array<{ name: string; providerId: 'opencode'; model?: string }>>;
|
||||
};
|
||||
|
||||
liveDescribe('OpenCode default model resolution live e2e', () => {
|
||||
it('materializes an OpenCode Default teammate through the real model-list pipe', async () => {
|
||||
const orchestratorCli =
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||
await assertExecutable(orchestratorCli);
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
PATH: withBunOnPath(process.env.PATH ?? ''),
|
||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
|
||||
};
|
||||
const svc = new TeamProvisioningService() as unknown as DefaultModelResolver;
|
||||
|
||||
const defaultModel = await svc.resolveProviderDefaultModel(
|
||||
orchestratorCli,
|
||||
process.cwd(),
|
||||
'opencode',
|
||||
env,
|
||||
[],
|
||||
false
|
||||
);
|
||||
|
||||
expect(defaultModel).toMatch(/^opencode\/.+/);
|
||||
|
||||
await expect(
|
||||
svc.materializeEffectiveTeamMemberSpecs({
|
||||
claudePath: orchestratorCli,
|
||||
cwd: process.cwd(),
|
||||
members: [{ name: 'atlas', providerId: 'opencode' }],
|
||||
defaults: { providerId: 'anthropic' },
|
||||
primaryProviderId: 'opencode',
|
||||
primaryEnv: {
|
||||
env,
|
||||
authSource: 'opencode_managed',
|
||||
providerArgs: [],
|
||||
geminiRuntimeAuth: null,
|
||||
},
|
||||
providerArgsResolver: () => [],
|
||||
limitContext: false,
|
||||
})
|
||||
).resolves.toEqual([{ name: 'atlas', providerId: 'opencode', model: defaultModel }]);
|
||||
}, 60_000);
|
||||
|
||||
it('keeps the real OpenCode catalog hydrated instead of summary-only big-pickle', async () => {
|
||||
const orchestratorCli =
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||
await assertExecutable(orchestratorCli);
|
||||
|
||||
const env = buildOpenCodeLiveEnv();
|
||||
const modelList = await runJsonCommand<ProviderModelListResponse>(
|
||||
orchestratorCli,
|
||||
['model', 'list', '--json', '--provider', 'opencode'],
|
||||
env
|
||||
);
|
||||
const modelListIds =
|
||||
modelList.providers?.opencode?.models
|
||||
?.map((model) => model.id?.trim())
|
||||
.filter((id): id is string => Boolean(id)) ?? [];
|
||||
|
||||
expect(modelList.providers?.opencode?.defaultModel).toBe('opencode/big-pickle');
|
||||
expect(modelListIds.length).toBeGreaterThan(50);
|
||||
expect(modelListIds).toContain('opencode/big-pickle');
|
||||
expect(modelListIds.some((id) => id !== 'opencode/big-pickle')).toBe(true);
|
||||
|
||||
const runtimeStatus = await runJsonCommand<RuntimeStatusResponse>(
|
||||
orchestratorCli,
|
||||
['runtime', 'status', '--json', '--provider', 'opencode'],
|
||||
env
|
||||
);
|
||||
const provider = runtimeStatus.providers?.opencode;
|
||||
const catalogIds =
|
||||
provider?.modelCatalog?.models
|
||||
?.map((model) => model.launchModel?.trim() || model.id?.trim())
|
||||
.filter((id): id is string => Boolean(id)) ?? [];
|
||||
|
||||
expect(provider?.modelCatalog?.defaultLaunchModel).toBe('opencode/big-pickle');
|
||||
expect(provider?.models?.length ?? 0).toBeGreaterThan(50);
|
||||
expect(catalogIds.length).toBeGreaterThan(50);
|
||||
expect(catalogIds).toContain('opencode/big-pickle');
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
async function assertExecutable(filePath: string): Promise<void> {
|
||||
await fs.access(filePath, fsConstants.X_OK);
|
||||
}
|
||||
|
||||
function withBunOnPath(value: string): string {
|
||||
const candidates = [
|
||||
process.env.BUN_INSTALL ? path.join(process.env.BUN_INSTALL, 'bin') : null,
|
||||
process.env.HOME ? path.join(process.env.HOME, '.bun', 'bin') : null,
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/local/bin',
|
||||
].filter((candidate): candidate is string => Boolean(candidate));
|
||||
return [...candidates, value].join(path.delimiter);
|
||||
}
|
||||
|
||||
function buildOpenCodeLiveEnv(): NodeJS.ProcessEnv {
|
||||
const realHome = os.userInfo().homedir;
|
||||
return {
|
||||
...process.env,
|
||||
HOME: realHome,
|
||||
USERPROFILE: realHome,
|
||||
PATH: withBunOnPath(process.env.PATH ?? ''),
|
||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
|
||||
};
|
||||
}
|
||||
|
||||
async function runJsonCommand<T>(
|
||||
binaryPath: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv
|
||||
): Promise<T> {
|
||||
const { stdout } = await execFileAsync(binaryPath, args, {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
timeout: 60_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
return JSON.parse(stdout) as T;
|
||||
}
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
import { promises as fs } from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
getTeamsBasePath,
|
||||
setClaudeBasePathOverride,
|
||||
} from '../../../../src/main/utils/pathDecoder';
|
||||
|
||||
import { formatProgressDump } from './memberWorkSyncLiveHarness';
|
||||
import {
|
||||
createOpenCodeLiveHarness,
|
||||
type OpenCodeLiveHarness,
|
||||
waitForOpenCodeLanesStopped,
|
||||
waitUntil,
|
||||
} from './openCodeLiveTestHarness';
|
||||
|
||||
import type { TeamProvisioningProgress } from '../../../../src/shared/types';
|
||||
|
||||
const liveDescribe =
|
||||
process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_LOCAL_PROVIDER_APP_LAUNCH === '1'
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
const LOCAL_MODEL = 'llama.cpp/qwen-test:0.5b';
|
||||
|
||||
liveDescribe('OpenCode local provider app launch live e2e', () => {
|
||||
let tempDir: string;
|
||||
let tempClaudeRoot: string;
|
||||
let fakeServer: FakeOpenAiCompatibleServer | null;
|
||||
let harness: OpenCodeLiveHarness | null;
|
||||
let teamName: string | null;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-local-provider-app-launch-'));
|
||||
tempClaudeRoot = path.join(tempDir, '.claude');
|
||||
await fs.mkdir(tempClaudeRoot, { recursive: true });
|
||||
setClaudeBasePathOverride(tempClaudeRoot);
|
||||
fakeServer = null;
|
||||
harness = null;
|
||||
teamName = null;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (harness && teamName) {
|
||||
await harness.svc.stopTeam(teamName).catch(() => undefined);
|
||||
await waitForOpenCodeLanesStopped(teamName);
|
||||
}
|
||||
await harness?.dispose().catch(() => undefined);
|
||||
await fakeServer?.close().catch(() => undefined);
|
||||
setClaudeBasePathOverride(null);
|
||||
if (process.env.OPENCODE_E2E_KEEP_TEMP === '1') {
|
||||
console.info(`[OpenCodeLocalProviderAppLaunch.live] preserved temp dir: ${tempDir}`);
|
||||
} else {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
clearBenignSlowConfigReadWarnings();
|
||||
}, 90_000);
|
||||
|
||||
it(
|
||||
'creates and stops an OpenCode team through the app service using a configured authless local provider',
|
||||
async () => {
|
||||
const projectPath = path.join(tempDir, 'project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectPath, 'README.md'),
|
||||
'# OpenCode local provider app launch live e2e\n',
|
||||
'utf8'
|
||||
);
|
||||
fakeServer = await startFakeOpenAiCompatibleServer();
|
||||
await writeFakeLocalOpenCodeConfig({
|
||||
projectPath,
|
||||
baseUrl: fakeServer.baseUrl,
|
||||
});
|
||||
|
||||
harness = await createOpenCodeLiveHarness({
|
||||
tempDir,
|
||||
selectedModel: LOCAL_MODEL,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
teamName = `opencode-local-provider-app-${Date.now()}`;
|
||||
const progressEvents: TeamProvisioningProgress[] = [];
|
||||
const { runId } = await harness.svc.createTeam(
|
||||
{
|
||||
teamName,
|
||||
cwd: projectPath,
|
||||
providerId: 'opencode',
|
||||
model: LOCAL_MODEL,
|
||||
skipPermissions: true,
|
||||
members: [
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'opencode',
|
||||
model: LOCAL_MODEL,
|
||||
mcpPolicy: { mode: 'appOnly' },
|
||||
},
|
||||
],
|
||||
},
|
||||
(progress) => progressEvents.push(progress)
|
||||
);
|
||||
|
||||
const progressDump = formatProgressDump(progressEvents);
|
||||
expect(runId, progressDump).toBeTruthy();
|
||||
expect(
|
||||
progressEvents.some((progress) =>
|
||||
progress.message.includes('OpenCode team launch is ready')
|
||||
),
|
||||
progressDump
|
||||
).toBe(true);
|
||||
expect(progressDump).not.toContain('provider not connected');
|
||||
expect(progressDump).not.toContain('not authenticated');
|
||||
expect(progressDump).not.toContain('OpenCode team launch is not enabled');
|
||||
expect(fakeServer.requests, progressDump).toContain('POST /v1/chat/completions');
|
||||
|
||||
const runtimeSnapshot = await harness.svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(runtimeSnapshot.runId).toBe(runId);
|
||||
expect(runtimeSnapshot.members.bob).toMatchObject({
|
||||
alive: true,
|
||||
providerId: 'opencode',
|
||||
laneId: 'primary',
|
||||
laneKind: 'primary',
|
||||
runtimeModel: LOCAL_MODEL,
|
||||
historicalBootstrapConfirmed: true,
|
||||
});
|
||||
|
||||
const deliveryMarker = `local-provider-delivery-${Date.now()}`;
|
||||
const chatBodyCountBeforeDelivery = fakeServer.chatBodies.length;
|
||||
const delivery = await harness.svc.deliverOpenCodeMemberMessage(teamName, {
|
||||
memberName: 'bob',
|
||||
messageId: `local-provider-delivery-${Date.now()}`,
|
||||
replyRecipient: 'user',
|
||||
source: 'manual',
|
||||
text: [
|
||||
`Local provider delivery marker: ${deliveryMarker}`,
|
||||
'Answer with PONG. Do not edit files.',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(delivery.delivered, JSON.stringify(delivery, null, 2)).toBe(true);
|
||||
await waitUntil(
|
||||
async () =>
|
||||
fakeServer!.chatBodies.length > chatBodyCountBeforeDelivery &&
|
||||
fakeServer!.chatBodies.some((body) => JSON.stringify(body).includes(deliveryMarker)),
|
||||
60_000,
|
||||
500
|
||||
);
|
||||
|
||||
await harness.svc.stopTeam(teamName);
|
||||
await waitForOpenCodeLanesStopped(teamName);
|
||||
clearBenignSlowConfigReadWarnings();
|
||||
},
|
||||
300_000
|
||||
);
|
||||
|
||||
it(
|
||||
'fails app service launch for an unknown local model before creating OpenCode lanes',
|
||||
async () => {
|
||||
const projectPath = path.join(tempDir, 'unknown-model-project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
fakeServer = await startFakeOpenAiCompatibleServer();
|
||||
await writeFakeLocalOpenCodeConfig({
|
||||
projectPath,
|
||||
baseUrl: fakeServer.baseUrl,
|
||||
});
|
||||
|
||||
harness = await createOpenCodeLiveHarness({
|
||||
tempDir,
|
||||
selectedModel: 'llama.cpp/missing-test:0.5b',
|
||||
projectPath,
|
||||
});
|
||||
|
||||
teamName = `opencode-local-provider-unknown-${Date.now()}`;
|
||||
const progressEvents: TeamProvisioningProgress[] = [];
|
||||
const { runId } = await harness.svc.createTeam(
|
||||
{
|
||||
teamName,
|
||||
cwd: projectPath,
|
||||
providerId: 'opencode',
|
||||
model: 'llama.cpp/missing-test:0.5b',
|
||||
skipPermissions: true,
|
||||
members: [
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'opencode',
|
||||
model: 'llama.cpp/missing-test:0.5b',
|
||||
},
|
||||
],
|
||||
},
|
||||
(progress) => progressEvents.push(progress)
|
||||
);
|
||||
expect(runId).toBeTruthy();
|
||||
await waitUntil(
|
||||
async () => progressEvents.some((progress) => progress.state === 'failed'),
|
||||
30_000,
|
||||
500
|
||||
);
|
||||
|
||||
const progressDump = formatProgressDump(progressEvents);
|
||||
expect(progressEvents.some((progress) => progress.state === 'failed'), progressDump).toBe(
|
||||
true
|
||||
);
|
||||
expect(progressDump).toMatch(/missing-test:0\.5b|not available|unavailable/i);
|
||||
expect(fakeServer.requests, progressDump).not.toContain('POST /v1/chat/completions');
|
||||
await waitUntil(
|
||||
async () => {
|
||||
const laneIndexPath = path.join(
|
||||
getTeamsBasePath(),
|
||||
teamName!,
|
||||
'runtime',
|
||||
'opencode',
|
||||
'lanes.json'
|
||||
);
|
||||
try {
|
||||
const parsed = JSON.parse(await fs.readFile(laneIndexPath, 'utf8')) as {
|
||||
lanes?: Record<string, unknown>;
|
||||
};
|
||||
return Object.keys(parsed.lanes ?? {}).length === 0;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return true;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
15_000,
|
||||
500
|
||||
);
|
||||
clearBenignSlowConfigReadWarnings();
|
||||
},
|
||||
180_000
|
||||
);
|
||||
});
|
||||
|
||||
interface FakeOpenAiCompatibleServer {
|
||||
baseUrl: string;
|
||||
requests: string[];
|
||||
chatBodies: unknown[];
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function startFakeOpenAiCompatibleServer(): Promise<FakeOpenAiCompatibleServer> {
|
||||
const requests: string[] = [];
|
||||
const chatBodies: unknown[] = [];
|
||||
const server = http.createServer(async (request, response) => {
|
||||
requests.push(`${request.method ?? 'GET'} ${request.url ?? '/'}`);
|
||||
if (request.url === '/v1/models') {
|
||||
sendJson(response, 200, {
|
||||
object: 'list',
|
||||
data: [{ id: 'qwen-test:0.5b', object: 'model' }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' && request.url === '/v1/chat/completions') {
|
||||
const body = JSON.parse((await readRequestBody(request)) || '{}') as { stream?: boolean };
|
||||
chatBodies.push(body);
|
||||
if (body.stream) {
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
});
|
||||
response.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: 'chatcmpl-test',
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model: 'qwen-test:0.5b',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { role: 'assistant', content: 'PONG' },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
})}\n\n`
|
||||
);
|
||||
response.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: 'chatcmpl-test',
|
||||
object: 'chat.completion.chunk',
|
||||
created,
|
||||
model: 'qwen-test:0.5b',
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
})}\n\n`
|
||||
);
|
||||
response.end('data: [DONE]\n\n');
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, {
|
||||
id: 'chatcmpl-test',
|
||||
object: 'chat.completion',
|
||||
model: 'qwen-test:0.5b',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'PONG' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 404, { error: { message: 'not found' } });
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
await closeServer(server);
|
||||
throw new Error('Fake OpenAI-compatible server did not bind to a TCP port');
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
chatBodies,
|
||||
close: () => closeServer(server),
|
||||
};
|
||||
}
|
||||
|
||||
async function writeFakeLocalOpenCodeConfig(input: {
|
||||
projectPath: string;
|
||||
baseUrl: string;
|
||||
}): Promise<void> {
|
||||
const configPath = path.join(input.projectPath, 'opencode.json');
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
provider: {
|
||||
'llama.cpp': {
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
options: {
|
||||
baseURL: `${input.baseUrl}/v1`,
|
||||
},
|
||||
models: {
|
||||
'qwen-test:0.5b': {},
|
||||
},
|
||||
},
|
||||
},
|
||||
model: LOCAL_MODEL,
|
||||
small_model: LOCAL_MODEL,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
async function readRequestBody(request: http.IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
function sendJson(response: http.ServerResponse, status: number, body: unknown): void {
|
||||
response.writeHead(status, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function closeServer(server: http.Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
function clearBenignSlowConfigReadWarnings(): void {
|
||||
const warn = vi.mocked(console.warn);
|
||||
if (
|
||||
warn.mock.calls.length > 0 &&
|
||||
warn.mock.calls.every((call) =>
|
||||
call.map((part) => String(part)).join(' ').includes('[getConfig] slow read diag=')
|
||||
)
|
||||
) {
|
||||
warn.mockClear();
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,8 @@ const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_
|
|||
const DEFAULT_MODEL = 'opencode/big-pickle';
|
||||
|
||||
liveDescribe('OpenCode team provisioning live e2e', () => {
|
||||
const liveDefaultModelIt =
|
||||
process.env.OPENCODE_E2E_DEFAULT_MODEL_LAUNCH === '1' ? it : it.skip;
|
||||
let tempDir: string;
|
||||
let tempClaudeRoot: string;
|
||||
|
||||
|
|
@ -205,6 +207,187 @@ liveDescribe('OpenCode team provisioning live e2e', () => {
|
|||
.catch(() => undefined);
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
liveDefaultModelIt(
|
||||
'creates and stops a pure OpenCode team when all OpenCode model selections are Default',
|
||||
async () => {
|
||||
const orchestratorCli =
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||
await assertExecutable(orchestratorCli);
|
||||
const projectPath = path.join(tempDir, 'default-model-project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectPath, 'opencode.json'),
|
||||
`${JSON.stringify({ model: DEFAULT_MODEL, small_model: DEFAULT_MODEL }, null, 2)}\n`
|
||||
);
|
||||
|
||||
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
|
||||
const bridgeEnv = {
|
||||
...createStableBridgeEnv(),
|
||||
PATH: withBunOnPath(process.env.PATH ?? ''),
|
||||
XDG_DATA_HOME: path.join(tempDir, 'xdg-data-default-model'),
|
||||
AGENT_TEAMS_MCP_CLAUDE_DIR: tempClaudeRoot,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND: mcpLaunchSpec.command,
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY: mcpLaunchSpec.args[0] ?? '',
|
||||
CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON: JSON.stringify(mcpLaunchSpec.args),
|
||||
};
|
||||
const bridgeClient = new OpenCodeBridgeCommandClient({
|
||||
binaryPath: orchestratorCli,
|
||||
tempDirectory: path.join(tempDir, 'bridge-input-default-model'),
|
||||
env: bridgeEnv,
|
||||
});
|
||||
const stateChangingCommands = createStateChangingCommands({
|
||||
bridge: bridgeClient,
|
||||
controlDir: path.join(tempDir, 'control-default-model'),
|
||||
});
|
||||
const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
|
||||
stateChangingCommands,
|
||||
timeoutMs: 180_000,
|
||||
launchTimeoutMs: 180_000,
|
||||
reconcileTimeoutMs: 90_000,
|
||||
stopTimeoutMs: 90_000,
|
||||
});
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(readinessBridge);
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||
|
||||
const teamName = `opencode-team-default-model-${Date.now()}`;
|
||||
const progressEvents: TeamProvisioningProgress[] = [];
|
||||
|
||||
try {
|
||||
const { runId } = await svc.createTeam(
|
||||
{
|
||||
teamName,
|
||||
cwd: projectPath,
|
||||
providerId: 'opencode',
|
||||
skipPermissions: true,
|
||||
members: [
|
||||
{
|
||||
name: 'atlas',
|
||||
role: 'Developer',
|
||||
providerId: 'opencode',
|
||||
},
|
||||
],
|
||||
},
|
||||
(progress) => {
|
||||
progressEvents.push(progress);
|
||||
}
|
||||
);
|
||||
|
||||
expect(runId).toBeTruthy();
|
||||
const progressDump = progressEvents
|
||||
.map((progress) =>
|
||||
[
|
||||
progress.state,
|
||||
progress.message,
|
||||
progress.messageSeverity,
|
||||
progress.error,
|
||||
progress.cliLogsTail,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ')
|
||||
)
|
||||
.join('\n');
|
||||
expect(
|
||||
progressEvents.some((progress) =>
|
||||
progress.message.includes('OpenCode team launch is ready')
|
||||
),
|
||||
progressDump
|
||||
).toBe(true);
|
||||
expect(progressDump).not.toContain('OpenCode launch requires a selected raw model id');
|
||||
expect(progressDump).not.toContain('Failed to parse runtime default model list');
|
||||
expect(progressDump).not.toContain('Failed to load runtime default model list');
|
||||
|
||||
const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(runtimeSnapshot.runId).toBe(runId);
|
||||
expect(runtimeSnapshot.members.atlas).toMatchObject({
|
||||
alive: true,
|
||||
providerId: 'opencode',
|
||||
laneId: 'primary',
|
||||
laneKind: 'primary',
|
||||
runtimeModel: DEFAULT_MODEL,
|
||||
historicalBootstrapConfirmed: true,
|
||||
});
|
||||
expect(hasOpenCodeRuntimeHandle(runtimeSnapshot.members.atlas)).toBe(true);
|
||||
|
||||
await svc.stopTeam(teamName);
|
||||
await waitUntil(async () => {
|
||||
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName);
|
||||
return Object.keys(laneIndex.lanes).length === 0;
|
||||
}, 90_000);
|
||||
|
||||
const relaunchProgressEvents: TeamProvisioningProgress[] = [];
|
||||
const { runId: relaunchRunId } = await svc.launchTeam(
|
||||
{
|
||||
teamName,
|
||||
cwd: projectPath,
|
||||
providerId: 'opencode',
|
||||
skipPermissions: true,
|
||||
},
|
||||
(progress) => {
|
||||
relaunchProgressEvents.push(progress);
|
||||
}
|
||||
);
|
||||
expect(relaunchRunId).toBeTruthy();
|
||||
expect(relaunchRunId).not.toBe(runId);
|
||||
const relaunchProgressDump = relaunchProgressEvents
|
||||
.map((progress) =>
|
||||
[
|
||||
progress.state,
|
||||
progress.message,
|
||||
progress.messageSeverity,
|
||||
progress.error,
|
||||
progress.cliLogsTail,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ')
|
||||
)
|
||||
.join('\n');
|
||||
expect(
|
||||
relaunchProgressEvents.some((progress) =>
|
||||
progress.message.includes('OpenCode team launch is ready')
|
||||
),
|
||||
relaunchProgressDump
|
||||
).toBe(true);
|
||||
expect(relaunchProgressDump).not.toContain(
|
||||
'OpenCode launch requires a selected raw model id'
|
||||
);
|
||||
expect(relaunchProgressDump).not.toContain('No OpenCode model is available');
|
||||
expect(relaunchProgressDump).not.toContain('Failed to parse runtime default model list');
|
||||
expect(relaunchProgressDump).not.toContain('Failed to load runtime default model list');
|
||||
|
||||
const relaunchedSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(relaunchedSnapshot.runId).toBe(relaunchRunId);
|
||||
expect(relaunchedSnapshot.members.atlas).toMatchObject({
|
||||
alive: true,
|
||||
providerId: 'opencode',
|
||||
laneId: 'primary',
|
||||
laneKind: 'primary',
|
||||
runtimeModel: DEFAULT_MODEL,
|
||||
historicalBootstrapConfirmed: true,
|
||||
});
|
||||
expect(hasOpenCodeRuntimeHandle(relaunchedSnapshot.members.atlas)).toBe(true);
|
||||
|
||||
await svc.stopTeam(teamName);
|
||||
await waitUntil(async () => {
|
||||
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName);
|
||||
return Object.keys(laneIndex.lanes).length === 0;
|
||||
}, 90_000);
|
||||
} finally {
|
||||
await svc.stopTeam(teamName).catch(() => undefined);
|
||||
await readinessBridge
|
||||
.cleanupOpenCodeHosts({
|
||||
reason: 'opencode-team-default-model-live-e2e-cleanup',
|
||||
mode: 'force',
|
||||
projectPath,
|
||||
staleAgeMs: null,
|
||||
leaseStaleAgeMs: null,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
},
|
||||
300_000
|
||||
);
|
||||
});
|
||||
|
||||
function createStateChangingCommands(input: {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
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 { OpenCodeTeamLaunchReadiness } from '../../../../src/main/services/team/opencode/readiness/OpenCodeTeamLaunchReadiness';
|
||||
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';
|
||||
import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../../../../src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability';
|
||||
|
||||
describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||
it('maps readiness failures to a structured prepare block', async () => {
|
||||
|
|
@ -376,6 +376,44 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
);
|
||||
});
|
||||
|
||||
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';
|
||||
|
|
@ -607,7 +645,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
|
||||
expect(result.teamLaunchState).toBe('clean_success');
|
||||
expect(result.warnings).toContain(
|
||||
'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried once.'
|
||||
'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried launch.'
|
||||
);
|
||||
expect(checkReadiness).toHaveBeenCalledTimes(2);
|
||||
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(2);
|
||||
|
|
@ -637,6 +675,50 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
);
|
||||
});
|
||||
|
||||
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<
|
||||
|
|
@ -775,7 +857,8 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
const result = await adapter.launch(launchInput());
|
||||
|
||||
expect(result.teamLaunchState).toBe('partial_failure');
|
||||
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(2);
|
||||
expect(checkReadiness).toHaveBeenCalledTimes(4);
|
||||
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(4);
|
||||
expect(result.diagnostics).toContain(
|
||||
'error:opencode_bridge: OpenCode bridge failed: OpenCode bridge capability snapshot precondition mismatch'
|
||||
);
|
||||
|
|
@ -1094,6 +1177,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
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('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"');
|
||||
|
|
@ -1143,6 +1227,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
expect(sentText).toContain('review workflow tools');
|
||||
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.');
|
||||
});
|
||||
|
||||
|
|
@ -1544,7 +1629,9 @@ function runtimeSnapshot(capabilitySnapshotId: string) {
|
|||
};
|
||||
}
|
||||
|
||||
function successfulOpenCodeLaunchData(): OpenCodeLaunchTeamCommandData {
|
||||
function successfulOpenCodeLaunchData(
|
||||
overrides: { model?: string } = {}
|
||||
): OpenCodeLaunchTeamCommandData {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
|
|
@ -1553,7 +1640,7 @@ function successfulOpenCodeLaunchData(): OpenCodeLaunchTeamCommandData {
|
|||
sessionId: 'oc-session-1',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimePid: 123,
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
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' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue