diff --git a/action.yml b/action.yml index 1d6270a9d..2bcb70c84 100644 --- a/action.yml +++ b/action.yml @@ -182,6 +182,9 @@ outputs: session_id: description: "The Claude Code session ID that can be used with --resume to continue this conversation" value: ${{ steps.run.outputs.session_id }} + final_message: + description: "Claude's final assistant text response, extracted from the last message in the stream. Useful for read-only review workflows that want to capture Claude's response in a sandboxed AI job and post it from a separate, more-privileged job (defense-in-depth). Empty when Claude's last action was a tool call rather than text." + value: ${{ steps.run.outputs.final_message }} runs: using: "composite" diff --git a/base-action/action.yml b/base-action/action.yml index d2a1d516b..eab1b0ad3 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -85,6 +85,9 @@ outputs: session_id: description: "The Claude Code session ID that can be used with --resume to continue this conversation" value: ${{ steps.run_claude.outputs.session_id }} + final_message: + description: "Claude's final assistant text response, extracted from the last message in the stream. Useful for read-only review workflows that want to capture Claude's response in a sandboxed AI job and post it from a separate, more-privileged job (defense-in-depth). Empty when Claude's last action was a tool call rather than text." + value: ${{ steps.run_claude.outputs.final_message }} runs: using: "composite" diff --git a/base-action/src/index.ts b/base-action/src/index.ts index 8ec84ac1b..07290363e 100644 --- a/base-action/src/index.ts +++ b/base-action/src/index.ts @@ -62,6 +62,9 @@ async function run() { if (result.structuredOutput) { core.setOutput("structured_output", result.structuredOutput); } + if (result.finalMessage) { + core.setOutput("final_message", result.finalMessage); + } } catch (error) { setExecutionFileOutputIfPresent(); core.setFailed(`Action failed with error: ${error}`); diff --git a/base-action/src/run-claude-sdk.ts b/base-action/src/run-claude-sdk.ts index e65d93c26..96abca2e0 100644 --- a/base-action/src/run-claude-sdk.ts +++ b/base-action/src/run-claude-sdk.ts @@ -15,6 +15,14 @@ export type ClaudeRunResult = { sessionId?: string; conclusion: "success" | "failure"; structuredOutput?: string; + /** + * Claude's final assistant text response, extracted from the last + * `type: "assistant"` message in the stream. Useful for read-only + * review workflows that capture the response in a sandboxed AI job + * and post it from a separate, more-privileged job (defense-in-depth). + * Undefined when Claude's last action was a tool call (no text). + */ + finalMessage?: string; }; /** Filename for the user request file, written by prompt generation */ @@ -129,6 +137,48 @@ function sanitizeSdkOutput( return null; } +/** + * Extract the final assistant text response from the message stream. + * + * Walks `messages` backward to find the last `type: "assistant"` message + * and joins all of its `type: "text"` content blocks with newlines. + * + * Returns `undefined` when there are no assistant messages, or when the + * last assistant message contains only tool_use blocks (no text). This is + * intentional: when the agent's last action was a tool call rather than + * speech, there is no "final message" to surface. + * + * Mirrors the text-extraction logic in src/entrypoints/format-turns.ts so + * the output here matches what users see in the Step Summary. + */ +export function extractFinalAssistantMessage( + messages: SDKMessage[], +): string | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (!msg || msg.type !== "assistant") continue; + if (!("message" in msg) || !msg.message) continue; + + const content = (msg.message as { content?: unknown }).content; + if (!Array.isArray(content)) return undefined; + + const textParts: string[] = []; + for (const block of content) { + if ( + block != null && + typeof block === "object" && + (block as { type?: unknown }).type === "text" && + typeof (block as { text?: unknown }).text === "string" + ) { + textParts.push((block as { text: string }).text); + } + } + + return textParts.length > 0 ? textParts.join("\n") : undefined; + } + return undefined; +} + /** * Run Claude using the Agent SDK */ @@ -236,5 +286,14 @@ export async function runClaudeWithSdk( ); } + // Extract Claude's final assistant text response for downstream steps. + // See ClaudeRunResult.finalMessage for the rationale (defense-in-depth + // review workflows that post from a separate, more-privileged job). + const finalMessage = extractFinalAssistantMessage(messages); + if (finalMessage) { + result.finalMessage = finalMessage; + core.info(`Set final_message (${finalMessage.length} chars)`); + } + return result; } diff --git a/base-action/test/run-claude-sdk.test.ts b/base-action/test/run-claude-sdk.test.ts index 877e88463..b6bb2926d 100644 --- a/base-action/test/run-claude-sdk.test.ts +++ b/base-action/test/run-claude-sdk.test.ts @@ -4,6 +4,8 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { mkdtemp, readFile, rm, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; +import { extractFinalAssistantMessage } from "../src/run-claude-sdk"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; describe("runClaudeWithSdk", () => { const originalRunnerTemp = process.env.RUNNER_TEMP; @@ -64,3 +66,179 @@ describe("runClaudeWithSdk", () => { } }); }); + +/** + * Helper to cast plain test fixtures to SDKMessage[]. + * The real SDK types are unions with many discriminator fields; tests only + * need to match the shape that `extractFinalAssistantMessage` reads. + */ +const asMessages = (fixtures: unknown[]): SDKMessage[] => + fixtures as SDKMessage[]; + +describe("extractFinalAssistantMessage", () => { + test("returns undefined for an empty message stream", () => { + expect(extractFinalAssistantMessage(asMessages([]))).toBeUndefined(); + }); + + test("returns undefined when there are no assistant messages", () => { + const messages = asMessages([ + { type: "system", subtype: "init", session_id: "abc", tools: [] }, + { + type: "user", + message: { role: "user", content: [{ type: "text", text: "hi" }] }, + }, + { type: "result", subtype: "success", is_error: false }, + ]); + + expect(extractFinalAssistantMessage(messages)).toBeUndefined(); + }); + + test("returns text from a single assistant message", () => { + const messages = asMessages([ + { + type: "assistant", + message: { + content: [{ type: "text", text: "Here is my review." }], + }, + }, + ]); + + expect(extractFinalAssistantMessage(messages)).toBe("Here is my review."); + }); + + test("joins multiple text blocks in a single assistant message with newlines", () => { + const messages = asMessages([ + { + type: "assistant", + message: { + content: [ + { type: "text", text: "First paragraph." }, + { type: "text", text: "Second paragraph." }, + ], + }, + }, + ]); + + expect(extractFinalAssistantMessage(messages)).toBe( + "First paragraph.\nSecond paragraph.", + ); + }); + + test("ignores tool_use blocks and returns only text content", () => { + const messages = asMessages([ + { + type: "assistant", + message: { + content: [ + { type: "text", text: "Let me check the diff." }, + { + type: "tool_use", + id: "tool_1", + name: "Read", + input: { path: "diff.txt" }, + }, + { type: "text", text: "Looks good overall." }, + ], + }, + }, + ]); + + expect(extractFinalAssistantMessage(messages)).toBe( + "Let me check the diff.\nLooks good overall.", + ); + }); + + test("returns undefined when the last assistant message has only tool_use blocks", () => { + const messages = asMessages([ + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "tool_1", + name: "Read", + input: { path: "x" }, + }, + ], + }, + }, + ]); + + expect(extractFinalAssistantMessage(messages)).toBeUndefined(); + }); + + test("does NOT walk back to earlier assistants when the last has only tool_use", () => { + // Intentional behavior: "final message" means the literal last assistant's + // text response, not any earlier text the agent said. If the last assistant + // turn is a tool call, we surface undefined rather than digging further. + const messages = asMessages([ + { + type: "assistant", + message: { + content: [{ type: "text", text: "Earlier thoughts." }], + }, + }, + { + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "tool_1", + name: "Read", + input: { path: "x" }, + }, + ], + }, + }, + ]); + + expect(extractFinalAssistantMessage(messages)).toBeUndefined(); + }); + + test("finds the last assistant when followed by a result message", () => { + const messages = asMessages([ + { + type: "assistant", + message: { + content: [{ type: "text", text: "All done." }], + }, + }, + { type: "result", subtype: "success", is_error: false }, + ]); + + expect(extractFinalAssistantMessage(messages)).toBe("All done."); + }); + + test("returns the LAST assistant's text when multiple assistant messages exist", () => { + const messages = asMessages([ + { + type: "assistant", + message: { content: [{ type: "text", text: "First reply." }] }, + }, + { + type: "user", + message: { role: "user", content: [{ type: "text", text: "ok" }] }, + }, + { + type: "assistant", + message: { content: [{ type: "text", text: "Final reply." }] }, + }, + ]); + + expect(extractFinalAssistantMessage(messages)).toBe("Final reply."); + }); + + test("returns undefined when assistant message has non-array content", () => { + // Defensive: malformed SDK output shouldn't crash extraction. + const messages = asMessages([ + { + type: "assistant", + message: { content: "not an array" }, + }, + ]); + + expect(extractFinalAssistantMessage(messages)).toBeUndefined(); + }); +}); diff --git a/src/entrypoints/run.ts b/src/entrypoints/run.ts index d5dcba5ca..b934cb9c9 100644 --- a/src/entrypoints/run.ts +++ b/src/entrypoints/run.ts @@ -301,6 +301,9 @@ async function run() { if (claudeResult.structuredOutput) { core.setOutput("structured_output", claudeResult.structuredOutput); } + if (claudeResult.finalMessage) { + core.setOutput("final_message", claudeResult.finalMessage); + } core.setOutput("conclusion", claudeResult.conclusion); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error);