From 1005f02db26dd6c80be47fbf48065cc6128e0ac9 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 15 Apr 2026 14:59:27 -0400 Subject: [PATCH] fix(sdk): stop duplicating prompt in runPhaseStepSession user message (#2223) runPhaseStepSession was passing the full prompt string as both the user-visible prompt: argument and as systemPrompt.append, sending the same (potentially large) text twice per invocation and doubling the token cost for every phase step session. runPlanSession correctly uses a short directive as the user message and reserves the full content for systemPrompt.append only. Apply the same pattern to runPhaseStepSession: use a brief "Execute this phase step: " directive as the user message. Closes #2194 Co-authored-by: Claude Sonnet 4.6 --- sdk/src/session-runner.test.ts | 98 ++++++++++++++++++++++++++++++++++ sdk/src/session-runner.ts | 2 +- 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 sdk/src/session-runner.test.ts diff --git a/sdk/src/session-runner.test.ts b/sdk/src/session-runner.test.ts new file mode 100644 index 000000000..56ef7a90a --- /dev/null +++ b/sdk/src/session-runner.test.ts @@ -0,0 +1,98 @@ +/** + * 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 }> = []; + +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 }) => { + 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 { + 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'); + }); +}); diff --git a/sdk/src/session-runner.ts b/sdk/src/session-runner.ts index 716f0da81..693a5927b 100644 --- a/sdk/src/session-runner.ts +++ b/sdk/src/session-runner.ts @@ -277,7 +277,7 @@ export async function runPhaseStepSession( const cwd = options?.cwd ?? process.cwd(); const queryStream = query({ - prompt: prompt, + prompt: `Execute this phase step: ${phaseStep}`, options: { systemPrompt: { type: 'preset',