From c02d8726da537322b3c1fc26423a91f56b7bf34c Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 10 Jul 2026 15:23:33 -0700 Subject: [PATCH] fix(agents): persist and resume native sessions --- crates/agentos-sidecar-core/src/engine.rs | 56 +++++-- crates/agentos-sidecar/src/acp_extension.rs | 60 +++++-- .../core/tests/agent-pkg-matrix.e2e.test.ts | 69 +++++--- .../tests/claude-session-persistence.test.ts | 151 ++++++++++++++++++ .../core/tests/fixtures/agent-matrix-cell.mjs | 97 ++++++++++- packages/core/tests/opencode-session.test.ts | 9 +- packages/core/tests/pi-cli-headless.test.ts | 110 ++++++++++++- packages/core/tests/pi-headless.test.ts | 116 +++++++++++++- registry/agent/claude/src/adapter.ts | 87 ++++++++-- registry/agent/claude/tests/adapter.test.mjs | 26 ++- registry/agent/pi/src/adapter.ts | 81 +++++++--- .../agent/pi/tests/session-manager.test.mjs | 68 +++++--- website/public/docs/docs/agents/claude.md | 9 +- website/public/docs/docs/agents/pi.md | 9 +- .../docs/architecture/sessions-persistence.md | 6 +- 15 files changed, 832 insertions(+), 122 deletions(-) create mode 100644 packages/core/tests/claude-session-persistence.test.ts diff --git a/crates/agentos-sidecar-core/src/engine.rs b/crates/agentos-sidecar-core/src/engine.rs index 8c7ab25586..ac587f623a 100644 --- a/crates/agentos-sidecar-core/src/engine.rs +++ b/crates/agentos-sidecar-core/src/engine.rs @@ -1098,24 +1098,36 @@ fn native_resume_method(agent_capabilities: Option<&Value>) -> Option<&'static s None } -/// Normalize an adapter "no such session" error (`-32603` + `details == -/// "NotFoundError"`) into the shared `unknown_session` discriminator. Strict on -/// purpose: malformed `session/load` must still propagate. Mirrors the native -/// `normalize_unknown_session_error`. +/// Normalize the exact missing-session shapes emitted by OpenCode and pi-acp +/// into the shared `unknown_session` discriminator. Strict on purpose: malformed +/// `session/load` must still propagate. Mirrors the native sidecar implementation. fn normalize_unknown_session_error(response: &mut Value) { let Some(error) = response.get_mut("error").and_then(Value::as_object_mut) else { return; }; let code = error.get("code").and_then(Value::as_i64); - let Some(data) = error.get_mut("data").and_then(Value::as_object_mut) else { + if code == Some(-32603) { + let Some(data) = error.get_mut("data").and_then(Value::as_object_mut) else { + return; + }; + if data.get("details").and_then(Value::as_str) == Some("NotFoundError") { + data.insert( + String::from("kind"), + Value::String(String::from("unknown_session")), + ); + } return; - }; - let details = data.get("details").and_then(Value::as_str); - if code == Some(-32603) && details == Some("NotFoundError") { - data.insert( - String::from("kind"), - Value::String(String::from("unknown_session")), - ); + } + if code == Some(-32602) { + let Some(details) = error.get("data").and_then(Value::as_str) else { + return; + }; + if details.starts_with("Unknown sessionId:") { + error.insert( + String::from("data"), + serde_json::json!({ "kind": "unknown_session", "details": details }), + ); + } } } @@ -1256,6 +1268,26 @@ mod tests { use super::*; use crate::host::{AgentOutput, SpawnAgentRequest, SpawnedAgent}; + #[test] + fn unknown_session_normalization_pins_adapter_shapes() { + let mut opencode = json!({ + "error": { "code": -32603, "data": { "details": "NotFoundError" } } + }); + normalize_unknown_session_error(&mut opencode); + assert!(is_unknown_session_error(&opencode)); + + let mut pi_acp = json!({ + "error": { "code": -32602, "data": "Unknown sessionId: missing" } + }); + normalize_unknown_session_error(&mut pi_acp); + assert!(is_unknown_session_error(&pi_acp)); + + let mut malformed = json!({ + "error": { "code": -32602, "data": { "sessionId": ["expected string"] } } + }); + normalize_unknown_session_error(&mut malformed); + assert!(!is_unknown_session_error(&malformed)); + } #[derive(Default)] struct MockHost { killed: Vec<(String, String)>, diff --git a/crates/agentos-sidecar/src/acp_extension.rs b/crates/agentos-sidecar/src/acp_extension.rs index 4f2ef41491..f46ab6162c 100644 --- a/crates/agentos-sidecar/src/acp_extension.rs +++ b/crates/agentos-sidecar/src/acp_extension.rs @@ -2788,27 +2788,37 @@ fn trace_acp_response(method: &str, response: &Value) { /// Normalize adapter-specific "no such session" errors from `session/load` into /// the shared `unknown_session` discriminator used by the resume state machine. /// -/// OpenCode currently reports a missing session as JSON-RPC `-32603` with -/// `error.data.details == "NotFoundError"`: its ACP server converts thrown -/// non-`RequestError` exceptions into `internalError({ details: error.message })`, -/// and `Session.get` throws a `NotFoundError` whose message is the class name. -/// Convert exactly that shape into `error.data.kind = "unknown_session"` before -/// fallback matching. Do not broaden this to message substrings or all -/// `-32603`/`-32602` errors; malformed `session/load` must still propagate. +/// OpenCode reports `-32603` plus `{ details: "NotFoundError" }`. pi-acp 0.0.23 +/// reports `-32602` with a string data value beginning `Unknown sessionId:`. +/// Normalize only those exact adapter shapes; malformed load requests and other +/// internal errors must still propagate. fn normalize_unknown_session_error(response: &mut Value) { let Some(error) = response.get_mut("error").and_then(Value::as_object_mut) else { return; }; let code = error.get("code").and_then(Value::as_i64); - let Some(data) = error.get_mut("data").and_then(Value::as_object_mut) else { + if code == Some(-32603) { + let Some(data) = error.get_mut("data").and_then(Value::as_object_mut) else { + return; + }; + if data.get("details").and_then(Value::as_str) == Some("NotFoundError") { + data.insert( + String::from("kind"), + Value::String(String::from("unknown_session")), + ); + } return; - }; - let details = data.get("details").and_then(Value::as_str); - if code == Some(-32603) && details == Some("NotFoundError") { - data.insert( - String::from("kind"), - Value::String(String::from("unknown_session")), - ); + } + if code == Some(-32602) { + let Some(details) = error.get("data").and_then(Value::as_str) else { + return; + }; + if details.starts_with("Unknown sessionId:") { + error.insert( + String::from("data"), + serde_json::json!({ "kind": "unknown_session", "details": details }), + ); + } } } @@ -3100,7 +3110,7 @@ mod tests { } #[test] - fn unknown_session_normalization_pins_opencode_shape() { + fn unknown_session_normalization_pins_adapter_shapes() { let mut opencode = serde_json::json!({ "error": { "code": -32603, "message": "Internal error", "data": { "details": "NotFoundError" } } }); @@ -3111,6 +3121,17 @@ mod tests { ); assert!(is_unknown_session_error(&opencode)); + let mut pi_acp = serde_json::json!({ + "error": { "code": -32602, "message": "Invalid params", + "data": "Unknown sessionId: missing-pi-session" } + }); + normalize_unknown_session_error(&mut pi_acp); + assert_eq!( + pi_acp.pointer("/error/data/kind").and_then(Value::as_str), + Some("unknown_session") + ); + assert!(is_unknown_session_error(&pi_acp)); + let mut malformed = serde_json::json!({ "error": { "code": -32602, "message": "Invalid params", "data": { "_errors": [], "sessionId": { "_errors": ["expected string"] } } } @@ -3118,6 +3139,13 @@ mod tests { normalize_unknown_session_error(&mut malformed); assert!(!is_unknown_session_error(&malformed)); + let mut other_invalid_params = serde_json::json!({ + "error": { "code": -32602, "message": "Invalid params", + "data": "cwd must be an absolute path" } + }); + normalize_unknown_session_error(&mut other_invalid_params); + assert!(!is_unknown_session_error(&other_invalid_params)); + let mut other_internal = serde_json::json!({ "error": { "code": -32603, "message": "Internal error", "data": { "details": "SomethingElse" } } }); diff --git a/packages/core/tests/agent-pkg-matrix.e2e.test.ts b/packages/core/tests/agent-pkg-matrix.e2e.test.ts index 6355008f45..0fdcf53c74 100644 --- a/packages/core/tests/agent-pkg-matrix.e2e.test.ts +++ b/packages/core/tests/agent-pkg-matrix.e2e.test.ts @@ -10,7 +10,8 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; * For every package manager (npm/pnpm/yarn/bun) × every agent * (pi/pi-cli/claude/opencode) this installs the PUBLISHED packages into an * isolated temp project and asserts a real user can: install → create a session - * → prompt → stream tokens LIVE. It is the regression net for the exact issues + * → prompt → stream tokens LIVE → native resume → transcript restore. It is the + * regression net for the exact issues * that bit us shipping the preview: * * - retired/stale model ids (Anthropic 404 → empty turn), @@ -30,6 +31,9 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; * ANTHROPIC_API_KEY required * AGENTOS_MATRIX_CORE @rivet-dev/agentos-core version/tag (default "latest") * AGENTOS_MATRIX_AGENTS @agentos-software/* version/tag (default "latest") + * AGENTOS_MATRIX_CORE_SPEC exact install spec, e.g. file:/path/to/packages/core + * AGENTOS_MATRIX__SPEC exact per-agent install spec; PI_CLI uses an underscore + * AGENTOS_MATRIX_REPO_ROOT import built artifacts from this checkout; skips installation * AGENTOS_MATRIX_MODEL opencode model id (default a current Haiku) * AGENTOS_MATRIX_PMS comma list to restrict package managers * AGENTOS_MATRIX_AGENTS_LIST comma list to restrict agents @@ -40,6 +44,7 @@ const CORE_VERSION = process.env.AGENTOS_MATRIX_CORE || "latest"; const AGENTS_VERSION = process.env.AGENTOS_MATRIX_AGENTS || "latest"; const CELL = resolve(import.meta.dirname, "fixtures/agent-matrix-cell.mjs"); const CELL_TIMEOUT_MS = 240_000; +const REPO_ROOT = process.env.AGENTOS_MATRIX_REPO_ROOT; const AGENT_PKGS: Record = { pi: "@agentos-software/pi", @@ -48,6 +53,11 @@ const AGENT_PKGS: Record = { opencode: "@agentos-software/opencode", }; +function agentInstallSpec(agent: string): string { + const envName = `AGENTOS_MATRIX_${agent.toUpperCase().replaceAll("-", "_")}_SPEC`; + return process.env[envName] || `${AGENT_PKGS[agent]}@${AGENTS_VERSION}`; +} + function commandAvailable(cmd: string): boolean { try { const r = spawnSync(cmd, ["--version"], { stdio: "ignore" }); @@ -97,6 +107,7 @@ const ALL_AGENTS = ( const availablePms = ALL_PMS.filter(commandAvailable); +// biome-ignore format: keep the matrix definition readable with inline Vitest options. describe.skipIf(!ENABLED)("agent × package-manager e2e matrix (real API)", () => { const tmpDirs: string[] = []; @@ -130,13 +141,14 @@ describe.skipIf(!ENABLED)("agent × package-manager e2e matrix (real API)", () = for (const pm of availablePms) { for (const agent of ALL_AGENTS) { it( - `${pm} + ${agent}: install → session → live token streaming`, - // opencode's ACP bootstrap (and real LLM APIs) flake transiently; - // retry the whole cell so a flake doesn't red the gate. Persistent - // failures still fail after the retries. + `${pm} + ${agent}: install → stream → native resume → transcript restore`, + // OpenCode's ACP bootstrap and real LLM APIs can flake transiently. + // Retry the whole cell; persistent failures still fail the gate. { timeout: CELL_TIMEOUT_MS + 200_000, retry: 2 }, async () => { - const dir = mkdtempSync(join(tmpdir(), `agentos-matrix-${pm}-${agent}-`)); + const dir = mkdtempSync( + join(tmpdir(), `agentos-matrix-${pm}-${agent}-`), + ); tmpDirs.push(dir); // yarn 1.x global cache contends under repeated runs; isolate it. const cacheDir = join(dir, ".pm-cache"); @@ -147,17 +159,20 @@ describe.skipIf(!ENABLED)("agent × package-manager e2e matrix (real API)", () = npm_config_cache: cacheDir, }; - const pkgs = [ - `@rivet-dev/agentos-core@${CORE_VERSION}`, - `${AGENT_PKGS[agent]}@${AGENTS_VERSION}`, - ]; - for (const [cmd, args] of installArgs(pm, pkgs)) { - execFileSync(cmd, args, { - cwd: dir, - env: childEnv, - stdio: "pipe", - timeout: 180_000, - }); + if (!REPO_ROOT) { + const pkgs = [ + process.env.AGENTOS_MATRIX_CORE_SPEC || + `@rivet-dev/agentos-core@${CORE_VERSION}`, + agentInstallSpec(agent), + ]; + for (const [cmd, args] of installArgs(pm, pkgs)) { + execFileSync(cmd, args, { + cwd: dir, + env: childEnv, + stdio: "pipe", + timeout: 180_000, + }); + } } cpSync(CELL, join(dir, "agent-matrix-cell.mjs")); @@ -180,15 +195,27 @@ describe.skipIf(!ENABLED)("agent × package-manager e2e matrix (real API)", () = const result = JSON.parse(line.slice("E2E_RESULT_JSON:".length)); // eslint-disable-next-line no-console - console.log(`[matrix] ${pm}/${agent}:`, JSON.stringify(result.metrics)); - - expect(result.ok, `prompt produced output (err: ${result.error})`).toBe( - true, + console.log( + `[matrix] ${pm}/${agent}:`, + JSON.stringify({ metrics: result.metrics, error: result.error }), ); + + expect( + result.ok, + `prompt produced output (err: ${result.error})`, + ).toBe(true); expect( result.streaming, `tokens streamed live (metrics: ${JSON.stringify(result.metrics)})`, ).toBe(true); + expect( + result.nativeResume, + `native session state resumed (metrics: ${JSON.stringify(result.metrics)})`, + ).toBe(true); + expect( + result.transcriptRestore, + `missing native state restored from transcript (metrics: ${JSON.stringify(result.metrics)})`, + ).toBe(true); }, ); } diff --git a/packages/core/tests/claude-session-persistence.test.ts b/packages/core/tests/claude-session-persistence.test.ts new file mode 100644 index 0000000000..7e4bf1ab15 --- /dev/null +++ b/packages/core/tests/claude-session-persistence.test.ts @@ -0,0 +1,151 @@ +import claude from "@agentos-software/claude-code"; +import { describe, expect, test } from "vitest"; +import { AgentOs } from "../src/agent-os.js"; +import { + createAnthropicFixture, + startLlmock, + stopLlmock, +} from "./helpers/llmock-helper.js"; + +function requestContains(request: unknown, expected: string): boolean { + return JSON.stringify(request).includes(expected); +} + +async function createClaudeVm(mockUrl: string): Promise { + return AgentOs.create({ + loopbackExemptPorts: [Number(new URL(mockUrl).port)], + software: [claude], + }); +} + +async function closeSessionAndWait( + vm: AgentOs, + sessionId: string, +): Promise { + vm.closeSession(sessionId); + const closePromise = ( + vm as AgentOs & { _sessionClosePromises: Map> } + )._sessionClosePromises.get(sessionId); + if (closePromise) await closePromise; +} + +async function waitForClaudeSession(vm: AgentOs, sessionId: string) { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const files = await vm.readdirRecursive("/home/agentos/.claude"); + if (files.some((entry) => entry.path.includes(sessionId))) return files; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return vm.readdirRecursive("/home/agentos/.claude"); +} + +describe("Claude native session persistence", () => { + test("resumes an existing persisted Claude session natively", async () => { + const firstPrompt = "Remember the native Claude token: maple-1618."; + const secondPrompt = "What native Claude token did I give you?"; + const result = await startLlmock([ + createAnthropicFixture( + { predicate: (req) => requestContains(req, firstPrompt) }, + { content: "I will remember maple-1618." }, + ), + createAnthropicFixture( + { predicate: (req) => requestContains(req, secondPrompt) }, + { content: "The token was maple-1618." }, + ), + ]); + const vm = await createClaudeVm(result.url); + + let sessionId: string | undefined; + try { + const env = { + ANTHROPIC_API_KEY: "mock-key", + ANTHROPIC_BASE_URL: result.url, + }; + sessionId = ( + await vm.createSession("claude", { cwd: "/home/agentos", env }) + ).sessionId; + const persistedSessionId = sessionId; + expect(vm.getSessionCapabilities(sessionId)?.loadSession).toBe(true); + expect( + (await vm.prompt(sessionId, firstPrompt)).response.error, + ).toBeUndefined(); + const persistedFiles = await waitForClaudeSession(vm, sessionId); + await closeSessionAndWait(vm, sessionId); + expect( + persistedFiles.some((entry) => entry.path.includes(persistedSessionId)), + `Claude did not persist session ${sessionId}; files: ${persistedFiles + .map((entry) => entry.path) + .join(", ")}`, + ).toBe(true); + + const resumed = await vm.resumeSession(sessionId, "claude", { + cwd: "/home/agentos", + env, + transcriptPath: `/root/.agentos/threads/${sessionId}.md`, + }); + expect(resumed).toEqual({ sessionId, mode: "native" }); + expect( + (await vm.prompt(resumed.sessionId, secondPrompt)).response.error, + ).toBeUndefined(); + + const secondRequest = result.mock + .getRequests() + .find((request) => requestContains(request, secondPrompt)); + expect(secondRequest).toBeDefined(); + expect(requestContains(secondRequest, firstPrompt)).toBe(true); + expect( + requestContains(secondRequest, "You are continuing an earlier session"), + ).toBe(false); + } finally { + if (sessionId) vm.closeSession(sessionId); + await vm.dispose(); + await stopLlmock(result.mock); + } + }, 120_000); + + test("restores Claude from the transcript when native state is missing", async () => { + const result = await startLlmock([ + createAnthropicFixture( + { predicate: () => true }, + { content: "Recovered from the transcript pointer." }, + ), + ]); + const vm = await createClaudeVm(result.url); + + let liveSessionId: string | undefined; + try { + const externalSessionId = "00000000-0000-4000-8000-000000000077"; + const transcriptPath = `/root/.agentos/threads/${externalSessionId}.md`; + const resumed = await vm.resumeSession(externalSessionId, "claude", { + cwd: "/home/agentos", + env: { + ANTHROPIC_API_KEY: "mock-key", + ANTHROPIC_BASE_URL: result.url, + }, + transcriptPath, + }); + liveSessionId = resumed.sessionId; + expect(resumed.mode).toBe("fallback"); + + expect( + (await vm.prompt(liveSessionId, "Continue the recovered session.")) + .response.error, + ).toBeUndefined(); + expect( + result.mock + .getRequests() + .some( + (request) => + requestContains( + request, + "You are continuing an earlier session", + ) && requestContains(request, transcriptPath), + ), + ).toBe(true); + } finally { + if (liveSessionId) vm.closeSession(liveSessionId); + await vm.dispose(); + await stopLlmock(result.mock); + } + }, 120_000); +}); diff --git a/packages/core/tests/fixtures/agent-matrix-cell.mjs b/packages/core/tests/fixtures/agent-matrix-cell.mjs index efe4745701..d51c917150 100644 --- a/packages/core/tests/fixtures/agent-matrix-cell.mjs +++ b/packages/core/tests/fixtures/agent-matrix-cell.mjs @@ -9,6 +9,9 @@ // Driven by env: AGENT (pi|pi-cli|claude|opencode), ANTHROPIC_API_KEY, // AGENTOS_MATRIX_MODEL (opencode model id; must be a CURRENT id). +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + const AGENT = process.env.AGENT || "pi"; const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; const ANTHROPIC_BASE_URL = @@ -26,10 +29,36 @@ const PKG = { }[AGENT]; if (!PKG) throw new Error(`unknown AGENT ${AGENT}`); -const { AgentOs } = await import("@rivet-dev/agentos-core"); -const software = (await import(PKG)).default; - -const result = { agent: AGENT, pkg: PKG, ok: false, streaming: false, error: null }; +const REPO_ROOT = process.env.AGENTOS_MATRIX_REPO_ROOT; +const localAgentDirs = { + pi: "pi", + "pi-cli": "pi-cli", + claude: "claude", + opencode: "opencode", +}; +const importTarget = (path) => pathToFileURL(path).href; +const coreModule = REPO_ROOT + ? await import(importTarget(join(REPO_ROOT, "packages/core/dist/index.js"))) + : await import("@rivet-dev/agentos-core"); +const agentModule = REPO_ROOT + ? await import( + importTarget( + join(REPO_ROOT, "registry/agent", localAgentDirs[AGENT], "dist/index.js"), + ), + ) + : await import(PKG); +const { AgentOs } = coreModule; +const software = agentModule.default; + +const result = { + agent: AGENT, + pkg: PKG, + ok: false, + streaming: false, + nativeResume: false, + transcriptRestore: false, + error: null, +}; let vm; let sessionId; @@ -113,9 +142,10 @@ try { }); promptStart = performance.now(); + const nativeToken = `native-${AGENT}-2718`; const { text, response } = await vm.prompt( sessionId, - "Write a haiku about the ocean. Output only the haiku.", + `Remember the token ${nativeToken}. Then write a haiku about the ocean. Output only the haiku.`, ); const resolvedAt = performance.now() - promptStart; @@ -141,6 +171,53 @@ try { result.ok = !response?.error && (text || "").length > 0; result.streaming = streaming; + + vm.closeSession(sessionId); + await vm._sessionClosePromises?.get(sessionId); + const native = await vm.resumeSession(sessionId, AGENT, { + cwd: workspaceDir, + env, + transcriptPath: `/root/.agentos/threads/${sessionId}.md`, + }); + const nativeReply = await vm.prompt( + native.sessionId, + "What token did I ask you to remember? Output only the token.", + ); + result.nativeResume = + native.mode === "native" && + native.sessionId === sessionId && + nativeReply.text.includes(nativeToken); + + vm.closeSession(native.sessionId); + await vm._sessionClosePromises?.get(native.sessionId); + const missingSessionId = `00000000-0000-4000-8000-${ + { + pi: "000000000041", + "pi-cli": "000000000042", + claude: "000000000043", + opencode: "000000000044", + }[AGENT] + }`; + const restoreToken = `restore-${AGENT}-3141`; + const transcriptDir = `${workspaceDir}/.agentos/threads`; + const transcriptPath = `${transcriptDir}/${missingSessionId}.md`; + await vm.mkdir(transcriptDir, { recursive: true }); + await vm.writeFile( + transcriptPath, + `# Earlier conversation\n\nUser asked the agent to remember ${restoreToken}.`, + ); + const restored = await vm.resumeSession(missingSessionId, AGENT, { + cwd: workspaceDir, + env, + transcriptPath, + }); + sessionId = restored.sessionId; + const restoredReply = await vm.prompt( + restored.sessionId, + `Use your file-reading tool to read ${transcriptPath}, then output only its restore token. Do not describe what you will do.`, + ); + result.transcriptRestore = + restored.mode === "fallback" && restoredReply.text.includes(restoreToken); result.metrics = { resolvedAt: Math.round(resolvedAt), totalUpdates: updates.length, @@ -152,6 +229,10 @@ try { gapMs: Math.round(gap), textLen: (text || "").length, textSample: (text || "").slice(0, 80), + nativeResumeMode: native.mode, + nativeReply: nativeReply.text.slice(0, 80), + restoreMode: restored.mode, + restoreReply: restoredReply.text.slice(0, 80), }; } catch (err) { result.error = String(err?.stack || err); @@ -165,4 +246,8 @@ try { } console.log("E2E_RESULT_JSON:" + JSON.stringify(result)); -process.exit(result.ok && result.streaming ? 0 : 1); +process.exit( + result.ok && result.streaming && result.nativeResume && result.transcriptRestore + ? 0 + : 1, +); diff --git a/packages/core/tests/opencode-session.test.ts b/packages/core/tests/opencode-session.test.ts index 89084aedd8..7ea397a8ad 100644 --- a/packages/core/tests/opencode-session.test.ts +++ b/packages/core/tests/opencode-session.test.ts @@ -1,11 +1,12 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { createServer, type IncomingMessage, type ServerResponse, } from "node:http"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import opencode from "@agentos-software/opencode"; import type { Fixture, ToolCall } from "@copilotkit/llmock"; import { describe, expect, test } from "vitest"; import type { AgentCapabilities, AgentInfo } from "../src/agent-os.js"; @@ -16,12 +17,12 @@ import { startLlmock, stopLlmock, } from "./helpers/llmock-helper.js"; +import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import { createVmOpenCodeHome, createVmWorkspace, readVmText, } from "./helpers/opencode-helper.js"; -import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; const MODULE_ACCESS_CWD = resolve(import.meta.dirname, ".."); const REGISTRY_COMMAND_DIR_CANDIDATES = [ @@ -243,14 +244,14 @@ async function createOpenCodeVm(mockUrl: string): Promise { return AgentOs.create({ loopbackExemptPorts: [Number(new URL(mockUrl).port)], mounts: moduleAccessMounts(MODULE_ACCESS_CWD), - // opencode is pre-packed + projected by default. - software: [...shellSoftware], + software: [opencode, ...shellSoftware], }); } async function createOpenCodeOnlyVm(mockUrl: string): Promise { return AgentOs.create({ loopbackExemptPorts: [Number(new URL(mockUrl).port)], + software: [opencode], }); } diff --git a/packages/core/tests/pi-cli-headless.test.ts b/packages/core/tests/pi-cli-headless.test.ts index 5f639a3498..d0aa525149 100644 --- a/packages/core/tests/pi-cli-headless.test.ts +++ b/packages/core/tests/pi-cli-headless.test.ts @@ -1,8 +1,7 @@ import { resolve } from "node:path"; -import type { Fixture, ToolCall } from "@copilotkit/llmock"; -import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import common from "@agentos-software/common"; import piCli from "@agentos-software/pi-cli"; +import type { Fixture, ToolCall } from "@copilotkit/llmock"; import { describe, expect, test } from "vitest"; import { AgentOs } from "../src/agent-os.js"; import { @@ -10,6 +9,7 @@ import { startLlmock, stopLlmock, } from "./helpers/llmock-helper.js"; +import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; const MODULE_ACCESS_CWD = resolve(import.meta.dirname, ".."); @@ -21,6 +21,10 @@ function getRequestBody(req: unknown): Record { : direct; } +function requestContains(req: unknown, expected: string): boolean { + return JSON.stringify(getRequestBody(req)).includes(expected); +} + function createToolFixtures( toolCall: ToolCall, expectedToolResult: string, @@ -152,6 +156,108 @@ describe("full createSession('pi-cli') inside the VM", () => { } }, 120_000); + test("resumes an existing persisted Pi CLI session natively", async () => { + const firstPrompt = "Remember the native Pi CLI token: cedar-3141."; + const secondPrompt = "What native Pi CLI token did I give you?"; + const { mock, url } = await startLlmock([ + createAnthropicFixture( + { predicate: (req) => requestContains(req, firstPrompt) }, + { content: "I will remember cedar-3141." }, + ), + createAnthropicFixture( + { predicate: (req) => requestContains(req, secondPrompt) }, + { content: "The token was cedar-3141." }, + ), + ]); + const vm = await createPiCliVm(url); + + let sessionId: string | undefined; + try { + const homeDir = await createVmPiHome(vm, url); + const workspaceDir = await createVmWorkspace(vm); + const env = { + HOME: homeDir, + ANTHROPIC_API_KEY: "mock-key", + ANTHROPIC_BASE_URL: url, + }; + sessionId = (await vm.createSession("pi-cli", { cwd: workspaceDir, env })) + .sessionId; + expect(vm.getSessionCapabilities(sessionId)?.loadSession).toBe(true); + expect( + (await vm.prompt(sessionId, firstPrompt)).response.error, + ).toBeUndefined(); + vm.closeSession(sessionId); + + const resumed = await vm.resumeSession(sessionId, "pi-cli", { + cwd: workspaceDir, + env, + transcriptPath: `/root/.agentos/threads/${sessionId}.md`, + }); + expect(resumed).toEqual({ sessionId, mode: "native" }); + expect( + (await vm.prompt(resumed.sessionId, secondPrompt)).response.error, + ).toBeUndefined(); + const secondRequest = mock + .getRequests() + .find((request) => requestContains(request, secondPrompt)); + expect(secondRequest).toBeDefined(); + expect(requestContains(secondRequest, firstPrompt)).toBe(true); + } finally { + if (sessionId) vm.closeSession(sessionId); + await vm.dispose(); + await stopLlmock(mock); + } + }, 120_000); + + test("restores Pi CLI from the transcript when native state is missing", async () => { + const { mock, url } = await startLlmock([ + createAnthropicFixture( + { predicate: () => true }, + { content: "Recovered from the transcript pointer." }, + ), + ]); + const vm = await createPiCliVm(url); + + let liveSessionId: string | undefined; + try { + const homeDir = await createVmPiHome(vm, url); + const workspaceDir = await createVmWorkspace(vm); + const externalSessionId = "00000000-0000-4000-8000-000000000088"; + const transcriptPath = `/root/.agentos/threads/${externalSessionId}.md`; + const resumed = await vm.resumeSession(externalSessionId, "pi-cli", { + cwd: workspaceDir, + env: { + HOME: homeDir, + ANTHROPIC_API_KEY: "mock-key", + ANTHROPIC_BASE_URL: url, + }, + transcriptPath, + }); + liveSessionId = resumed.sessionId; + expect(resumed.mode).toBe("fallback"); + + expect( + (await vm.prompt(liveSessionId, "Continue the recovered session.")) + .response.error, + ).toBeUndefined(); + expect( + mock + .getRequests() + .some( + (request) => + requestContains( + request, + "You are continuing an earlier session", + ) && requestContains(request, transcriptPath), + ), + ).toBe(true); + } finally { + if (liveSessionId) vm.closeSession(liveSessionId); + await vm.dispose(); + await stopLlmock(mock); + } + }, 120_000); + // Blocked on shell `>` redirect output being visible to `vm.readFile()`. // This is the unmodified upstream Pi CLI bash path (`createLocalBashOperations` // spawning the shell directly), with no Agent OS operations override, so the diff --git a/packages/core/tests/pi-headless.test.ts b/packages/core/tests/pi-headless.test.ts index b1cd922223..b39aad4de2 100644 --- a/packages/core/tests/pi-headless.test.ts +++ b/packages/core/tests/pi-headless.test.ts @@ -1,8 +1,7 @@ import { resolve } from "node:path"; -import type { Fixture, ToolCall } from "@copilotkit/llmock"; -import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; import common from "@agentos-software/common"; import pi from "@agentos-software/pi"; +import type { Fixture, ToolCall } from "@copilotkit/llmock"; import { describe, expect, test } from "vitest"; import type { AgentCapabilities, AgentInfo } from "../src/agent-os.js"; import { AgentOs } from "../src/agent-os.js"; @@ -11,6 +10,7 @@ import { startLlmock, stopLlmock, } from "./helpers/llmock-helper.js"; +import { moduleAccessMounts } from "./helpers/node-modules-mount.js"; const MODULE_ACCESS_CWD = resolve(import.meta.dirname, ".."); @@ -22,6 +22,10 @@ function getRequestBody(req: unknown): Record { : direct; } +function requestContains(req: unknown, expected: string): boolean { + return JSON.stringify(getRequestBody(req)).includes(expected); +} + function createToolFixtures( toolCall: ToolCall, expectedToolResult: string, @@ -206,6 +210,114 @@ describe("full createSession('pi') inside the VM", () => { } }, 120_000); + test("resumes an existing persisted Pi session natively", async () => { + const firstPrompt = "Remember the native Pi token: orchid-2718."; + const secondPrompt = "What native Pi token did I give you?"; + const { mock, url } = await startLlmock([ + createAnthropicFixture( + { predicate: (req) => requestContains(req, firstPrompt) }, + { content: "I will remember orchid-2718." }, + ), + createAnthropicFixture( + { predicate: (req) => requestContains(req, secondPrompt) }, + { content: "The token was orchid-2718." }, + ), + ]); + const vm = await createPiVm(url); + + let sessionId: string | undefined; + try { + const homeDir = await createVmPiHome(vm, url); + const workspaceDir = await createVmWorkspace(vm); + const env = { + HOME: homeDir, + ANTHROPIC_API_KEY: "mock-key", + ANTHROPIC_BASE_URL: url, + }; + sessionId = (await vm.createSession("pi", { cwd: workspaceDir, env })) + .sessionId; + + expect(vm.getSessionCapabilities(sessionId)?.loadSession).toBe(true); + expect( + (await vm.prompt(sessionId, firstPrompt)).response.error, + ).toBeUndefined(); + vm.closeSession(sessionId); + + const resumed = await vm.resumeSession(sessionId, "pi", { + cwd: workspaceDir, + env, + transcriptPath: `/root/.agentos/threads/${sessionId}.md`, + }); + expect(resumed).toEqual({ sessionId, mode: "native" }); + + expect( + (await vm.prompt(resumed.sessionId, secondPrompt)).response.error, + ).toBeUndefined(); + const secondRequest = mock + .getRequests() + .find((request) => requestContains(request, secondPrompt)); + expect(secondRequest).toBeDefined(); + expect(requestContains(secondRequest, firstPrompt)).toBe(true); + expect( + requestContains(secondRequest, "You are continuing an earlier session"), + ).toBe(false); + } finally { + if (sessionId) vm.closeSession(sessionId); + await vm.dispose(); + await stopLlmock(mock); + } + }, 120_000); + + test("restores Pi from the transcript when native state is missing", async () => { + const { mock, url } = await startLlmock([ + createAnthropicFixture( + { predicate: () => true }, + { content: "Recovered from the transcript pointer." }, + ), + ]); + const vm = await createPiVm(url); + + let liveSessionId: string | undefined; + try { + const homeDir = await createVmPiHome(vm, url); + const workspaceDir = await createVmWorkspace(vm); + const externalSessionId = "00000000-0000-4000-8000-000000000099"; + const transcriptPath = `/root/.agentos/threads/${externalSessionId}.md`; + const resumed = await vm.resumeSession(externalSessionId, "pi", { + cwd: workspaceDir, + env: { + HOME: homeDir, + ANTHROPIC_API_KEY: "mock-key", + ANTHROPIC_BASE_URL: url, + }, + transcriptPath, + }); + liveSessionId = resumed.sessionId; + expect(resumed.mode).toBe("fallback"); + expect(resumed.sessionId).not.toBe(externalSessionId); + + expect( + (await vm.prompt(liveSessionId, "Continue the recovered session.")) + .response.error, + ).toBeUndefined(); + expect( + mock + .getRequests() + .some( + (request) => + requestContains( + request, + "You are continuing an earlier session", + ) && requestContains(request, transcriptPath), + ), + ).toBe(true); + } finally { + if (liveSessionId) vm.closeSession(liveSessionId); + await vm.dispose(); + await stopLlmock(mock); + } + }, 120_000); + // Blocked on shell `>` redirect output being visible to `vm.readFile()`. // The vanilla Pi SDK bash backend spawns the shell directly and the redirect // runs inside the guest shell, but the written bytes do not reconcile to the diff --git a/registry/agent/claude/src/adapter.ts b/registry/agent/claude/src/adapter.ts index 99e87d2b85..f9504f7da8 100644 --- a/registry/agent/claude/src/adapter.ts +++ b/registry/agent/claude/src/adapter.ts @@ -8,6 +8,8 @@ import { type CancelNotification, type InitializeRequest, type InitializeResponse, + type LoadSessionRequest, + type LoadSessionResponse, type NewSessionRequest, type NewSessionResponse, type PromptRequest, @@ -16,6 +18,7 @@ import { type SessionUpdate, type SetSessionModeRequest, type SetSessionModeResponse, + RequestError, ndJsonStream, } from "@agentclientprotocol/sdk"; import { @@ -68,6 +71,17 @@ const traceAdapterMessages = process.env.CLAUDE_CODE_TRACE_ADAPTER_MESSAGES === "1"; const traceFile = process.env.CLAUDE_CODE_TRACE_FILE; +function claudeConfigDir(): string { + const configured = process.env.CLAUDE_CONFIG_DIR?.trim(); + const resolved = + configured || resolvePath(process.env.HOME?.trim() || "/home/agentos", ".claude"); + // The SDK lookup helpers and the spawned CLI otherwise derive their home via + // different runtime shims inside the VM and can disagree on /root vs + // /home/agentos. Pin the value once so persisted sessions remain discoverable. + process.env.CLAUDE_CONFIG_DIR = resolved; + return resolved; +} + function traceAdapter(message: string): void { if (!traceAdapterMessages) return; process.stderr.write(`[agentos-claude] ${message}\n`); @@ -79,6 +93,7 @@ function traceAdapter(message: string): void { async function loadPatchedClaudeSdkRuntime(): Promise< { cliPath: string; + getSessionMessages: typeof import("@anthropic-ai/claude-agent-sdk").getSessionMessages; query: typeof import("@anthropic-ai/claude-agent-sdk").query; } > { @@ -89,6 +104,7 @@ async function loadPatchedClaudeSdkRuntime(): Promise< const runtime = await import(resolveClaudeSdkPath({ packageDir, sdkPath })); return { cliPath, + getSessionMessages: runtime.getSessionMessages, query: runtime.query, }; } @@ -354,9 +370,10 @@ export class ClaudeQuerySession { readonly sessionId: string, private readonly cwd: string, private mode: PermissionMode, - params: NewSessionRequest, + params: NewSessionRequest | LoadSessionRequest, private readonly pathToClaudeCodeExecutable: string, queryFactory: QueryFactory, + resumeSessionId?: string, ) { traceAdapter( `session_ctor id=${sessionId} cwd=${cwd} mode=${mode} cli=${pathToClaudeCodeExecutable}`, @@ -368,6 +385,7 @@ export class ClaudeQuerySession { cwd, env: { ...process.env, + CLAUDE_CONFIG_DIR: claudeConfigDir(), CLAUDE_CODE_SHELL: process.env.CLAUDE_CODE_SHELL ?? "/bin/sh", CLAUDE_CODE_IGNORE_STARTUP_EXIT_CODE: @@ -382,7 +400,9 @@ export class ClaudeQuerySession { CLAUDE_CODE_NODE_SHELL_WRAPPER: process.env.CLAUDE_CODE_NODE_SHELL_WRAPPER ?? "1", CLAUDE_CODE_SKIP_INITIAL_MESSAGES: - process.env.CLAUDE_CODE_SKIP_INITIAL_MESSAGES ?? "1", + // The initial-message path initializes Claude's on-disk transcript. + // Skipping it makes a new session impossible to resume natively. + "0", CLAUDE_CODE_SKIP_SPECIAL_ENTRYPOINTS: process.env.CLAUDE_CODE_SKIP_SPECIAL_ENTRYPOINTS ?? "1", CLAUDE_CODE_USE_PIPE_OUTPUT: @@ -399,7 +419,10 @@ export class ClaudeQuerySession { includePartialMessages: true, pathToClaudeCodeExecutable, permissionMode: normalizeClaudePermissionMode(mode), - persistSession: false, + persistSession: true, + ...(resumeSessionId + ? { resume: resumeSessionId } + : { sessionId }), sandbox: { enabled: false }, settingSources: ["project"], spawnClaudeCodeProcess: ({ command, args, cwd, env }) => { @@ -650,7 +673,9 @@ export class ClaudeQuerySession { } } - private async initialize(params: NewSessionRequest): Promise { + private async initialize( + params: NewSessionRequest | LoadSessionRequest, + ): Promise { this.pendingMcpServers = toSdkMcpServers(params.mcpServers); traceAdapter( `session_initialize id=${this.sessionId} mcpServers=${Object.keys( @@ -1050,6 +1075,7 @@ class ClaudeSdkAgent implements Agent { version: "0.1.0", }, agentCapabilities: { + loadSession: true, promptCapabilities: { audio: false, embeddedContext: false, @@ -1062,6 +1088,41 @@ class ClaudeSdkAgent implements Agent { async newSession(params: NewSessionRequest): Promise { const sessionId = randomUUID(); const sdk = await claudeSdkRuntimePromise; + await this.startSession(sessionId, params, sdk); + return { + sessionId, + modes: claudeModes(), + }; + } + + async loadSession(params: LoadSessionRequest): Promise { + const sdk = await claudeSdkRuntimePromise; + claudeConfigDir(); + // Session IDs are UUIDs and globally unique within Claude's config root. + // Searching all project directories also avoids realpath mismatches between + // the SDK runtime shim and the spawned CLI inside an AgentOS VM. + const storedMessages = await sdk.getSessionMessages(params.sessionId); + if (storedMessages.length === 0) { + throw RequestError.invalidParams( + { kind: "unknown_session" }, + `Unknown Claude session: ${params.sessionId}`, + ); + } + await this.startSession(params.sessionId, params, sdk, params.sessionId); + return { modes: claudeModes() }; + } + + private async startSession( + sessionId: string, + params: NewSessionRequest | LoadSessionRequest, + sdk: ClaudeSdkRuntime, + resumeSessionId?: string, + ): Promise { + const existing = this.sessions.get(sessionId); + if (existing) { + existing.close(); + this.sessions.delete(sessionId); + } const session = new ClaudeQuerySession( this.conn, sessionId, @@ -1070,19 +1131,10 @@ class ClaudeSdkAgent implements Agent { params, sdk.cliPath, sdk.query, + resumeSessionId, ); await session.ready(); this.sessions.set(sessionId, session); - return { - sessionId, - modes: { - currentModeId: "default", - availableModes: ACP_MODES.map((id) => ({ - id, - name: id, - })), - }, - }; } async prompt(params: PromptRequest): Promise { @@ -1117,6 +1169,13 @@ class ClaudeSdkAgent implements Agent { } } +function claudeModes(): NonNullable { + return { + currentModeId: "default", + availableModes: ACP_MODES.map((id) => ({ id, name: id })), + }; +} + function joinPromptText(prompt: PromptPart[] | undefined): string { return (prompt ?? []) .map((part) => (part.type === "text" ? (part.text ?? "") : "")) diff --git a/registry/agent/claude/tests/adapter.test.mjs b/registry/agent/claude/tests/adapter.test.mjs index 80855ac989..9cd22aea6c 100644 --- a/registry/agent/claude/tests/adapter.test.mjs +++ b/registry/agent/claude/tests/adapter.test.mjs @@ -55,7 +55,7 @@ function makeQuery({ endImmediately = false } = {}) { }; } -function makeSession({ endImmediately = false, conn } = {}) { +function makeSession({ endImmediately = false, conn, resumeSessionId } = {}) { const c = conn ?? makeConn(); let capturedOptions; const queryFactory = (arg) => { @@ -70,10 +70,34 @@ function makeSession({ endImmediately = false, conn } = {}) { { cwd: "/workspace", mcpServers: undefined }, "/usr/bin/claude", queryFactory, + resumeSessionId, ); return { sess, conn: c, getOptions: () => capturedOptions }; } +test("claude sessions persist by default under their ACP session id", () => { + const { getOptions } = makeSession(); + assert.equal(getOptions().persistSession, true); + assert.equal(getOptions().sessionId, "sess-1"); + assert.equal(getOptions().env.CLAUDE_CODE_SKIP_INITIAL_MESSAGES, "0"); + assert.equal(getOptions().resume, undefined); +}); + +test("claude session/load resumes native history instead of skipping it", () => { + const previous = process.env.CLAUDE_CODE_SKIP_INITIAL_MESSAGES; + process.env.CLAUDE_CODE_SKIP_INITIAL_MESSAGES = "1"; + try { + const { getOptions } = makeSession({ resumeSessionId: "sess-1" }); + assert.equal(getOptions().persistSession, true); + assert.equal(getOptions().resume, "sess-1"); + assert.equal(getOptions().sessionId, undefined); + assert.equal(getOptions().env.CLAUDE_CODE_SKIP_INITIAL_MESSAGES, "0"); + } finally { + if (previous === undefined) delete process.env.CLAUDE_CODE_SKIP_INITIAL_MESSAGES; + else process.env.CLAUDE_CODE_SKIP_INITIAL_MESSAGES = previous; + } +}); + // ── Fix #5: input_json_delta maps to the correct tool by content-block index ── test("claude #5: partial tool input is attributed by content-block index, not insertion order", async () => { const { sess, conn } = makeSession(); diff --git a/registry/agent/pi/src/adapter.ts b/registry/agent/pi/src/adapter.ts index 1eea462bdb..4dd642ff41 100644 --- a/registry/agent/pi/src/adapter.ts +++ b/registry/agent/pi/src/adapter.ts @@ -15,7 +15,7 @@ import { type Agent, AgentSideConnection, - type RequestError, + RequestError, ndJsonStream, } from "@agentclientprotocol/sdk"; import type { @@ -24,6 +24,8 @@ import type { CancelNotification, InitializeRequest, InitializeResponse, + LoadSessionRequest, + LoadSessionResponse, NewSessionRequest, NewSessionResponse, PromptRequest, @@ -102,31 +104,49 @@ Object.defineProperty(process, "stdin", { }); type SessionManagerLike = { - inMemory(cwd?: string): unknown; - continueRecent(cwd: string, sessionDir: string): unknown; + create(cwd: string, sessionDir?: string): unknown; + open(path: string, sessionDir?: string): unknown; + list( + cwd: string, + sessionDir?: string, + ): Promise>; }; /** - * Choose the Pi `SessionManager` for a new session. - * - * By default sessions are in-memory (nothing is written to disk), so a - * conversation is lost when the adapter process restarts. When the embedder - * provides a session directory via the `PI_SESSION_DIR` env var, use pi's - * `continueRecent` instead: it persists the session's `.jsonl` under that - * directory and resumes the most recent one, so conversations survive an adapter - * restart. No behavior change unless `PI_SESSION_DIR` is set. - * - * Exported for unit testing (the real `newSession` path needs the Pi SDK). + * Create a fresh persisted Pi session. `PI_SESSION_DIR` overrides Pi's default + * per-cwd session directory; it does not control whether persistence is enabled. */ -export function resolveSessionManager( +export function createSessionManager( SessionManager: SessionManagerLike, cwd: string, env: Record = process.env, ): unknown { - const sessionDir = env.PI_SESSION_DIR?.trim(); - return sessionDir - ? SessionManager.continueRecent(cwd, sessionDir) - : SessionManager.inMemory(cwd); + return SessionManager.create(cwd, resolveSessionDir(env)); +} + +/** Open one exact persisted Pi session for ACP `session/load`. */ +export async function loadSessionManager( + SessionManager: SessionManagerLike, + cwd: string, + sessionId: string, + env: Record = process.env, +): Promise { + const sessionDir = resolveSessionDir(env); + const sessions = await SessionManager.list(cwd, sessionDir); + const match = sessions.find((session) => session.id === sessionId); + if (!match) { + throw RequestError.invalidParams( + { kind: "unknown_session" }, + `Unknown Pi session: ${sessionId}`, + ); + } + return SessionManager.open(match.path, sessionDir); +} + +function resolveSessionDir( + env: Record, +): string | undefined { + return env.PI_SESSION_DIR?.trim() || undefined; } type ModelLike = { @@ -842,6 +862,7 @@ export class PiSdkAgent implements Agent { version: "0.1.0", }, agentCapabilities: { + loadSession: true, promptCapabilities: { image: true, audio: false, @@ -853,6 +874,20 @@ export class PiSdkAgent implements Agent { async newSession( params: NewSessionRequest, + ): Promise { + return this.activateSession(params, "new"); + } + + async loadSession( + params: LoadSessionRequest, + ): Promise { + const response = await this.activateSession(params, "load"); + return { modes: response.modes }; + } + + private async activateSession( + params: NewSessionRequest | LoadSessionRequest, + mode: "new" | "load", ): Promise { const __trace = startPhaseTrace(); this.cwd = params.cwd; @@ -893,11 +928,19 @@ export class PiSdkAgent implements Agent { params.cwd, agentDir, ); + const sessionManager = + mode === "load" + ? await loadSessionManager( + SessionManager, + params.cwd, + (params as LoadSessionRequest).sessionId, + ) + : createSessionManager(SessionManager, params.cwd); const { session } = await __trace.span("createAgentSession", () => createAgentSession({ cwd: params.cwd, - sessionManager: resolveSessionManager(SessionManager, params.cwd), + sessionManager, resourceLoader, tools: this.wrapTools( createCodingTools(params.cwd, { diff --git a/registry/agent/pi/tests/session-manager.test.mjs b/registry/agent/pi/tests/session-manager.test.mjs index 2dad083450..21e14dc1f5 100644 --- a/registry/agent/pi/tests/session-manager.test.mjs +++ b/registry/agent/pi/tests/session-manager.test.mjs @@ -2,10 +2,10 @@ import test from "node:test"; import assert from "node:assert/strict"; import { resolve as resolvePath } from "node:path"; -// Unit tests for resolveSessionManager. The real newSession path needs the Pi -// SDK, so we test the SessionManager selection directly with a fake. +// Unit tests for Pi session manager selection. The real session path needs the +// Pi SDK, so we test the persistence and exact-load behavior with a fake. const packageDir = resolvePath(import.meta.dirname, ".."); -const { resolveSessionManager } = await import( +const { createSessionManager, loadSessionManager } = await import( resolvePath(packageDir, "dist", "adapter.js") ); @@ -13,35 +13,63 @@ function fakeSessionManager() { const calls = []; return { calls, - inMemory(cwd) { - calls.push(["inMemory", cwd]); - return { kind: "inMemory", cwd }; + create(cwd, dir) { + calls.push(["create", cwd, dir]); + return { kind: "create", cwd, dir }; }, - continueRecent(cwd, dir) { - calls.push(["continueRecent", cwd, dir]); - return { kind: "continueRecent", cwd, dir }; + open(path, dir) { + calls.push(["open", path, dir]); + return { kind: "open", path, dir }; + }, + async list(cwd, dir) { + calls.push(["list", cwd, dir]); + return [ + { id: "session-a", path: "/sessions/a.jsonl" }, + { id: "session-b", path: "/sessions/b.jsonl" }, + ]; }, }; } -test("PI_SESSION_DIR persists + resumes via continueRecent", () => { +test("new sessions persist in PI_SESSION_DIR without resuming an earlier session", () => { const sm = fakeSessionManager(); - const result = resolveSessionManager(sm, "/workspace", { + const result = createSessionManager(sm, "/workspace", { PI_SESSION_DIR: "/sessions/a/main", }); - assert.deepEqual(sm.calls, [["continueRecent", "/workspace", "/sessions/a/main"]]); - assert.equal(result.kind, "continueRecent"); + assert.deepEqual(sm.calls, [["create", "/workspace", "/sessions/a/main"]]); + assert.equal(result.kind, "create"); +}); + +test("new sessions persist in Pi's default directory when PI_SESSION_DIR is unset", () => { + const sm = fakeSessionManager(); + const result = createSessionManager(sm, "/workspace", {}); + assert.deepEqual(sm.calls, [["create", "/workspace", undefined]]); + assert.equal(result.kind, "create"); }); -test("no PI_SESSION_DIR falls back to in-memory (default, unchanged)", () => { +test("blank PI_SESSION_DIR uses Pi's default persisted directory", () => { const sm = fakeSessionManager(); - const result = resolveSessionManager(sm, "/workspace", {}); - assert.deepEqual(sm.calls, [["inMemory", "/workspace"]]); - assert.equal(result.kind, "inMemory"); + createSessionManager(sm, "/workspace", { PI_SESSION_DIR: " " }); + assert.deepEqual(sm.calls, [["create", "/workspace", undefined]]); +}); + +test("session/load opens the exact persisted Pi session", async () => { + const sm = fakeSessionManager(); + const result = await loadSessionManager(sm, "/workspace", "session-b", { + PI_SESSION_DIR: "/sessions", + }); + assert.deepEqual(sm.calls, [ + ["list", "/workspace", "/sessions"], + ["open", "/sessions/b.jsonl", "/sessions"], + ]); + assert.equal(result.kind, "open"); }); -test("blank/whitespace PI_SESSION_DIR is ignored", () => { +test("missing session/load returns the typed fallback sentinel", async () => { const sm = fakeSessionManager(); - resolveSessionManager(sm, "/workspace", { PI_SESSION_DIR: " " }); - assert.deepEqual(sm.calls, [["inMemory", "/workspace"]]); + await assert.rejects( + loadSessionManager(sm, "/workspace", "missing", {}), + (error) => + error?.code === -32602 && error?.data?.kind === "unknown_session", + ); }); diff --git a/website/public/docs/docs/agents/claude.md b/website/public/docs/docs/agents/claude.md index a2525dd2b8..2320abf6af 100644 --- a/website/public/docs/docs/agents/claude.md +++ b/website/public/docs/docs/agents/claude.md @@ -19,6 +19,13 @@ Set the relevant variable(s) on the session's `env`, sourced from your server's See [LLM Credentials](/docs/llm-credentials), and Claude Code's [environment variables](https://code.claude.com/docs/en/env-vars) for the full list. +## Persistent sessions + +Claude sessions persist to Claude Code's standard project store by default and +support native ACP `session/load`. Keep the VM root durable so the adapter can +restore full native context after sleep or restart. If that store is unavailable, +agentOS falls back to its persisted transcript. + ## Skills Claude Code discovers [agent skills](https://docs.claude.com/en/docs/claude-code/skills) from `SKILL.md` files under its skills directory. Write the skill into the VM before creating a session and Claude Code loads it automatically. @@ -31,4 +38,4 @@ Expose extra tools to the agent by passing `mcpServers` to `createSession`. Both ## 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](/docs/agents/custom). \ No newline at end of file +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](/docs/agents/custom). diff --git a/website/public/docs/docs/agents/pi.md b/website/public/docs/docs/agents/pi.md index 793955ff5b..658562526c 100644 --- a/website/public/docs/docs/agents/pi.md +++ b/website/public/docs/docs/agents/pi.md @@ -15,6 +15,13 @@ Set the relevant variable on the session's `env`, sourced from your server's env See [LLM Credentials](/docs/llm-credentials), and Pi's [providers docs](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/providers.md) for the full list. +## Persistent Pi sessions + +Pi persists each conversation as JSONL under its standard per-project session +directory and supports native ACP `session/load`. Set `PI_SESSION_DIR` in the +session's `env` only to override the storage directory. Use durable VM storage; +if the native file is unavailable, agentOS falls back to its persisted transcript. + ## Skills Pi discovers `SKILL.md` files from its skills directory. Write the skill into the VM before creating a session and Pi loads it automatically. @@ -40,4 +47,4 @@ See the [Pi extension documentation](https://github.com/badlogic/pi-mono/tree/ma ## Customizing the agent -Pi 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 Pi build as a new agent, see [Custom Agents](/docs/agents/custom). \ No newline at end of file +Pi 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 Pi build as a new agent, see [Custom Agents](/docs/agents/custom). diff --git a/website/public/docs/docs/architecture/sessions-persistence.md b/website/public/docs/docs/architecture/sessions-persistence.md index bbc812b4f4..6a7929f2ca 100644 --- a/website/public/docs/docs/architecture/sessions-persistence.md +++ b/website/public/docs/docs/architecture/sessions-persistence.md @@ -117,8 +117,8 @@ When native resume succeeds: - the agent restores its own context - no transcript preamble is injected -OpenCode uses this path when its own session store is still available in the -durable VM filesystem. +The built-in OpenCode, Pi, Pi CLI, and Claude adapters use this path when their +native session stores are still available in the durable VM filesystem. ### Transcript Fallback @@ -185,4 +185,4 @@ The transcript file is not canonical state. It is a disposable render of - RivetKit actor session actions: `rivetkit-rust/packages/rivetkit-agent-os/src/actions/session.rs` - RivetKit persistence helpers: - `rivetkit-rust/packages/rivetkit-agent-os/src/persistence.rs` \ No newline at end of file + `rivetkit-rust/packages/rivetkit-agent-os/src/persistence.rs`