diff --git a/base-action/src/run-claude-sdk.ts b/base-action/src/run-claude-sdk.ts index 67d2da1d..f83edf5e 100644 --- a/base-action/src/run-claude-sdk.ts +++ b/base-action/src/run-claude-sdk.ts @@ -208,7 +208,10 @@ export async function runClaudeWithSdk( throw new Error("No result message received from Claude"); } - const isSuccess = resultMessage.subtype === "success"; + // 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 = + resultMessage.subtype === "success" && !resultMessage.is_error; result.conclusion = isSuccess ? "success" : "failure"; // Handle structured output @@ -234,14 +237,21 @@ export async function runClaudeWithSdk( } if (!isSuccess) { + if (resultMessage.subtype === "success" && resultMessage.is_error) { + core.error( + "Claude result reported subtype success with is_error:true (run did not complete successfully)", + ); + } if ("errors" in resultMessage && resultMessage.errors) { core.error(`Execution failed: ${resultMessage.errors.join(", ")}`); } throw new Error( `Claude execution failed: ${ - "errors" in resultMessage && resultMessage.errors - ? resultMessage.errors.join(", ") - : "unknown error" + resultMessage.subtype === "success" && resultMessage.is_error + ? "result is_error:true" + : "errors" in resultMessage && resultMessage.errors + ? resultMessage.errors.join(", ") + : "unknown error" }`, ); } diff --git a/base-action/test/run-claude-sdk.test.ts b/base-action/test/run-claude-sdk.test.ts index 877e8846..cb503184 100644 --- a/base-action/test/run-claude-sdk.test.ts +++ b/base-action/test/run-claude-sdk.test.ts @@ -63,4 +63,69 @@ describe("runClaudeWithSdk", () => { consoleLogSpy.mockRestore(); } }); + + test("fails when result subtype is success but is_error is true", 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-sonnet-5", + }; + + const errorResultMessage = { + type: "result", + subtype: "success", + is_error: true, + duration_ms: 434, + num_turns: 1, + total_cost_usd: 0, + permission_denials: [], + }; + + mock.module("@anthropic-ai/claude-agent-sdk", () => ({ + query: async function* () { + yield initMessage; + yield errorResultMessage; + }, + })); + + try { + const { runClaudeWithSdk } = await import("../src/run-claude-sdk"); + + await expect( + runClaudeWithSdk(promptPath, { + sdkOptions: {}, + showFullOutput: false, + hasJsonSchema: false, + }), + ).rejects.toThrow("result is_error:true"); + + const executionFile = join(tempDir, "claude-execution-output.json"); + await expect(readFile(executionFile, "utf-8")).resolves.toBe( + JSON.stringify([initMessage, errorResultMessage], null, 2), + ); + expect(coreErrorSpy).toHaveBeenCalledWith( + "Claude result reported subtype success with is_error:true (run did not complete successfully)", + ); + } finally { + consoleErrorSpy.mockRestore(); + consoleLogSpy.mockRestore(); + coreErrorSpy.mockRestore(); + } + }); });