Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions base-action/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions base-action/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
59 changes: 59 additions & 0 deletions base-action/src/run-claude-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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;
}
178 changes: 178 additions & 0 deletions base-action/test/run-claude-sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
});
});
3 changes: 3 additions & 0 deletions src/entrypoints/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down