From 161af7867c4872e1eb05de59fc5bdfbff2717b49 Mon Sep 17 00:00:00 2001 From: Gor Grigoryan <150702073+gor-st@users.noreply.github.com> Date: Sun, 14 Dec 2025 18:40:18 +0400 Subject: [PATCH] feat: add session_id output to enable resuming conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new `session_id` output that exposes the Claude Code session ID, allowing other workflows or Claude Code instances to resume the conversation using `--resume `. Changes: - Add parseAndSetSessionId() function to extract session_id from the system.init message in execution output - Add session_id output to both action.yml and base-action/action.yml - Add comprehensive tests for the new functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- action.yml | 3 + base-action/action.yml | 3 + base-action/src/run-claude.ts | 33 ++++++++++ base-action/test/structured-output.test.ts | 71 +++++++++++++++++++++- 4 files changed, 109 insertions(+), 1 deletion(-) diff --git a/action.yml b/action.yml index bb9d43ae4..3ee6f862a 100644 --- a/action.yml +++ b/action.yml @@ -127,6 +127,9 @@ outputs: structured_output: description: "JSON string containing all structured output fields when --json-schema is provided in claude_args. Use fromJSON() to parse: fromJSON(steps.id.outputs.structured_output).field_name" value: ${{ steps.claude-code.outputs.structured_output }} + session_id: + description: "The Claude Code session ID that can be used with --resume to continue this conversation" + value: ${{ steps.claude-code.outputs.session_id }} runs: using: "composite" diff --git a/base-action/action.yml b/base-action/action.yml index 9e2a33715..f5297e80c 100644 --- a/base-action/action.yml +++ b/base-action/action.yml @@ -82,6 +82,9 @@ outputs: structured_output: description: "JSON string containing all structured output fields when --json-schema is provided in claude_args (use fromJSON() or jq to parse)" value: ${{ steps.run_claude.outputs.structured_output }} + 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 }} runs: using: "composite" diff --git a/base-action/src/run-claude.ts b/base-action/src/run-claude.ts index e3308942b..e57f1f996 100644 --- a/base-action/src/run-claude.ts +++ b/base-action/src/run-claude.ts @@ -122,6 +122,36 @@ export function prepareRunConfig( }; } +/** + * Parses session_id from execution file and sets GitHub Action output + * Exported for testing + */ +export async function parseAndSetSessionId( + executionFile: string, +): Promise { + try { + const content = await readFile(executionFile, "utf-8"); + const messages = JSON.parse(content) as { + type: string; + subtype?: string; + session_id?: string; + }[]; + + // Find the system.init message which contains session_id + const initMessage = messages.find( + (m) => m.type === "system" && m.subtype === "init", + ); + + if (initMessage?.session_id) { + core.setOutput("session_id", initMessage.session_id); + core.info(`Set session_id: ${initMessage.session_id}`); + } + } catch (error) { + // Don't fail the action if session_id extraction fails + core.warning(`Failed to extract session_id: ${error}`); + } +} + /** * Parses structured_output from execution file and sets GitHub Action outputs * Only runs if --json-schema was explicitly provided in claude_args @@ -355,6 +385,9 @@ export async function runClaude(promptPath: string, options: ClaudeOptions) { core.setOutput("execution_file", EXECUTION_FILE); + // Extract and set session_id + await parseAndSetSessionId(EXECUTION_FILE); + // Parse and set structured outputs only if user provided --json-schema in claude_args if (hasJsonSchema) { try { diff --git a/base-action/test/structured-output.test.ts b/base-action/test/structured-output.test.ts index dba8312d8..8fde6cb5a 100644 --- a/base-action/test/structured-output.test.ts +++ b/base-action/test/structured-output.test.ts @@ -4,7 +4,10 @@ import { describe, test, expect, afterEach, beforeEach, spyOn } from "bun:test"; import { writeFile, unlink } from "fs/promises"; import { tmpdir } from "os"; import { join } from "path"; -import { parseAndSetStructuredOutputs } from "../src/run-claude"; +import { + parseAndSetStructuredOutputs, + parseAndSetSessionId, +} from "../src/run-claude"; import * as core from "@actions/core"; // Mock execution file path @@ -35,16 +38,19 @@ async function createMockExecutionFile( // Spy on core functions let setOutputSpy: any; let infoSpy: any; +let warningSpy: any; beforeEach(() => { setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {}); infoSpy = spyOn(core, "info").mockImplementation(() => {}); + warningSpy = spyOn(core, "warning").mockImplementation(() => {}); }); describe("parseAndSetStructuredOutputs", () => { afterEach(async () => { setOutputSpy?.mockRestore(); infoSpy?.mockRestore(); + warningSpy?.mockRestore(); try { await unlink(TEST_EXECUTION_FILE); } catch { @@ -156,3 +162,66 @@ describe("parseAndSetStructuredOutputs", () => { ); }); }); + +describe("parseAndSetSessionId", () => { + afterEach(async () => { + setOutputSpy?.mockRestore(); + infoSpy?.mockRestore(); + warningSpy?.mockRestore(); + try { + await unlink(TEST_EXECUTION_FILE); + } catch { + // Ignore if file doesn't exist + } + }); + + test("should extract session_id from system.init message", async () => { + const messages = [ + { type: "system", subtype: "init", session_id: "test-session-123" }, + { type: "result", cost_usd: 0.01 }, + ]; + await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages)); + + await parseAndSetSessionId(TEST_EXECUTION_FILE); + + expect(setOutputSpy).toHaveBeenCalledWith("session_id", "test-session-123"); + expect(infoSpy).toHaveBeenCalledWith("Set session_id: test-session-123"); + }); + + test("should handle missing session_id gracefully", async () => { + const messages = [ + { type: "system", subtype: "init" }, + { type: "result", cost_usd: 0.01 }, + ]; + await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages)); + + await parseAndSetSessionId(TEST_EXECUTION_FILE); + + expect(setOutputSpy).not.toHaveBeenCalled(); + }); + + test("should handle missing system.init message gracefully", async () => { + const messages = [{ type: "result", cost_usd: 0.01 }]; + await writeFile(TEST_EXECUTION_FILE, JSON.stringify(messages)); + + await parseAndSetSessionId(TEST_EXECUTION_FILE); + + expect(setOutputSpy).not.toHaveBeenCalled(); + }); + + test("should handle malformed JSON gracefully with warning", async () => { + await writeFile(TEST_EXECUTION_FILE, "{ invalid json"); + + await parseAndSetSessionId(TEST_EXECUTION_FILE); + + expect(setOutputSpy).not.toHaveBeenCalled(); + expect(warningSpy).toHaveBeenCalled(); + }); + + test("should handle non-existent file gracefully with warning", async () => { + await parseAndSetSessionId("/nonexistent/file.json"); + + expect(setOutputSpy).not.toHaveBeenCalled(); + expect(warningSpy).toHaveBeenCalled(); + }); +});