Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/persist-agentapi-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pstdio/pocketcoder-cli": patch
---

Persist native AgentAPI transcripts when templates declare conversation restore support.
14 changes: 10 additions & 4 deletions docs/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
169 changes: 169 additions & 0 deletions packages/contracts/src/agent-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { describe, expect, test } from "bun:test";
import { agentApiHarness, parseTemplateManifest } from "./index";

const DIGEST = "a".repeat(64);

function nativeManifest(): Record<string, unknown> {
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<string, unknown> {
const manifest = nativeManifest();
const spec = manifest.spec as {
agent: Record<string, unknown>;
persistence?: Record<string, unknown>;
};
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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).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<string, unknown> }).agent.termWidth = termWidth;
expect(() => parseTemplateManifest(manifest)).toThrow();
}

const acp = nativeManifest();
(acp.spec as { agent: Record<string, unknown> }).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<string, unknown> }).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",
]);
});
});
1 change: 1 addition & 0 deletions packages/contracts/src/template-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"--",
Expand Down
16 changes: 10 additions & 6 deletions packages/contracts/src/template-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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;
Expand Down
33 changes: 33 additions & 0 deletions packages/contracts/src/template-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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[],
Expand Down
63 changes: 0 additions & 63 deletions packages/contracts/src/template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }).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<string, unknown> }).agent.termWidth = termWidth;
expect(() => parseTemplateManifest(manifest)).toThrow();
}

const acp = nativeManifest();
(acp.spec as { agent: Record<string, unknown> }).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<string, unknown> }).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",
]);
});
});
Loading