From be9912d7923dc882764d6a0996c0b507c98ce217 Mon Sep 17 00:00:00 2001 From: atsushi-ishibashi Date: Sat, 29 Aug 2026 22:07:26 +0900 Subject: [PATCH] docs(claude): document native session resume and add regression test --- docs/content/docs/agents/claude.mdx | 10 ++ examples/claude/client.ts | 40 +++++- software/claude/tests/adapter.test.mjs | 176 ++++++++++++++++++++++++- 3 files changed, 224 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/agents/claude.mdx b/docs/content/docs/agents/claude.mdx index 4486ab7e17..68650ad5a5 100644 --- a/docs/content/docs/agents/claude.mdx +++ b/docs/content/docs/agents/claude.mdx @@ -43,6 +43,16 @@ Expose extra tools to the agent by passing `mcpServers` to `openSession`. Both l **Pre-install `npx`-launched servers.** A local server started with `npx -y …` writes install progress to **stdout** on its first run, which corrupts the MCP stdio handshake (you'll see `Connection closed`). Pre-install it in the VM so `npx` is silent — `await agent.process.exec("npm install -g @modelcontextprotocol/server-filesystem")` before the session — or pin the package and point `command` at the installed binary. +## Persisting sessions across VM restarts + +Session persistence is enabled for the packaged Claude Code agent. Claude Code writes each session to `CLAUDE_CONFIG_DIR`, which defaults to `/home/agentos/.claude`. The `/home/agentos` directory lives on the VM's durable storage, so persisted sessions survive VM sleep and restarts without extra configuration. Read [Persistence](/agentos/docs/persistence) for what the VM keeps across restarts. + +When you prompt a session whose agent process is no longer running, agentOS restores it with the native ACP `session/resume` request. The adapter forwards that to the Claude Agent SDK's `resume` option, which runs `claude --resume ` against the persisted session file, so the resumed session keeps its full state: conversation context, tool results, and compaction. The transcript preamble described in [Sessions](/agentos/docs/sessions#restoration) is used only when the persisted session file cannot be found. The adapter does not disable persistence and does not set `CLAUDE_CODE_SKIP_INITIAL_MESSAGES`, so the resumed session replays correctly. + + + +If you override `CLAUDE_CONFIG_DIR`, keep it under `/home/agentos` or another durable mount. Paths under `/tmp` or in-memory mounts are cleared on restart, and the resume then fails with a resource-not-found error. The session's `env` and `cwd` are stored with the session and replayed on restore, so the override applies to the resumed process too. + ## Customizing the agent Claude Code is a built-in agent, but it's just a software package under the hood. To ship your own ACP adapter, swap the underlying agent SDK, or register a tweaked build as a new agent, see [Custom Agents](/agentos/docs/agents/custom). diff --git a/examples/claude/client.ts b/examples/claude/client.ts index 76c0973080..745cc59ac7 100644 --- a/examples/claude/client.ts +++ b/examples/claude/client.ts @@ -92,6 +92,44 @@ async function withMcp() { // docs:end mcp } +// ── Persisted sessions ──────────────────────────────────────────── +// +// Claude Code writes every session to `CLAUDE_CONFIG_DIR` (default +// `/home/agentos/.claude`). That directory lives on durable VM storage, so a +// session opened before a VM restart can be resumed after it with the SDK's +// native resume rather than a transcript replay. +async function resumeAcrossRestart() { + // docs:start resume + // Keep CLAUDE_CONFIG_DIR under /home/agentos so the session file survives + // VM restarts. This is the default; set it explicitly only to relocate it. + const sessionId = "assistant"; + await agent.sessions.open({ + sessionId, + agent: "claude", + env: { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!, + CLAUDE_CONFIG_DIR: "/home/agentos/.claude", + }, + }); + + await agent.sessions.prompt({ + sessionId, + content: [ + { type: "text", text: "Remember that my favorite color is teal." }, + ], + }); + + // Later, after the VM has slept or restarted, prompt the same session id. + // agentOS restores it through ACP `session/resume`, which runs + // `claude --resume ` against the persisted session file. + const result = await agent.sessions.prompt({ + sessionId, + content: [{ type: "text", text: "What is my favorite color?" }], + }); + console.log(result.message?.content ?? []); + // docs:end resume +} + // ── Skills + MCP together ───────────────────────────────────────── async function withSkillAndMcp() { const skill = `--- @@ -146,4 +184,4 @@ Write commit messages in the imperative mood and keep the subject under 50 chara console.log(result.message?.content ?? []); } -export { quickStart, withSkill, withMcp, withSkillAndMcp }; +export { quickStart, withSkill, withMcp, resumeAcrossRestart, withSkillAndMcp }; diff --git a/software/claude/tests/adapter.test.mjs b/software/claude/tests/adapter.test.mjs index 5e8b10c6a8..f44f48d3f8 100644 --- a/software/claude/tests/adapter.test.mjs +++ b/software/claude/tests/adapter.test.mjs @@ -1,9 +1,12 @@ import test from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { once } from "node:events"; +import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve as resolvePath } from "node:path"; import { Readable, Writable } from "node:stream"; -import { resolve as resolvePath } from "node:path"; import { ClientSideConnection, PROTOCOL_VERSION, @@ -73,6 +76,39 @@ async function withAdapter(run, extraEnv = {}) { } } +/** Recursively collect every `.jsonl` Claude Code wrote under `dir`. */ +function findPersistedSessionFiles(dir, sessionId) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } + return entries.flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return findPersistedSessionFiles(path, sessionId); + return entry.name === `${sessionId}.jsonl` ? [path] : []; + }); +} + +/** Flatten an LLMock-normalized chat message into its text fragments. */ +function messageTexts(message) { + if (typeof message.content === "string") return [message.content]; + if (!Array.isArray(message.content)) return []; + return message.content.flatMap((part) => + typeof part?.text === "string" ? [part.text] : [], + ); +} + +async function initialize(connection) { + return await connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + clientInfo: { name: "agentos-test", version: "0.0.1" }, + }); +} + test("published Claude Agent ACP command initializes over stdio", async () => { await withAdapter(async (connection) => { const result = await connection.initialize({ @@ -239,3 +275,141 @@ test("published Claude Agent ACP prompts a second process while the first remain await mock.stop(); } }); + +test("published Claude Agent ACP resumes a persisted session natively after its adapter process restarts", async () => { + // The packaged agent keeps Claude Code's session directory on durable + // storage (`CLAUDE_CONFIG_DIR`, `/home/agentos/.claude` by default). agentOS + // restores a session after a VM restart with ACP `session/resume`, which the + // adapter maps onto the SDK `resume` option: Claude Code reloads its own + // persisted session file, so the resumed turn carries the prior context + // without any transcript being re-sent by agentOS. + const configDir = mkdtempSync(join(tmpdir(), "agentos-claude-config-")); + const mock = new LLMock({ port: 0, logLevel: "silent" }); + mock.addFixtures([ + { + match: { userMessage: "Reply with resume-first" }, + response: { content: "resume-first" }, + }, + { + match: { userMessage: "Reply with resume-second" }, + response: { content: "resume-second" }, + }, + ]); + const baseUrl = await mock.start(); + const env = { ANTHROPIC_BASE_URL: baseUrl, CLAUDE_CONFIG_DIR: configDir }; + try { + const sessionId = await withAdapter(async (connection) => { + await initialize(connection); + const session = await connection.newSession({ + cwd: packageDir, + mcpServers: [], + }); + const first = await connection.prompt({ + sessionId: session.sessionId, + prompt: [{ type: "text", text: "Reply with resume-first" }], + }); + assert.equal(first.stopReason, "end_turn"); + return session.sessionId; + }, env); + + // Session persistence is on by default: Claude Code wrote the session + // file under CLAUDE_CONFIG_DIR before its adapter process exited. + const persisted = findPersistedSessionFiles( + join(configDir, "projects"), + sessionId, + ); + assert.equal( + persisted.length, + 1, + `expected one persisted session file for ${sessionId} under ${configDir}`, + ); + + const requestsBeforeResume = mock.getRequests().length; + await withAdapter(async (connection) => { + await initialize(connection); + const resumed = await connection.resumeSession({ + sessionId, + cwd: packageDir, + mcpServers: [], + }); + assert.ok( + resumed.configOptions?.some((option) => option.id === "model"), + "resumed session must expose the model config option", + ); + assert.ok( + resumed.configOptions?.some((option) => option.id === "effort"), + "resumed session must expose the agentOS effort config option", + ); + const second = await connection.prompt({ + sessionId, + prompt: [{ type: "text", text: "Reply with resume-second" }], + }); + assert.equal(second.stopReason, "end_turn"); + }, env); + + const resumedTurn = mock + .getRequests() + .slice(requestsBeforeResume) + .map((entry) => entry.body?.messages ?? []) + .find((messages) => + messages.some( + (message) => + message.role === "user" && + messageTexts(message).some((text) => + text.includes("Reply with resume-second"), + ), + ), + ); + assert.ok(resumedTurn, "the resumed prompt must reach the model"); + const priorUser = resumedTurn.filter( + (message) => + message.role === "user" && + messageTexts(message).some((text) => + text.includes("Reply with resume-first"), + ), + ); + const priorAssistant = resumedTurn.filter( + (message) => + message.role === "assistant" && + messageTexts(message).some((text) => text.includes("resume-first")), + ); + assert.equal( + priorUser.length, + 1, + "native resume must restore the prior user turn from the persisted session file", + ); + assert.equal( + priorAssistant.length, + 1, + "native resume must restore the prior assistant turn from the persisted session file", + ); + } finally { + await mock.stop(); + rmSync(configDir, { recursive: true, force: true }); + } +}); + +test("published Claude Agent ACP reports a session missing from CLAUDE_CONFIG_DIR as resource not found", async () => { + // A session directory that did not survive the restart must surface the ACP + // resource-not-found error so agentOS can take its documented fallback path + // instead of silently starting an unrelated session. + const configDir = mkdtempSync(join(tmpdir(), "agentos-claude-config-")); + try { + await withAdapter(async (connection) => { + await initialize(connection); + await assert.rejects( + connection.resumeSession({ + sessionId: randomUUID(), + cwd: packageDir, + mcpServers: [], + }), + (error) => { + assert.equal(error.code, -32002, `unexpected error: ${error.message}`); + return true; + }, + ); + }, { CLAUDE_CONFIG_DIR: configDir }); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } +});