diff --git a/.changeset/persist-agentapi-state.md b/.changeset/persist-agentapi-state.md new file mode 100644 index 0000000..61356c5 --- /dev/null +++ b/.changeset/persist-agentapi-state.md @@ -0,0 +1,5 @@ +--- +"@pstdio/pocketcoder-cli": patch +--- + +Persist native AgentAPI transcripts when templates declare conversation restore support. diff --git a/docs/templates.md b/docs/templates.md index fb8552f..7f1377a 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -64,7 +64,9 @@ command, mount, network, privilege, or driver. environment. `transport` defaults to `"pty"`; `"acp"` adds AgentAPI's `--experimental-acp` adapter for agents whose command speaks ACP. PocketCoder passes optional `termWidth` (10–65535) to AgentAPI only for PTY agents; it is - rejected for ACP because ACP does not emulate a terminal. See the + rejected for ACP because ACP does not emulate a terminal. The derived agent + service and its health check use `http://127.0.0.1:3284`, so an explicit + `AGENTAPI_ALLOWED_HOSTS` must include `127.0.0.1`. See the [transport decision](agent-transport-decision.md) for the cross-agent compatibility analysis. PocketCoder waits for AgentAPI's fixed status endpoint, synchronizes complete messages @@ -176,9 +178,13 @@ Setup steps default to `runOn: ["create"]`. Mark validation or repair steps with `runOn: ["restore"]` when they are safe against restored content. `conversationRestore: supported` requires a separate harness-state mount and `sessionCompatibility`; otherwise use the honest `filesystem_only` default. -For native `agent` templates the supervisor blocks new messages, waits for -AgentAPI to become stable, captures the final transcript, and terminates it -before snapshotting. Legacy templates may still provide `checkpointHook`. +Native PTY `agent` templates must also set `agent.stateFile` to a normalized +file path below a persistence mount. PocketCoder passes it to AgentAPI as +`--state-file`, which makes AgentAPI load and save its transcript. AgentAPI +does not support state persistence for ACP, so native ACP templates must use +`filesystem_only`. The supervisor blocks new messages, waits for AgentAPI to +become stable, captures the final transcript, and terminates it before +snapshotting. Legacy templates may still provide `checkpointHook`. `conversationRetention` controls how long the canonical display transcript is readable after terminal state (default `168h`). It is separate from checkpoint diff --git a/packages/contracts/src/agent-api.test.ts b/packages/contracts/src/agent-api.test.ts new file mode 100644 index 0000000..1033cfa --- /dev/null +++ b/packages/contracts/src/agent-api.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "bun:test"; +import { agentApiHarness, parseTemplateManifest } from "./index"; + +const DIGEST = "a".repeat(64); + +function nativeManifest(): Record { + return { + apiVersion: "pocketcoder.dev/v1alpha1", + kind: "Template", + metadata: { name: "fixture", description: "test" }, + spec: { + version: "1.0.0", + image: `registry.test/agent@sha256:${DIGEST}`, + agent: { + type: "codex", + command: ["codex", "--full-auto"], + cwd: "/workspace", + env: { CODEX_HOME: "/state/codex" }, + }, + resources: { cpu: "2", memory: "2Gi" }, + }, + }; +} + +function nativeRestoreManifest(stateFile?: string): Record { + const manifest = nativeManifest(); + const spec = manifest.spec as { + agent: Record; + persistence?: Record; + }; + if (stateFile !== undefined) spec.agent.stateFile = stateFile; + spec.persistence = { + mounts: [ + { name: "worktree", target: "/workspace", maxBytes: 1024, maxFiles: 10 }, + { name: "agent-state", target: "/state", maxBytes: 1024, maxFiles: 10 }, + ], + conversationRestore: "supported", + sessionCompatibility: "agentapi-0.12", + }; + return manifest; +} + +describe("AgentAPI transport", () => { + test("passes a persisted state file to AgentAPI", () => { + const spec = parseTemplateManifest(nativeRestoreManifest("/state/agentapi.json")).manifest.spec; + + expect(agentApiHarness(spec).command).toEqual([ + "/usr/local/bin/agentapi", + "server", + "--type", + "codex", + "--state-file", + "/state/agentapi.json", + "--port", + "3284", + "--", + "codex", + "--full-auto", + ]); + }); + + test("requires a persisted state file for native conversation restore", () => { + for (const stateFile of [undefined, "/tmp/agentapi.json"]) { + expect(() => parseTemplateManifest(nativeRestoreManifest(stateFile))).toThrow( + "supported conversation restore requires agent.stateFile below a persistence mount", + ); + } + }); + + test("requires the state file to be below a persistence mount", () => { + expect(() => parseTemplateManifest(nativeRestoreManifest("/state"))).toThrow( + "supported conversation restore requires agent.stateFile below a persistence mount", + ); + }); + + test("rejects AgentAPI state persistence for ACP transport", () => { + const stateFile = nativeManifest(); + (stateFile.spec as { agent: Record }).agent = { + type: "opencode", + transport: "acp", + command: ["opencode", "acp"], + stateFile: "/state/agentapi.json", + }; + expect(() => parseTemplateManifest(stateFile)).toThrow( + "stateFile is only valid for PTY transport", + ); + + const restore = nativeRestoreManifest(); + (restore.spec as { agent: Record }).agent = { + type: "opencode", + transport: "acp", + command: ["opencode", "acp"], + }; + expect(() => parseTemplateManifest(restore)).toThrow( + "supported conversation restore requires PTY transport", + ); + }); + + test("rejects an unnormalized AgentAPI state file", () => { + const manifest = nativeManifest(); + (manifest.spec as { agent: Record }).agent.stateFile = + "/state/../tmp/agentapi.json"; + + expect(() => parseTemplateManifest(manifest)).toThrow( + "stateFile must be a normalized absolute filesystem path", + ); + }); + + test("passes an explicit terminal width only to PTY transport", () => { + const manifest = nativeManifest(); + (manifest.spec as { agent: Record }).agent.termWidth = 200; + const spec = parseTemplateManifest(manifest).manifest.spec; + + expect(agentApiHarness(spec).command).toEqual([ + "/usr/local/bin/agentapi", + "server", + "--type", + "codex", + "--term-width", + "200", + "--port", + "3284", + "--", + "codex", + "--full-auto", + ]); + }); + + test("rejects invalid terminal widths and ACP width settings", () => { + for (const termWidth of [9, 65_536, 20.5]) { + const manifest = nativeManifest(); + (manifest.spec as { agent: Record }).agent.termWidth = termWidth; + expect(() => parseTemplateManifest(manifest)).toThrow(); + } + + const acp = nativeManifest(); + (acp.spec as { agent: Record }).agent = { + type: "opencode", + transport: "acp", + termWidth: 200, + command: ["opencode", "acp"], + }; + expect(() => parseTemplateManifest(acp)).toThrow("termWidth is only valid for PTY transport"); + }); + + test("derives ACP transport for a native coding agent", () => { + const manifest = nativeManifest(); + (manifest.spec as { agent: Record }).agent = { + type: "opencode", + transport: "acp", + command: ["opencode", "acp"], + cwd: "/workspace", + }; + const spec = parseTemplateManifest(manifest).manifest.spec; + + expect(agentApiHarness(spec).command).toEqual([ + "/usr/local/bin/agentapi", + "server", + "--type", + "opencode", + "--experimental-acp", + "--port", + "3284", + "--", + "opencode", + "acp", + ]); + }); +}); diff --git a/packages/contracts/src/template-runtime.ts b/packages/contracts/src/template-runtime.ts index 0a0fec2..efc9010 100644 --- a/packages/contracts/src/template-runtime.ts +++ b/packages/contracts/src/template-runtime.ts @@ -45,6 +45,7 @@ export function agentApiHarness(spec: TemplateSpec): Harness { spec.agent.type, ...(spec.agent.transport === "acp" ? ["--experimental-acp"] : []), ...(spec.agent.termWidth === undefined ? [] : ["--term-width", String(spec.agent.termWidth)]), + ...(spec.agent.stateFile === undefined ? [] : ["--state-file", spec.agent.stateFile]), "--port", "3284", "--", diff --git a/packages/contracts/src/template-schema.ts b/packages/contracts/src/template-schema.ts index c955e5d..1730eed 100644 --- a/packages/contracts/src/template-schema.ts +++ b/packages/contracts/src/template-schema.ts @@ -61,6 +61,7 @@ export const AgentSchema = HarnessSchema.extend({ .default("custom"), transport: z.enum(["pty", "acp"]).default("pty"), termWidth: z.number().int().min(10).max(65_535).optional(), + stateFile: z.string().refine(isAbsolutePath, "expected an absolute path").optional(), }); export const TerminalSchema = z.object({ @@ -209,12 +210,15 @@ const TemplateSpecInputSchema = z }); } if (!spec.agent) return; - if (spec.agent.transport === "acp" && spec.agent.termWidth !== undefined) { - ctx.addIssue({ - code: "custom", - path: ["agent", "termWidth"], - message: "termWidth is only valid for PTY transport", - }); + if (spec.agent.transport === "acp") { + for (const field of ["termWidth", "stateFile"] as const) { + if (spec.agent[field] === undefined) continue; + ctx.addIssue({ + code: "custom", + path: ["agent", field], + message: `${field} is only valid for PTY transport`, + }); + } } for (const field of ["harness", "services", "checkpointHook"] as const) { if (spec[field] === undefined) continue; diff --git a/packages/contracts/src/template-validation.ts b/packages/contracts/src/template-validation.ts index b0e89de..bf5f29b 100644 --- a/packages/contracts/src/template-validation.ts +++ b/packages/contracts/src/template-validation.ts @@ -188,6 +188,7 @@ function validatePersistence(spec: TemplateSpec, ctx: z.RefinementCtx): void { validatePersistenceMount(spec, mounts, mount, index, seenNames, ctx); } validateSourceMount(spec, mounts, ctx); + validateAgentStateFile(spec, mounts, ctx); if ( spec.persistence.conversationRestore === "supported" && (!spec.persistence.sessionCompatibility || mounts.length < 2) @@ -201,6 +202,38 @@ function validatePersistence(spec: TemplateSpec, ctx: z.RefinementCtx): void { } } +function validateAgentStateFile( + spec: TemplateSpec, + mounts: PersistenceMount[], + ctx: z.RefinementCtx, +): void { + if (!isAgentApiNative(spec)) return; + const stateFile = spec.agent.stateFile; + const normalized = stateFile !== undefined && isNormalizedFilesystemPath(stateFile); + if (stateFile !== undefined && !normalized) { + ctx.addIssue({ + code: "custom", + path: ["spec", "agent", "stateFile"], + message: "stateFile must be a normalized absolute filesystem path", + }); + } + if (spec.agent.transport === "acp" && spec.persistence.conversationRestore === "supported") { + ctx.addIssue({ + code: "custom", + path: ["spec", "persistence", "conversationRestore"], + message: "supported conversation restore requires PTY transport", + }); + return; + } + if (spec.persistence.conversationRestore !== "supported") return; + if (normalized && mounts.some((mount) => stateFile.startsWith(`${mount.target}/`))) return; + ctx.addIssue({ + code: "custom", + path: ["spec", "agent", "stateFile"], + message: "supported conversation restore requires agent.stateFile below a persistence mount", + }); +} + function validatePersistenceMount( spec: TemplateSpec, mounts: PersistenceMount[], diff --git a/packages/contracts/src/template.test.ts b/packages/contracts/src/template.test.ts index 82d4063..e331329 100644 --- a/packages/contracts/src/template.test.ts +++ b/packages/contracts/src/template.test.ts @@ -310,66 +310,3 @@ describe("template persistence and routing", () => { expect(findRoute(snapshot, "other", "GET", "/status")).toBeNull(); }); }); - -describe("AgentAPI transport", () => { - test("passes an explicit terminal width only to PTY transport", () => { - const manifest = nativeManifest(); - (manifest.spec as { agent: Record }).agent.termWidth = 200; - const spec = parseTemplateManifest(manifest).manifest.spec; - - expect(agentApiHarness(spec).command).toEqual([ - "/usr/local/bin/agentapi", - "server", - "--type", - "codex", - "--term-width", - "200", - "--port", - "3284", - "--", - "codex", - "--full-auto", - ]); - }); - - test("rejects invalid terminal widths and ACP width settings", () => { - for (const termWidth of [9, 65_536, 20.5]) { - const manifest = nativeManifest(); - (manifest.spec as { agent: Record }).agent.termWidth = termWidth; - expect(() => parseTemplateManifest(manifest)).toThrow(); - } - - const acp = nativeManifest(); - (acp.spec as { agent: Record }).agent = { - type: "opencode", - transport: "acp", - termWidth: 200, - command: ["opencode", "acp"], - }; - expect(() => parseTemplateManifest(acp)).toThrow("termWidth is only valid for PTY transport"); - }); - - test("derives ACP transport for a native coding agent", () => { - const manifest = nativeManifest(); - (manifest.spec as { agent: Record }).agent = { - type: "opencode", - transport: "acp", - command: ["opencode", "acp"], - cwd: "/workspace", - }; - const spec = parseTemplateManifest(manifest).manifest.spec; - - expect(agentApiHarness(spec).command).toEqual([ - "/usr/local/bin/agentapi", - "server", - "--type", - "opencode", - "--experimental-acp", - "--port", - "3284", - "--", - "opencode", - "acp", - ]); - }); -});