Skip to content

Commit be9912d

Browse files
docs(claude): document native session resume and add regression test
1 parent 65dc5e6 commit be9912d

3 files changed

Lines changed: 224 additions & 2 deletions

File tree

docs/content/docs/agents/claude.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ Expose extra tools to the agent by passing `mcpServers` to `openSession`. Both l
4343
**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.
4444
</Note>
4545

46+
## Persisting sessions across VM restarts
47+
48+
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.
49+
50+
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 <sessionId>` 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.
51+
52+
<CodeSnippet file="examples/claude/client.ts" title="client.ts" region="resume" />
53+
54+
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.
55+
4656
## Customizing the agent
4757

4858
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).

examples/claude/client.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,44 @@ async function withMcp() {
9292
// docs:end mcp
9393
}
9494

95+
// ── Persisted sessions ────────────────────────────────────────────
96+
//
97+
// Claude Code writes every session to `CLAUDE_CONFIG_DIR` (default
98+
// `/home/agentos/.claude`). That directory lives on durable VM storage, so a
99+
// session opened before a VM restart can be resumed after it with the SDK's
100+
// native resume rather than a transcript replay.
101+
async function resumeAcrossRestart() {
102+
// docs:start resume
103+
// Keep CLAUDE_CONFIG_DIR under /home/agentos so the session file survives
104+
// VM restarts. This is the default; set it explicitly only to relocate it.
105+
const sessionId = "assistant";
106+
await agent.sessions.open({
107+
sessionId,
108+
agent: "claude",
109+
env: {
110+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!,
111+
CLAUDE_CONFIG_DIR: "/home/agentos/.claude",
112+
},
113+
});
114+
115+
await agent.sessions.prompt({
116+
sessionId,
117+
content: [
118+
{ type: "text", text: "Remember that my favorite color is teal." },
119+
],
120+
});
121+
122+
// Later, after the VM has slept or restarted, prompt the same session id.
123+
// agentOS restores it through ACP `session/resume`, which runs
124+
// `claude --resume <sessionId>` against the persisted session file.
125+
const result = await agent.sessions.prompt({
126+
sessionId,
127+
content: [{ type: "text", text: "What is my favorite color?" }],
128+
});
129+
console.log(result.message?.content ?? []);
130+
// docs:end resume
131+
}
132+
95133
// ── Skills + MCP together ─────────────────────────────────────────
96134
async function withSkillAndMcp() {
97135
const skill = `---
@@ -146,4 +184,4 @@ Write commit messages in the imperative mood and keep the subject under 50 chara
146184
console.log(result.message?.content ?? []);
147185
}
148186

149-
export { quickStart, withSkill, withMcp, withSkillAndMcp };
187+
export { quickStart, withSkill, withMcp, resumeAcrossRestart, withSkillAndMcp };

software/claude/tests/adapter.test.mjs

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import test from "node:test";
22
import assert from "node:assert/strict";
33
import { spawn } from "node:child_process";
4+
import { randomUUID } from "node:crypto";
45
import { once } from "node:events";
6+
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
7+
import { tmpdir } from "node:os";
8+
import { join, resolve as resolvePath } from "node:path";
59
import { Readable, Writable } from "node:stream";
6-
import { resolve as resolvePath } from "node:path";
710
import {
811
ClientSideConnection,
912
PROTOCOL_VERSION,
@@ -73,6 +76,39 @@ async function withAdapter(run, extraEnv = {}) {
7376
}
7477
}
7578

