diff --git a/base-action/src/parse-sdk-options.ts b/base-action/src/parse-sdk-options.ts index a2c58d5c..0cd65580 100644 --- a/base-action/src/parse-sdk-options.ts +++ b/base-action/src/parse-sdk-options.ts @@ -204,6 +204,9 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions { const modelFromClaudeArgs = extraArgs["model"] || undefined; delete extraArgs["model"]; + const maxTurnsFromClaudeArgs = extraArgs["max-turns"] || undefined; + delete extraArgs["max-turns"]; + const additionalDirectories = extraArgs["add-dir"] ? extraArgs["add-dir"] .split(ACCUMULATE_DELIMITER) @@ -308,7 +311,11 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions { const sdkOptions: SdkOptions = { // Direct options from ClaudeOptions inputs model: options.model || modelFromClaudeArgs, - maxTurns: options.maxTurns ? parseInt(options.maxTurns, 10) : undefined, + maxTurns: options.maxTurns + ? parseInt(options.maxTurns, 10) + : maxTurnsFromClaudeArgs + ? parseInt(maxTurnsFromClaudeArgs, 10) + : undefined, allowedTools: mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined, disallowedTools: diff --git a/base-action/src/run-claude-sdk.ts b/base-action/src/run-claude-sdk.ts index f83edf5e..4d3ec73a 100644 --- a/base-action/src/run-claude-sdk.ts +++ b/base-action/src/run-claude-sdk.ts @@ -208,6 +208,17 @@ export async function runClaudeWithSdk( throw new Error("No result message received from Claude"); } + if ( + resultMessage.subtype === "success" && + !resultMessage.is_error && + sdkOptions.maxTurns !== undefined && + resultMessage.num_turns > sdkOptions.maxTurns + ) { + const message = `Claude reported a successful result after ${resultMessage.num_turns} turns, exceeding the configured maximum of ${sdkOptions.maxTurns}`; + core.error(message); + throw new Error(message); + } + // subtype "success" with is_error:true means the run errored without producing // a real result — treat it as failure so CI does not show a misleading green check. const isSuccess = diff --git a/base-action/test/parse-sdk-options.test.ts b/base-action/test/parse-sdk-options.test.ts index 813bafdf..fc524e15 100644 --- a/base-action/test/parse-sdk-options.test.ts +++ b/base-action/test/parse-sdk-options.test.ts @@ -521,6 +521,31 @@ describe("parseSdkOptions", () => { }); }); + describe("max turns handling", () => { + test("should map --max-turns from claudeArgs to sdkOptions.maxTurns", () => { + const options: ClaudeOptions = { + claudeArgs: "--max-turns 60", + }; + + const result = parseSdkOptions(options); + + expect(result.sdkOptions.maxTurns).toBe(60); + expect(result.sdkOptions.extraArgs?.["max-turns"]).toBeUndefined(); + }); + + test("should prefer the direct maxTurns option", () => { + const options: ClaudeOptions = { + maxTurns: "25", + claudeArgs: "--max-turns 60", + }; + + const result = parseSdkOptions(options); + + expect(result.sdkOptions.maxTurns).toBe(25); + expect(result.sdkOptions.extraArgs?.["max-turns"]).toBeUndefined(); + }); + }); + describe("environment variables passthrough", () => { test("should include OTEL environment variables in sdkOptions.env", () => { // Set up test environment variables diff --git a/base-action/test/run-claude-sdk.test.ts b/base-action/test/run-claude-sdk.test.ts index cb503184..0657fb9a 100644 --- a/base-action/test/run-claude-sdk.test.ts +++ b/base-action/test/run-claude-sdk.test.ts @@ -128,4 +128,71 @@ describe("runClaudeWithSdk", () => { coreErrorSpy.mockRestore(); } }); + + test("fails closed when a successful result exceeds maxTurns", async () => { + const consoleErrorSpy = spyOn(console, "error").mockImplementation( + () => {}, + ); + const consoleLogSpy = spyOn(console, "log").mockImplementation(() => {}); + const coreErrorSpy = spyOn( + await import("@actions/core"), + "error", + ).mockImplementation(() => {}); + + tempDir = await mkdtemp(join(tmpdir(), "claude-sdk-")); + process.env.RUNNER_TEMP = tempDir; + + const promptPath = join(tempDir, "prompt.txt"); + await writeFile(promptPath, "test prompt"); + + const initMessage = { + type: "system", + subtype: "init", + session_id: "session-123", + model: "claude-opus-4-7", + }; + + const successResultMessage = { + type: "result", + subtype: "success", + is_error: false, + duration_ms: 960000, + num_turns: 73, + total_cost_usd: 0, + permission_denials: [], + }; + + mock.module("@anthropic-ai/claude-agent-sdk", () => ({ + query: async function* () { + yield initMessage; + yield successResultMessage; + }, + })); + + try { + const { runClaudeWithSdk } = await import("../src/run-claude-sdk"); + + await expect( + runClaudeWithSdk(promptPath, { + sdkOptions: { maxTurns: 60 }, + showFullOutput: false, + hasJsonSchema: false, + }), + ).rejects.toThrow( + "Claude reported a successful result after 73 turns, exceeding the configured maximum of 60", + ); + + const executionFile = join(tempDir, "claude-execution-output.json"); + await expect(readFile(executionFile, "utf-8")).resolves.toBe( + JSON.stringify([initMessage, successResultMessage], null, 2), + ); + expect(coreErrorSpy).toHaveBeenCalledWith( + "Claude reported a successful result after 73 turns, exceeding the configured maximum of 60", + ); + } finally { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + coreErrorSpy.mockRestore(); + } + }); });