mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-21 19:08:57 +08:00
fix: enforce max turns from claude args (#1607)
This commit is contained in:
@@ -204,6 +204,9 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
|||||||
const modelFromClaudeArgs = extraArgs["model"] || undefined;
|
const modelFromClaudeArgs = extraArgs["model"] || undefined;
|
||||||
delete extraArgs["model"];
|
delete extraArgs["model"];
|
||||||
|
|
||||||
|
const maxTurnsFromClaudeArgs = extraArgs["max-turns"] || undefined;
|
||||||
|
delete extraArgs["max-turns"];
|
||||||
|
|
||||||
const additionalDirectories = extraArgs["add-dir"]
|
const additionalDirectories = extraArgs["add-dir"]
|
||||||
? extraArgs["add-dir"]
|
? extraArgs["add-dir"]
|
||||||
.split(ACCUMULATE_DELIMITER)
|
.split(ACCUMULATE_DELIMITER)
|
||||||
@@ -308,7 +311,11 @@ export function parseSdkOptions(options: ClaudeOptions): ParsedSdkOptions {
|
|||||||
const sdkOptions: SdkOptions = {
|
const sdkOptions: SdkOptions = {
|
||||||
// Direct options from ClaudeOptions inputs
|
// Direct options from ClaudeOptions inputs
|
||||||
model: options.model || modelFromClaudeArgs,
|
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:
|
allowedTools:
|
||||||
mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined,
|
mergedAllowedTools.length > 0 ? mergedAllowedTools : undefined,
|
||||||
disallowedTools:
|
disallowedTools:
|
||||||
|
|||||||
@@ -208,6 +208,17 @@ export async function runClaudeWithSdk(
|
|||||||
throw new Error("No result message received from Claude");
|
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
|
// 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.
|
// a real result — treat it as failure so CI does not show a misleading green check.
|
||||||
const isSuccess =
|
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", () => {
|
describe("environment variables passthrough", () => {
|
||||||
test("should include OTEL environment variables in sdkOptions.env", () => {
|
test("should include OTEL environment variables in sdkOptions.env", () => {
|
||||||
// Set up test environment variables
|
// Set up test environment variables
|
||||||
|
|||||||
@@ -128,4 +128,71 @@ describe("runClaudeWithSdk", () => {
|
|||||||
coreErrorSpy.mockRestore();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user