79+
/** Recursively collect every `<sessionId>.jsonl` Claude Code wrote under `dir`. */
80+
function findPersistedSessionFiles(dir, sessionId) {
81+
let entries;
82+
try {
83+
entries = readdirSync(dir, { withFileTypes: true });
84+
} catch (error) {
85+
if (error.code === "ENOENT") return [];
86+
throw error;
87+
}
88+
return entries.flatMap((entry) => {
89+
const path = join(dir, entry.name);
90+
if (entry.isDirectory()) return findPersistedSessionFiles(path, sessionId);
91+
return entry.name === `${sessionId}.jsonl` ? [path] : [];
92+
});
93+
}
94+
95+
/** Flatten an LLMock-normalized chat message into its text fragments. */
96+
function messageTexts(message) {
97+
if (typeof message.content === "string") return [message.content];
98+
if (!Array.isArray(message.content)) return [];
99+
return message.content.flatMap((part) =>
100+
typeof part?.text === "string" ? [part.text] : [],
101+
);
102+
}
103+
104+
async function initialize(connection) {
105+
return await connection.initialize({
106+
protocolVersion: PROTOCOL_VERSION,
107+
clientCapabilities: {},
108+
clientInfo: { name: "agentos-test", version: "0.0.1" },
109+
});
110+
}
111+
76112
test("published Claude Agent ACP command initializes over stdio", async () => {
77113
await withAdapter(async (connection) => {
78114
const result = await connection.initialize({
@@ -239,3 +275,141 @@ test("published Claude Agent ACP prompts a second process while the first remain
239275
await mock.stop();
240276
}
241277
});
278+
279+
test("published Claude Agent ACP resumes a persisted session natively after its adapter process restarts", async () => {
280+
// The packaged agent keeps Claude Code's session directory on durable
281+
// storage (`CLAUDE_CONFIG_DIR`, `/home/agentos/.claude` by default). agentOS
282+
// restores a session after a VM restart with ACP `session/resume`, which the
283+
// adapter maps onto the SDK `resume` option: Claude Code reloads its own
284+
// persisted session file, so the resumed turn carries the prior context
285+
// without any transcript being re-sent by agentOS.
286+
const configDir = mkdtempSync(join(tmpdir(), "agentos-claude-config-"));
287+
const mock = new LLMock({ port: 0, logLevel: "silent" });
288+
mock.addFixtures([
289+
{
290+
match: { userMessage: "Reply with resume-first" },
291+
response: { content: "resume-first" },
292+
},
293+
{
294+
match: { userMessage: "Reply with resume-second" },
295+
response: { content: "resume-second" },
296+
},
297+
]);
298+
const baseUrl = await mock.start();
299+
const env = { ANTHROPIC_BASE_URL: baseUrl, CLAUDE_CONFIG_DIR: configDir };
300+
try {
301+
const sessionId = await withAdapter(async (connection) => {
302+
await initialize(connection);
303+
const session = await connection.newSession({
304+
cwd: packageDir,
305+
mcpServers: [],
306+
});
307+
const first = await connection.prompt({
308+
sessionId: session.sessionId,
309+
prompt: [{ type: "text", text: "Reply with resume-first" }],
310+
});
311+
assert.equal(first.stopReason, "end_turn");
312+
return session.sessionId;
313+
}, env);
314+
315+
// Session persistence is on by default: Claude Code wrote the session
316+
// file under CLAUDE_CONFIG_DIR before its adapter process exited.
317+
const persisted = findPersistedSessionFiles(
318+
join(configDir, "projects"),
319+
sessionId,
320+
);
321+
assert.equal(
322+
persisted.length,
323+
1,
324+
`expected one persisted session file for ${sessionId} under ${configDir}`,
325+
);
326+
327+
const requestsBeforeResume = mock.getRequests().length;
328+
await withAdapter(async (connection) => {
329+
await initialize(connection);
330+
const resumed = await connection.resumeSession({
331+
sessionId,
332+
cwd: packageDir,
333+
mcpServers: [],
334+
});
335+
assert.ok(
336+
resumed.configOptions?.some((option) => option.id === "model"),
337+
"resumed session must expose the model config option",
338+
);
339+
assert.ok(
340+
resumed.configOptions?.some((option) => option.id === "effort"),
341+
"resumed session must expose the agentOS effort config option",
342+
);
343+
const second = await connection.prompt({
344+
sessionId,
345+
prompt: [{ type: "text", text: "Reply with resume-second" }],
346+
});
347+
assert.equal(second.stopReason, "end_turn");
348+
}, env);
349+
350+
const resumedTurn = mock
351+
.getRequests()
352+
.slice(requestsBeforeResume)
353+
.map((entry) => entry.body?.messages ?? [])
354+
.find((messages) =>
355+
messages.some(
356+
(message) =>
357+
message.role === "user" &&
358+
messageTexts(message).some((text) =>
359+
text.includes("Reply with resume-second"),
360+
),
361+
),
362+
);
363+
assert.ok(resumedTurn, "the resumed prompt must reach the model");
364+
const priorUser = resumedTurn.filter(
365+
(message) =>
366+
message.role === "user" &&
367+
messageTexts(message).some((text) =>
368+
text.includes("Reply with resume-first"),
369+
),
370+
);
371+
const priorAssistant = resumedTurn.filter(
372+
(message) =>
373+
message.role === "assistant" &&
374+
messageTexts(message).some((text) => text.includes("resume-first")),
375+
);
376+
assert.equal(
377+
priorUser.length,
378+
1,
379+
"native resume must restore the prior user turn from the persisted session file",
380+
);
381+
assert.equal(
382+
priorAssistant.length,
383+
1,
384+
"native resume must restore the prior assistant turn from the persisted session file",
385+
);
386+
} finally {
387+
await mock.stop();
388+
rmSync(configDir, { recursive: true, force: true });
389+
}
390+
});
391+
392+
test("published Claude Agent ACP reports a session missing from CLAUDE_CONFIG_DIR as resource not found", async () => {
393+
// A session directory that did not survive the restart must surface the ACP
394+
// resource-not-found error so agentOS can take its documented fallback path
395+
// instead of silently starting an unrelated session.
396+
const configDir = mkdtempSync(join(tmpdir(), "agentos-claude-config-"));
397+
try {
398+
await withAdapter(async (connection) => {
399+
await initialize(connection);
400+
await assert.rejects(
401+
connection.resumeSession({
402+
sessionId: randomUUID(),
403+
cwd: packageDir,
404+
mcpServers: [],
405+
}),
406+
(error) => {
407+
assert.equal(error.code, -32002, `unexpected error: ${error.message}`);
408+
return true;
409+
},
410+
);
411+
}, { CLAUDE_CONFIG_DIR: configDir });
412+
} finally {
413+
rmSync(configDir, { recursive: true, force: true });
414+
}
415+
});

0 commit comments

Comments
 (0)