* 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.
This commit is contained in:
@@ -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<void
|
||||
|
||||
// ─── Auto command ─────────────────────────────────────────────────────────
|
||||
if (args.command === 'auto') {
|
||||
// #2832: refuse to silently route non-Claude runtime projects through the
|
||||
// Claude Agent SDK. Load project config (best effort — falls back to
|
||||
// defaults when missing) and gate before constructing GSD/InitRunner.
|
||||
try {
|
||||
const cfg = await loadConfig(args.projectDir, args.ws);
|
||||
assertRuntimeSupportsAutoMode(cfg);
|
||||
} catch (err) {
|
||||
console.error(`Fatal error: ${(err as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const gsd = new GSD({
|
||||
projectDir: args.projectDir,
|
||||
model: args.model,
|
||||
|
||||
84
sdk/src/runtime-gate.test.ts
Normal file
84
sdk/src/runtime-gate.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Unit tests for runtime-gate.ts
|
||||
*
|
||||
* Regression tests for #2832: gsd-sdk auto silently routed Codex (and other
|
||||
* non-Claude) runtime projects through the Claude Agent SDK, picked
|
||||
* Claude-Sonnet defaults from the profile map, and reported instant failures.
|
||||
* The gate fails fast with an actionable error so users either set the right
|
||||
* runtime or fall back to the in-session GSD slash commands.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { assertRuntimeSupportsAutoMode } from './runtime-gate.js';
|
||||
|
||||
describe('assertRuntimeSupportsAutoMode', () => {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
52
sdk/src/runtime-gate.ts
Normal file
52
sdk/src/runtime-gate.ts
Normal file
@@ -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<string, unknown> | { runtime?: unknown }): void {
|
||||
const cfg = (config ?? {}) as Record<string, unknown>;
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
@@ -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<GSDConfig>),
|
||||
);
|
||||
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<GSDConfig>),
|
||||
);
|
||||
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<GSDConfig>),
|
||||
);
|
||||
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<GSDConfig>),
|
||||
);
|
||||
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<GSDConfig>),
|
||||
{ model: 'gpt-5.3-codex' },
|
||||
);
|
||||
const opts = mockQueryCalls[0].options as { model?: string };
|
||||
expect(opts.model).toBe('gpt-5.3-codex');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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<string, string> = {
|
||||
balanced: 'claude-sonnet-4-6',
|
||||
|
||||
Reference in New Issue
Block a user