Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
17a8266
test(opencode): cover precise preflight classifications
CompleteDotTech Jul 26, 2026
485182a
fix(ui): surface runtime launchability honestly
CompleteDotTech Jul 26, 2026
1071001
test(chat): cover unavailable OpenCode launches
CompleteDotTech Jul 26, 2026
f07476b
fix(test): type OpenCode launcher fixture
CompleteDotTech Jul 26, 2026
5de26e1
fix(opencode): preserve PowerShell inner launch failures
CompleteDotTech Jul 26, 2026
2301a97
fix(opencode): classify launch races by runner
CompleteDotTech Jul 26, 2026
4467eac
fix(opencode): gate capability probes on preflight
CompleteDotTech Jul 26, 2026
1e2b9bd
fix(opencode): bind launcher plan to spawn env
CompleteDotTech Jul 26, 2026
d77bc49
fix(opencode): share scoped launch environment
CompleteDotTech Jul 26, 2026
18a108a
fix(opencode): preserve synchronous launch failures
CompleteDotTech Jul 26, 2026
8b30a11
fix(ui): explain unavailable runtimes in summoning
CompleteDotTech Jul 26, 2026
f3b2769
fix(opencode): keep ambiguous Windows races generic
CompleteDotTech Jul 26, 2026
0517035
fix(opencode): preserve scoped preflight on rebase
CompleteDotTech Jul 26, 2026
d4aba87
test(opencode): update launch-race API contract
CompleteDotTech Jul 26, 2026
6e8a7b7
Merge main into fix/opencode-preflight-3862
CompleteDotTech Jul 26, 2026
34272a8
Merge main into fix/opencode-preflight-3862
CompleteDotTech Jul 26, 2026
43eae7b
Merge main into fix/opencode-preflight-3862
CompleteDotTech Jul 26, 2026
882b15d
fix: remove unused availability fixture path
CompleteDotTech Jul 26, 2026
cfaaee5
Merge main into fix/opencode-preflight-3862
CompleteDotTech Jul 26, 2026
2a56a6f
test: align launch failure contract
CompleteDotTech Jul 26, 2026
edd4162
merge: refresh OpenCode preflight with main
CompleteDotTech Jul 26, 2026
18818e0
merge: refresh OpenCode preflight with main
CompleteDotTech Jul 26, 2026
11777d2
merge: reconcile runtime preflight updates
CompleteDotTech Jul 26, 2026
8485b46
merge: refresh OpenCode preflight with main
CompleteDotTech Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/app/api/api-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,12 +462,12 @@ for (const contract of contracts) {
);
assert.match(
sendSource,
/const reportLaunchFailure = \(err: NodeJS\.ErrnoException\) => \{[\s\S]{0,800}?const launchCode =[\s\S]{0,300}?launchFailure \?\?= \{[\s\S]{0,180}?code: sshRuntime \? err\.code \?\? "runtime_launch_failed" : launchCode,[\s\S]{0,400}?pushProgress\([\s\S]{0,220}?launchError,[\s\S]{0,300}?code: launchCode/,
"/chat/send: launch state, progress, and the post-spawn ENOENT race event must reuse one normalized message",
/const reportLaunchFailure = \(err: NodeJS\.ErrnoException\) => \{[\s\S]*?const launchCode =[\s\S]*?launchFailure \?\?= \{[\s\S]*?code: sshRuntime \? err\.code \?\? "runtime_launch_failed" : launchCode,[\s\S]*?message: launchError,[\s\S]*?pushProgress\([\s\S]*?launchError,[\s\S]*?code: launchFailure\.code[\s\S]*?message: launchError/,
"/chat/send: launch state, progress, and the post-spawn race event must reuse one normalized message and structured code",
);
assert.match(
sendSource,
/const localLaunchError = localRuntimeLaunchError\([\s\S]{0,100}?err\.code,[\s\S]{0,80}?const launchError = sshRuntime[\s\S]{0,180}?: localLaunchError\.message/,
/const localLaunchError = localRuntimeLaunchError\([\s\S]{0,200}?err\.code,[\s\S]{0,800}?const launchError = sshRuntime[\s\S]{0,600}?: localLaunchError\.message/,
"/chat/send: every local post-spawn failure uses the shared runner-specific normalizer while SSH retains transport diagnostics",
);
assert.match(
Expand Down
14 changes: 10 additions & 4 deletions src/app/api/chat/send/chat-send-capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,16 @@ assert.notEqual(
assert.equal(openCodeCapabilityProbeScope(), "default", "the unscoped probe cache has a stable explicit scope");
assert.deepEqual(openCodeProbeSpawnOptions("linux"), { detached: true }, "POSIX OpenCode probes create an isolated process group that timeout cleanup can terminate");
assert.deepEqual(openCodeProbeSpawnOptions("win32"), { detached: false }, "Windows probes rely on taskkill's explicit process-tree cleanup rather than a detached process group");
const firstCapabilities = await openCodeRunCapabilities("probe-fixture", async () => ({
helpProbe: { complete: true, output: " --format <format> Output format: text, json\n" },
versionProbe: { complete: true, output: "opencode 1.0.0" },
}), () => "probe-fixture-launcher");
const probeEnv = { PATH: "/scoped/opencode", XDG_RUNTIME_DIR: "/tmp" };
let observedProbeEnv: NodeJS.ProcessEnv | undefined;
const firstCapabilities = await openCodeRunCapabilities("probe-fixture", async (env) => {
observedProbeEnv = env;
return {
helpProbe: { complete: true, output: " --format <format> Output format: text, json\n" },
versionProbe: { complete: true, output: "opencode 1.0.0" },
};
}, probeEnv);
assert.equal(observedProbeEnv, probeEnv, "callers can retain one scoped environment for preflight, probes, and launch");
const replacementCapabilities = await openCodeRunCapabilities("probe-fixture", async () => ({
helpProbe: { complete: true, output: " --format <format> Output format: text\n" },
versionProbe: { complete: true, output: "opencode 1.0.0" },
Expand Down
22 changes: 14 additions & 8 deletions src/app/api/chat/send/chat-send-capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,8 @@ function advertisedStructuredSwitches(options: string[], noValueOptions: string[
}

async function probeOpenCodeRunContract(env: NodeJS.ProcessEnv): Promise<OpenCodeRunContractProbe> {
const helpLaunch = openCodeLaunch(["run", "--help"]);
const versionLaunch = openCodeLaunch(["--version"]);
const helpLaunch = openCodeLaunch(["run", "--help"], process.platform, env);
const versionLaunch = openCodeLaunch(["--version"], process.platform, env);
const [helpProbe, versionProbe] = await Promise.all([
probeOutput(helpLaunch.command, helpLaunch.args, env, helpLaunch.input),
probeOutput(versionLaunch.command, versionLaunch.args, env, versionLaunch.input),
Expand Down Expand Up @@ -601,12 +601,13 @@ export function hermesChatSupportsModel(): Promise<boolean> {

/** OpenCode is direct-spawned so its own documented capability is authoritative. */
export function openCodeRunSupportsModel(): Promise<boolean> {
const launch = openCodeLaunch(["run", "--help"]);
const env = openCodeSpawnEnv();
const launch = openCodeLaunch(["run", "--help"], process.platform, env);
return (openCodeModelFlagProbe ??= probeHelp(
launch.command,
launch.args,
(help) => /(^|\s)--model(?![\w-])/m.test(help),
openCodeSpawnEnv(),
env,
launch.input,
));
}
Expand All @@ -619,13 +620,18 @@ export function openCodeRunSupportsModel(): Promise<boolean> {
export async function openCodeRunCapabilities(
familiarId?: string,
probeRunContract: (env: NodeJS.ProcessEnv) => Promise<OpenCodeRunContractProbe> = probeOpenCodeRunContract,
capabilityIdentity: CapabilityIdentityProbe = openCodeCapabilityLaunchIdentity,
now: CapabilityClock = Date.now,
spawnEnvOrCapabilityIdentity?: NodeJS.ProcessEnv | CapabilityIdentityProbe,
capabilityIdentityNow: CapabilityClock = Date.now,
): Promise<OpenCodeRunCapabilities> {
// Probe the exact scoped environment used for this chat turn. A version
// string alone is not a safe cache key: package managers and shims can
// replace a CLI in place while preserving both PATH and `--version`.
const env = openCodeSpawnEnv(familiarId);
const env = typeof spawnEnvOrCapabilityIdentity === "function"
? openCodeSpawnEnv(familiarId)
: spawnEnvOrCapabilityIdentity ?? openCodeSpawnEnv(familiarId);
const capabilityIdentity = typeof spawnEnvOrCapabilityIdentity === "function"
? spawnEnvOrCapabilityIdentity
: openCodeCapabilityLaunchIdentity;
// Hashing a large npm executable is streamed asynchronously and overlaps
// the help/version children, so it never blocks the request event loop.
const launcherIdentityBeforeProbe = Promise.resolve(capabilityIdentity(env));
Expand All @@ -642,7 +648,7 @@ export async function openCodeRunCapabilities(
? versionProbe.output.match(/\b\d+(?:\.\d+){1,3}(?:[-+][\w.-]+)?\b/)?.[0] ?? null
: null;
const scope = openCodeCapabilityProbeScope(familiarId);
const observedNow = now();
const observedNow = capabilityIdentityNow();
pruneVerifiedOpenCodeCapabilities(observedNow);
if (helpProbe.complete) {
const capabilities = {
Expand Down
21 changes: 18 additions & 3 deletions src/app/api/chat/send/harness-routing-opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ assert.match(
);
assert.match(
route,
/let localRuntimePlan: LocalRuntimePlan \| null = null;[\s\S]*?const openCodeCapabilities = openCodeDirect[\s\S]*?probeReadyLocalRuntimeCapability\(\{[\s\S]*?plan: localRuntimePlan,[\s\S]*?runner: "opencode",[\s\S]*?probe: \(\) => openCodeRunCapabilities\(body\.familiarId\),[\s\S]*?resolveOpenCodeCompatibility\(openCodeCapabilities\)/,
/let localRuntimePlan: LocalRuntimePlan \| null = null;[\s\S]*?const openCodeCapabilities = openCodeDirect[\s\S]*?probeReadyLocalRuntimeCapability\(\{[\s\S]*?plan: localRuntimePlan,[\s\S]*?runner: "opencode",[\s\S]*?probe: \(\) => openCodeRunCapabilities\(body\.familiarId, undefined, localRuntimePlan\?\.env\),[\s\S]*?resolveOpenCodeCompatibility\(openCodeCapabilities\)/,
"OpenCode capability discovery starts only after its exact local launch plan is ready",
);
assert.match(
Expand Down Expand Up @@ -70,9 +70,24 @@ assert.match(
/const openCodeLaunchCommand = openCodeDirect[\s\S]*?command: localPlan\.command,[\s\S]*?args: \[\.\.\.localPlan\.fixedArgs\],[\s\S]*?input: JSON\.stringify\(spawnArgs\)[\s\S]*?const availability =[\s\S]*?command: localPlan\.command,[\s\S]*?env: localPlan\.env,[\s\S]*?let child:[\s\S]*?try \{[\s\S]*?child = spawn\(command\.command, command\.args, \{[\s\S]*?env: localPlan\.env,[\s\S]*?writeOpenCodeLaunchInput\(child, openCodeLaunchCommand\)/,
"OpenCode carries one Windows-safe outer host, inner command, and scoped environment from early preflight through the immediate spawn recheck",
);
assert.match(
route,
/const env = openCodeSpawnEnv\(body\.familiarId\);[\s\S]*?const launch = openCodeLaunch\(\[\], process\.platform, env\);[\s\S]*?runner: "opencode",[\s\S]*?powerShellHostedCommand:[\s\S]*?launch\.input !== undefined \? openCodeCommand\(\) : undefined[\s\S]*?const availability =[\s\S]*?command: localPlan\.command,[\s\S]*?powerShellHostedCommand:[\s\S]*?localPlan\.powerShellHostedCommand,[\s\S]*?if \(availability\.state !== "ready"\) \{[\s\S]*?launchFailure = \{ code: availability\.code, message: availability\.message \};[\s\S]*?return null;/,
"OpenCode preflights the exact PowerShell/JSON-stdin plan and returns the shared structured error before spawn",
);
assert.match(
route,
/child\.on\("error", \(err: NodeJS\.ErrnoException\) => \{[\s\S]*?const openCodeWindowsOuterLaunchFailure =[\s\S]*?process\.platform === "win32" && err\.code === "ENOENT";[\s\S]*?const openCodeCommandMissing =[\s\S]*?!openCodeWindowsOuterLaunchFailure[\s\S]*?launchFailure \?\?= \{[\s\S]*?"runtime_missing"[\s\S]*?"runtime_launch_failed"/,
"an ambiguous Windows outer-process race stays generic while a missing POSIX OpenCode command remains distinct",
);
assert.match(
route,
/OPENCODE_COMMAND_NOT_FOUND_MARKER[\s\S]*?OPENCODE_LAUNCH_FAILED_MARKER[\s\S]*?launchFailure = \{[\s\S]*?code: commandMissing \? "runtime_missing" : "runtime_launch_failed"[\s\S]*?push\(\{ kind: "error", code: launchFailure\.code, message: launchError \}\)/,
"a PowerShell-hosted inner OpenCode race is streamed as a launch failure instead of becoming synthetic no-output text",
);
assert.match(
capabilities,
/const launch = openCodeLaunch\(\["run", "--help"\]\);[\s\S]*?launch\.command,[\s\S]*?launch\.args,[\s\S]*?openCodeSpawnEnv\(\),/,
/async function probeOpenCodeRunContract\(env: NodeJS\.ProcessEnv\)[\s\S]*?const helpLaunch = openCodeLaunch\(\["run", "--help"\], process\.platform, env\);[\s\S]*?probeOutput\(helpLaunch\.command, helpLaunch\.args, env, helpLaunch\.input\)/,
"OpenCode probes its CLI with the same Windows-safe command and WSL-compatible environment as a chat run",
);
assert.match(
Expand All @@ -89,7 +104,7 @@ assert.match(
const earlyGate = route.indexOf("const openCodeCapabilities");
assert.ok(earlyGate >= 0, "capability routing begins only after local plans are established");
for (const capabilityCall of [
"openCodeRunCapabilities(body.familiarId)",
"openCodeRunCapabilities(body.familiarId, undefined, localRuntimePlan?.env)",
"probe: hermesChatSupportsModel",
"probeCovenCapability(covenRunSupportsModel)",
"probeCovenCapability(covenRunSupportsPermission)",
Expand Down
32 changes: 32 additions & 0 deletions src/app/api/chat/send/route-opencode.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,38 @@ try {
const quotedResumeBody = await quotedResumeResponse.text();
assert.match(quotedResumeBody, /Session not found in the documentation\./, "plain fallback preserves assistant text that resembles a resume failure");
assert.doesNotMatch(quotedResumeBody, /No assistant text returned/, "quoted resume-failure text does not become a synthetic empty-response error");

// The OpenCode-specific preflight runs before capability discovery or a
// prompt/model command. Removing the same shim used by the successful turn
// proves the route returns the shared structured remediation and never
// manufactures an auth/no-output assistant response.
await rm(path.join(bin, executable), { force: true });
const missingResponse = await POST(new Request("http://localhost/api/chat/send", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ familiarId: "opal", prompt: "missing OpenCode must not run", projectRoot: familiarWorkspace }),
}));
assert.equal(missingResponse.status, 200, await missingResponse.clone().text());
const missingBody = await missingResponse.text();
const missingEvents = missingBody
.split("\n")
.filter((line) => line.startsWith("data: "))
.map((line) => JSON.parse(line.slice("data: ".length)));
const missingError = missingEvents.find((event) => event.kind === "error");
assert.equal(missingError?.code, "runtime_missing", "missing OpenCode is classified before the chat launch");
assert.match(missingError?.message ?? "", /OpenCode CLI not found on PATH/, "missing OpenCode uses its install/PATH remediation");
assert.doesNotMatch(missingBody, /installed but not authenticated|produced no output/i, "a missing OpenCode binary never becomes an auth or empty-output diagnosis");
assert.ok(!missingEvents.some((event) => event.kind === "assistant_chunk"), "a missing OpenCode binary streams no fabricated assistant response");
const missingDone = missingEvents.findLast((event) => event.kind === "done");
assert.equal(missingDone?.isError, true, "the no-spawn OpenCode result is terminally errored");
if (missingDone?.sessionId) {
const missingConversation = await loadConversation(missingDone.sessionId);
assert.equal(
(missingConversation?.turns ?? []).filter((turn) => turn.role === "assistant").length,
0,
"the unavailable OpenCode turn never persists a fabricated assistant message",
);
}
} finally {
if (previousHome === undefined) delete process.env.COVEN_HOME;
else process.env.COVEN_HOME = previousHome;
Expand Down
Loading
Loading