From f9ed47ac8b92f7a94bc5f8621f26adbec5ecef00 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 29 Apr 2026 08:03:32 -0400 Subject: [PATCH] fix(#2832): gsd-sdk auto detects Codex runtime correctly (#2844) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#2832): gsd-sdk auto detects Codex runtime correctly Two-part fix for #2832 (gsd-sdk auto silently routing non-Claude runtime projects through the Claude Agent SDK): 1. Runtime gate at the `auto` entry point. New `runtime-gate.ts` exports `assertRuntimeSupportsAutoMode(config)` which throws an actionable error when `GSD_RUNTIME` / `config.runtime` resolves to a non-Claude runtime (codex, gemini, opencode, etc.). The autonomous orchestrator only knows how to drive `@anthropic-ai/claude-agent-sdk` today; failing fast with a clear pointer at the in-session slash commands beats the previous instant `[FAILED] $0.00 0.1s` flake. Wired into `cli.ts` before the GSD/InitRunner construction. 2. Runtime-aware `resolveModel()` in `session-runner.ts`. The profile -> id map (`balanced -> claude-sonnet-4-6`, etc.) was applied unconditionally, so even with `runtime: codex` and `resolve_model_ids: omit` the SDK forced a Claude id into `query()`. Now the profile map only fires when the runtime is Claude and the explicit `resolve_model_ids: "omit"` knob short-circuits to undefined, mirroring `query/config-query.ts`. Tests (vitest, sdk/src): - runtime-gate.test.ts (8 cases): claude / unset / unknown pass; codex, gemini, opencode throw; GSD_RUNTIME wins over config.runtime; error message references #2832 and the slash-command workaround. - session-runner.test.ts (4 new cases under "resolveModel runtime awareness (#2832)"): codex runtime + balanced profile -> no model injected; resolve_model_ids: omit -> no model; claude runtime still resolves to claude-sonnet-4-6 (no regression); explicit options.model wins on any runtime. * fix(#2832): address CR — env-precedence in resolveModel + accurate source attribution Two CodeRabbit findings on PR #2844: 1. session-runner.ts:resolveModel() (Major) — read runtime via detectRuntime() so GSD_RUNTIME env precedence is honored. Without this, a Codex run with a Claude-shaped config still fell into the Claude-only profile-id branch. 2. runtime-gate.ts:assertRuntimeSupportsAutoMode() (Minor) — when GSD_RUNTIME holds an unsupported value, detectRuntime() falls through to config but the source label still reported the discarded env value. Fix: validate env against SUPPORTED_RUNTIMES before attributing the source. Tests added for both: env-precedence in session-runner, source attribution in runtime-gate. 17/17 pass. --- sdk/src/cli.ts | 14 ++++++ sdk/src/runtime-gate.test.ts | 84 ++++++++++++++++++++++++++++++++++ sdk/src/runtime-gate.ts | 52 +++++++++++++++++++++ sdk/src/session-runner.test.ts | 66 ++++++++++++++++++++++++++ sdk/src/session-runner.ts | 26 ++++++++++- 5 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 sdk/src/runtime-gate.test.ts create mode 100644 sdk/src/runtime-gate.ts diff --git a/sdk/src/cli.ts b/sdk/src/cli.ts index ff63fdb89..367b0cd77 100644 --- a/sdk/src/cli.ts +++ b/sdk/src/cli.ts @@ -17,6 +17,8 @@ import { CLITransport } from './cli-transport.js'; import { WSTransport } from './ws-transport.js'; import { InitRunner } from './init-runner.js'; import { validateWorkstreamName } from './workstream-utils.js'; +import { loadConfig } from './config.js'; +import { assertRuntimeSupportsAutoMode } from './runtime-gate.js'; // ─── Parsed CLI args ───────────────────────────────────────────────────────── @@ -556,6 +558,18 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise { + let prevEnv: string | undefined; + beforeEach(() => { + prevEnv = process.env.GSD_RUNTIME; + delete process.env.GSD_RUNTIME; + }); + afterEach(() => { + if (prevEnv === undefined) delete process.env.GSD_RUNTIME; + else process.env.GSD_RUNTIME = prevEnv; + }); + + it('passes for runtime: claude (config)', () => { + expect(() => assertRuntimeSupportsAutoMode({ runtime: 'claude' })).not.toThrow(); + }); + + it('passes when no runtime configured (defaults to claude)', () => { + expect(() => assertRuntimeSupportsAutoMode(undefined)).not.toThrow(); + expect(() => assertRuntimeSupportsAutoMode({})).not.toThrow(); + }); + + it('throws for runtime: codex via config', () => { + expect(() => assertRuntimeSupportsAutoMode({ runtime: 'codex' })).toThrow(/codex/); + }); + + it('throws for GSD_RUNTIME=codex even when config says claude', () => { + process.env.GSD_RUNTIME = 'codex'; + expect(() => assertRuntimeSupportsAutoMode({ runtime: 'claude' })).toThrow(/codex/); + }); + + it('error message references issue #2832 and slash-command workaround', () => { + let caught: Error | undefined; + try { + assertRuntimeSupportsAutoMode({ runtime: 'codex' }); + } catch (err) { + caught = err as Error; + } + expect(caught).toBeDefined(); + expect(caught!.message).toMatch(/#2832/); + expect(caught!.message).toMatch(/gsd-discuss-phase|gsd-plan-phase|gsd-execute-phase/); + }); + + it('throws for gemini runtime', () => { + expect(() => assertRuntimeSupportsAutoMode({ runtime: 'gemini' })).toThrow(); + }); + + it('throws for opencode runtime', () => { + expect(() => assertRuntimeSupportsAutoMode({ runtime: 'opencode' })).toThrow(); + }); + + it('passes for unknown runtime values (fall through to claude default)', () => { + // Mirrors detectRuntime: unknown values are NOT in SUPPORTED_RUNTIMES, + // so they fall through to 'claude' rather than hard-blocking. + expect(() => assertRuntimeSupportsAutoMode({ runtime: 'totally-bogus' })).not.toThrow(); + }); + + it('attributes source to config when GSD_RUNTIME is set to an unsupported value', () => { + // Unsupported env values fall through to config in detectRuntime; the + // gate's error message must report config (not the discarded env value) + // as the source so users debug the right thing. + process.env.GSD_RUNTIME = 'unsupported-env'; + let caught: Error | undefined; + try { + assertRuntimeSupportsAutoMode({ runtime: 'codex' }); + } catch (err) { + caught = err as Error; + } + expect(caught).toBeDefined(); + expect(caught!.message).toMatch(/config\.runtime="codex"/); + expect(caught!.message).not.toMatch(/GSD_RUNTIME=unsupported-env/); + }); +}); diff --git a/sdk/src/runtime-gate.ts b/sdk/src/runtime-gate.ts new file mode 100644 index 000000000..2d00a9bf7 --- /dev/null +++ b/sdk/src/runtime-gate.ts @@ -0,0 +1,52 @@ +/** + * Runtime gate — guards entry points that only support the Claude runtime. + * + * The autonomous SDK orchestrator (`gsd-sdk auto`) currently drives plan + * execution through `@anthropic-ai/claude-agent-sdk`. It has no Codex / + * Gemini / OpenCode dispatcher today, so silently routing a non-Claude + * project's autonomous run through the Claude path is incorrect: it picks + * Claude models, hits Claude APIs, and confuses users debugging "why is my + * Codex run choosing claude-sonnet-4-6?" (issue #2832). + * + * Fail fast with an actionable error instead. The fix surfaces the limitation + * up front and points users at the supported in-session GSD slash commands + * for non-Claude runtimes. + */ +import { detectRuntime, SUPPORTED_RUNTIMES, type Runtime } from './query/helpers.js'; + +/** + * Throw a clear error when the active runtime is not Claude. + * + * Precedence mirrors `detectRuntime`: `GSD_RUNTIME` env var > `config.runtime` + * > `'claude'`. Unknown / missing runtime values default to Claude (the + * historical behavior) so existing Claude users are unaffected. + * + * @param config Project config (with optional `runtime` field). + * @throws Error with a runtime-specific actionable message when non-Claude. + */ +export function assertRuntimeSupportsAutoMode(config?: Record | { runtime?: unknown }): void { + const cfg = (config ?? {}) as Record; + const runtime: Runtime = detectRuntime({ runtime: cfg.runtime }); + if (runtime === 'claude') return; + + // Source attribution must reflect what `detectRuntime()` actually used: + // a `GSD_RUNTIME` value that isn't in SUPPORTED_RUNTIMES falls through to + // the config tier, so reporting it as the source would be misleading. + const env = process.env.GSD_RUNTIME; + const envIsSupported = + typeof env === 'string' && (SUPPORTED_RUNTIMES as readonly string[]).includes(env); + const source = envIsSupported + ? `GSD_RUNTIME=${env}` + : `config.runtime="${String(cfg.runtime ?? '')}"`; + + throw new Error( + `gsd-sdk auto currently supports the Claude runtime only ` + + `(detected runtime=${runtime} via ${source}). ` + + `Autonomous terminal runs through the Claude Agent SDK; non-Claude ` + + `runtimes (Codex, Gemini, OpenCode, etc.) must drive GSD via the ` + + `in-session slash commands (e.g. /gsd-discuss-phase, /gsd-plan-phase, ` + + `/gsd-execute-phase) until issue #2832 lands a multi-runtime executor. ` + + `To run on Claude anyway, unset GSD_RUNTIME and set runtime: "claude" ` + + `in .planning/config.json.`, + ); +} diff --git a/sdk/src/session-runner.test.ts b/sdk/src/session-runner.test.ts index 56ef7a90a..9454dccfe 100644 --- a/sdk/src/session-runner.test.ts +++ b/sdk/src/session-runner.test.ts @@ -95,4 +95,70 @@ describe('runPhaseStepSession', () => { expect(result.success).toBe(true); expect(result.sessionId).toBe('test-session'); }); + + // ─── #2832: runtime-aware model resolution ───────────────────────────────── + // Issue: with `runtime: codex` and `model_profile: balanced`, resolveModel + // mapped balanced -> 'claude-sonnet-4-6' and forced the Codex run through + // a Claude model. For non-Claude runtimes the SDK must NOT inject a Claude + // model id; it should leave model unset (or honor explicit overrides) and + // let the runtime use its configured default. + describe('resolveModel runtime awareness (#2832)', () => { + it('does NOT pass a Claude profile model id when runtime is codex', async () => { + await runPhaseStepSession( + 'prompt', + PhaseStepType.Execute, + makeConfig({ runtime: 'codex', model_profile: 'balanced' } as Partial), + ); + const opts = mockQueryCalls[0].options as { model?: string }; + expect(opts.model).toBeUndefined(); + }); + + it('does NOT pass a Claude profile model id when resolve_model_ids is "omit"', async () => { + await runPhaseStepSession( + 'prompt', + PhaseStepType.Execute, + makeConfig({ resolve_model_ids: 'omit', model_profile: 'balanced' } as Partial), + ); + const opts = mockQueryCalls[0].options as { model?: string }; + expect(opts.model).toBeUndefined(); + }); + + it('still maps profile -> Claude id when runtime is claude (no regression)', async () => { + await runPhaseStepSession( + 'prompt', + PhaseStepType.Execute, + makeConfig({ runtime: 'claude', model_profile: 'balanced' } as Partial), + ); + const opts = mockQueryCalls[0].options as { model?: string }; + expect(opts.model).toBe('claude-sonnet-4-6'); + }); + + it('respects GSD_RUNTIME env precedence over config (no Claude id when env=codex)', async () => { + const prev = process.env.GSD_RUNTIME; + process.env.GSD_RUNTIME = 'codex'; + try { + await runPhaseStepSession( + 'prompt', + PhaseStepType.Execute, + makeConfig({ model_profile: 'balanced' } as Partial), + ); + const opts = mockQueryCalls[0].options as { model?: string }; + expect(opts.model).toBeUndefined(); + } finally { + if (prev === undefined) delete process.env.GSD_RUNTIME; + else process.env.GSD_RUNTIME = prev; + } + }); + + it('explicit options.model wins for any runtime', async () => { + await runPhaseStepSession( + 'prompt', + PhaseStepType.Execute, + makeConfig({ runtime: 'codex' } as Partial), + { model: 'gpt-5.3-codex' }, + ); + const opts = mockQueryCalls[0].options as { model?: string }; + expect(opts.model).toBe('gpt-5.3-codex'); + }); + }); }); diff --git a/sdk/src/session-runner.ts b/sdk/src/session-runner.ts index ed7aac3af..da57de3ac 100644 --- a/sdk/src/session-runner.ts +++ b/sdk/src/session-runner.ts @@ -13,6 +13,7 @@ import type { GSDConfig } from './config.js'; import { buildExecutorPrompt, parseAgentTools, DEFAULT_ALLOWED_TOOLS } from './prompt-builder.js'; import type { GSDEventStream, EventStreamContext } from './event-stream.js'; import { getToolsForPhase } from './tool-scoping.js'; +import { detectRuntime } from './query/helpers.js'; // ─── Model resolution ──────────────────────────────────────────────────────── @@ -20,11 +21,34 @@ import { getToolsForPhase } from './tool-scoping.js'; * Resolve model identifier from options or config profile. * * Priority: explicit model option > config model_profile > default. + * + * Runtime-aware (#2832): the profile -> Claude-id map only applies when the + * project is targeting the Claude runtime. For Codex, Gemini, OpenCode, etc., + * forcing a Claude model id (e.g. 'claude-sonnet-4-6') silently routes the + * autonomous run through the Claude path, which is wrong for those runtimes. + * In those cases — and whenever `resolve_model_ids: "omit"` is set — leave + * `model` unset so the runtime falls back to its configured default. */ function resolveModel(options?: SessionOptions, config?: GSDConfig): string | undefined { if (options?.model) return options.model; - // Map model_profile names to model IDs + // Honor the explicit "don't resolve model ids" config knob (#2652, #2832). + // Mirrors `query/config-query.ts` resolve_model_ids === 'omit' branch. + if ((config as Record | undefined)?.resolve_model_ids === 'omit') { + return undefined; + } + + // Profile -> Claude id map. Applies only on the Claude runtime. + // Use `detectRuntime` so `GSD_RUNTIME` env precedence is honored — a Codex + // run with a Claude-shaped config must NOT be silently routed to Claude. + const runtime = detectRuntime({ + runtime: (config as Record | undefined)?.runtime, + }); + if (runtime !== 'claude') { + // Non-Claude runtimes: never inject a Claude id from the profile map. + return undefined; + } + if (config?.model_profile) { const profileMap: Record = { balanced: 'claude-sonnet-4-6',