fix: enforce max turns from claude args (#1607)

This commit is contained in:
ulofiai
2026-08-07 07:55:06 -07:00
committed by GitHub
parent 1623c36729
commit 6ef6450f51
4 changed files with 111 additions and 1 deletions
+8 -1
View File
@@ -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:
+11
View File
@@ -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 =
@@ -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
+67
View File
@@ -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();
}
});
});