Files
msd-core/sdk/src/session-runner.test.ts
Tom Boucher f9ed47ac8b fix(#2832): gsd-sdk auto detects Codex runtime correctly (#2844)
* 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.
2026-04-29 08:03:32 -04:00

165 lines
6.6 KiB
TypeScript

/**
* Unit tests for session-runner.ts
*
* Regression test for #2194: runPhaseStepSession was passing the full prompt
* string as both the user-visible prompt: message and systemPrompt.append,
* doubling the token cost on every phase step invocation.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PhaseStepType } from './types.js';
import { CONFIG_DEFAULTS } from './config.js';
import type { GSDConfig } from './config.js';
// ─── Mock the Agent SDK ───────────────────────────────────────────────────────
// Capture the query call options so we can assert on them without making real API calls.
const mockQueryCalls: Array<{ prompt: string; options: Record<string, unknown> }> = [];
vi.mock('@anthropic-ai/claude-agent-sdk', () => {
async function* fakeQueryStream() {
// Yield a minimal success result message so processQueryStream completes.
yield {
type: 'result',
subtype: 'success',
session_id: 'test-session',
total_cost_usd: 0,
duration_ms: 1,
num_turns: 1,
usage: {
input_tokens: 10,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
};
}
return {
query: vi.fn((args: { prompt: string; options: Record<string, unknown> }) => {
mockQueryCalls.push({ prompt: args.prompt, options: args.options });
return fakeQueryStream();
}),
};
});
// ─── Import SUT after mock is hoisted ────────────────────────────────────────
import { runPhaseStepSession } from './session-runner.js';
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeConfig(overrides: Partial<GSDConfig> = {}): GSDConfig {
return { ...CONFIG_DEFAULTS, ...overrides };
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('runPhaseStepSession', () => {
beforeEach(() => {
mockQueryCalls.length = 0;
});
it('does not duplicate the prompt in both the user message and systemPrompt.append', async () => {
const fullPrompt = 'You are a researcher. Investigate the topic thoroughly.\n\n## Context\nDetailed instructions here...';
await runPhaseStepSession(fullPrompt, PhaseStepType.Research, makeConfig());
expect(mockQueryCalls).toHaveLength(1);
const call = mockQueryCalls[0];
const appendValue = (call.options.systemPrompt as { append?: string })?.append;
// The full prompt must appear in systemPrompt.append (that is its correct location).
expect(appendValue).toBe(fullPrompt);
// The user-visible prompt: must NOT be the full prompt — it should be a short directive.
expect(call.prompt).not.toBe(fullPrompt);
expect(call.prompt.length).toBeLessThan(fullPrompt.length);
});
it('passes the full prompt in systemPrompt.append', async () => {
const fullPrompt = 'Complex multi-line\nprompt with $VARIABLES and $(command) patterns.';
await runPhaseStepSession(fullPrompt, PhaseStepType.Execute, makeConfig());
const call = mockQueryCalls[0];
const appendValue = (call.options.systemPrompt as { append?: string })?.append;
expect(appendValue).toBe(fullPrompt);
});
it('returns a successful PlanResult', async () => {
const result = await runPhaseStepSession('Test prompt', PhaseStepType.Verify, makeConfig());
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');
});
});
});