chore(chat): merge main while preserving stacked find features

This commit is contained in:
Mohamed Boudra
2026-09-15 16:19:18 +02:00
794 changed files with 36260 additions and 17173 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ npm run cli -- daemon status # Check daemon status
### Imports
- Use path alias `@server/*` in server package (maps to `./src/`)
- Use relative `.js` paths for imports within shipped server code, including type imports. TypeScript preserves `@server/*` aliases in JavaScript and declarations, and production Node has no alias resolver. Vitest tests support the alias.
- No barrel `index.ts` re-exports — they create unnecessary indirection
### Naming
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.8.0-beta.1",
"version": "0.8.0",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -71,11 +71,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.8.0-beta.1",
"@getpaseo/highlight": "0.8.0-beta.1",
"@getpaseo/plugin": "0.8.0-beta.1",
"@getpaseo/protocol": "0.8.0-beta.1",
"@getpaseo/relay": "0.8.0-beta.1",
"@getpaseo/client": "0.8.0",
"@getpaseo/highlight": "0.8.0",
"@getpaseo/plugin": "0.8.0",
"@getpaseo/protocol": "0.8.0",
"@getpaseo/relay": "0.8.0",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -140,14 +140,15 @@ async function main(): Promise<void> {
await client.connect();
const seen = new Set<string>();
client.on("checkout_status_update", (message) => seen.add(message.payload.cwd));
await client.observeEvents(["checkout_status_update"]).ready;
const workspaces = await client.fetchWorkspaces({
subscribe: { subscriptionId: `startup-git-${scenario}` },
subscribe: {},
sort: [{ key: "activity_at", direction: "desc" }],
page: { limit: 200 },
});
await client.fetchAgents({
scope: "active",
subscribe: { subscriptionId: `startup-agents-${scenario}` },
subscribe: {},
page: { limit: 200 },
});
@@ -18,13 +18,11 @@ process.title = "Paseo Supervisor";
interface DaemonRunnerConfig {
devMode: boolean;
reclaimStalePidLock: boolean;
workerArgs: string[];
}
function parseConfig(argv: string[]): DaemonRunnerConfig {
let devMode = false;
let reclaimStalePidLock = false;
const workerArgs: string[] = [];
for (const arg of argv) {
@@ -33,13 +31,14 @@ function parseConfig(argv: string[]): DaemonRunnerConfig {
continue;
}
if (arg === "--reclaim-stale-pid-lock") {
reclaimStalePidLock = true;
continue;
throw new Error(
"--reclaim-stale-pid-lock was removed: stop the existing supervisor before starting another.",
);
}
workerArgs.push(arg);
}
return { devMode, reclaimStalePidLock, workerArgs };
return { devMode, workerArgs };
}
function resolveWorkerEntry(): string {
@@ -116,7 +115,6 @@ async function main(): Promise<void> {
try {
await acquirePidLock(paseoHome, null, {
ownerPid: process.pid,
reclaimStaleDesktopLock: config.reclaimStalePidLock,
});
} catch (error) {
if (error instanceof PidLockError) {
@@ -177,6 +175,7 @@ async function main(): Promise<void> {
onWorkerReady: async ({ listen }) => {
await updatePidLock(paseoHome, { listen }, { ownerPid: process.pid });
},
onWorkerExit: () => updatePidLock(paseoHome, { listen: null }, { ownerPid: process.pid }),
onSupervisorExit: releaseLock,
});
requestSupervisorShutdown = supervisor.requestShutdown;
@@ -36,7 +36,10 @@ async function runSupervisorFixture(options: {
const workerPath = path.join(tempDir, "worker.mjs");
const runnerPath = path.join(tempDir, "runner.mjs");
await writeFile(workerPath, options.workerSource);
await writeFile(
workerPath,
`process.send?.({ type: "paseo:ready", listen: "fixture" });\n${options.workerSource}`,
);
await writeFile(
runnerPath,
`
@@ -0,0 +1,122 @@
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
async function runWorker(worker: string, publicationDelay = 0) {
const home = await mkdtemp(path.join(tmpdir(), "paseo-readiness-"));
const workerPath = path.join(home, "worker.mjs");
const runnerPath = path.join(home, "runner.mjs");
const eventsPath = path.join(home, "events.jsonl");
const supervisor = new URL("./supervisor.ts", import.meta.url).href;
await writeFile(workerPath, worker);
await writeFile(
runnerPath,
`
import { appendFile } from "node:fs/promises";
import { runSupervisor } from ${JSON.stringify(supervisor)};
const record = (event) => appendFile(${JSON.stringify(eventsPath)}, JSON.stringify(event) + "\\n");
runSupervisor({
name: "ReadinessTest", startupMessage: "starting", restartOnCrash: true,
resolveWorkerEntry: () => ${JSON.stringify(workerPath)}, workerArgs: [], workerExecArgv: [],
onWorkerReady: async ({ listen }) => {
await new Promise(resolve => setTimeout(resolve, ${publicationDelay}));
await record(listen);
},
onWorkerExit: () => record(null),
});
`,
);
const env = Object.fromEntries(
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
);
const child = spawn(process.execPath, ["--import", "tsx", runnerPath], {
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
env: { ...env, HOME: home, PASEO_HOME: home },
stdio: ["ignore", "pipe", "pipe"],
});
let output = "";
child.stdout.on("data", (chunk) => {
output += chunk;
});
child.stderr.on("data", (chunk) => {
output += chunk;
});
try {
const code = await new Promise<number | null>((resolve, reject) => {
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(output));
}, 5_000);
child.once("error", reject);
child.once("close", (exitCode) => {
clearTimeout(timer);
resolve(exitCode);
});
});
const events = await readFile(eventsPath, "utf8").catch(() => "");
return {
code,
output,
events: events
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line)),
};
} finally {
await rm(home, { recursive: true, force: true });
}
}
test("a worker that exits before ready fails the supervisor without respawning", async () => {
const result = await runWorker(`
import { existsSync, writeFileSync } from "node:fs";
const marker = process.argv[1] + ".started";
if (existsSync(marker)) process.exit(0);
writeFileSync(marker, "started");
process.exit(1);
`);
expect(result.code).toBe(1);
expect(result.output).not.toContain("Restarting worker");
});
test("a replacement worker must independently reach ready", async () => {
const result = await runWorker(`
import { existsSync, writeFileSync } from "node:fs";
const marker = process.argv[1] + ".started";
if (existsSync(marker)) process.exit(0);
writeFileSync(marker, "started");
process.on("message", message => {
if (message.type === "paseo:graceful-shutdown") process.exit(0);
});
process.send({ type: "paseo:ready", listen: "test-endpoint" });
process.send({ type: "paseo:restart" });
`);
expect(result.code).toBe(1);
expect(result.events, result.output).toEqual(["test-endpoint", null, null]);
});
test("exit clears publication after an in-flight ready write", async () => {
const result = await runWorker(
`
process.send({ type: "paseo:ready", listen: "test-endpoint" }, () => process.exit(0));
`,
100,
);
expect(result.code).toBe(0);
expect(result.events.at(-1)).toBeNull();
});
test("requested shutdown before first readiness exits successfully", async () => {
const result = await runWorker(`
process.on("message", message => {
if (message.type === "paseo:graceful-shutdown") process.exit(0);
});
process.send({ type: "paseo:shutdown", reason: "cancelled_start" });
`);
expect(result.code).toBe(0);
expect(result.events).toEqual([null]);
});
+63 -30
View File
@@ -51,6 +51,7 @@ interface SupervisorOptions {
env?: NodeJS.ProcessEnv;
} | null;
onWorkerReady?: (message: { listen: string }) => Promise<void> | void;
onWorkerExit?: () => Promise<void> | void;
restartOnCrash?: boolean;
onSupervisorExit?: () => Promise<void> | void;
logFile?: SupervisorLogFileOptions;
@@ -129,6 +130,8 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
let restarting = false;
let shuttingDown = false;
let exiting = false;
let lifecycleFailed = false;
let publication = Promise.resolve();
let forceKillTimer: NodeJS.Timeout | null = null;
const logStream = createSupervisorLogStream(options.logFile);
@@ -244,6 +247,8 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
}
const currentChild = child;
let reachedReady = false;
// Serialize endpoint writes with exit/clear before allowing another worker to spawn.
const heartbeat = setInterval(() => {
const message: SupervisorHeartbeatMessage = { type: "paseo:supervisor-heartbeat" };
if (currentChild.connected) {
@@ -276,18 +281,21 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
child.on("message", (msg: unknown) => {
const lifecycleMessage = parseLifecycleMessage(msg);
if (!lifecycleMessage) {
if (!lifecycleMessage || child !== currentChild) {
return;
}
if (lifecycleMessage.type === "paseo:ready") {
reachedReady = true;
writeLifecycleLog("Worker ready", { listen: lifecycleMessage.listen });
Promise.resolve(options.onWorkerReady?.({ listen: lifecycleMessage.listen })).catch(
(error) => {
publication = publication
.then(() => options.onWorkerReady?.({ listen: lifecycleMessage.listen }))
.catch((error) => {
lifecycleFailed = true;
const message = error instanceof Error ? error.message : String(error);
log(`Worker ready callback failed: ${message}`);
},
);
requestShutdown("endpoint_publication_failed");
});
return;
}
@@ -303,35 +311,57 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
requestRestart(reason);
});
child.on("error", (error) => {
lifecycleFailed = true;
log(`Worker spawn failed: ${error.message}`);
exitSupervisor(1);
});
child.on("exit", (code, signal) => {
clearInterval(heartbeat);
clearForceKillTimer();
const exitDescriptor = describeExit(code, signal);
writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor });
child = null;
publication = publication
.then(() => options.onWorkerExit?.())
.catch((error) => {
lifecycleFailed = true;
log(`Worker exit callback failed: ${String(error)}`);
});
void publication.then(() => {
const exitDescriptor = describeExit(code, signal);
writeLifecycleLog("Worker exited", { code, signal, exit: exitDescriptor });
if (shuttingDown) {
log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`);
exitSupervisor(0);
if (lifecycleFailed || (!reachedReady && !shuttingDown)) {
log(`Worker exited before successful readiness (${exitDescriptor}). Supervisor exiting.`);
exitSupervisor(1);
return;
}
if (shuttingDown) {
log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`);
exitSupervisor(0);
return;
}
const crashed =
restartOnCrash &&
((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM"));
if (restarting || crashed) {
restarting = false;
log(
crashed
? `Worker crashed (${exitDescriptor}). Restarting worker...`
: `Worker exited (${exitDescriptor}). Restarting worker...`,
);
spawnWorker();
return;
}
log(`Worker exited (${exitDescriptor}). Supervisor exiting.`);
exitSupervisor(typeof code === "number" ? code : 1);
return;
}
const crashed =
restartOnCrash &&
((code !== 0 && code !== null) || (signal !== null && signal !== "SIGTERM"));
if (restarting || crashed) {
restarting = false;
log(
crashed
? `Worker crashed (${exitDescriptor}). Restarting worker...`
: `Worker exited (${exitDescriptor}). Restarting worker...`,
);
spawnWorker();
return;
}
log(`Worker exited (${exitDescriptor}). Supervisor exiting.`);
exitSupervisor(typeof code === "number" ? code : 1);
});
});
};
@@ -389,7 +419,10 @@ export function runSupervisor(options: SupervisorOptions): SupervisorController
writeLifecycleLog("Supervisor shutdown requested", { reason });
log(`${reason}. Stopping worker...`);
if (!child) {
exitSupervisor(0);
void publication.then(() => {
exitSupervisor(lifecycleFailed ? 1 : 0);
return;
});
return;
}
requestWorkerShutdown(reason);
+1 -1
View File
@@ -151,7 +151,7 @@ async function main(): Promise<void> {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "mcp-inject-real-claude" },
subscribe: {},
});
const results: ProviderRunResult[] = [];
@@ -47,7 +47,7 @@ async function runVoiceRoundTrip(params: {
const client = new DaemonClient({ url: `${params.daemonUrl}/ws` });
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: `voice-e2e-${randomUUID()}` },
subscribe: {},
});
const mode = await client.setVoiceMode(true, params.voiceAgentId);
@@ -77,7 +77,7 @@ async function runVoiceRoundTrip(params: {
});
});
const offStream = client.on("agent_stream", (msg) => {
const offStream = client.subscribeAgentTimeline(activeVoiceAgentId, (msg) => {
if (msg.type !== "agent_stream") return;
if (msg.payload.agentId !== activeVoiceAgentId) return;
if (msg.payload.event.type !== "timeline") return;
@@ -88,6 +88,8 @@ async function runVoiceRoundTrip(params: {
}
});
await offStream.ready;
const chunkBytes = 4800;
for (let offset = 0; offset < pcm.length; offset += chunkBytes) {
const chunk = pcm.subarray(offset, Math.min(pcm.length, offset + chunkBytes));
@@ -49,7 +49,7 @@ async function main(): Promise<void> {
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "voice-debug" } });
await client.fetchAgents({ subscribe: {} });
const voiceCwd = mkdtempSync(path.join(tmpdir(), "voice-roundtrip-debug-"));
const voiceAgent = await client.createAgent({
@@ -88,7 +88,7 @@ async function main(): Promise<void> {
}
});
const offStream = client.on("agent_stream", (msg) => {
const offStream = client.subscribeAgentTimeline(voiceAgentId, (msg) => {
if (msg.type !== "agent_stream") return;
if (msg.payload.event.type !== "timeline") return;
const item = msg.payload.event.item;
@@ -100,6 +100,8 @@ async function main(): Promise<void> {
});
});
await offStream.ready;
let audioChunkCount = 0;
const firstAudio = new Promise<void>((resolve) => {
const offAudio = client.on("audio_output", (msg) => {
@@ -354,46 +354,44 @@ second line'`,
expect(result.attachment.text).toContain("[Assistant] Done.");
});
it("selects the fork boundary before collapsing later tool updates", () => {
const result = buildAgentForkContextAttachment({
boundaryMessageId: "assistant-1",
rows: [
row(1, { type: "user_message", text: "Run it", messageId: "user-1" }),
row(
2,
toolCallItem({
callId: "terminal-1",
name: "terminal",
status: "running",
detail: {
type: "plain_text",
label: "before boundary",
},
it("rejects a checkpoint whose projected tool state changed later", () => {
expect(() =>
buildAgentForkContextAttachment({
boundaryMessageId: "assistant-1",
rows: [
row(1, { type: "user_message", text: "Run it", messageId: "user-1" }),
row(
2,
toolCallItem({
callId: "terminal-1",
name: "terminal",
status: "running",
detail: {
type: "plain_text",
label: "before boundary",
},
}),
),
row(3, {
type: "assistant_message",
text: "Partial result.",
messageId: "assistant-1",
}),
),
row(3, {
type: "assistant_message",
text: "Partial result.",
messageId: "assistant-1",
}),
row(
4,
toolCallItem({
callId: "terminal-1",
name: "terminal",
status: "completed",
detail: {
type: "plain_text",
label: "after boundary",
},
}),
),
],
});
expect(result.attachment.text).toContain("[Terminal] before boundary");
expect(result.attachment.text).toContain("[Assistant] Partial result.");
expect(result.attachment.text).not.toContain("after boundary");
row(
4,
toolCallItem({
callId: "terminal-1",
name: "terminal",
status: "completed",
detail: {
type: "plain_text",
label: "after boundary",
},
}),
),
],
}),
).toThrow("Fork from a later completed response");
});
it("selects a synthetic assistant error by its timeline cursor", () => {
@@ -230,12 +230,12 @@ function selectForkContextRows(input: {
boundaryCursor: { epoch: string; seq: number } | null;
boundaryMessageId: string | null;
} {
const projectedRows = projectTimelineRows({ rows: input.rows, mode: "projected" });
const boundaryCursor = input.cursorBoundary?.cursor ?? null;
const boundaryMessageId = input.boundaryMessageId?.trim() || null;
if (!boundaryCursor && !boundaryMessageId) {
const projected = projectTimelineRows({ rows: input.rows, mode: "projected" });
return {
items: projected.map((entry) => entry.item),
items: projectedRows.map((entry) => entry.item),
boundaryCursor: null,
boundaryMessageId: null,
};
@@ -248,8 +248,8 @@ function selectForkContextRows(input: {
throw new Error("Selected timeline position is no longer available.");
}
const boundaryIndex = boundaryCursor
? input.rows.findIndex((row) => row.seq === boundaryCursor.seq)
: input.rows.findLastIndex(
? projectedRows.findIndex((row) => row.seqEnd === boundaryCursor.seq)
: projectedRows.findLastIndex(
(row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId,
);
if (boundaryIndex < 0) {
@@ -259,8 +259,13 @@ function selectForkContextRows(input: {
: "Selected assistant message is no longer available.",
);
}
const selectedRows = input.rows.slice(0, boundaryIndex + 1);
const projected = projectTimelineRows({ rows: selectedRows, mode: "projected" });
const boundarySeq = projectedRows[boundaryIndex].seqEnd;
if (projectedRows.some((row) => row.seqStart <= boundarySeq && row.seqEnd > boundarySeq)) {
throw new Error(
"This checkpoint changed after it was created. Fork from a later completed response instead.",
);
}
const projected = projectedRows.filter((row) => row.seqEnd <= boundarySeq);
return {
items: projected.map((entry) => entry.item),
@@ -69,7 +69,7 @@ test("loads archived records for history and active records with the interactive
await ensureAgentLoaded(archived.id, { agentManager: manager, agentStorage: storage, logger });
await ensureAgentLoaded(active.id, { agentManager: manager, agentStorage: storage, logger });
expect(resumeOptions).toEqual([{ purpose: "history" }, undefined]);
expect(resumeOptions).toEqual([{ purpose: "history" }, { purpose: "interactive" }]);
} finally {
await Promise.all([
manager.closeAgent(archivedId).catch(() => undefined),
@@ -366,7 +366,16 @@ function getProjectedTimelineItems(rows: AgentTimelineRow[]): AgentTimelineItem[
}
function expectContiguousRowSeqs(rows: AgentTimelineRow[], expected: number[]): void {
expect(rows.map((row) => row.seq)).toEqual(expected);
const projected = projectTimelineRows({ rows, mode: "projected" });
const covered = projected.flatMap((row) =>
row.sourceSeqRanges.flatMap((range) =>
Array.from(
{ length: range.endSeq - range.startSeq + 1 },
(_, index) => range.startSeq + index,
),
),
);
expect([...new Set(covered)].sort((a, b) => a - b)).toEqual(expected);
}
function expectContiguousLiveSeqs(events: AgentManagerEvent[], expected: number[]): void {
@@ -524,11 +533,10 @@ describe("target coalesced behavior", () => {
const rows = await harness.manager.getTimelineRows(agentId);
const events = getTimelineStreamEvents(harness.events, agentId);
expect(rows).toHaveLength(2);
expect(rows).toHaveLength(1);
expect(events).toHaveLength(2);
expect(getTimelineItems(rows)).toEqual([
{ type: "assistant_message", text: "x" },
{ type: "assistant_message", text: "x".repeat(999) },
{ type: "assistant_message", text: "x".repeat(1000) },
]);
expect(events.map((event) => (event.type === "agent_stream" ? event.event : null))).toEqual([
{
@@ -567,10 +575,7 @@ describe("target coalesced behavior", () => {
const rows = await harness.manager.getTimelineRows(agentId);
const events = getTimelineStreamEvents(harness.events, agentId);
expect(getTimelineItems(rows)).toEqual([
{ type: "reasoning", text: "r" },
{ type: "reasoning", text: "r".repeat(99) },
]);
expect(getTimelineItems(rows)).toEqual([{ type: "reasoning", text: "r".repeat(100) }]);
expect(events).toHaveLength(2);
expectContiguousRowSeqs(rows, [1, 2]);
expectContiguousLiveSeqs(events, [1, 2]);
@@ -604,8 +609,7 @@ describe("target coalesced behavior", () => {
await vi.advanceTimersByTimeAsync(COALESCE_WINDOW_MS);
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
{ type: "assistant_message", text: "a" },
{ type: "assistant_message", text: "a".repeat(499) },
{ type: "assistant_message", text: "a".repeat(500) },
runningToolCall,
]);
expectContiguousLiveSeqs(getTimelineStreamEvents(harness.events, agentId), [1, 2, 3]);
@@ -617,15 +621,14 @@ describe("target coalesced behavior", () => {
// The "b" burst arrives inside the window that just flushed, so it gets no
// leading flush of its own and coalesces whole.
await vi.advanceTimersByTimeAsync(BEFORE_COALESCE_WINDOW_MS);
expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(3);
expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(2);
await vi.advanceTimersByTimeAsync(1);
const rows = await harness.manager.getTimelineRows(agentId);
const events = getTimelineStreamEvents(harness.events, agentId);
expect(getTimelineItems(rows)).toEqual([
{ type: "assistant_message", text: "a" },
{ type: "assistant_message", text: "a".repeat(499) },
{ type: "assistant_message", text: "a".repeat(500) },
runningToolCall,
{ type: "assistant_message", text: "b".repeat(500) },
]);
@@ -665,10 +668,7 @@ describe("target coalesced behavior", () => {
const rows = await harness.manager.getTimelineRows(agentId);
const events = getTimelineStreamEvents(harness.events, agentId);
expect(getTimelineItems(rows)).toEqual([
toolCall({ output: "chunk-0" }),
toolCall({ output: "chunk-199" }),
]);
expect(getTimelineItems(rows)).toEqual([toolCall({ output: "chunk-199" })]);
expect(events).toHaveLength(2);
expectContiguousRowSeqs(rows, [1, 2]);
expectContiguousLiveSeqs(events, [1, 2]);
@@ -695,9 +695,8 @@ describe("target coalesced behavior", () => {
const events = getTimelineStreamEvents(harness.events, agentId);
expect(getTimelineItems(rows)).toEqual([
toolCall({ callId: "tool-1", output: "one-a" }),
toolCall({ callId: "tool-2", output: "two-b" }),
toolCall({ callId: "tool-1", output: "one-b" }),
toolCall({ callId: "tool-2", output: "two-b" }),
]);
expect(events).toHaveLength(3);
expectContiguousRowSeqs(rows, [1, 2, 3]);
@@ -876,14 +875,8 @@ describe("target coalesced behavior", () => {
const firstEvents = getTimelineStreamEvents(harness.events, first.agentId);
const secondEvents = getTimelineStreamEvents(harness.events, second.agentId);
expect(getTimelineItems(firstRows)).toEqual([
{ type: "assistant_message", text: "a" },
{ type: "assistant_message", text: "a" },
]);
expect(getTimelineItems(secondRows)).toEqual([
{ type: "assistant_message", text: "b" },
{ type: "assistant_message", text: "b" },
]);
expect(getTimelineItems(firstRows)).toEqual([{ type: "assistant_message", text: "aa" }]);
expect(getTimelineItems(secondRows)).toEqual([{ type: "assistant_message", text: "bb" }]);
expect(firstEvents).toHaveLength(2);
expect(secondEvents).toHaveLength(2);
expectContiguousRowSeqs(firstRows, [1, 2]);
@@ -916,10 +909,7 @@ describe("target coalesced behavior", () => {
const rows = await harness.manager.getTimelineRows(agentId);
const events = getTimelineStreamEvents(harness.events, agentId);
expect(getTimelineItems(rows)).toEqual([
{ type: "assistant_message", text: "codex" },
{ type: "assistant_message", text: "claude" },
]);
expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: "codexclaude" }]);
expect(events.map((event) => (event.type === "agent_stream" ? event.event : null))).toEqual([
{ type: "timeline", provider: "codex", item: { type: "assistant_message", text: "codex" } },
{
@@ -991,10 +981,7 @@ describe("target coalesced behavior", () => {
const events = getTimelineStreamEvents(harness.events, agentId);
// The empty chunk is dropped before the coalescer, so " " leads the burst.
expect(getTimelineItems(rows)).toEqual([
{ type: "assistant_message", text: " " },
{ type: "assistant_message", text: "\n\tdone" },
]);
expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: " \n\tdone" }]);
expect(events).toHaveLength(2);
expectContiguousRowSeqs(rows, [1, 2]);
expectContiguousLiveSeqs(events, [1, 2]);
@@ -1189,9 +1176,7 @@ describe("target coalesced behavior", () => {
const timelineEvents = getTimelineStreamEvents(idempotencyHarness.events, agentId);
expect(getTimelineItems(rows)).toEqual([
{ type: "assistant_message", text: "a" },
{ type: "assistant_message", text: "bc" },
TOOL_CALL,
{ type: "assistant_message", text: "abc" },
TOOL_CALL,
]);
expect(timelineEvents).toHaveLength(4);
@@ -1243,8 +1228,7 @@ describe("target coalesced behavior", () => {
const rowsAfterSecondFlush = await reentryHarness.manager.getTimelineRows(agentId);
const timelineEvents = getTimelineStreamEvents(reentryHarness.events, agentId);
expect(getTimelineItems(rowsAfterSecondFlush)).toEqual([
{ type: "assistant_message", text: "A" },
{ type: "assistant_message", text: "B" },
{ type: "assistant_message", text: "AB" },
]);
expect(timelineEvents).toHaveLength(2);
expectContiguousRowSeqs(rowsAfterSecondFlush, [1, 2]);
@@ -1274,20 +1258,19 @@ describe("target coalesced behavior", () => {
const rows = await harness.manager.getTimelineRows(agentId);
const events = getTimelineStreamEvents(harness.events, agentId);
expect(getTimelineItems(rows)).toEqual([
{ type: "assistant_message", text: "flush" },
{ type: "assistant_message", text: "-pending" },
{ type: "assistant_message", text: "flush-pending" },
]);
expect(events).toHaveLength(2);
await vi.advanceTimersByTimeAsync(100);
expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(2);
expect(await harness.manager.getTimelineRows(agentId)).toHaveLength(1);
expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(2);
} finally {
harness.cleanup();
}
});
test("history hydration is uncoalesced and immediate", async () => {
test("history hydration projects immediately without waiting for a streaming window", async () => {
vi.useFakeTimers();
const harness = createHarness();
try {
@@ -1298,20 +1281,16 @@ describe("target coalesced behavior", () => {
const rows = await harness.manager.getTimelineRows(agentId);
expect(getTimelineItems(rows)).toEqual([
{ type: "assistant_message", text: "a1" },
{ type: "assistant_message", text: "a2" },
{ type: "reasoning", text: "r1" },
{ type: "reasoning", text: "r2" },
{ type: "assistant_message", text: "a1a2" },
{ type: "reasoning", text: "r1r2" },
]);
expectContiguousRowSeqs(rows, [1, 2, 3, 4]);
expect(getTimelineStreamEvents(harness.events, agentId)).toHaveLength(0);
await vi.advanceTimersByTimeAsync(100);
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
{ type: "assistant_message", text: "a1" },
{ type: "assistant_message", text: "a2" },
{ type: "reasoning", text: "r1" },
{ type: "reasoning", text: "r2" },
{ type: "assistant_message", text: "a1a2" },
{ type: "reasoning", text: "r1r2" },
]);
} finally {
harness.cleanup();
@@ -1362,10 +1341,7 @@ describe("target coalesced behavior", () => {
const rows = await harness.manager.getTimelineRows(agentId);
const streamEvents = getStreamEvents(harness.events, agentId);
expect(getTimelineItems(rows)).toEqual([
{ type: "assistant_message", text: "hello " },
{ type: "assistant_message", text: "world" },
]);
expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: "hello world" }]);
expect(getProjectedTimelineItems(rows)).toEqual([
{ type: "assistant_message", text: "hello world" },
]);
@@ -1417,8 +1393,7 @@ describe("target coalesced behavior", () => {
const rows = await harness.manager.getTimelineRows(agentId);
expect(getTimelineItems(rows)).toEqual([
{ type: "assistant_message", text: "a1" },
{ type: "assistant_message", text: "a2" },
{ type: "assistant_message", text: "a1a2" },
{ type: "reasoning", text: "r1r2" },
{ type: "assistant_message", text: "b1b2" },
]);
@@ -1434,8 +1409,7 @@ describe("target coalesced behavior", () => {
await vi.advanceTimersByTimeAsync(100);
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
{ type: "assistant_message", text: "a1" },
{ type: "assistant_message", text: "a2" },
{ type: "assistant_message", text: "a1a2" },
{ type: "reasoning", text: "r1r2" },
{ type: "assistant_message", text: "b1b2" },
]);
@@ -19,6 +19,7 @@ import { toAgentPayload } from "./agent-projections.js";
import { projectTimelineRows } from "./timeline-projection.js";
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js";
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js";
import type { StoredAgentRecord } from "./agent-storage.js";
import type {
@@ -36,6 +37,7 @@ import type {
AgentProvider,
AgentPersistenceHandle,
AgentRunOptions,
AgentResumeSessionOptions,
AgentRunResult,
AgentSession,
AgentSessionConfig,
@@ -68,6 +70,12 @@ function deferred<T>(): Deferred<T> {
return { promise, resolve, reject };
}
async function drainAsyncGenerator<T>(generator: AsyncGenerator<T>): Promise<void> {
for await (const _ of generator) {
// Drain provider events while AgentManager subscribers observe them.
}
}
function waitForAgentLifecycle(
manager: AgentManager,
agentId: string,
@@ -519,6 +527,32 @@ class TestAgentSession implements AgentSession {
async close(): Promise<void> {}
}
class ResumeTrackingTestAgentClient extends TestAgentClient {
private readonly retryStarted = deferred<void>();
override async resumeSession(
_handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
this.resumeOverrides.push(config);
const signalRetryStarted = () => this.retryStarted.resolve();
return new (class extends TestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
signalRetryStarted();
return await super.startTurn();
}
})({
provider: this.provider,
cwd: config?.cwd ?? process.cwd(),
daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt,
});
}
waitForRetryStart(): Promise<void> {
return this.retryStarted.promise;
}
}
class McpCapableTestAgentSession extends TestAgentSession {
override readonly capabilities = {
...TEST_CAPABILITIES,
@@ -3677,6 +3711,201 @@ test("updateProviderRegistry removes providers omitted from the next registry",
expect(removedClient.createSessionCalls).toBe(0);
});
test("retires loaded agents when their plugin provider is replaced", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-plugin-provider-reload-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const provider = "plugin-provider";
class OriginalPluginClient extends TestAgentClient {
session: CloseRecordingTestAgentSession | null = null;
constructor() {
super(provider);
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const session = new CloseRecordingTestAgentSession(config);
session.describePersistence = () => ({
provider,
sessionId: "plugin-session",
});
this.session = session;
return session;
}
}
const original = new OriginalPluginClient();
const replacement = new TestAgentClient(provider);
const manager = new AgentManager({
clients: { [provider]: original },
providerDefinitions: { [provider]: { enabled: true } },
registry: storage,
logger,
});
const created = await manager.createAgent({ provider, cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
manager.updateProviderRegistry({
providerDefinitions: { [provider]: { enabled: true } },
clients: { [provider]: replacement },
retiredProviders: [provider],
});
await manager.waitForAgentClose(created.id);
expect(original.session?.closed).toBe(true);
expect(manager.getAgent(created.id)).toBeNull();
const resumed = await ensureAgentLoaded(created.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
expect(resumed.id).toBe(created.id);
expect(replacement.resumeOverrides).toEqual([
expect.objectContaining({ cwd: workdir, provider }),
]);
} finally {
await manager.closeAgent(created.id).catch(() => undefined);
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("a prompt after provider replacement reopens the stale session", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-prompt-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const provider = "plugin-provider";
class StalePluginSession extends CloseRecordingTestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
throw new StaleProviderSessionError("stale-bridge");
}
}
class StalePluginClient extends TestAgentClient {
constructor() {
super(provider);
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const session = new StalePluginSession(config);
session.describePersistence = () => ({
provider,
sessionId: "plugin-session",
});
return session;
}
}
const staleClient = new StalePluginClient();
const replacement = new ResumeTrackingTestAgentClient(provider);
const manager = new AgentManager({
clients: { [provider]: staleClient },
providerDefinitions: { [provider]: { enabled: true } },
registry: storage,
logger,
});
const created = await manager.createAgent({ provider, cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
manager.updateProviderRegistry({
providerDefinitions: { [provider]: { enabled: true } },
clients: { [provider]: replacement },
});
const dispatch = await startAgentRun(manager, created.id, "continue after reload", logger);
expect(dispatch.disposition).toBe("turn_started");
await replacement.waitForRetryStart();
const result = await manager.waitForAgentEvent(created.id);
expect(result.status).toBe("idle");
expect(replacement.resumeOverrides).toHaveLength(1);
} finally {
await manager.closeAgent(created.id).catch(() => undefined);
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("a replacement prompt recovers when the retired session fails to start", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-replacement-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const provider = "plugin-provider";
class StaleReplacementSession extends TestAgentSession {
private starts = 0;
override async startTurn(): Promise<{ turnId: string }> {
this.starts += 1;
if (this.starts > 1) throw new StaleProviderSessionError("stale-bridge");
return { turnId: "initial-turn" };
}
override async interrupt(): Promise<void> {
if (this.starts > 1) throw new StaleProviderSessionError("stale-bridge");
this.pushEvent({
type: "turn_canceled",
provider: this.provider,
turnId: "initial-turn",
});
}
}
class StaleReplacementClient extends TestAgentClient {
constructor() {
super(provider);
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const session = new StaleReplacementSession(config);
session.describePersistence = () => ({ provider, sessionId: "plugin-session" });
return session;
}
}
const replacement = new ResumeTrackingTestAgentClient(provider);
const manager = new AgentManager({
clients: { [provider]: new StaleReplacementClient() },
providerDefinitions: { [provider]: { enabled: true } },
registry: storage,
logger,
rescueTimeouts: { interruptSessionMs: 20 },
});
const created = await manager.createAgent({ provider, cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
const initial = manager.streamAgent(created.id, "initial");
void drainAsyncGenerator(initial);
await manager.waitForAgentRunStart(created.id);
manager.updateProviderRegistry({
providerDefinitions: { [provider]: { enabled: true } },
clients: { [provider]: replacement },
});
const dispatch = await startAgentRun(manager, created.id, "replacement", logger, {
replaceRunning: true,
});
expect(dispatch.disposition).toBe("turn_started");
await replacement.waitForRetryStart();
const result = await manager.waitForAgentEvent(created.id);
expect(result.status).toBe("idle");
expect(replacement.resumeOverrides).toHaveLength(1);
} finally {
await manager.closeAgent(created.id).catch(() => undefined);
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("createAgent passes explicit model strings through to the provider", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -4867,14 +5096,14 @@ test("archiveSnapshot clears persisted attention and normalizes running status",
const archivedRecord = await manager.archiveSnapshot(snapshot.id, archivedAt);
expect(archivedRecord.archivedAt).toBe(archivedAt);
expect(archivedRecord.lastStatus).toBe("idle");
expect(archivedRecord.lastStatus).toBe("closed");
expect(archivedRecord.requiresAttention).toBe(false);
expect(archivedRecord.attentionReason).toBeNull();
expect(archivedRecord.attentionTimestamp).toBeNull();
const persisted = await storage.get(snapshot.id);
expect(persisted?.archivedAt).toBe(archivedAt);
expect(persisted?.lastStatus).toBe("idle");
expect(persisted?.lastStatus).toBe("closed");
expect(persisted?.requiresAttention).toBe(false);
expect(persisted?.attentionReason).toBeNull();
expect(persisted?.attentionTimestamp).toBeNull();
@@ -5157,14 +5386,17 @@ test("fetchTimeline returns a bounded reset window when cursor epoch is stale",
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
messageId: "one",
text: "one",
});
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
messageId: "two",
text: "two",
});
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
messageId: "three",
text: "three",
});
@@ -5238,21 +5470,14 @@ test("getTimelineRows falls back to the in-memory timeline when no durable store
});
await expect(manager.getTimelineRows(snapshot.id)).resolves.toEqual([
{
seq: 1,
timestamp: expect.any(String),
item: {
type: "assistant_message",
text: "row one",
},
},
{
seq: 2,
seqStart: 1,
seqEnd: 2,
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
collapsed: ["assistant_merge"],
timestamp: expect.any(String),
item: {
type: "assistant_message",
text: "row two",
},
item: { type: "assistant_message", text: "row onerow two" },
},
]);
});
@@ -5314,7 +5539,7 @@ test("getAgent does not expose committed history internals once manager owns the
expect(fetched.rows.map((row) => row.seq)).toEqual([1, 2]);
});
test("coalesces assistant chunks and persists the canonical row", async () => {
test("streams coalesced assistant chunks and retains the projected message", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provisional-timeline-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -5371,8 +5596,8 @@ test("coalesces assistant chunks and persists the canonical row", async () => {
}
// The coalescer flushes the first chunk on the leading edge, so "final " ships
// as its own row and "reply" follows on the trailing window. Clients read the
// projected timeline, which merges the two back into one assistant message.
// as its own event and "reply" follows on the trailing window. History retains
// their complete projected assistant message.
const assistantTimelineEvents = streamEvents.filter(
(event) => event.itemType === "assistant_message",
);
@@ -5395,18 +5620,15 @@ test("coalesces assistant chunks and persists the canonical row", async () => {
expect(manager.getTimeline(snapshot.id)).toEqual([
{
type: "assistant_message",
text: "final ",
},
{
type: "assistant_message",
text: "reply",
text: "final reply",
},
]);
const fetched = await manager.fetchTimeline(snapshot.id, {
direction: "tail",
limit: 0,
});
expect(fetched.rows).toHaveLength(2);
expect(fetched.rows).toHaveLength(1);
expect(fetched.rows[0]).toMatchObject({ seqStart: 1, seqEnd: 2 });
expect(assistantTimelineEvents[0]?.epoch).toBe(fetched.epoch);
expect(projectTimelineRows({ rows: fetched.rows, mode: "projected" }).map((e) => e.item)).toEqual(
[
@@ -5442,28 +5664,34 @@ test("fetchTimeline supports older-history pagination with before seq", async ()
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
messageId: "first",
text: "first",
});
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
messageId: "second",
text: "second",
});
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
messageId: "third",
text: "third",
});
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
messageId: "fourth",
text: "fourth",
});
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
messageId: "fifth",
text: "fifth",
});
const result = await manager.fetchTimeline(snapshot.id, {
direction: "before",
cursor: {
epoch: manager.fetchTimeline(snapshot.id).epoch,
seq: 5,
},
limit: 2,
@@ -5500,14 +5728,17 @@ test("does not trim committed history", async () => {
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
messageId: "first",
text: "first",
});
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
messageId: "second",
text: "second",
});
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
messageId: "third",
text: "third",
});
@@ -5605,8 +5836,7 @@ test("hydrateTimeline preserves assistant chunk, reasoning, and tool timeline hi
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true });
expect(manager.getTimeline(snapshot.id)).toEqual([
{ type: "assistant_message", text: "chunk one " },
{ type: "assistant_message", text: "chunk two" },
{ type: "assistant_message", text: "chunk one chunk two" },
{ type: "reasoning", text: "internal" },
{
type: "tool_call",
@@ -9388,7 +9618,7 @@ test("ensureUnarchivedAgentLoaded does not resume an archived agent", async () =
}
});
test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", async () => {
test("a stored-only archive waits for an in-flight resume and then closes it", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-resume-race-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const resumeStarted = deferred<void>();
@@ -9412,16 +9642,17 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a
});
await manager.closeAgent(agent.id);
const load = ensureUnarchivedAgentLoaded(agent.id, {
const load = ensureAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
await resumeStarted.promise;
await manager.archiveSnapshot(agent.id, new Date().toISOString());
const archive = manager.archiveSnapshot(agent.id, new Date().toISOString());
resumeAllowed.resolve();
await archive;
await expect(load).rejects.toThrow(`Agent is archived: ${agent.id}`);
await expect(load).resolves.toMatchObject({ id: agent.id });
expect(manager.getAgent(agent.id)).toBeNull();
expect((await storage.get(agent.id))?.archivedAt).toEqual(expect.any(String));
} finally {
@@ -9431,7 +9662,7 @@ test("ensureUnarchivedAgentLoaded closes a runtime archived while it resumes", a
}
});
test("ensureUnarchivedAgentLoaded fences an archived agent after joining a shared resume", async () => {
test("a stored-only archive closes a shared resume before a later protected load returns", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archived-shared-resume-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const resumeStarted = deferred<void>();
@@ -9466,8 +9697,9 @@ test("ensureUnarchivedAgentLoaded fences an archived agent after joining a share
agentStorage: storage,
logger,
});
await manager.archiveSnapshot(agent.id, new Date().toISOString());
const archive = manager.archiveSnapshot(agent.id, new Date().toISOString());
resumeAllowed.resolve();
await archive;
await sharedLoad;
await expect(protectedLoad).rejects.toThrow(`Agent is archived: ${agent.id}`);
@@ -9705,7 +9937,7 @@ test("concurrent explicit closes tear down the runtime once", async () => {
}
});
test("provider close failure still persists and emits a resumable closed agent", async () => {
test("provider close failure retains the runtime for cleanup instead of allowing another writer", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-close-failure-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new (class extends TestAgentClient {
@@ -9725,12 +9957,10 @@ test("provider close failure still persists and emits a resumable closed agent",
"00000000-0000-4000-8000-000000000217",
{ workspaceId: undefined },
);
const closed = waitForAgentLifecycle(manager, created.id, "closed");
await expect(manager.closeAgent(created.id)).rejects.toThrow("provider cleanup failed");
await closed;
expect(manager.getAgent(created.id)?.session).toBe(created.session);
const stored = await storage.get(created.id);
expect(stored).toMatchObject({ lastStatus: "closed" });
expect(stored).toMatchObject({ lastStatus: "idle" });
expect(stored?.archivedAt).toBeFalsy();
await expect(
@@ -10076,7 +10306,7 @@ test("canonical submitted prompt keeps wire identity while rewind resolves provi
]);
const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 20 }).rows;
expect(timeline).toEqual([
expect(timeline).toMatchObject([
{
seq: 1,
timestamp: expect.any(String),
@@ -10745,3 +10975,54 @@ test("onWorkspaceStateMayHaveChanged is not called for running shell tool calls"
expect(onWorkspaceStateMayHaveChanged).not.toHaveBeenCalled();
});
test("concurrent native restores run once before resuming the same agent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-unarchive-ownership-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const restoreStarted = deferred<void>();
const restoreAllowed = deferred<void>();
const operations: string[] = [];
const client = new (class extends TestAgentClient {
async unarchiveNativeSession(): Promise<void> {
operations.push("restore");
restoreStarted.resolve();
await restoreAllowed.promise;
}
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
_launchContext?: AgentLaunchContext,
options?: AgentResumeSessionOptions,
): Promise<AgentSession> {
operations.push(`resume:${options?.purpose}`);
return super.resumeSession(handle, config);
}
})();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
let agentId: string | undefined;
try {
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = created.id;
await manager.archiveAgent(agentId);
const first = manager.unarchiveSnapshot(agentId);
await restoreStarted.promise;
const second = manager.unarchiveSnapshot(agentId);
const load = ensureAgentLoaded(agentId, {
agentManager: manager,
agentStorage: storage,
logger,
});
restoreAllowed.resolve();
await Promise.all([first, second, load]);
expect(operations).toEqual(["restore", "resume:interactive"]);
expect((await storage.get(agentId))?.archivedAt).toBeNull();
expect(manager.getAgent(agentId)?.persistence?.sessionId).toBe(created.persistence?.sessionId);
} finally {
restoreAllowed.resolve();
if (agentId) await manager.closeAgent(agentId);
await storage.flush();
rmSync(workdir, { recursive: true, force: true });
}
});
+107 -38
View File
@@ -1,3 +1,4 @@
import { projectTimelineRows } from "./timeline-projection.js";
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
@@ -77,6 +78,7 @@ import {
} from "./agent-run-state.js";
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
import { resolveCreateAgentTitles } from "./create-agent-title.js";
import type { PaseoToolCatalogFactory } from "./tools/types.js";
@@ -775,6 +777,7 @@ export class AgentManager {
updateProviderRegistry(input: {
providerDefinitions: ProviderEnabledMap;
clients: ProviderClientMap;
retiredProviders?: readonly AgentProvider[];
}): void {
this.providerEnabled.clear();
this.providerDefinitions.clear();
@@ -791,6 +794,18 @@ export class AgentManager {
this.clients.set(provider, client);
}
}
for (const provider of input.retiredProviders ?? []) {
for (const agent of this.agents.values()) {
if (agent.provider !== provider) continue;
void this.closeAgent(agent.id).catch((error) => {
this.logger.warn(
{ err: error, agentId: agent.id, provider },
"Failed to close agent after provider retirement",
);
});
}
}
}
getRegisteredProviderIds(): AgentProvider[] {
@@ -1140,7 +1155,10 @@ export class AgentManager {
async getTimelineRows(id: string): Promise<AgentTimelineRow[]> {
this.requireAgent(id);
if (this.durableTimelineStore) {
return await this.durableTimelineStore.getCommittedRows(id);
return projectTimelineRows({
rows: await this.durableTimelineStore.getCommittedRows(id),
mode: "projected",
}).map((entry) => Object.assign({ seq: entry.seqEnd }, entry));
}
return this.timelineStore.getRows(id);
}
@@ -1268,8 +1286,20 @@ export class AgentManager {
},
resumeOptions?: AgentResumeSessionOptions,
): Promise<ManagedAgent> {
const resolvedAgentId = validateAgentId(
agentId ?? this.idFactory(),
"resumeAgentFromPersistence",
);
return this.trackAgentRegistrationOperation(
this.resumeAgentFromPersistenceInternal(handle, overrides, agentId, options, resumeOptions),
this.runLifecycleMutation(resolvedAgentId, () =>
this.resumeAgentFromPersistenceInternal(
handle,
overrides,
resolvedAgentId,
options,
resumeOptions,
),
),
);
}
@@ -1303,6 +1333,12 @@ export class AgentManager {
resolvedAgentId,
);
// Decide residency from durable state inside the lifecycle lane. A loader may
// have read the record before a queued archive or restore completed.
const record = this.registry ? await this.registry.get(resolvedAgentId) : null;
const currentResumeOptions = record
? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) }
: resumeOptions;
const client = this.requireClient(handle.provider);
const available = await client.isAvailable();
if (!available) {
@@ -1319,7 +1355,7 @@ export class AgentManager {
undefined,
{
reason: "resume",
purpose: resumeOptions?.purpose ?? "interactive",
purpose: currentResumeOptions?.purpose ?? "interactive",
workspaceId: options?.workspaceId ?? null,
},
);
@@ -1328,7 +1364,7 @@ export class AgentManager {
handle,
providerLaunchConfig,
launchContext,
resumeOptions,
currentResumeOptions,
);
await this.requireExternalMcpSupport(session, storedConfig);
return this.registerSession(session, storedConfig, resolvedAgentId, {
@@ -1635,14 +1671,11 @@ export class AgentManager {
"agent.manager.close.start",
);
await this.drainSessionEvents(agentId);
// Retain ownership until shutdown succeeds. A failed close may still own a
// native writer, so publishing a resumable closed snapshot would orphan it.
await agent.session.close();
this.cancelRunningProviderSubagents(agentId);
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
let closeError: unknown;
try {
await agent.session.close();
} catch (error) {
closeError = error;
}
let persistError: unknown;
try {
@@ -1660,9 +1693,6 @@ export class AgentManager {
"agent.manager.close.complete",
);
if (closeError !== undefined) {
throw closeError;
}
if (persistError !== undefined) {
throw persistError;
}
@@ -1686,7 +1716,10 @@ export class AgentManager {
return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId));
}
private async archiveAgentUnlocked(agentId: string): Promise<{ archivedAt: string }> {
private async archiveAgentUnlocked(
agentId: string,
requestedArchivedAt?: string,
): Promise<{ archivedAt: string }> {
const agent = this.requireAgent(agentId);
if (!this.registry) {
throw new Error("Agent storage is not configured");
@@ -1700,7 +1733,7 @@ export class AgentManager {
throw new Error(`Agent ${agentId} not found in storage after snapshot`);
}
const { archivedAt } = await this.markRecordArchived(stored);
const { archivedAt } = await this.markRecordArchived(stored, requestedArchivedAt);
agent.updatedAt = new Date(archivedAt);
await this.closeAgentRuntime(agentId);
await this.syncNativeArchiveState(stored.provider, stored.persistence, "archive");
@@ -1750,14 +1783,16 @@ export class AgentManager {
} else if (this.agents.has(currentChild.id)) {
await this.archiveAgentUnlocked(currentChild.id);
} else {
await this.archiveSnapshot(currentChild.id, new Date().toISOString());
await this.archiveSnapshotUnlocked(currentChild.id, new Date().toISOString());
}
});
}
}
private async markRecordArchived(record: StoredAgentRecord): Promise<ArchivedStoredAgentRecord> {
const archivedAt = new Date().toISOString();
private async markRecordArchived(
record: StoredAgentRecord,
archivedAt = new Date().toISOString(),
): Promise<ArchivedStoredAgentRecord> {
const archivedRecord = await this.persistArchivedRecord(record, {
archivedAt,
updatedAt: archivedAt,
@@ -2095,12 +2130,23 @@ export class AgentManager {
}
async archiveSnapshot(agentId: string, archivedAt: string): Promise<StoredAgentRecord> {
return this.runLifecycleMutation(agentId, () =>
this.archiveSnapshotUnlocked(agentId, archivedAt),
);
}
private async archiveSnapshotUnlocked(
agentId: string,
archivedAt: string,
): Promise<StoredAgentRecord> {
const registry = this.requireRegistry();
const liveAgent = this.getAgent(agentId);
if (liveAgent) {
await this.persistSnapshot(liveAgent, {
internal: liveAgent.internal,
});
// A stored-only archive can have waited behind a persisted resume. Reuse the
// live archive transition so its newly acquired runtime is closed as well.
if (this.agents.has(agentId)) {
await this.archiveAgentUnlocked(agentId, archivedAt);
const archivedRecord = await registry.get(agentId);
if (!archivedRecord) throw new Error(`Agent not found: ${agentId}`);
return archivedRecord;
}
const record = await registry.get(agentId);
@@ -2112,14 +2158,8 @@ export class AgentManager {
await this.syncNativeArchiveState(record.provider, record.persistence, "archive");
if (this.agents.has(agentId)) {
this.notifyAgentState(agentId);
} else {
this.discardRetainedAgentState(agentId);
if (!nextRecord.internal) {
this.dispatchStoredAgentState(nextRecord);
}
}
this.discardRetainedAgentState(agentId);
if (!nextRecord.internal) this.dispatchStoredAgentState(nextRecord);
await this.fireAgentArchived(agentId);
await this.cascadeArchiveChildren(agentId);
@@ -2130,6 +2170,15 @@ export class AgentManager {
async unarchiveSnapshot(
agentId: string,
updates?: { workspaceId?: string; labels?: AgentLabelPatch },
): Promise<boolean> {
return this.runLifecycleMutation(agentId, () =>
this.unarchiveSnapshotUnlocked(agentId, updates),
);
}
private async unarchiveSnapshotUnlocked(
agentId: string,
updates?: { workspaceId?: string; labels?: AgentLabelPatch },
): Promise<boolean> {
const registry = this.requireRegistry();
const record = await registry.get(agentId);
@@ -2137,8 +2186,9 @@ export class AgentManager {
return false;
}
// Archived history may have loaded a runtime that still owns the native writer.
await this.closeAgent(agentId);
// Close and native restore share the lifecycle lane with persisted resume.
// No new history or interactive runtime can acquire the writer between them.
if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId);
await this.syncNativeArchiveState(record.provider, record.persistence, "restore");
await registry.upsert({
@@ -2360,6 +2410,13 @@ export class AgentManager {
if (pendingRun.settled) {
throw error;
}
if (isStaleProviderSessionError(error)) {
pendingRun.start = { status: "failed", error: error.message };
agent.pendingReplacement = false;
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
this.runs.settleForegroundRun(agentId, pendingRun.token);
throw error;
}
agent.pendingReplacement = false;
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
pendingRun.start = { status: "failed", error: errorMsg };
@@ -2885,6 +2942,9 @@ export class AgentManager {
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void> {
const agent = this.requireAgent(agentId);
if (agent.inFlightPermissionResponses.has(requestId)) {
throw new Error("A response to this permission request is already being submitted");
}
agent.inFlightPermissionResponses.add(requestId);
try {
@@ -3079,8 +3139,11 @@ export class AgentManager {
epoch: this.timelineStore.getEpoch(agentId),
});
}
await this.refreshRuntimeInfo(agent);
// Rewind stages provider events under the run lock; publish its final state directly.
this.refreshSessionPersistence(agent);
await this.refreshSessionState(agent, { emit: false });
await this.persistSnapshot(agent);
this.emitState(agent, { persist: false });
this.logger.info(
{ agentId, provider: agent.provider, messageId, mode },
"agent.rewind.complete",
@@ -4231,14 +4294,18 @@ export class AgentManager {
private onStreamThreadStarted(agent: ActiveManagedAgent): void {
const previousSessionId = agent.persistence?.sessionId ?? null;
this.refreshSessionPersistence(agent);
if (agent.persistence?.sessionId !== previousSessionId) {
this.emitState(agent);
}
void this.refreshRuntimeInfo(agent);
}
private refreshSessionPersistence(agent: ActiveManagedAgent): void {
const handle = agent.session.describePersistence();
if (handle) {
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
if (agent.persistence?.sessionId !== previousSessionId) {
this.emitState(agent);
}
}
void this.refreshRuntimeInfo(agent);
}
private async onStreamTimelineEvent(params: {
@@ -4443,6 +4510,7 @@ export class AgentManager {
): void {
const hadPendingPermissions = agent.pendingPermissions.size > 0;
agent.pendingPermissions.set(event.request.id, event.request);
this.refreshSessionPersistence(agent);
if (!hadPendingPermissions && !agent.internal) {
this.broadcastAgentAttention(agent, "permission");
}
@@ -4457,6 +4525,7 @@ export class AgentManager {
}): void {
const { agent, event, options, flags } = params;
agent.pendingPermissions.delete(event.requestId);
this.refreshSessionPersistence(agent);
if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) {
agent.bufferedPermissionResolutions.set(event.requestId, event);
flags.shouldDispatchEvent = false;
@@ -8,6 +8,7 @@ import type {
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
@@ -21,7 +22,9 @@ export type AgentRunController = Pick<
| "replaceAgentRun"
| "steerOrReplaceActiveTurn"
| "streamAgent"
>;
> & {
reloadAgentSession(agentId: string): Promise<unknown>;
};
export interface StartAgentRunOptions {
replaceRunning?: boolean;
@@ -78,6 +81,14 @@ async function startOrReplaceRun(
return { iterator, replaced };
}
async function drainAgentRunIterator(
iterator: AsyncGenerator<import("./agent-sdk-types.js").AgentStreamEvent>,
): Promise<void> {
for await (const _ of iterator) {
// Events are broadcast via AgentManager subscribers.
}
}
export async function startAgentRun(
agentManager: AgentRunController,
agentId: string,
@@ -104,6 +115,26 @@ export async function startAgentRun(
if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
return { disposition: "out_of_band" };
}
try {
return await startAgentRunInner(agentManager, agentId, prompt, logger, options);
} catch (error) {
if (!isStaleProviderSessionError(error)) throw error;
logger.info({ agentId, err: error }, "Provider session went stale; reopening from persistence");
// The live session belongs to a retired plugin runtime. Reload swaps in a
// fresh session on the current runtime while preserving history and labels.
await agentManager.reloadAgentSession(agentId);
return await startAgentRunInner(agentManager, agentId, prompt, logger, options);
}
}
async function startAgentRunInner(
agentManager: AgentRunController,
agentId: string,
prompt: AgentPromptInput,
logger: Logger,
options?: StartAgentRunOptions,
): Promise<{ disposition: PromptDispatchDisposition }> {
const snapshot = agentManager.getAgent(agentId);
const steered = await steerOrReplaceActiveRun(agentManager, agentId, prompt, options);
if (steered?.disposition === "steered") {
return steered;
@@ -122,8 +153,17 @@ export async function startAgentRun(
);
void (async () => {
try {
for await (const _ of iterator) {
// Events are broadcast via AgentManager subscribers.
try {
await drainAgentRunIterator(iterator);
} catch (error) {
if (!isStaleProviderSessionError(error)) throw error;
logger.info(
{ agentId, err: error },
"Provider session went stale; reopening from persistence",
);
await agentManager.reloadAgentSession(agentId);
const retry = await startOrReplaceRun(agentManager, agentId, prompt, options);
await drainAgentRunIterator(retry.iterator);
}
logger.trace(
{
@@ -229,6 +269,7 @@ const AGENT_RUN_START_TIMEOUT_MS = 60_000;
export async function waitForAgentRunStartWithTimeout(
agentManager: AgentManager,
agentId: string,
signal?: AbortSignal,
): Promise<void> {
const provider = agentManager.getAgent(agentId)?.provider ?? "provider";
const startAbort = new AbortController();
@@ -243,7 +284,9 @@ export async function waitForAgentRunStartWithTimeout(
);
try {
await agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal });
await agentManager.waitForAgentRunStart(agentId, {
signal: signal ? AbortSignal.any([startAbort.signal, signal]) : startAbort.signal,
});
} finally {
clearTimeout(startTimeout);
}
@@ -1,3 +1,4 @@
import type { ProjectedTimelineRow } from "./timeline-projection.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
export interface AgentTimelineRow {
@@ -19,7 +20,7 @@ export interface AgentTimelineFetchOptions {
direction?: AgentTimelineFetchDirection;
cursor?: AgentTimelineCursor;
/**
* Number of canonical rows to return.
* Number of projected items to return.
* - undefined: store default
* - 0: all rows in the selected window
*/
@@ -41,7 +42,9 @@ export interface AgentTimelineFetchResult {
window: AgentTimelineWindow;
hasOlder: boolean;
hasNewer: boolean;
rows: AgentTimelineRow[];
startSeq: number | null;
endSeq: number | null;
rows: ProjectedTimelineRow[];
}
export interface AgentTimelineStore {
@@ -11,17 +11,17 @@ describe("InMemoryAgentTimelineStore", () => {
{
seq: 5,
timestamp: "2026-01-01T00:00:00.000Z",
item: { type: "assistant_message", text: "five" },
item: { type: "assistant_message", text: "five", messageId: "five" },
},
{
seq: 6,
timestamp: "2026-01-01T00:00:01.000Z",
item: { type: "assistant_message", text: "six" },
item: { type: "assistant_message", text: "six", messageId: "six" },
},
{
seq: 7,
timestamp: "2026-01-01T00:00:02.000Z",
item: { type: "assistant_message", text: "seven" },
item: { type: "assistant_message", text: "seven", messageId: "seven" },
},
],
});
@@ -32,7 +32,7 @@ describe("InMemoryAgentTimelineStore", () => {
limit: 2,
});
expect(result).toEqual({
expect(result).toMatchObject({
epoch: "epoch-1",
direction: "before",
reset: false,
@@ -45,12 +45,12 @@ describe("InMemoryAgentTimelineStore", () => {
{
seq: 6,
timestamp: "2026-01-01T00:00:01.000Z",
item: { type: "assistant_message", text: "six" },
item: { type: "assistant_message", text: "six", messageId: "six" },
},
{
seq: 7,
timestamp: "2026-01-01T00:00:02.000Z",
item: { type: "assistant_message", text: "seven" },
item: { type: "assistant_message", text: "seven", messageId: "seven" },
},
],
});
@@ -65,17 +65,17 @@ describe("InMemoryAgentTimelineStore", () => {
{
seq: 5,
timestamp: "2026-01-01T00:00:00.000Z",
item: { type: "assistant_message", text: "five" },
item: { type: "assistant_message", text: "five", messageId: "five" },
},
{
seq: 6,
timestamp: "2026-01-01T00:00:01.000Z",
item: { type: "assistant_message", text: "six" },
item: { type: "assistant_message", text: "six", messageId: "six" },
},
{
seq: 7,
timestamp: "2026-01-01T00:00:02.000Z",
item: { type: "assistant_message", text: "seven" },
item: { type: "assistant_message", text: "seven", messageId: "seven" },
},
],
});
@@ -86,7 +86,7 @@ describe("InMemoryAgentTimelineStore", () => {
limit: 1,
});
expect(result).toEqual({
expect(result).toMatchObject({
epoch: "epoch-1",
direction: "after",
reset: true,
@@ -99,9 +99,115 @@ describe("InMemoryAgentTimelineStore", () => {
{
seq: 7,
timestamp: "2026-01-01T00:00:02.000Z",
item: { type: "assistant_message", text: "seven" },
item: { type: "assistant_message", text: "seven", messageId: "seven" },
},
],
});
});
});
describe("projected timeline retention", () => {
it("retains one full tool state while streaming every source update", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent");
for (let seq = 1; seq <= 2000; seq++) {
const item = {
type: "tool_call" as const,
callId: "child",
name: "task",
status: "running" as const,
error: null,
detail: { type: "plain_text" as const, label: "Child", text: "x".repeat(seq * 128) },
};
expect(store.append("agent", item)).toMatchObject({ seq, item });
}
const result = store.fetch("agent", { limit: 0 });
expect(result.rows).toHaveLength(1);
expect(JSON.stringify(result.rows).length).toBeLessThan(260_000);
expect(result.window.maxSeq).toBe(2000);
});
it("catches up a mid-message cursor with the complete projected message", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent", { epoch: "e" });
store.append("agent", { type: "assistant_message", messageId: "m", text: "A" });
store.append("agent", { type: "assistant_message", messageId: "m", text: "B" });
expect(
store.fetch("agent", { direction: "after", cursor: { epoch: "e", seq: 1 } }).rows,
).toMatchObject([
{
item: { text: "AB" },
seqStart: 1,
seqEnd: 2,
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
},
]);
});
});
describe("projected sequence ownership", () => {
it("preserves fetched coverage when another source chunk arrives", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("a");
store.append("a", { type: "assistant_message", text: "A" });
const before = store.fetch("a");
store.append("a", { type: "assistant_message", text: "B" });
expect(before.rows[0]).toMatchObject({
item: { text: "A" },
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
});
expect(store.fetch("a").rows[0]).toMatchObject({
item: { text: "AB" },
seqEnd: 2,
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
});
});
it("preserves source positions when seeding a projected history whose last update is an earlier tool", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("a");
const tool = {
type: "tool_call" as const,
callId: "t",
name: "shell",
error: null,
detail: { type: "plain_text" as const, label: "work" },
};
store.append("a", { ...tool, status: "running" });
store.append("a", { type: "assistant_message", text: "Answer" });
store.append("a", { ...tool, status: "completed" });
store.initialize("b", { rows: store.getRows("a") });
expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4);
expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]);
});
it("returns the last projected assistant message without joining different messages", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("a");
store.append("a", { type: "assistant_message", messageId: "first", text: "First" });
store.append("a", { type: "assistant_message", messageId: "second", text: "Sec" });
store.append("a", { type: "assistant_message", messageId: "second", text: "ond" });
expect(store.getLastAssistantMessage("a")).toBe("Second");
});
});
it("includes transitive tool updates in a contiguous projected tail", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("a");
const tool = (callId: string, status: "running" | "completed") => ({
type: "tool_call" as const,
callId,
name: "shell",
status,
error: null,
detail: { type: "plain_text" as const, label: "work" },
});
store.append("a", tool("first", "running"));
store.append("a", tool("second", "running"));
store.append("a", { type: "user_message", text: "Continue" });
store.append("a", tool("first", "completed"));
store.append("a", { type: "assistant_message", text: "Answer" });
store.append("a", tool("second", "completed"));
const tail = store.fetch("a", { limit: 1 });
expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]);
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
});
@@ -1,4 +1,9 @@
import { randomUUID } from "node:crypto";
import {
TimelineProjection,
selectProjectedTimelinePage,
type ProjectedTimelineRow,
} from "./timeline-projection.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type {
AgentTimelineFetchOptions,
@@ -16,125 +21,15 @@ export interface SeedAgentTimelineOptions {
interface AgentTimelineState {
epoch: string;
rows: AgentTimelineRow[];
projection: TimelineProjection;
minSeq: number;
nextSeq: number;
}
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
function cloneRow(row: AgentTimelineRow): AgentTimelineRow {
function cloneRow<T extends AgentTimelineRow>(row: T): T {
return { ...row };
}
interface FetchContext {
state: AgentTimelineState;
direction: NonNullable<AgentTimelineFetchOptions["direction"]>;
limit: number;
selectAll: boolean;
cursor: AgentTimelineFetchOptions["cursor"];
minSeq: number;
maxSeq: number;
window: { minSeq: number; maxSeq: number; nextSeq: number };
}
function fetchTail(ctx: FetchContext): AgentTimelineFetchResult {
const { state, direction, limit, selectAll, minSeq, window } = ctx;
const selected =
selectAll || limit >= state.rows.length
? state.rows
: state.rows.slice(state.rows.length - limit);
return {
epoch: state.epoch,
direction,
reset: false,
staleCursor: false,
gap: false,
window,
hasOlder: selected.length > 0 && selected[0].seq > minSeq,
hasNewer: false,
rows: selected.map(cloneRow),
};
}
function fetchAfter(ctx: FetchContext): AgentTimelineFetchResult {
const { state, direction, limit, selectAll, cursor, minSeq, maxSeq, window } = ctx;
const baseSeq = cursor?.seq ?? 0;
const startIdx = state.rows.findIndex((row) => row.seq > baseSeq);
if (startIdx < 0) {
return {
epoch: state.epoch,
direction,
reset: false,
staleCursor: false,
gap: false,
window,
hasOlder: baseSeq >= minSeq,
hasNewer: false,
rows: [],
};
}
const selected = selectAll
? state.rows.slice(startIdx)
: state.rows.slice(startIdx, startIdx + limit);
const lastSelected = selected[selected.length - 1];
return {
epoch: state.epoch,
direction,
reset: false,
staleCursor: false,
gap: false,
window,
hasOlder: selected[0].seq > minSeq,
hasNewer: lastSelected !== null && lastSelected !== undefined && lastSelected.seq < maxSeq,
rows: selected.map(cloneRow),
};
}
function fetchBefore(ctx: FetchContext): AgentTimelineFetchResult {
const { state, direction, limit, selectAll, cursor, minSeq, window } = ctx;
const beforeSeq = cursor?.seq ?? state.nextSeq;
const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq);
const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive);
const selected =
selectAll || limit >= boundedRows.length
? boundedRows
: boundedRows.slice(boundedRows.length - limit);
return {
epoch: state.epoch,
direction,
reset: false,
staleCursor: false,
gap: false,
window,
hasOlder: selected.length > 0 && selected[0].seq > minSeq,
hasNewer: endExclusive >= 0,
rows: selected.map(cloneRow),
};
}
function fetchReset(
ctx: FetchContext,
flags: { staleCursor: boolean; gap: boolean },
): AgentTimelineFetchResult {
const { state, direction, limit, selectAll, minSeq, window } = ctx;
const rows =
selectAll || limit >= state.rows.length
? state.rows.map(cloneRow)
: state.rows.slice(state.rows.length - limit).map(cloneRow);
return {
epoch: state.epoch,
direction,
reset: true,
staleCursor: flags.staleCursor,
gap: flags.gap,
window,
hasOlder: rows.length > 0 && rows[0].seq > minSeq,
hasNewer: false,
rows,
};
}
export class InMemoryAgentTimelineStore {
private readonly states = new Map<string, AgentTimelineState>();
@@ -147,10 +42,13 @@ export class InMemoryAgentTimelineStore {
const rows = options?.rows?.length
? options.rows.map(cloneRow)
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
const nextSeq = options?.nextSeq ?? (rows.length ? rows[rows.length - 1].seq + 1 : 1);
const nextSeq = rows.reduce((next, row) => Math.max(next, row.seq + 1), options?.nextSeq ?? 1);
const projection = new TimelineProjection();
for (const row of rows) projection.append(row);
this.states.set(agentId, {
epoch: options?.epoch ?? randomUUID(),
rows,
projection,
minSeq: projection.getRows()[0]?.seqStart ?? 0,
nextSeq,
});
}
@@ -160,19 +58,23 @@ export class InMemoryAgentTimelineStore {
}
getItems(agentId: string): AgentTimelineItem[] {
return this.requireState(agentId).rows.map((row) => row.item);
return this.requireState(agentId)
.projection.getRows()
.map((row) => row.item);
}
getRows(agentId: string): AgentTimelineRow[] {
return this.requireState(agentId).rows.map(cloneRow);
getRows(agentId: string): ProjectedTimelineRow[] {
return this.requireState(agentId).projection.getRows().map(cloneRow);
}
getSubmittedUserMessage(agentId: string, clientMessageId: string): AgentTimelineRow | null {
const row = this.requireState(agentId).rows.find(
(candidate) =>
candidate.item.type === "user_message" &&
candidate.item.clientMessageId === clientMessageId,
);
const row = this.requireState(agentId)
.projection.getRows()
.find(
(candidate) =>
candidate.item.type === "user_message" &&
candidate.item.clientMessageId === clientMessageId,
);
return row ? cloneRow(row) : null;
}
@@ -181,19 +83,10 @@ export class InMemoryAgentTimelineStore {
clientMessageId: string,
providerMessageId: string,
): AgentTimelineRow | null {
const state = this.requireState(agentId);
const index = state.rows.findIndex(
(candidate) =>
candidate.item.type === "user_message" &&
candidate.item.clientMessageId === clientMessageId,
return this.requireState(agentId).projection.enrichSubmittedUserMessage(
clientMessageId,
providerMessageId,
);
const row = state.rows[index];
if (!row || row.item.type !== "user_message") {
return null;
}
const enriched: AgentTimelineRow = { ...row, providerMessageId };
state.rows[index] = enriched;
return cloneRow(enriched);
}
getEpoch(agentId: string): string {
@@ -203,62 +96,37 @@ export class InMemoryAgentTimelineStore {
fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
const state = this.requireState(agentId);
const direction = options?.direction ?? "tail";
const requestedLimit = options?.limit;
const limit =
requestedLimit === undefined
? DEFAULT_TIMELINE_FETCH_LIMIT
: Math.max(0, Math.floor(requestedLimit));
const cursor = options?.cursor;
const minSeq = state.rows.length ? state.rows[0].seq : 0;
const maxSeq = state.rows.length ? state.rows[state.rows.length - 1].seq : 0;
const selectAll = limit === 0;
const window = {
minSeq,
maxSeq,
nextSeq: state.nextSeq,
};
const ctx: FetchContext = {
state,
const rows = state.projection.getRows();
const window = { minSeq: state.minSeq, maxSeq: state.nextSeq - 1, nextSeq: state.nextSeq };
const staleCursor = cursor !== undefined && cursor.epoch !== state.epoch;
const gap =
!staleCursor &&
direction === "after" &&
cursor !== undefined &&
rows.length > 0 &&
cursor.seq < state.minSeq - 1;
const reset = staleCursor || gap;
const page = selectProjectedTimelinePage({
rows,
bounds: window,
direction: reset ? "tail" : direction,
cursorSeq: cursor?.seq,
limit: options?.limit ?? DEFAULT_TIMELINE_FETCH_LIMIT,
});
return {
epoch: state.epoch,
direction,
limit,
selectAll,
cursor,
minSeq,
maxSeq,
reset,
staleCursor,
gap,
window,
hasOlder: page.hasOlder,
hasNewer: page.hasNewer,
startSeq: page.startSeq,
endSeq: page.endSeq,
rows: page.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)),
};
if (cursor && typeof cursor.epoch === "string" && cursor.epoch !== state.epoch) {
return fetchReset(ctx, { staleCursor: true, gap: false });
}
if (direction === "after" && cursor && state.rows.length > 0 && cursor.seq < minSeq - 1) {
return fetchReset(ctx, { staleCursor: false, gap: true });
}
if (state.rows.length === 0) {
return {
epoch: state.epoch,
direction,
reset: false,
staleCursor: false,
gap: false,
window,
hasOlder: false,
hasNewer: false,
rows: [],
};
}
if (direction === "tail") {
return fetchTail(ctx);
}
if (direction === "after") {
return fetchAfter(ctx);
}
return fetchBefore(ctx);
}
append(
@@ -275,34 +143,21 @@ export class InMemoryAgentTimelineStore {
...(options?.providerMessageId ? { providerMessageId: options.providerMessageId } : {}),
};
state.nextSeq += 1;
state.rows.push(row);
if (state.minSeq === 0) state.minSeq = row.seq;
state.projection.append(row);
return cloneRow(row);
}
getLastItem(agentId: string): AgentTimelineItem | null {
const state = this.requireState(agentId);
return state.rows[state.rows.length - 1]?.item ?? null;
return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null;
}
getLastAssistantMessage(agentId: string): string | null {
const rows = this.requireState(agentId).rows;
const chunks: string[] = [];
for (let i = rows.length - 1; i >= 0; i -= 1) {
const item = rows[i].item;
if (item.type !== "assistant_message") {
if (chunks.length > 0) {
break;
}
continue;
}
chunks.push(item.text);
}
if (chunks.length === 0) {
return null;
}
return chunks.toReversed().join("");
const row = this.requireState(agentId)
.projection.getRows()
.findLast((candidate) => candidate.item.type === "assistant_message");
return row?.item.type === "assistant_message" ? row.item.text : null;
}
private requireState(agentId: string): AgentTimelineState {
@@ -55,6 +55,7 @@ export type EnsureWorkspaceForCreate = (
export interface CreateAgentFromSessionInput {
kind: "session";
onAgentReady?: (agent: ManagedAgent) => Promise<void>;
agentId?: string;
config: AgentSessionConfig;
workspaceId: string;
@@ -190,6 +191,8 @@ export async function createAgentCommand(
agentId: snapshot.id,
});
if (input.kind === "session") await input.onAgentReady?.(snapshot);
let liveSnapshot = snapshot;
let initialPromptStarted = false;
let initialPromptError: unknown | null = null;
@@ -8,6 +8,10 @@ import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "./agent-sdk-types.js";
import { PluginAgentClientRegistry } from "./plugin-provider.js";
import {
isStaleProviderSessionError,
StaleProviderSessionError,
} from "./stale-provider-session-error.js";
const CAPABILITIES = [
"prompt.message",
@@ -25,6 +29,10 @@ interface ProviderHarnessOptions {
function createProviderHarness(options: ProviderHarnessOptions = {}) {
let listener: ((event: ProviderEvent) => void) | null = null;
let closeCount = 0;
let resolveClosed!: () => void;
const closed = new Promise<void>((resolve) => {
resolveClosed = resolve;
});
const inputs: ProviderInput[] = [];
const emit = (event: ProviderEvent) => listener?.(event);
const capabilities = options.capabilities ?? CAPABILITIES;
@@ -180,6 +188,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
},
async close() {
closeCount += 1;
resolveClosed();
},
};
@@ -191,7 +200,12 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
},
};
return { registration, inputs, closeCount: () => closeCount };
return {
registration,
inputs,
closeCount: () => closeCount,
waitForClose: () => closed,
};
}
function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]) {
@@ -258,6 +272,84 @@ describe("PluginAgentClientRegistry", () => {
expect(eventsOfType(events, "turn_failed")).toHaveLength(1);
});
test("closes a stale session after its plugin provider is replaced", async () => {
const old = createProviderHarness();
const next = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
try {
registry.replace([old.registration]);
const stale = await registry.clients()[old.registration.id]!.createSession({
provider: old.registration.id,
cwd: "/workspace",
});
const persistence = stale.describePersistence();
expect(persistence).not.toBeNull();
registry.replace([next.registration]);
await old.waitForClose();
expect(old.closeCount()).toBe(1);
await expect(stale.close()).resolves.toBeUndefined();
const replacement = registry.clients()[next.registration.id];
expect(replacement).toBeDefined();
const resumed = await replacement!.resumeSession(persistence!, {
cwd: "/workspace",
});
await expect(
resumed.startTurn("after reload", { clientMessageId: "after-reload" }),
).resolves.toEqual({ turnId: "turn-1" });
expect(next.inputs).toContainEqual(
expect.objectContaining({
type: "session.open",
history: "replay",
persistence: {
version: 1,
data: { token: "root" },
},
}),
);
await resumed.close();
} finally {
await registry.shutdown();
}
});
test("prompting a stale session raises StaleProviderSessionError", async () => {
const old = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
try {
registry.replace([old.registration]);
const stale = await registry.clients()[old.registration.id]!.createSession({
provider: old.registration.id,
cwd: "/workspace",
});
registry.replace([]);
await old.waitForClose();
expect(old.closeCount()).toBe(1);
const failure = await stale
.startTurn("after reload", { clientMessageId: "after-reload" })
.then(
() => null,
(error: unknown) => error,
);
expect(failure).toBeInstanceOf(StaleProviderSessionError);
expect(isStaleProviderSessionError(failure)).toBe(true);
expect(isStaleProviderSessionError(new Error("Provider connection is closed"))).toBe(false);
expect(isStaleProviderSessionError(new Error("boom"))).toBe(false);
} finally {
await registry.shutdown();
}
});
test("adapts callback providers into the existing AgentClient and AgentSession path", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
@@ -58,6 +58,7 @@ import {
} from "./create-agent-mode.js";
import type { ProviderDefinition } from "./provider-registry.js";
import { runProviderTurn } from "./providers/provider-runner.js";
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
interface Deferred<Value> {
promise: Promise<Value>;
@@ -130,6 +131,10 @@ class ProviderRuntime {
return this.connection?.capabilities ?? [];
}
get isClosed(): boolean {
return this.closed;
}
async isAvailable(): Promise<boolean> {
await this.getConnection();
return true;
@@ -559,6 +564,7 @@ class ProviderRuntimeSession {
sessionId: this.providerSessionId,
prompt,
});
if (this.terminal) throw new StaleProviderSessionError(this.id);
const pending = deferred<Extract<ProviderEvent, { type: "session.prompt_result" }>>();
this.prompts.set(prompt.clientMessageId, pending);
try {
@@ -568,6 +574,9 @@ class ProviderRuntimeSession {
prompt,
});
return (await pending.promise).result;
} catch (error) {
if (this.terminal || this.runtime.isClosed) throw new StaleProviderSessionError(this.id);
throw error;
} finally {
this.prompts.delete(prompt.clientMessageId);
}
@@ -623,6 +632,8 @@ class ProviderRuntimeSession {
requestId: randomUUID(),
sessionId: this.providerSessionId,
});
} catch (error) {
if (!this.runtime.isClosed) throw error;
} finally {
this.runtime.removeSession(this.id, this.providerSessionId);
}
@@ -0,0 +1,201 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import pino from "pino";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { createDaemonTestContext } from "../test-utils/daemon-test-context.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createMessageCollector } from "../test-utils/message-collector.js";
import { CodexAppServerAgentSession } from "./providers/codex-app-server-agent.js";
import { createFakeCodexAppServer } from "./providers/codex/test-utils/fake-app-server.js";
import type { AgentClient } from "./agent-sdk-types.js";
test("projects Codex child history and confines old-client degradation to the child transcript", async () => {
const cwd = await mkdtemp(join(tmpdir(), "paseo-projected-contract-"));
const app = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd },
null,
pino({ level: "silent" }),
async () => app.child,
);
await session.connect();
const provider: AgentClient = {
provider: "codex",
capabilities: session.capabilities,
isAvailable: async () => true,
createSession: async () => session,
resumeSession: async () => session,
fetchCatalog: async () => ({ models: [], modes: [] }),
};
const ctx = await createDaemonTestContext({
pluginsEnabled: false,
relayEnabled: false,
agentClients: { codex: provider },
});
const legacy = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
capabilities: { [CLIENT_CAPS.projectedSubagentTimeline]: false },
});
await legacy.connect();
const messages = createMessageCollector(legacy);
const observation = legacy.observeEvents(["agent.provider_subagents.update"]);
const unsubscribe = observation.subscribe({ snapshot() {}, update() {} });
await observation.ready;
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Projected contract",
});
app.startsTurn({ threadId: "thread-1" });
app.startsSubAgent({ callId: "spawn-child", threadId: "child-thread", agentPath: "child" });
for (const delta of ["A", "B", "C"]) {
app.child.stdout.write(
JSON.stringify({
method: "item/agentMessage/delta",
params: { threadId: "child-thread", itemId: "message-1", delta },
}) + "\n",
);
}
await expect
.poll(() => ctx.daemon.daemon.agentManager.listProviderSubagents(agent.id).length)
.toBe(1);
await expect
.poll(
() =>
ctx.daemon.daemon.agentManager.fetchProviderSubagentTimeline(agent.id, "child-thread")
.window.maxSeq,
)
.toBe(3);
const child = await ctx.client.fetchProviderSubagentTimeline(agent.id, "child-thread", {
limit: 1,
});
expect(child.projection).toBe("projected");
expect(child.rows).toMatchObject([{ seqStart: 1, seqEnd: 3, item: { text: "ABC" } }]);
const catchUp = await ctx.client.fetchProviderSubagentTimeline(agent.id, "child-thread", {
direction: "after",
cursor: { epoch: child.epoch, seq: 1 },
});
expect(catchUp.rows).toMatchObject([{ item: { text: "ABC" } }]);
const oldChild = await legacy.fetchProviderSubagentTimeline(agent.id, "child-thread");
expect(oldChild.rows).toMatchObject([
{ item: { type: "assistant_message", text: expect.stringContaining("upgrade") } },
]);
expect((await legacy.listProviderSubagents(agent.id)).subagents).toHaveLength(1);
expect(
messages.messages.some(
(message) =>
message.type === "agent.provider_subagents.update" && message.payload.kind === "upsert",
),
).toBe(true);
expect(
messages.messages.some(
(message) =>
message.type === "agent.provider_subagents.update" && message.payload.kind === "timeline",
),
).toBe(false);
const root = await legacy.fetchAgentTimeline(agent.id, { projection: "canonical", limit: 0 });
expect(root.error).toBeNull();
expect(root.projection).toBe("projected");
expect(root.entries).toHaveLength(1);
expect((await legacy.buildAgentForkContext(agent.id)).attachment.text).toContain(
"[Child] child",
);
app.assertNoErrors();
} finally {
unsubscribe();
messages.unsubscribe();
await legacy.close();
await ctx.cleanup();
await rm(cwd, { recursive: true, force: true });
}
}, 30_000);
test("resuming Codex restores child descriptors and complete messages without historical deltas", async () => {
const app = createFakeCodexAppServer({
"thread/read": (params) => {
const { threadId } = params as { threadId: string };
return {
thread: {
id: threadId,
turns: [
{
id: "turn",
items:
threadId === "root"
? [
{
type: "subAgentActivity",
id: "spawn",
kind: "started",
agentThreadId: "child",
agentPath: "explore",
},
]
: [{ type: "agentMessage", id: "message", text: "Complete child answer" }],
},
],
},
};
},
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: tmpdir() },
{ sessionId: "root" },
pino({ level: "silent" }),
async () => app.child,
);
try {
await session.connect();
const children = [];
for await (const event of session.streamHistory()) {
if (event.type === "provider_subagent") children.push(event.event);
}
expect(children).toMatchObject([
{ type: "upsert", id: "child", status: "completed" },
{
type: "timeline",
id: "child",
item: { type: "assistant_message", text: "Complete child answer" },
},
]);
app.assertNoErrors();
} finally {
await session.close();
}
});
test("retains only the latest cumulative Pi tool progress payload", async () => {
const { parseToolArgs, parseToolResult, mapToolDetail } =
await import("./providers/pi/tool-call-mapper.js");
const { InMemoryAgentTimelineStore } = await import("./agent-timeline-store.js");
const store = new InMemoryAgentTimelineStore();
store.initialize("agent");
const tool = parseToolArgs("custom_progress", {});
const messages: string[] = [];
for (let index = 0; index < 20; index++) {
messages.push(`Update ${index}: ${"x".repeat(2048)}`);
const result = parseToolResult({
content: [{ type: "text", text: "running" }],
details: { results: [{ messages: [...messages] }] },
});
store.append("agent", {
type: "tool_call",
callId: "same-call",
name: "custom_progress",
status: "running",
error: null,
detail: mapToolDetail(tool, result),
});
}
const history = store.fetch("agent", { limit: 0 });
expect(history.rows).toHaveLength(1);
expect(history.rows[0]).toMatchObject({
seqStart: 1,
seqEnd: 20,
item: { detail: { output: { details: { results: [{ messages }] } } } },
});
expect(JSON.stringify(history.rows).length).toBeLessThan(50_000);
});
@@ -138,6 +138,79 @@ async function runTestCatalogActivities(
}
describe("ProviderSnapshotManager public surface", () => {
test("publishes one model per provider identity and retains the first definition", async () => {
const first = { provider: "codex", id: "shared", label: "First", isDefault: true };
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
extraClients: {
codex: createExtraClient("codex", {
isAvailable: async () => true,
fetchCatalog: async () => ({
models: [first, { ...first, label: "Duplicate" }],
modes: [],
}),
}),
claude: createExtraClient("claude", {
isAvailable: async () => true,
fetchCatalog: async () => ({ models: [{ ...first, provider: "claude" }], modes: [] }),
}),
},
});
try {
const codex = await manager.getProvider({ provider: "codex", wait: true });
const claude = await manager.getProvider({ provider: "claude", wait: true });
expect(codex.models).toEqual([first]);
expect(claude.models).toEqual([{ ...first, provider: "claude" }]);
} finally {
manager.destroy();
}
});
test("normalizes plugin model catalogs at the same publication boundary", async () => {
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
manager.replacePluginProviders([
{
id: "catalog-plugin",
label: "Catalog plugin",
async connect() {
let listener: ((event: ProviderEvent) => void) | undefined;
return {
version: 1,
capabilities: [],
async send(input) {
if (input.type === "catalog")
listener?.({
type: "catalog",
requestId: input.requestId,
catalog: {
models: [
{ id: "same", label: "First" },
{ id: "same", label: "Second" },
],
modes: [],
},
});
},
onEvent(next) {
listener = next;
return () => {
listener = undefined;
};
},
async close() {},
};
},
},
]);
try {
const entry = await manager.getProvider({ provider: "catalog-plugin", wait: true });
expect(entry.models).toHaveLength(1);
expect(entry.models?.[0]).toMatchObject({ id: "same", label: "First" });
} finally {
manager.destroy();
}
});
test("carries a plugin provider icon in snapshot metadata", () => {
const iconSvg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
const registration: ProviderRegistration = {
@@ -2281,6 +2354,7 @@ describe("ProviderSnapshotManager cwd routing", () => {
const withoutPlugin = manager.replacePluginProviders([]);
expect(manager.hasProvider(registration.id)).toBe(false);
expect(withoutPlugin.clients[registration.id]).toBeUndefined();
expect(withoutPlugin.retiredProviders).toEqual([registration.id]);
} finally {
await manager.shutdown();
manager.destroy();
@@ -1,3 +1,4 @@
import { normalizeAgentModelCatalog } from "@getpaseo/protocol/agent-types";
import { createHash } from "node:crypto";
import { EventEmitter } from "node:events";
import { homedir } from "node:os";
@@ -191,6 +192,7 @@ export interface AgentManagerProviderState {
>
>;
clients: Partial<Record<AgentProvider, AgentClient>>;
retiredProviders?: readonly AgentProvider[];
}
interface ProviderLoadOptions {
@@ -402,6 +404,9 @@ export class ProviderSnapshotManager {
this.createAgentManagerState(this.generation.definitions, clients);
this.pluginProviders.replace(registrations);
const plugins = this.pluginProviders.definitions();
const retiredProviders = Object.keys(previousPlugins).filter(
(provider) => previousPlugins[provider] !== plugins[provider],
);
const definitions = { ...this.generation.definitions };
const changed = new Set<AgentProvider>();
for (const provider of new Set([...Object.keys(previousPlugins), ...Object.keys(plugins)])) {
@@ -415,7 +420,7 @@ export class ProviderSnapshotManager {
const generation = this.createGeneration(definitions, this.providerOverrides);
const state = this.createAgentManagerState(definitions, clients);
this.installGeneration(generation, clients, changed);
return state;
return { ...state, retiredProviders };
}
private ensureClient(
@@ -1017,13 +1022,20 @@ export class ProviderSnapshotManager {
return;
}
const models = normalizeAgentModelCatalog(catalog.models);
if (models.length !== catalog.models.length) {
this.logger.warn(
{ provider, discardedRows: catalog.models.length - models.length },
"Provider catalog contains repeated model IDs; retaining the first definition",
);
}
setEntry({
...base,
defaultModeId:
catalog.defaultModeId === undefined ? base.defaultModeId : catalog.defaultModeId,
status: "ready",
enabled: true,
models: catalog.models,
models,
modes: catalog.modes,
fetchedAt: new Date().toISOString(),
});
@@ -45,7 +45,7 @@ describe("ProviderSubagentStore", () => {
updatedAt: "2026-07-12T10:00:02.000Z",
}),
]);
expect(subagents.fetchTimeline("parent-a", "child-1").rows).toEqual([
expect(subagents.fetchTimeline("parent-a", "child-1").rows).toMatchObject([
{
seq: 1,
timestamp: "2026-07-12T10:00:01.000Z",
@@ -98,8 +98,11 @@ describe("ProviderSubagentStore", () => {
direction: "tail",
limit: 1,
});
expect(page.rows).toHaveLength(101);
expect(page.rows[0]?.seq).toBe(1);
expect(page.rows).toHaveLength(1);
expect(page.rows[0]?.seqStart).toBe(1);
expect(page.rows[0]?.item).toMatchObject({
text: Array.from({ length: 101 }, (_, i) => String(i)).join(""),
});
expect(page.rows.at(-1)?.seq).toBe(101);
expect(page.hasOlder).toBe(false);
});
@@ -6,7 +6,6 @@ import type {
AgentTimelineFetchResult,
AgentTimelineRow,
} from "../agent-timeline-store-types.js";
import { selectTimelineWindowByProjectedLimit } from "../timeline-projection.js";
export type ProviderSubagentStatus = "running" | "completed" | "failed" | "canceled";
@@ -151,30 +150,7 @@ export class ProviderSubagentStore {
subagentId: string,
options?: AgentTimelineFetchOptions,
): AgentTimelineFetchResult {
const direction = options?.direction ?? "tail";
const limit = options?.limit === undefined ? 200 : Math.max(0, Math.floor(options.limit));
const timeline = this.timelines.fetch(storeKey(parentAgentId, subagentId), {
...options,
limit: 0,
});
if (limit === 0 || timeline.rows.length === 0) {
return timeline;
}
const selected = selectTimelineWindowByProjectedLimit({
rows: timeline.rows,
direction: timeline.reset ? "tail" : direction,
limit,
});
const firstRow = selected.selectedRows[0];
const lastRow = selected.selectedRows[selected.selectedRows.length - 1];
return {
...timeline,
rows: selected.selectedRows,
hasOlder:
timeline.hasOlder || (firstRow !== undefined && firstRow.seq > timeline.window.minSeq),
hasNewer:
timeline.hasNewer || (lastRow !== undefined && lastRow.seq < timeline.window.maxSeq),
};
return this.timelines.fetch(storeKey(parentAgentId, subagentId), options);
}
deleteParent(parentAgentId: string): ProviderSubagentStoreEvent[] {
@@ -1794,10 +1794,9 @@ describe("ACPAgentClient modelTransformer", () => {
describe("ACPAgentClient catalog discovery without a model resolver", () => {
test("never switches models during catalog discovery even with multiple models and a thinking picker", async () => {
// The per-model probing that switches models lives on KimiACPAgentClient
// (see kimi-acp-agent.test.ts). The base client ships no catalog model resolver, so a
// slow or nonconforming ACP can't stall its catalog probe on extra setSessionConfigOption
// round trips.
// Model discovery extensions live on providers that opt in. The base
// client ships no catalog model resolver, so a slow or nonconforming ACP can't
// stall its catalog probe on extra setSessionConfigOption round trips.
const setSessionConfigOption = vi.fn();
class TestACPAgentClient extends ACPAgentClient {
@@ -393,7 +393,7 @@ export type ACPExtensionCommandsParser = (
* the catalog plumbing.
*/
export interface ACPCatalogModelResolverContext {
connection: ClientSideConnection;
connection: Pick<ClientSideConnection, "setSessionConfigOption" | "extMethod">;
sessionId: string;
models: AgentModelDefinition[];
configOptions: SessionConfigOption[] | null | undefined;
@@ -404,10 +404,9 @@ export interface ACPCatalogModelResolverContext {
}
/**
* Optional hook that refines the catalog's model list using the live probe session.
* The base client ships no resolver — catalog discovery derives models from the initial
* session response and never mutates the probe. Providers that need per-model data (Kimi)
* inject a resolver so the extra round trips stay off every other ACP.
* Providers own model discovery through this hook, including extension RPCs. Without a
* resolver, the base client derives models from the initial session response. The client
* owns the probe lifetime and refresh deadline; resolvers return the complete model list.
*/
export type ACPCatalogModelResolver = (
context: ACPCatalogModelResolverContext,
@@ -24,6 +24,12 @@ interface TestClaudeSession {
close(): Promise<void>;
}
function isLoadingCompactionEvent(event: AgentStreamEvent): boolean {
return (
event.type === "timeline" && event.item.type === "compaction" && event.item.status === "loading"
);
}
function isPermissionResolvedEvent(
event: AgentStreamEvent,
): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> {
@@ -1981,6 +1987,15 @@ describe("ClaudeAgentSession context window usage", () => {
};
}
function createCompactingStatus(): Record<string, unknown> {
return {
type: "system",
subtype: "status",
status: "compacting",
session_id: "session-1",
};
}
test("emits turn_started before the submitted user message", async () => {
const session = await createSessionForTurns([[]]);
const events: AgentStreamEvent[] = [];
@@ -2857,6 +2872,110 @@ describe("ClaudeAgentSession context window usage", () => {
}
});
test("repeated compacting statuses open a single compaction marker", async () => {
const session = await createSessionForTurns([
[
createCompactingStatus(),
createCompactingStatus(),
createCompactingStatus(),
createCompactBoundary(),
createCompactingStatus(),
createCompactingStatus(),
createCompactBoundary(),
createSuccessResult(),
],
]);
try {
const events = await collectStreamEvents(session, "compact twice");
const compactions = events.flatMap((event) =>
event.type === "timeline" && event.item.type === "compaction" ? [event.item.status] : [],
);
expect(compactions).toEqual(["loading", "completed", "loading", "completed"]);
} finally {
await session.close();
}
});
test("a compaction abandoned mid-turn does not suppress the next compaction marker", async () => {
// The first turn starts compacting and then ends without ever reaching a
// compact_boundary, so the marker it opened is never resolved.
const session = await createSessionForTurns([
[createCompactingStatus(), createSuccessResult()],
[createCompactingStatus(), createSuccessResult()],
]);
try {
const abandonedTurn = await collectStreamEvents(session, "abandoned compaction");
expect(abandonedTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
const nextTurn = await collectStreamEvents(session, "next compaction");
expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
} finally {
await session.close();
}
});
test("an interrupted compaction does not suppress the next compaction marker", async () => {
// The first turn starts compacting and is then interrupted, so it never reaches a
// compact_boundary and the marker it opened is never resolved.
const session = await createSessionForTurns([
[createCompactingStatus()],
[createCompactingStatus(), createSuccessResult()],
]);
try {
const interruptedTurn: AgentStreamEvent[] = [];
const streaming = (async () => {
for await (const event of streamSession(session, "interrupted compaction")) {
interruptedTurn.push(event);
}
})();
await vi.waitFor(() => {
expect(interruptedTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
});
await session.interrupt();
await streaming;
expect(interruptedTurn).toContainEqual(
expect.objectContaining({ type: "turn_canceled", provider: "claude" }),
);
const nextTurn = await collectStreamEvents(session, "next compaction");
expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
} finally {
await session.close();
}
});
test("a compaction abandoned in an autonomous turn does not suppress the next marker", async () => {
// Trailing output after the foreground result opens an autonomous turn, which starts
// compacting and is then ended by the next foreground turn, never reaching a boundary.
const session = await createSessionForTurns([
[createSuccessResult(), createMessageStartEvent(), createCompactingStatus()],
[createCompactingStatus(), createSuccessResult()],
]);
try {
const observed: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => {
observed.push(event);
});
await collectStreamEvents(session, "foreground turn");
await vi.waitFor(() => {
expect(observed.filter(isLoadingCompactionEvent)).toHaveLength(1);
});
unsubscribe();
const nextTurn = await collectStreamEvents(session, "next compaction");
expect(nextTurn.filter(isLoadingCompactionEvent)).toHaveLength(1);
} finally {
await session.close();
}
});
test("result.result is surfaced as an assistant message when no model output was produced", async () => {
const session = await createSessionForTest();
@@ -2095,6 +2095,7 @@ class ClaudeAgentSession implements AgentSession {
private lastOptionsModel: string | null = null;
private lastRuntimeModel: string | null = null;
private compacting = false;
private compactionMarkerOpen = false;
private queryPumpPromise: Promise<void> | null = null;
private queryRestartNeeded = false;
private pendingInterruptAbort = false;
@@ -2514,9 +2515,8 @@ class ClaudeAgentSession implements AgentSession {
}
/**
* A denied request is the only record the transcript gets. Plans especially:
* the pending card is the only place the plan text lives, so losing it means
* the user can no longer read what they just declined.
* Settle the original call immediately, before waiting for the SDK tool result.
* Plan resolution must use the native call ID so it cannot move past a follow-up.
*/
private recordDeniedPermissionTimeline(
request: AgentPermissionRequest,
@@ -2537,28 +2537,24 @@ class ClaudeAgentSession implements AgentSession {
return;
}
if (request.kind === "plan") {
let planText: string | null = null;
if (typeof request.metadata?.planText === "string") {
planText = request.metadata.planText;
} else if (typeof request.input?.plan === "string") {
planText = request.input.plan;
}
if (!planText) return;
this.pushToolCall({
type: "tool_call",
name: "plan_approval",
callId: request.id,
status: "completed",
error: null,
detail: { type: "plan", text: planText },
metadata: {
approved: false,
actionId: response.selectedActionId ?? "reject",
},
});
this.pushToolCall(
mapClaudeFailedToolCall({
name: "ExitPlanMode",
callId: this.planToolCallId(request),
input: request.input,
error: response.message ?? "Permission denied",
metadata: { actionId: response.selectedActionId ?? "reject" },
}),
);
}
}
private planToolCallId(request: AgentPermissionRequest): string {
return typeof request.metadata?.toolUseId === "string"
? request.metadata.toolUseId
: request.id;
}
private resolveDeniedPermission(
request: AgentPermissionRequest,
response: Extract<AgentPermissionResponse, { behavior: "deny" }>,
@@ -2609,8 +2605,8 @@ class ClaudeAgentSession implements AgentSession {
await this.setMode(targetMode);
this.pushToolCall(
mapClaudeCompletedToolCall({
name: "plan_approval",
callId: pending.request.id,
name: "ExitPlanMode",
callId: this.planToolCallId(pending.request),
input: pending.request.input ?? null,
output: {
approved: true,
@@ -3572,6 +3568,7 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundInput = null;
this.cancelCurrentTurn = null;
this.activeTurnHasAssistantText = false;
this.compactionMarkerOpen = false;
this.syncTurnState("foreground turn terminal");
}
@@ -3583,6 +3580,7 @@ class ClaudeAgentSession implements AgentSession {
}
if (terminalSeen) {
this.compactionMarkerOpen = false;
if (this.activeForegroundTurnId) {
this.activeForegroundTurnId = null;
this.activeForegroundQuery = null;
@@ -3624,6 +3622,7 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundQuery = null;
this.activeForegroundInput = null;
this.activeTurnHasAssistantText = false;
this.compactionMarkerOpen = false;
this.syncTurnState("autonomous turn completed");
}
@@ -4260,15 +4259,22 @@ class ClaudeAgentSession implements AgentSession {
const status = toObjectRecord(message)?.status;
if (status === "compacting") {
this.compacting = true;
events.push({
type: "timeline",
item: { type: "compaction", status: "loading" },
provider: "claude",
});
// Claude Code repeats this status every 30 seconds until the compaction
// finishes. Each repeat used to open its own marker, and the app only ever
// resolves one of them, so the rest stayed on "Compacting..." forever.
if (!this.compactionMarkerOpen) {
this.compactionMarkerOpen = true;
events.push({
type: "timeline",
item: { type: "compaction", status: "loading" },
provider: "claude",
});
}
}
return;
}
if (message.subtype === "compact_boundary") {
this.compactionMarkerOpen = false;
const compactMetadata = readCompactionMetadata(message);
events.push({
type: "timeline",
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
mapClaudeCanceledToolCall,
mapClaudeCompletedToolCall,
mapClaudeFailedToolCall,
mapClaudeRunningToolCall,
@@ -15,6 +16,40 @@ function expectMapped<T>(item: T | null): T {
}
describe("claude tool-call mapper", () => {
it("preserves a plan's original tool identity and outcome for live and replayed results", () => {
const proposal = { name: "ExitPlanMode", callId: "plan-tool-1", input: { plan: "Ship it" } };
const pending = expectMapped(mapClaudeRunningToolCall(proposal));
const rejected = expectMapped(
mapClaudeFailedToolCall({ ...proposal, error: "Denied by user" }),
);
const approved = expectMapped(mapClaudeCompletedToolCall(proposal));
const canceled = expectMapped(mapClaudeCanceledToolCall(proposal));
expect(canceled).toMatchObject({
callId: proposal.callId,
name: "plan_approval",
status: "canceled",
detail: { type: "plan", text: "Ship it" },
});
expect(pending).toMatchObject({
callId: "plan-tool-1",
detail: { type: "plan", text: "Ship it" },
});
expect(rejected).toMatchObject({
callId: pending.callId,
name: "plan_approval",
status: "completed",
error: null,
detail: pending.detail,
metadata: { approved: false },
});
expect(approved).toMatchObject({
callId: pending.callId,
name: "plan_approval",
status: "completed",
detail: pending.detail,
metadata: { approved: true },
});
});
it("maps running shell calls with canonical fields", () => {
const item = expectMapped(
mapClaudeRunningToolCall({
@@ -12,6 +12,8 @@ interface MapperParams {
metadata?: Record<string, unknown>;
}
const ClaudePlanInputSchema = z.object({ plan: z.string() });
const ClaudeToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]);
type ClaudeToolCallStatus = z.infer<typeof ClaudeToolCallStatusSchema>;
@@ -113,6 +115,28 @@ function mapClaudeToolCall(
}
const trimmedName = raw.name.trim();
if (trimmedName === "ExitPlanMode") {
const plan = ClaudePlanInputSchema.safeParse(raw.input);
if (plan.success) {
// The permission callback and transcript tool_result describe the same call.
// Keep its native identity so resolution updates the proposal's original position.
return {
type: "tool_call",
callId,
// Older clients suppress the pending native call while showing its approval card.
name: status === "running" ? trimmedName : "plan_approval",
status: status === "failed" ? "completed" : status,
error: null,
detail: { type: "plan", text: plan.data.plan },
metadata: {
...raw.metadata,
...(status === "completed" || status === "failed"
? { approved: status === "completed" }
: {}),
},
};
}
}
const toolKind = resolveClaudeToolKind(trimmedName);
const name = toolKind === "speak" ? "speak" : trimmedName;
const input = raw.input ?? null;
@@ -97,6 +97,88 @@ async function createConnectedSession(
}
describe("Codex app-server provider features", () => {
test.each([
"gpt-6-astra",
"gpt-5.6",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
"gpt-5.4",
])("exposes and sends Fast for %s", async (model) => {
const { session, appServer } = await createConnectedSession({ model });
try {
expect(session.features).toContainEqual(
expect.objectContaining({
id: "fast_mode",
value: false,
}),
);
await session.setFeature?.("fast_mode", true);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model,
serviceTier: "fast",
});
} finally {
await session.close();
}
});
test.each([
"gpt-5.3-codex-spark",
"gpt-5.3-codex",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.5-pro",
"gpt-5",
"gpt-4.1",
"o3",
"o4-mini",
"gpt-6-unknown",
])("does not expose or restore Fast for %s", async (model) => {
const { session, appServer } = await createConnectedSession({
model,
featureValues: { fast_mode: true },
});
try {
expect(session.features.map((feature) => feature.id)).toEqual(["plan_mode"]);
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(
`Codex fast mode is not available for model '${model}'`,
);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
serviceTier: expect.anything(),
});
} finally {
await session.close();
}
});
test("restores Fast on Astra and preserves it when switching supported models", async () => {
const { session, appServer } = await createConnectedSession({
model: "gpt-6-astra",
featureValues: { fast_mode: true },
});
try {
expect(session.features).toContainEqual(
expect.objectContaining({
id: "fast_mode",
value: true,
}),
);
await session.setModel("gpt-5.6-sol");
await session.setModel("gpt-6-astra");
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model: "gpt-6-astra",
serviceTier: "fast",
});
} finally {
await session.close();
}
});
test("features returns fast and plan toggles when supported", async () => {
const { session } = await createConnectedSession();
@@ -105,7 +187,7 @@ describe("Codex app-server provider features", () => {
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at 2x usage",
description: "Priority inference at increased usage",
tooltip: "Toggle fast mode",
icon: "zap",
value: false,
@@ -129,7 +211,7 @@ describe("Codex app-server provider features", () => {
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at 2x usage",
description: "Priority inference at increased usage",
tooltip: "Toggle fast mode",
icon: "zap",
value: true,
@@ -250,7 +332,7 @@ describe("Codex app-server provider features", () => {
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at 2x usage",
description: "Priority inference at increased usage",
tooltip: "Toggle fast mode",
icon: "zap",
value: true,
@@ -11,6 +11,7 @@ import type {
AgentLaunchContext,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
} from "../agent-sdk-types.js";
import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import {
@@ -20,6 +21,87 @@ import {
const logger = createTestLogger();
interface PendingPlanLifecycle {
close(): Promise<void>;
crashDuringActiveTurn(): Promise<void>;
expectCanceledPlan(): void;
}
async function withPendingPlan(
runScenario: (plan: PendingPlanLifecycle) => Promise<void>,
): Promise<void> {
const workdir = mkdtempSync(join(tmpdir(), "codex-plan-cancel-"));
const appServer = createFakeCodexAppServer();
const client = new ProcessExitCodexClient([appServer]);
const session = await client.createSession({
provider: "codex",
cwd: workdir,
modeId: "auto",
model: "gpt-5.4",
featureValues: { plan_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
const run = session.run("make a plan");
await appServer.waitForTurnStart();
appServer.startsTurn({ threadId: "thread-1", turnId: "plan-turn" });
appServer.updatesPlan({ threadId: "thread-1", steps: ["Inspect README"] });
appServer.completeTurn();
await run;
const proposal = events.find(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "plan_approval",
);
expect(proposal).toBeDefined();
const [request] = session.getPendingPermissions?.() ?? [];
expect(request).toBeDefined();
await runScenario({
close: () => session.close(),
async crashDuringActiveTurn() {
appServer.startsTurn({ threadId: "thread-1", turnId: "autonomous-turn" });
await expect
.poll(() => events.filter((event) => event.type === "turn_started").length)
.toBe(2);
appServer.child.emit("exit", 17, null);
await expect.poll(() => events.some((event) => event.type === "turn_failed")).toBe(true);
},
expectCanceledPlan() {
expect(session.getPendingPermissions?.()).toEqual([]);
expect(events).toContainEqual({
...proposal,
item: expect.objectContaining({
type: "tool_call",
name: "plan_approval",
callId: request?.id,
status: "canceled",
detail: { type: "plan", text: "- Inspect README" },
}),
});
},
});
} finally {
await session.close();
rmSync(workdir, { recursive: true, force: true });
}
}
test("closing a session retains a canceled pending plan", async () => {
await withPendingPlan(async (plan) => {
await plan.close();
plan.expectCanceledPlan();
});
});
test("an active provider crash retains a canceled pending plan", async () => {
await withPendingPlan(async (plan) => {
await plan.crashDuringActiveTurn();
plan.expectCanceledPlan();
});
});
class ProcessExitCodexClient extends CodexAppServerAgentClient implements AgentClient {
constructor(private readonly appServers: FakeCodexAppServer[]) {
super(logger);
@@ -111,7 +111,7 @@ interface CodexSessionTestAccess {
ensureThreadLoaded(): Promise<void>;
handleToolApprovalRequest(params: unknown): Promise<unknown>;
handleNotification(method: string, params: unknown): void;
loadPersistedHistory(): Promise<void>;
loadPersistedHistory(client: CodexClientLike | null): Promise<void>;
refreshResolvedCollaborationMode(): void;
serviceTier: "fast" | null;
planModeEnabled: boolean;
@@ -124,7 +124,7 @@ interface CodexClientLike {
}
type CodexTestSession = AgentSession & {
connected: boolean;
connectionState: "disconnected" | "history-ready" | "connected";
currentThreadId: string | null;
activeForegroundTurnId: string | null;
client: CodexClientLike | null;
@@ -165,7 +165,7 @@ function createSession(
options.goalsEnabled === true,
options.autoReviewEnabled === true,
) as CodexTestSession;
session.connected = true;
session.connectionState = "connected";
session.currentThreadId = "test-thread";
session.activeForegroundTurnId = "test-turn";
return session;
@@ -1435,7 +1435,7 @@ describe("Codex app-server provider", () => {
purpose: "history",
});
expect(threadRequests).toEqual(["thread/loaded/list", "thread/resume", "thread/read"]);
expect(threadRequests).toEqual(["thread/read"]);
await session.close();
appServer.assertNoErrors();
});
@@ -3931,7 +3931,7 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory();
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
@@ -3985,7 +3985,7 @@ describe("Codex app-server provider", () => {
})),
};
await asInternals(session).loadPersistedHistory();
await asInternals(session).loadPersistedHistory(session.client);
expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({
index: 0,
@@ -4054,7 +4054,7 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory();
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
@@ -4216,7 +4216,7 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory();
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
@@ -4281,7 +4281,7 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory();
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
@@ -4455,7 +4455,7 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory();
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
@@ -4516,7 +4516,7 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory();
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
@@ -5023,14 +5023,22 @@ describe("Codex app-server provider", () => {
turn: { status: "completed", error: null },
});
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.detail.type === "plan",
),
).toBe(false);
expect(events.at(-3)).toEqual({
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "tool_call",
callId: session.getPendingPermissions()[0]?.id,
name: "plan_approval",
status: "running",
error: null,
detail: {
type: "plan",
text: "- Inspect the existing auth flow\n- Implement the button behavior",
},
},
});
expect(events.at(-2)).toEqual({
type: "permission_requested",
provider: "codex",
@@ -5065,7 +5073,7 @@ describe("Codex app-server provider", () => {
});
});
test("does not emit Codex plan thread items as timeline cards while plan approval is pending", () => {
test("does not complete Codex plan timeline cards while plan approval is pending", () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
});
@@ -5091,6 +5099,7 @@ describe("Codex app-server provider", () => {
type: "timeline",
item: expect.objectContaining({
type: "tool_call",
status: "completed",
detail: expect.objectContaining({ type: "plan" }),
}),
}),
@@ -6073,7 +6082,8 @@ describe("Codex denied plan approvals", () => {
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "plan_approval",
event.item.name === "plan_approval" &&
event.item.status === "completed",
);
}
@@ -52,6 +52,7 @@ import { z } from "zod";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import { curateAgentActivity } from "../activity-curator.js";
import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js";
import {
mapCodexToolCallEnvelope,
mapCodexToolCallFromThreadItem,
@@ -1845,6 +1846,17 @@ function mapCodexThreadImageItem(
);
}
function mapCodexAgentMessage(item: Record<string, unknown>): AgentTimelineItem {
const question = codexAsyncQuestionToTimeline(item);
if (question) return question;
const messageId = nonEmptyString(item.id);
return {
type: "assistant_message",
text: typeof item.text === "string" ? item.text : "",
...(messageId ? { messageId } : {}),
};
}
export function threadItemToTimeline(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
@@ -1871,14 +1883,8 @@ export function threadItemToTimeline(
switch (normalizedType) {
case "userMessage":
return mapCodexThreadUserMessageItem(normalizedItem, includeUserMessage);
case "agentMessage": {
const messageId = nonEmptyString(normalizedItem.id);
return {
type: "assistant_message",
text: typeof normalizedItem.text === "string" ? normalizedItem.text : "",
...(messageId ? { messageId } : {}),
};
}
case "agentMessage":
return mapCodexAgentMessage(normalizedItem);
case "plan":
return mapCodexThreadPlanItem(normalizedItem);
case "reasoning":
@@ -3280,7 +3286,7 @@ interface CodexPendingPermissionHandler {
resolve: (value: unknown) => void;
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
questions?: CodexQuestionPrompt[];
planText?: string;
plan?: { text: string; turnId: string | undefined };
}
interface ConsumedRootCompaction {
@@ -3293,6 +3299,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private readonly logger: Logger;
private readonly config: AgentSessionConfig;
private readonly asyncQuestions: CodexAsyncQuestions;
private currentMode: string;
private hasWorkflowModeOverride: boolean;
private readonly providerOptions: CodexProviderOptions;
@@ -3364,7 +3371,7 @@ export class CodexAppServerAgentSession implements AgentSession {
// `thread/compacted` and a completed `contextCompaction` item.
private unpairedCompactionNotificationCompletions = 0;
private unpairedCompactionItemCompletions = 0;
private connected = false;
private connectionState: "disconnected" | "history-ready" | "connected" = "disconnected";
private connectionPromise: Promise<void> | null = null;
private closed = false;
private collaborationModes: Array<{
@@ -3405,6 +3412,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.currentMode = config.modeId ?? DEFAULT_CODEX_MODE_ID;
this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {});
this.config = config;
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) {
this.serviceTier = "fast";
@@ -3436,7 +3444,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.closed) {
throw this.createClosedError();
}
if (this.connected) return;
if (this.connectionState !== "disconnected") return;
if (this.connectionPromise) {
await this.connectionPromise;
if (this.closed) {
@@ -3460,6 +3468,11 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private async establishConnection(): Promise<void> {
if (this.initialResumePurpose === "history") {
await this.readArchivedHistory();
this.connectionState = "history-ready";
return;
}
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
if (this.closed) {
@@ -3482,16 +3495,14 @@ export class CodexAppServerAgentSession implements AgentSession {
await this.loadSkills();
if (this.currentThreadId) {
await this.ensureThreadLoaded({
allowArchivedHistory: this.initialResumePurpose === "history",
});
await this.loadPersistedHistory();
await this.ensureThreadLoaded();
await this.loadPersistedHistory(this.client);
}
if (this.closed) {
throw this.createClosedError();
}
this.connected = true;
this.connectionState = "connected";
} catch (error) {
try {
if (this.client === client) {
@@ -3542,7 +3553,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private handleUnexpectedTermination(error: Error): void {
this.connected = false;
this.connectionState = "disconnected";
const hasActiveRootTurn = this.activeForegroundTurnId !== null || this.currentTurnId !== null;
this.clearPendingPermissions({ preservePlanApprovals: !hasActiveRootTurn });
if (hasActiveRootTurn) {
@@ -3734,7 +3745,20 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingPermissionHandlers.set(requestId, {
resolve: () => undefined,
kind: "plan",
planText,
plan: { text: planText, turnId: this.activeForegroundTurnId ?? undefined },
});
// Reserve the proposal's place before a later prompt can resolve it.
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: {
type: "tool_call",
callId: requestId,
name: "plan_approval",
status: "running",
error: null,
detail: { type: "plan", text: planText },
},
});
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
}
@@ -3774,9 +3798,20 @@ export class CodexAppServerAgentSession implements AgentSession {
);
}
private async loadPersistedHistory(): Promise<void> {
if (!this.client || !this.currentThreadId) return;
const client = this.client;
private async readArchivedHistory(): Promise<void> {
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
await this.loadPersistedHistory(client);
} finally {
await client.dispose();
}
}
private async loadPersistedHistory(client: CodexAppServerClientLike | null): Promise<void> {
if (!client || !this.currentThreadId) return;
const threadId = this.currentThreadId;
const history = await loadCodexThreadHistoryTimeline({
@@ -3799,6 +3834,9 @@ export class CodexAppServerAgentSession implements AgentSession {
}
this.resetCodexUserMessageTurns();
for (const entry of timeline) {
if (entry.item.type === "tool_call" && entry.item.name === "request_user_input_async") {
entry.item = this.asyncQuestions.timeline(entry.item.callId) ?? entry.item;
}
if (entry.item.type === "user_message") {
this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId);
}
@@ -3854,9 +3892,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private async ensureThreadLoaded(
options: { allowArchivedHistory?: boolean } = {},
): Promise<void> {
private async ensureThreadLoaded(): Promise<void> {
if (!this.client || !this.currentThreadId) return;
const params: Record<string, unknown> = { threadId: this.currentThreadId };
const developerInstructions = composeSystemPromptParts(
@@ -3881,16 +3917,6 @@ export class CodexAppServerAgentSession implements AgentSession {
} catch (error) {
const threadId = this.currentThreadId;
const message = error instanceof Error ? error.message : String(error);
if (
options.allowArchivedHistory === true &&
isArchivedCodexThreadResumeError(error, threadId)
) {
this.logger.info(
{ threadId },
"Loading archived Codex thread history without resuming the native session",
);
return;
}
if (isArchivedCodexThreadResumeError(error, threadId)) {
try {
await this.client.request("thread/unarchive", { threadId });
@@ -3964,7 +3990,7 @@ export class CodexAppServerAgentSession implements AgentSession {
return expandCodexCustomPrompt(parsed.body, args);
}
if (!this.connected) {
if (this.connectionState === "disconnected") {
await this.connect();
} else {
await this.loadSkills();
@@ -4386,7 +4412,7 @@ export class CodexAppServerAgentSession implements AgentSession {
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
if (this.cachedRuntimeInfo) return { ...this.cachedRuntimeInfo };
if (!this.connected) {
if (this.connectionState === "disconnected") {
await this.connect();
}
if (!this.currentThreadId) {
@@ -4464,13 +4490,16 @@ export class CodexAppServerAgentSession implements AgentSession {
}
getPendingPermissions(): AgentPermissionRequest[] {
return Array.from(this.pendingPermissions.values());
return [...this.pendingPermissions.values(), ...this.asyncQuestions.pending()];
}
async respondToPermission(
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void> {
if (this.asyncQuestions.hasPending(requestId)) {
return this.respondToAsyncQuestion(requestId, response);
}
const pending = this.pendingPermissionHandlers.get(requestId);
if (!pending) {
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
@@ -4564,6 +4593,33 @@ export class CodexAppServerAgentSession implements AgentSession {
pending.resolve({ answers: {} });
}
private async respondToAsyncQuestion(
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void> {
const prepared = this.asyncQuestions.prepareResponse(requestId, response);
let followUpPrompt = prepared.prompt;
const expectedTurnId = this.activeForegroundTurnId;
if (prepared.prompt && expectedTurnId) {
const result = await this.steerActiveTurn(prepared.prompt, {
expectedTurnId,
clientMessageId: randomUUID(),
});
if (result.status !== "accepted") {
throw new Error("The active Codex turn changed. Retry sending your answer.");
}
followUpPrompt = undefined;
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() });
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId,
resolution: response,
});
return followUpPrompt ? { followUpPrompt } : undefined;
}
private handlePlanPermissionResponse(params: {
requestId: string;
response: AgentPermissionResponse;
@@ -4574,7 +4630,7 @@ export class CodexAppServerAgentSession implements AgentSession {
let followUpPrompt: string | undefined;
if (response.behavior === "allow") {
followUpPrompt = this.preparePlanImplementation({
planText: pending.planText ?? pendingRequest?.metadata?.planText,
planText: pending.plan?.text ?? pendingRequest?.metadata?.planText,
});
}
@@ -4606,29 +4662,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private resolvePlanPermission(requestId: string, resolution: AgentPermissionResponse): void {
if (resolution.behavior === "deny") {
// Every route into a denial lands here — the response handler, a new
// prompt, and an accepted steer — so the transcript record belongs here
// rather than in handlePlanPermissionResponse.
const planText =
this.pendingPermissionHandlers.get(requestId)?.planText ??
this.pendingPermissions.get(requestId)?.metadata?.planText;
if (typeof planText === "string") {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: {
type: "tool_call",
callId: requestId,
name: "plan_approval",
status: "completed",
error: null,
detail: { type: "plan", text: planText },
metadata: { approved: false },
},
});
}
}
this.recordPlanOutcome(requestId, resolution.behavior === "allow" ? "approved" : "rejected");
this.pendingPermissionHandlers.delete(requestId);
this.pendingPermissions.delete(requestId);
this.resolvedPermissionRequests.add(requestId);
@@ -4640,6 +4674,29 @@ export class CodexAppServerAgentSession implements AgentSession {
});
}
private recordPlanOutcome(
requestId: string,
outcome: "approved" | "rejected" | "canceled",
): void {
const plan = this.pendingPermissionHandlers.get(requestId)?.plan;
if (plan) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
turnId: plan.turnId,
item: {
type: "tool_call",
callId: requestId,
name: "plan_approval",
status: outcome === "canceled" ? "canceled" : "completed",
error: null,
detail: { type: "plan", text: plan.text },
metadata: outcome === "canceled" ? {} : { approved: outcome === "approved" },
},
});
}
}
private emitDeniedToolCallTimelineEvent(params: {
requestId: string;
response: Extract<AgentPermissionResponse, { behavior: "deny" }>;
@@ -4700,6 +4757,7 @@ export class CodexAppServerAgentSession implements AgentSession {
toolPolicy: this.config.toolPolicy,
systemPrompt: this.config.systemPrompt,
mcpServers: this.config.mcpServers,
asyncQuestions: this.asyncQuestions.serialize(),
},
};
}
@@ -4728,7 +4786,8 @@ export class CodexAppServerAgentSession implements AgentSession {
this.cachedRuntimeInfo = null;
this.persistedHistory = [];
this.historyPending = false;
await this.loadPersistedHistory();
await this.loadPersistedHistory(this.client);
this.reconcileAsyncQuestionsAfterRewind();
},
});
}
@@ -4806,6 +4865,9 @@ export class CodexAppServerAgentSession implements AgentSession {
if (options?.preservePlanApprovals && pending.kind === "plan") {
continue;
}
if (pending.kind === "plan") {
this.recordPlanOutcome(requestId, "canceled");
}
pending.resolve({ decision: "cancel" });
this.pendingPermissionHandlers.delete(requestId);
this.pendingPermissions.delete(requestId);
@@ -4816,7 +4878,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private async disposeClient(): Promise<void> {
const client = this.client;
this.connected = false;
this.connectionState = "disconnected";
this.currentTurnId = null;
if (client) {
await client.dispose();
@@ -4826,7 +4888,7 @@ export class CodexAppServerAgentSession implements AgentSession {
async listCommands(): Promise<AgentSlashCommand[]> {
const prompts = await listCodexCustomPrompts();
if (!this.connected) {
if (this.connectionState === "disconnected") {
await this.connect();
} else {
await this.loadSkills();
@@ -5127,7 +5189,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId;
const turnId = getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event;
this.logger.trace(
{
@@ -5903,6 +5965,7 @@ export class CodexAppServerAgentSession implements AgentSession {
error: parsed.errorMessage ?? "Codex turn failed",
});
} else if (parsed.status === "interrupted") {
this.dismissInterruptedAsyncQuestions();
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
} else {
if (this.planModeEnabled && this.latestPlanResult?.text) {
@@ -6255,6 +6318,43 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private receiveAsyncQuestion(threadId: string | null, item: unknown): void {
if (threadId !== this.currentThreadId) return;
const request = this.asyncQuestions.receive(item);
if (request)
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
}
private dismissInterruptedAsyncQuestions(): void {
const resolution: AgentPermissionResponse = { behavior: "deny", message: "Interrupted" };
for (const request of this.asyncQuestions.pending()) {
const prepared = this.asyncQuestions.prepareResponse(request.id, resolution);
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() });
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId: request.id,
resolution,
});
}
}
private reconcileAsyncQuestionsAfterRewind(): void {
const retainedIds = new Set(
this.persistedHistory.flatMap(({ item }) =>
item.type === "tool_call" && item.name === "request_user_input_async" ? [item.callId] : [],
),
);
for (const requestId of this.asyncQuestions.retain(retainedIds)) {
this.emitEvent({
type: "permission_resolved",
provider: CODEX_PROVIDER,
requestId,
resolution: { behavior: "deny", message: "Removed by rewind" },
});
}
}
private handleItemCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
): void {
@@ -6265,6 +6365,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return;
}
this.receiveAsyncQuestion(parsed.threadId, parsed.item);
if (this.isUserMessageItem(parsed.item)) {
this.handleUserMessageItem(parsed);
return;
@@ -0,0 +1,436 @@
import { tmpdir } from "node:os";
import { expect, test } from "vitest";
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
waitForNextEvent,
waitForTimelineToolCall,
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { AgentManager } from "../agent-manager.js";
const questionItem = {
type: "agentMessage",
id: "async-question-1",
text: "Which color?\n- Blue\n- Green",
phase: "final_answer",
delivery: "async",
questions: [{ title: "Which color?", options: ["Blue", "Green"] }],
};
async function setup(metadata?: Record<string, unknown>, rejectSteer = false) {
const appServer = createFakeCodexAppServer({
"turn/interrupt": () => ({}),
"turn/steer": () => {
if (rejectSteer) return { __jsonRpcError: { code: -32000, message: "Delivery failed" } };
return { turnId: "native-turn" };
},
"thread/read": () => ({ thread: { id: "thread-1", turns: [] } }),
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: tmpdir(), model: "gpt-5.4", modeId: "full-access" },
metadata ? { sessionId: "thread-1", metadata } : null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.startTurn("Help me choose a color while you inspect the project.");
const started = waitForNextEvent(session, "turn_started");
appServer.startsTurn({ threadId: "thread-1", turnId: "native-turn" });
await started;
async function ask() {
const shown = waitForTimelineToolCall(session, questionItem.id);
appServer.child.stdout.write(
JSON.stringify({
method: "item/completed",
params: { threadId: "thread-1", turnId: "native-turn", item: questionItem },
}) + "\n",
);
await shown;
}
async function say(text: string) {
const shown = waitForNextEvent(
session,
"timeline",
(event) => event.item.type === "assistant_message" && event.item.text.includes(text),
);
appServer.says({ threadId: "thread-1", text });
await shown;
}
async function finish(status: "completed" | "interrupted" = "completed") {
const finished = waitForNextEvent(
session,
status === "completed" ? "turn_completed" : "turn_canceled",
);
appServer.completeTurn({ status });
await finished;
}
function allowAnswers() {
rejectSteer = false;
}
return { session, appServer, events, ask, say, finish, allowAnswers };
}
const answer = { behavior: "allow" as const, updatedInput: { answers: { "Question 1": "Green" } } };
async function setupRewind(fail = false) {
const records = [
{ item: { ...questionItem, id: "earlier-pending" } },
{ item: { ...questionItem, id: "earlier-answered" }, resolution: ["Blue"] },
{ item: { ...questionItem, id: "removed-pending" } },
{ item: { ...questionItem, id: "removed-answered" }, resolution: ["Green"] },
];
function userMessage(id: string) {
return { type: "userMessage", id, content: [{ type: "text", text: id }] };
}
const turns = [
{
id: "earlier-turn",
items: [userMessage("earlier"), ...records.slice(0, 2).map((r) => r.item)],
},
{
id: "removed-turn",
items: [userMessage("rewind-here"), ...records.slice(2).map((r) => r.item)],
},
];
const appServer = createFakeCodexAppServer({
"thread/read": (params) => {
const { threadId } = params as { threadId: string };
return {
thread: { id: threadId, turns: threadId === "thread-1" ? turns : turns.slice(0, 1) },
};
},
...(fail
? {
"thread/rollback": () => ({ __jsonRpcError: { code: -32000, message: "Rewind failed" } }),
}
: {}),
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: tmpdir(), model: "gpt-5.4", modeId: "full-access" },
{ sessionId: "thread-1", metadata: { asyncQuestions: records } },
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
return { session, events, records };
}
test("rewind removes only questions outside the remaining history, including after resume", async () => {
const { session, events, records } = await setupRewind();
let metadata: Record<string, unknown> | undefined;
try {
await session.revertConversation({ messageId: "rewind-here" });
expect(session.getPendingPermissions().map((p) => p.id)).toEqual([
"permission-earlier-pending",
]);
metadata = session.describePersistence()!.metadata;
expect(metadata.asyncQuestions).toEqual(
records.slice(0, 2).map((record) => ({
resolution: record.resolution,
item: {
type: "agentMessage",
id: record.item.id,
delivery: "async",
questions: questionItem.questions,
},
})),
);
expect(events.filter((e) => e.type === "permission_resolved")).toEqual([
expect.objectContaining({ requestId: "permission-removed-pending" }),
]);
const history = [];
for await (const event of session.streamHistory()) history.push(event);
expect(
history.some(
(e) =>
e.type === "timeline" &&
e.item.type === "tool_call" &&
e.item.callId === "removed-pending",
),
).toBe(false);
} finally {
await session.close();
}
const resumed = await setup(metadata);
try {
expect(resumed.session.getPendingPermissions().map((p) => p.id)).toEqual([
"permission-earlier-pending",
]);
} finally {
await resumed.session.close();
}
});
test("failed rewind preserves question state", async () => {
const { session, events } = await setupRewind(true);
try {
const before = session.describePersistence();
await expect(session.revertConversation({ messageId: "rewind-here" })).rejects.toThrow(
"Rewind failed",
);
expect(session.describePersistence()).toEqual(before);
expect(session.getPendingPermissions().map((p) => p.id)).toEqual([
"permission-earlier-pending",
"permission-removed-pending",
]);
expect(events.some((e) => e.type === "permission_resolved")).toBe(false);
} finally {
await session.close();
}
});
async function manage(session: CodexAppServerAgentSession) {
const manager = new AgentManager({
clients: {
codex: {
provider: "codex",
capabilities: session.capabilities,
createSession: async () => session,
resumeSession: async () => session,
isAvailable: async () => true,
fetchCatalog: async () => ({ models: [], modes: [] }),
},
},
logger: createTestLogger(),
});
const agent = await manager.createAgent(
{ provider: "codex", cwd: tmpdir(), model: "gpt-5.4" },
undefined,
{ workspaceId: undefined },
);
return { manager, agent };
}
test("manager publishes and saves the provider state after rewind", async () => {
const { session } = await setupRewind();
const { manager, agent } = await manage(session);
try {
await manager.rewind(agent.id, "rewind-here", "conversation");
const snapshot = manager.getAgent(agent.id)!;
expect(Array.from(snapshot.pendingPermissions.keys())).toEqual(["permission-earlier-pending"]);
expect(snapshot.persistence).toEqual(session.describePersistence());
expect(snapshot.persistence?.sessionId).toBe("forked-thread");
} finally {
await manager.closeAgent(agent.id);
}
});
test("manager snapshots capture pending and answered question state before the turn ends", async () => {
const { session, ask } = await setup();
const { manager, agent } = await manage(session);
try {
await ask();
const [permission] = session.getPendingPermissions();
expect(manager.getAgent(agent.id)?.persistence?.metadata?.asyncQuestions).toEqual([
{
item: {
type: "agentMessage",
id: questionItem.id,
delivery: "async",
questions: questionItem.questions,
},
},
]);
await manager.respondToPermission(agent.id, permission.id, answer);
expect(manager.getAgent(agent.id)?.persistence?.metadata?.asyncQuestions).toEqual([
{
item: {
type: "agentMessage",
id: questionItem.id,
delivery: "async",
questions: questionItem.questions,
},
resolution: ["Green"],
},
]);
} finally {
await manager.closeAgent(agent.id);
}
});
test("concurrent answers deliver only one response to the active Codex turn", async () => {
const { session, appServer, ask } = await setup();
const { manager, agent } = await manage(session);
try {
await ask();
const [permission] = session.getPendingPermissions();
const results = await Promise.allSettled([
manager.respondToPermission(agent.id, permission.id, answer),
manager.respondToPermission(agent.id, permission.id, answer),
]);
expect(results.map((result) => result.status)).toEqual(["fulfilled", "rejected"]);
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toHaveLength(
1,
);
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await manager.closeAgent(agent.id);
}
});
test("a failed submission releases the request so the user can retry", async () => {
const { session, ask, allowAnswers } = await setup(undefined, true);
const { manager, agent } = await manage(session);
try {
await ask();
const [permission] = session.getPendingPermissions();
await expect(manager.respondToPermission(agent.id, permission.id, answer)).rejects.toThrow(
"Delivery failed",
);
expect(session.getPendingPermissions().map((request) => request.id)).toEqual([permission.id]);
allowAnswers();
await manager.respondToPermission(agent.id, permission.id, answer);
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await manager.closeAgent(agent.id);
}
});
test("shows an async question, keeps streaming, and delivers its answer without interrupting", async () => {
const { session, appServer, events, ask, say } = await setup();
try {
await ask();
const [permission] = session.getPendingPermissions();
expect(permission).toMatchObject({
kind: "question",
input: {
questions: [
{
header: "Question 1",
question: "Which color?",
isOther: true,
options: [{ label: "Blue" }, { label: "Green" }],
},
],
},
});
await say("I am still inspecting the project.");
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text.includes("still inspecting"),
),
).toBe(true);
await session.respondToPermission(permission.id, answer);
expect(session.getPendingPermissions()).toEqual([]);
expect(appServer.requests().filter((request) => request.method === "turn/steer")).toMatchObject(
[
{
params: {
expectedTurnId: "native-turn",
clientUserMessageId: expect.any(String),
input: [{ type: "text", text: expect.stringContaining("Which color?\nGreen") }],
},
},
],
);
expect(appServer.requests().filter((request) => request.method === "turn/interrupt")).toEqual(
[],
);
expect(
events.some(
(event) => event.type === "permission_resolved" && event.requestId === permission.id,
),
).toBe(true);
} finally {
await session.close();
}
});
test("keeps a failed answer pending for retry", async () => {
const { session, ask } = await setup(undefined, true);
try {
await ask();
const [permission] = session.getPendingPermissions();
expect(permission).toBeDefined();
await expect(session.respondToPermission(permission.id, answer)).rejects.toThrow();
expect(session.getPendingPermissions()).toHaveLength(1);
} finally {
await session.close();
}
});
test("Stop dismisses async questions before cancellation and keeps them dismissed after resume", async () => {
const first = await setup();
let metadata: Record<string, unknown> | undefined;
try {
await first.ask();
expect(first.session.getPendingPermissions()).toHaveLength(1);
first.session.subscribe((event) => {
if (event.type === "turn_canceled") {
metadata = first.session.describePersistence()!.metadata;
}
});
await first.session.interrupt();
await first.finish("interrupted");
expect(first.session.getPendingPermissions()).toEqual([]);
expect(metadata?.asyncQuestions).toEqual([
expect.objectContaining({ resolution: "dismissed" }),
]);
expect(first.events).toContainEqual(
expect.objectContaining({
type: "permission_resolved",
requestId: "permission-async-question-1",
resolution: { behavior: "deny", message: "Interrupted" },
}),
);
} finally {
await first.session.close();
}
const resumed = await setup(metadata);
try {
await resumed.ask();
expect(resumed.session.getPendingPermissions()).toEqual([]);
} finally {
await resumed.session.close();
}
});
test("late answers use the existing follow-up prompt and dismissal does not interrupt", async () => {
const { session, appServer, ask, finish } = await setup();
try {
await ask();
const [permission] = session.getPendingPermissions();
expect(permission).toBeDefined();
await finish();
expect(session.getPendingPermissions()).toHaveLength(1);
expect(await session.respondToPermission(permission.id, answer)).toEqual({
followUpPrompt: "Answers to your questions:\n\nWhich color?\nGreen",
});
expect(appServer.requests().some((request) => request.method === "turn/interrupt")).toBe(false);
} finally {
await session.close();
}
});
test("restores unanswered questions and does not reopen answered questions on duplicate events", async () => {
const first = await setup();
let metadata: Record<string, unknown> | undefined;
try {
await first.ask();
expect(first.session.getPendingPermissions()).toHaveLength(1);
metadata = first.session.describePersistence()!.metadata;
} finally {
await first.session.close();
}
const resumed = await setup(metadata);
try {
const [permission] = resumed.session.getPendingPermissions();
expect(permission).toBeDefined();
await resumed.session.respondToPermission(permission.id, { behavior: "deny" });
await resumed.ask();
expect(resumed.session.getPendingPermissions()).toEqual([]);
expect(
resumed.appServer.requests().some((request) => request.method === "turn/interrupt"),
).toBe(false);
} finally {
await resumed.session.close();
}
});
@@ -1,12 +1,22 @@
import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js";
const CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES = ["gpt-5", "gpt-4.1", "o3", "o4-mini"] as const;
// Codex Fast is distinct from API Priority processing. Keep model support aligned with
// https://developers.openai.com/codex/speed and https://developers.openai.com/codex/models.
const CODEX_FAST_MODE_SUPPORTED_MODELS = new Set([
"gpt-6-astra",
"gpt-5.6",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
"gpt-5.4",
]);
export const CODEX_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
type: "toggle",
id: "fast_mode",
label: "Fast",
description: "Priority inference at 2x usage",
description: "Priority inference at increased usage",
tooltip: "Toggle fast mode",
icon: "zap",
};
@@ -30,9 +40,7 @@ export function codexModelSupportsFastMode(modelId: string | null | undefined):
if (!normalizedModelId) {
return false;
}
return CODEX_FAST_MODE_SUPPORTED_MODEL_PREFIXES.some(
(prefix) => normalizedModelId === prefix || normalizedModelId.startsWith(prefix),
);
return CODEX_FAST_MODE_SUPPORTED_MODELS.has(normalizedModelId);
}
export function buildCodexFeatures(input: {
@@ -0,0 +1,151 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import { z } from "zod";
import { CodexAppServerClient } from "./codex/app-server-transport.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import type { AgentSession } from "../agent-sdk-types.js";
async function readNativeThreadPath(threadId: string): Promise<string> {
const client = new CodexAppServerClient(
spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] }),
createTestLogger(),
);
try {
await client.request("initialize", {
clientInfo: { name: "paseo-archive-regression", version: "1.0.0" },
});
client.notify("initialized", {});
const response = await client.request("thread/read", { threadId });
return z.object({ thread: z.object({ path: z.string() }) }).parse(response).thread.path;
} finally {
await client.dispose();
}
}
// Real native processes and a real completion. The second case represents records
// archived by older Paseo versions whose best-effort native archive failed.
test.runIf(process.env.PASEO_NATIVE_ARCHIVE_QA === "1").each([true, false])(
"Codex history releases its process and leaves native archive unchanged (native archived: %s)",
async (nativeArchived) => {
const cwd = await mkdtemp(path.join(tmpdir(), "codex-history-lifecycle-"));
const logger = createTestLogger();
const provider = new CodexAppServerAgentClient(logger);
const spawned: ChildProcessWithoutNullStreams[] = [];
let active: AgentSession | undefined;
let history: CodexAppServerAgentSession | undefined;
try {
const catalog = await provider.fetchCatalog({ scope: "global", force: false });
const model = catalog.models.find((entry) => entry.isDefault)!;
const config = {
provider: "codex",
cwd,
model: model.id,
modeId: "full-access",
thinkingOptionId: "low",
};
const initialProcesses: ChildProcessWithoutNullStreams[] = [];
const initial = new CodexAppServerAgentSession(config, null, logger, async () => {
const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] });
initialProcesses.push(child);
return child;
});
active = initial;
await initial.connect();
const reply = await active.run(
"Reply with exactly HISTORY_REMAINS_READ_ONLY. Do not use tools.",
);
expect(reply.finalText).toContain("HISTORY_REMAINS_READ_ONLY");
const handle = active.describePersistence()!;
await active.close();
active = undefined;
expect(
initialProcesses.every((child) => child.exitCode !== null || child.signalCode !== null),
).toBe(true);
if (nativeArchived) await provider.archiveNativeSession(handle);
const nativePath = await readNativeThreadPath(handle.sessionId);
expect(nativePath.includes(`${path.sep}archived_sessions${path.sep}`)).toBe(nativeArchived);
history = new CodexAppServerAgentSession(
config,
handle,
logger,
async () => {
const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] });
spawned.push(child);
return child;
},
{},
false,
false,
false,
undefined,
"history",
);
await history.connect();
const text: string[] = [];
for await (const event of history.streamHistory()) {
if (event.type === "timeline" && event.item.type === "assistant_message")
text.push(event.item.text);
}
expect(text.join("\n")).toContain("HISTORY_REMAINS_READ_ONLY");
expect(spawned).toHaveLength(1);
expect(
spawned.filter((child) => child.exitCode === null && child.signalCode === null),
).toEqual([]);
expect(await readNativeThreadPath(handle.sessionId)).toBe(nativePath);
// Native unarchive is possible after reading history; history held no writer.
await provider.unarchiveNativeSession(handle);
active = await provider.resumeSession(handle, config);
expect(active.describePersistence()?.sessionId).toBe(handle.sessionId);
const followUp = await active.run(
"Repeat exactly your previous assistant reply from this conversation. Do not use tools.",
);
expect(followUp.finalText).toContain("HISTORY_REMAINS_READ_ONLY");
expect(active.describePersistence()?.sessionId).toBe(handle.sessionId);
} finally {
await history?.close();
await active?.close();
await rm(cwd, { recursive: true, force: true });
}
},
120_000,
);
test.runIf(process.env.PASEO_NATIVE_ARCHIVE_QA === "1")(
"a failed Codex history read releases its temporary process",
async () => {
const spawned: ChildProcessWithoutNullStreams[] = [];
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: tmpdir() },
{ sessionId: "00000000-0000-4000-8000-000000000001" },
createTestLogger(),
async () => {
const child = spawn("codex", ["app-server"], { stdio: ["pipe", "pipe", "pipe"] });
spawned.push(child);
return child;
},
{},
false,
false,
false,
undefined,
"history",
);
try {
await expect(session.connect()).rejects.toThrow();
expect(spawned).toHaveLength(1);
expect(spawned.every((child) => child.exitCode !== null || child.signalCode !== null)).toBe(
true,
);
} finally {
await session.close();
}
},
30_000,
);
@@ -0,0 +1,155 @@
import { z } from "zod";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
ToolCallTimelineItem,
} from "../../agent-sdk-types.js";
const QuestionSchema = z.object({
title: z.string().trim().min(1),
options: z.array(z.string().min(1)).nullish(),
});
const ItemSchema = z.object({
type: z.literal("agentMessage"),
id: z.string().min(1),
delivery: z.literal("async"),
questions: z.array(QuestionSchema).min(1),
});
const RecordSchema = z.object({
item: ItemSchema,
resolution: z.union([z.literal("dismissed"), z.array(z.string())]).optional(),
});
type QuestionRecord = z.infer<typeof RecordSchema>;
function requestId(itemId: string): string {
return `permission-${itemId}`;
}
function toPermission(record: QuestionRecord): AgentPermissionRequest {
return {
id: requestId(record.item.id),
provider: "codex",
name: "request_user_input_async",
kind: "question",
title: "Question",
input: {
questions: record.item.questions.map((question, index) => ({
id: String(index),
header: `Question ${index + 1}`,
question: question.title,
options: (question.options ?? []).map((label) => ({ label })),
isOther: true,
})),
},
};
}
function toTimeline(record: QuestionRecord): ToolCallTimelineItem {
const answers = Array.isArray(record.resolution) ? record.resolution : undefined;
return {
type: "tool_call",
callId: record.item.id,
name: "request_user_input_async",
status: "completed",
error: null,
detail: {
type: "plain_text",
icon: "brain",
text:
record.item.questions
.map((question, index) =>
[question.title, answers ? answers[index] : (question.options ?? []).join(", ")]
.filter(Boolean)
.join("\n"),
)
.join("\n\n") + (record.resolution === "dismissed" ? "\n\nDismissed" : ""),
},
};
}
export function codexAsyncQuestionToTimeline(item: unknown): ToolCallTimelineItem | null {
const parsed = ItemSchema.safeParse(item);
return parsed.success ? toTimeline({ item: parsed.data }) : null;
}
/** Codex emits these as completed messages; the outstanding answer belongs to the session. */
export class CodexAsyncQuestions {
private readonly records = new Map<string, QuestionRecord>();
constructor(saved: unknown) {
const parsed = z.array(RecordSchema).safeParse(saved);
if (parsed.success) {
for (const record of parsed.data) this.records.set(requestId(record.item.id), record);
}
}
receive(item: unknown): AgentPermissionRequest | null {
const parsed = ItemSchema.safeParse(item);
if (!parsed.success || this.records.has(requestId(parsed.data.id))) return null;
const record = { item: parsed.data };
this.records.set(requestId(parsed.data.id), record);
return toPermission(record);
}
pending(): AgentPermissionRequest[] {
return Array.from(this.records.values())
.filter((record) => record.resolution === undefined)
.map(toPermission);
}
hasPending(id: string): boolean {
const record = this.records.get(id);
return record !== undefined && record.resolution === undefined;
}
prepareResponse(
id: string,
response: AgentPermissionResponse,
): { prompt?: string; complete: () => ToolCallTimelineItem } {
const record = this.records.get(id);
if (!record || record.resolution !== undefined)
throw new Error("Question is no longer pending");
let resolution: QuestionRecord["resolution"] = "dismissed";
let prompt: string | undefined;
if (response.behavior === "allow") {
const answers = z.record(z.string(), z.string()).parse(response.updatedInput?.answers);
resolution = record.item.questions.map((_, index) => {
const answer = answers[`Question ${index + 1}`]?.trim();
if (!answer) throw new Error(`Answer Question ${index + 1} before submitting`);
return answer;
});
const values = resolution;
prompt =
"Answers to your questions:\n\n" +
record.item.questions
.map((question, index) => `${question.title}\n${values[index]}`)
.join("\n\n");
}
return {
prompt,
complete: () => {
record.resolution = resolution;
return toTimeline(record);
},
};
}
timeline(itemId: string): ToolCallTimelineItem | null {
const record = this.records.get(requestId(itemId));
return record ? toTimeline(record) : null;
}
retain(itemIds: ReadonlySet<string>): string[] {
const removedPendingIds: string[] = [];
for (const [id, record] of this.records) {
if (itemIds.has(record.item.id)) continue;
this.records.delete(id);
if (record.resolution === undefined) removedPendingIds.push(id);
}
return removedPendingIds;
}
serialize(): unknown {
return Array.from(this.records.values());
}
}
@@ -618,7 +618,7 @@ function toJsonObject(value: unknown): JsonObject {
type StreamEventType = AgentStreamEvent["type"];
type StreamEventOfType<TType extends StreamEventType> = Extract<AgentStreamEvent, { type: TType }>;
function waitForNextEvent<TType extends StreamEventType>(
export function waitForNextEvent<TType extends StreamEventType>(
session: AgentSession,
type: TType,
accepts?: (event: StreamEventOfType<TType>) => boolean,
@@ -33,6 +33,13 @@ describe("CursorACPAgentClient model discovery", () => {
child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() },
connection: {
newSession: vi.fn().mockResolvedValue(this.response),
extMethod: async () => ({
models: (this.response.models?.availableModels ?? []).map((model) => ({
value: model.modelId,
name: model.name,
configOptions: [],
})),
}),
},
initialize: { agentCapabilities: {} },
} as SpawnedACPProcess;
@@ -1,6 +1,13 @@
import { zSessionConfigOption } from "@agentclientprotocol/sdk/dist/schema/zod.gen.js";
import type { Logger } from "pino";
import { z } from "zod";
import type { ACPConfigFeatureOption } from "./acp-agent.js";
import type { AgentModelDefinition } from "../agent-sdk-types.js";
import {
deriveSelectorOptions,
type ACPCatalogModelResolverContext,
type ACPConfigFeatureOption,
} from "./acp-agent.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
interface CursorACPAgentClientOptions {
@@ -26,6 +33,58 @@ export const CURSOR_FAST_FEATURE_OPTION: ACPConfigFeatureOption = {
icon: "zap",
};
const CursorModelCatalogSchema = z.object({
models: z.array(
z.object({
value: z.string().min(1),
name: z.string(),
configOptions: z.array(zSessionConfigOption),
}),
),
});
// Cursor model switches persist CLI preferences, even in a throwaway probe session.
// Its extension returns each model's parameter definitions without selecting it.
export async function resolveCursorCatalogModels({
connection,
models,
provider,
runRequest,
}: ACPCatalogModelResolverContext): Promise<AgentModelDefinition[]> {
const catalog = await runRequest(() => fetchCursorModelCatalog(connection));
const currentModelId = models.find((model) => model.isDefault)?.id;
return catalog.models.map((model) => {
const thinkingOptions = deriveSelectorOptions(model.configOptions, "thought_level");
const defaultThinkingOptionId = thinkingOptions.find((option) => option.isDefault)?.id;
return {
provider,
id: model.value,
label: model.name,
isDefault: model.value === currentModelId,
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
defaultThinkingOptionId,
};
});
}
async function fetchCursorModelCatalog(connection: ACPCatalogModelResolverContext["connection"]) {
try {
const response = await connection.extMethod("cursor/list_available_models", {});
return CursorModelCatalogSchema.parse(response);
} catch (error) {
const extensionUnavailable =
typeof error === "object" && error !== null && "code" in error && error.code === -32601;
if (extensionUnavailable) {
throw new Error(
"Update Cursor CLI: this version does not support cursor/list_available_models.",
{ cause: error },
);
}
throw error;
}
}
export class CursorACPAgentClient extends GenericACPAgentClient {
constructor(options: CursorACPAgentClientOptions) {
super({
@@ -40,6 +99,7 @@ export class CursorACPAgentClient extends GenericACPAgentClient {
initialCommandsWaitTimeoutMs: CURSOR_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
clientCapabilityMeta: CURSOR_CLIENT_CAPABILITY_META,
configFeatureOptions: [CURSOR_FAST_FEATURE_OPTION],
catalogModelResolver: resolveCursorCatalogModels,
});
}
}
@@ -0,0 +1,213 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
import { expect, test } from "vitest";
import { ZodError } from "zod";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { deriveModelDefinitionsFromACP, type ACPCatalogModelResolverContext } from "./acp-agent.js";
import { resolveCursorCatalogModels } from "./cursor-acp-agent.js";
const modelOption: SessionConfigOption = {
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "claude-haiku-4-5",
options: [
{ value: "claude-haiku-4-5", name: "Haiku 4.5" },
{ value: "grok-4.6", name: "Grok 4.6" },
],
};
const booleanThinking: SessionConfigOption = {
id: "thinking",
name: "Thinking",
category: "thought_level",
type: "select",
currentValue: "true",
options: [
{ value: "false", name: "Off" },
{ value: "true", name: "On" },
],
};
const effortThinking: SessionConfigOption = {
id: "effort",
name: "Effort",
category: "thought_level",
type: "select",
currentValue: "xhigh",
options: [
{ value: "low", name: "Low" },
{ value: "medium", name: "Medium" },
{ value: "high", name: "High" },
{ value: "xhigh", name: "Extra High" },
],
};
class CursorCatalogConnection {
selectedModel = "claude-haiku-4-5";
selectionHistory = ["claude-haiku-4-5"];
error: unknown = null;
catalog: Record<string, unknown> = {
models: [
{ value: "claude-haiku-4-5", name: "Haiku 4.5", configOptions: [booleanThinking] },
{ value: "grok-4.6", name: "Grok 4.6", configOptions: [effortThinking] },
],
};
async extMethod(method: string, params: Record<string, unknown>) {
if (method !== "cursor/list_available_models" || Object.keys(params).length !== 0) {
throw new Error(`Unsupported catalog request: ${method}`);
}
if (this.error) throw this.error;
return this.catalog;
}
async setSessionConfigOption({ value }: { value: string }) {
this.selectedModel = value;
this.selectionHistory.push(value);
const thinking = value === "grok-4.6" ? effortThinking : booleanThinking;
return { configOptions: [thinking] };
}
}
function createCatalogContext(
connection: CursorCatalogConnection,
configOptions: SessionConfigOption[] = [modelOption, booleanThinking],
): ACPCatalogModelResolverContext {
return {
connection,
sessionId: "catalog-session",
models: deriveModelDefinitionsFromACP("acp", null, configOptions),
configOptions,
runRequest: (request) => request(),
transformConfigOptions: (options) => options,
logger: createTestLogger(),
provider: "acp",
};
}
test("lists each model's thinking options without changing Cursor preferences", async () => {
const connection = new CursorCatalogConnection();
const context = createCatalogContext(connection);
const models = await resolveCursorCatalogModels(context);
expect(connection.selectedModel).toBe("claude-haiku-4-5");
expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]);
expect(
models.map(({ id, isDefault, thinkingOptions, defaultThinkingOptionId }) => ({
id,
isDefault,
thinking: thinkingOptions?.map((option) => option.id),
defaultThinkingOptionId,
})),
).toEqual([
{
id: "claude-haiku-4-5",
isDefault: true,
thinking: ["false", "true"],
defaultThinkingOptionId: "true",
},
{
id: "grok-4.6",
isDefault: false,
thinking: ["low", "medium", "high", "xhigh"],
defaultThinkingOptionId: "xhigh",
},
]);
});
test("reports that Cursor must be updated when the model extension is unavailable", async () => {
const connection = new CursorCatalogConnection();
connection.error = { code: -32601, message: "Method not found" };
await expect(resolveCursorCatalogModels(createCatalogContext(connection))).rejects.toThrow(
"Update Cursor CLI",
);
expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]);
});
test("discovers thinking models when the session default has no thinking selector", async () => {
const connection = new CursorCatalogConnection();
const context = createCatalogContext(connection, [modelOption]);
const models = await resolveCursorCatalogModels(context);
expect(models[1].thinkingOptions?.map((option) => option.id)).toEqual([
"low",
"medium",
"high",
"xhigh",
]);
expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]);
});
test("uses the extension's complete catalog even when session/new lists only one model", async () => {
const connection = new CursorCatalogConnection();
const context = createCatalogContext(connection);
context.models = context.models.slice(0, 1);
const models = await resolveCursorCatalogModels(context);
expect(models.map((model) => model.id)).toEqual(["claude-haiku-4-5", "grok-4.6"]);
expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]);
});
test("does not copy session thinking options onto a model without a selector", async () => {
const connection = new CursorCatalogConnection();
connection.catalog = {
models: [{ value: "composer-2", name: "Composer", configOptions: [] }],
};
const models = await resolveCursorCatalogModels(createCatalogContext(connection));
expect(models).toEqual([
{
provider: "acp",
id: "composer-2",
label: "Composer",
isDefault: false,
thinkingOptions: undefined,
defaultThinkingOptionId: undefined,
},
]);
});
test("keeps an empty extension catalog empty", async () => {
const connection = new CursorCatalogConnection();
connection.catalog = { models: [] };
await expect(resolveCursorCatalogModels(createCatalogContext(connection))).resolves.toEqual([]);
});
test.each([
{},
{ models: [{ value: "grok-4.6", name: "Grok 4.6" }] },
{
models: [
{
value: "grok-4.6",
name: "Grok 4.6",
configOptions: [{ ...effortThinking, currentValue: 1 }],
},
],
},
])("rejects malformed extension responses: %j", async (catalog) => {
const connection = new CursorCatalogConnection();
connection.catalog = catalog;
await expect(resolveCursorCatalogModels(createCatalogContext(connection))).rejects.toBeInstanceOf(
ZodError,
);
expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]);
});
test("propagates extension failures without changing preferences or returning stale options", async () => {
const connection = new CursorCatalogConnection();
const error = new Error("Connection closed");
connection.error = error;
await expect(resolveCursorCatalogModels(createCatalogContext(connection))).rejects.toBe(error);
expect(connection.selectionHistory).toEqual(["claude-haiku-4-5"]);
});
@@ -156,8 +156,13 @@ describe("KimiACPAgentClient per-model thinking options", () => {
expect(setSessionConfigOption).not.toHaveBeenCalled();
});
test("skips per-model probing when the provider has no thinking picker", async () => {
const setSessionConfigOption = vi.fn();
test("probes each model when session/new has no thought_level so a later model can still expose thinking options", async () => {
const setSessionConfigOption = vi.fn(async ({ value }: { value: string }) => ({
configOptions:
value === "kimi-k3"
? [modelConfigOption(value), effortThinkingConfigOption()]
: [modelConfigOption(value)],
}));
const client = createKimiClient(
async () =>
@@ -174,16 +179,28 @@ describe("KimiACPAgentClient per-model thinking options", () => {
}) as unknown as SpawnedACPProcess,
);
await client.fetchCatalog({
const catalog = await client.fetchCatalog({
scope: "workspace",
cwd: "/tmp/acp-kimi-no-thinking",
force: false,
});
expect(setSessionConfigOption).not.toHaveBeenCalled();
expect(setSessionConfigOption).toHaveBeenCalledTimes(2);
const kimiForCoding = catalog.models.find((model) => model.id === "kimi-for-coding");
const kimiK3 = catalog.models.find((model) => model.id === "kimi-k3");
expect(kimiForCoding?.thinkingOptions).toBeUndefined();
expect(kimiForCoding?.defaultThinkingOptionId).toBeUndefined();
expect(kimiK3?.thinkingOptions).toEqual([
expect.objectContaining({ id: "off", isDefault: false }),
expect.objectContaining({ id: "low", isDefault: false }),
expect.objectContaining({ id: "medium", isDefault: true }),
expect.objectContaining({ id: "high", isDefault: false }),
]);
expect(kimiK3?.defaultThinkingOptionId).toBe("medium");
});
test("keeps a model's default thinking options when its probe fails", async () => {
test("omits thinking options when a model's probe fails instead of keeping another model's list", async () => {
const setSessionConfigOption = vi.fn(async ({ value }: { value: string }) => {
if (value === "kimi-k3") {
throw new Error("probe rejected model switch");
@@ -212,10 +229,13 @@ describe("KimiACPAgentClient per-model thinking options", () => {
force: false,
});
const kimiForCoding = catalog.models.find((model) => model.id === "kimi-for-coding");
const kimiK3 = catalog.models.find((model) => model.id === "kimi-k3");
expect(kimiK3?.thinkingOptions).toEqual([
expect(kimiForCoding?.thinkingOptions).toEqual([
expect.objectContaining({ id: "off", isDefault: true }),
expect.objectContaining({ id: "on", isDefault: false }),
]);
expect(kimiK3?.thinkingOptions).toBeUndefined();
expect(kimiK3?.defaultThinkingOptionId).toBeUndefined();
});
});
@@ -18,23 +18,8 @@ interface KimiACPAgentClientOptions {
providerParams?: unknown;
}
/**
* Kimi reports different thinking-effort levels per model (a boolean on/off toggle for one
* model, a multi-level select for another), but only exposes the *currently selected*
* model's levels through `configOptions` — the model list itself carries no per-model
* effort metadata. `deriveModelDefinitionsFromACP` can only see whichever model the probe
* session defaulted to, so every other model would otherwise inherit that one model's
* thinking options.
*
* Reuse the single catalog probe session to switch through each candidate model in turn and
* read back its real thinking options, rather than spawning a probe per model. Skipped
* entirely when the provider reports one model or no thinking picker, so a misbehaving ACP
* that only advertises a single model pays no extra round trips.
*
* This lives on the Kimi client, not the base ACP adapter: only Kimi needs the extra
* `setSessionConfigOption` round trips, so no other ACP provider risks a slow or
* nonconforming agent stalling its catalog probe on model switching.
*/
// Kimi exposes thinking options only for the selected model. Keep its model-switching
// discovery here: other providers can supply a read-only catalog instead.
export async function resolveKimiCatalogModels({
connection,
sessionId,
@@ -49,7 +34,7 @@ export async function resolveKimiCatalogModels({
return models;
}
const modelOption = findSelectConfigOption({ configOptions, category: "model" });
if (!modelOption || !findSelectConfigOption({ configOptions, category: "thought_level" })) {
if (!modelOption) {
return models;
}
@@ -72,11 +57,24 @@ export async function resolveKimiCatalogModels({
thinkingOptions.find((option) => option.isDefault)?.id ?? undefined,
});
} catch (error) {
const errorMessage = toDiagnosticErrorMessage(error);
if (model.isDefault) {
logger.warn(
{ modelId: model.id, error: errorMessage },
`${provider} catalog probe could not refresh thinking options for current model "${model.id}"; keeping session options`,
);
resolved.push(model);
continue;
}
logger.warn(
{ modelId: model.id, error: toDiagnosticErrorMessage(error) },
`${provider} catalog probe could not resolve thinking options for model "${model.id}"; keeping its default options`,
{ modelId: model.id, error: errorMessage },
`${provider} catalog probe could not resolve thinking options for model "${model.id}"; omitting thinking options`,
);
resolved.push(model);
resolved.push({
...model,
thinkingOptions: undefined,
defaultThinkingOptionId: undefined,
});
}
}
return resolved;
@@ -612,6 +612,115 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
rmSync(cwd, { recursive: true, force: true });
}, 120_000);
test.each([
{ name: "absent", variants: {} },
{ name: "empty", variants: { default: {} } },
{ name: "configured", variants: { default: { reasoningEffort: "low" } } },
])("catalog exposes one Default when upstream default is $name", async ({ variants }) => {
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
connected: ["catalog-provider"],
all: [
{
id: "catalog-provider",
name: "Catalog provider",
source: "api",
models: {
model: {
name: "Variant model",
variants: { ...variants, high: {}, "variant:default": {} },
},
},
},
],
},
};
runtime.enqueueClient(openCodeClient);
const cwd = tmpCwd();
try {
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
resolveHomeDir: () => cwd,
});
const catalog = await client.fetchCatalog({ scope: "global", force: false });
const options = catalog.models[0].thinkingOptions ?? [];
expect(options).toEqual([
{ id: "default", label: "Default", isDefault: true },
{ id: "high", label: "high" },
{ id: "variant:default", label: "variant:default" },
]);
expect(catalog.models[0].defaultThinkingOptionId).toBe("default");
const execution = new TestOpenCodeClient();
execution.sessionCreateResponse = { data: { id: "ses_catalog_variants" } };
execution.sessionPromptAsyncEvents = [
{ type: "session.idle", properties: { sessionID: "ses_catalog_variants" } },
];
runtime.enqueueClient(execution);
const session = await client.createSession({
provider: "opencode",
cwd,
model: "catalog-provider/model",
thinkingOptionId: "default",
});
try {
await collectTurnEvents(streamSession(session, "Use OpenCode's default"));
await session.setThinkingOption!("high");
await collectTurnEvents(streamSession(session, "Use high"));
await session.setThinkingOption!("variant:default");
await collectTurnEvents(streamSession(session, "Use the literal variant name"));
await session.setThinkingOption!("default");
await collectTurnEvents(streamSession(session, "Return to OpenCode's default"));
expect(execution.calls.sessionPromptAsync).toEqual([
expect.not.objectContaining({ variant: expect.anything() }),
expect.objectContaining({ variant: "high" }),
expect.objectContaining({ variant: "variant:default" }),
expect.not.objectContaining({ variant: expect.anything() }),
]);
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test.each([
{ saved: "default", variant: undefined },
{ saved: "high", variant: "high" },
{ saved: "variant:default", variant: "variant:default" },
])("resume preserves saved thinking choice $saved", async ({ saved, variant }) => {
const runtime = new TestOpenCodeHarness();
const execution = new TestOpenCodeClient();
execution.sessionPromptAsyncEvents = [
{ type: "session.idle", properties: { sessionID: "ses_saved_variant" } },
];
runtime.enqueueClient(execution);
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const cwd = tmpCwd();
try {
const session = await client.resumeSession(
{ provider: "opencode", sessionId: "ses_saved_variant", metadata: { cwd } },
{ thinkingOptionId: saved },
);
try {
await collectTurnEvents(streamSession(session, "Keep my saved choice"));
expect(execution.calls.sessionPromptAsync.map((request) => request.variant)).toEqual([
variant,
]);
} finally {
await session.close();
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("fetchCatalog returns models with required fields", async () => {
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
@@ -806,11 +806,14 @@ function buildOpenCodeModelDefinition(
},
): AgentModelDefinition {
const rawVariants = model.variants ? Object.keys(model.variants) : [];
// OpenCode lists only overrides; its base model behavior is selected by omitting `variant`.
// Like OpenCode's web UI, Default omits `variant` and lets OpenCode resolve it.
// Reserve that choice instead of exposing a second upstream `default` entry.
const thinkingOptions = rawVariants.length
? [
{ id: OPENCODE_DEFAULT_VARIANT_ID, label: "Default", isDefault: true },
...rawVariants.map((id) => ({ id, label: id })),
...rawVariants
.filter((id) => id !== OPENCODE_DEFAULT_VARIANT_ID)
.map((id) => ({ id, label: id })),
]
: [];
@@ -2,7 +2,6 @@ import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { createServer, type ServerResponse } from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { Writable } from "node:stream";
@@ -367,7 +366,7 @@ describe("OpenCodeServerManager managed process ledger", () => {
describe.runIf(process.platform === "win32")(
"OpenCodeServerManager Windows OpenCode npm install",
() => {
test("starts the helper server from opencode.exe instead of the npm opencode.cmd shim", async () => {
test("resolves the Windows npm shim to its native helper executable", async () => {
const detectedOpenCode = await findExecutable("opencode");
expect(detectedOpenCode, "Windows CI must install opencode-ai before server tests").not.toBe(
null,
@@ -376,27 +375,21 @@ describe.runIf(process.platform === "win32")(
const tempDir = mkdtempSync(path.join(os.tmpdir(), "opencode-real-windows-"));
const opencodeHomeDir = path.join(tempDir, "opencode-home");
const managedProcesses = new FakeManagedProcesses();
const runtime = new FakeOpenCodeServerRuntime([4604], { autoAnnounce: true });
const manager = new OpenCodeServerManager({
logger: createTestLogger(),
managedProcesses,
managedProcesses: runtime.managedProcesses,
resolveHomeDir: () => opencodeHomeDir,
portAllocator: runtime.allocatePort,
spawnServerProcess: runtime.spawnServerProcess,
terminateProcess: runtime.terminateProcess,
});
let acquiredPort: number | null = null;
try {
const acquisition = await manager.acquireDedicated({
OPENCODE_AUTH_CONTENT: "{}",
OPENCODE_DISABLE_AUTOUPDATE: "1",
OPENCODE_DISABLE_AUTOCOMPACT: "1",
OPENCODE_DISABLE_MODELS_FETCH: "1",
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
OPENCODE_PURE: "1",
OPENCODE_TEST_HOME: path.join(tempDir, "test-home"),
});
acquiredPort = acquisition.server.port;
const records = await managedProcesses.list();
// Resolve the actual npm installation. Generation lifecycle coverage above
// owns process readiness; this regression owns the Windows launch command.
const acquisition = await manager.acquireCurrent();
const records = await runtime.managedProcesses.list();
expect(records).toHaveLength(1);
const record = records[0]!;
expect(path.extname(record.command).toLowerCase()).toBe(".exe");
@@ -404,15 +397,13 @@ describe.runIf(process.platform === "win32")(
path.normalize("node_modules/opencode-ai/bin/opencode.exe").toLowerCase(),
);
expect(record.command.toLowerCase()).not.toBe(detectedOpenCode!.toLowerCase());
expect(record.args).toEqual(["serve", "--port", String(acquiredPort)]);
expect(record.args).toEqual(["serve", "--port", String(acquisition.server.port)]);
await acquisition.release();
} finally {
await manager.shutdown().catch(() => undefined);
if (acquiredPort !== null) {
await waitForClosedPort(acquiredPort, 5_000);
}
rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
await manager.shutdown();
rmSync(tempDir, { recursive: true, force: true });
}
}, 60_000);
});
},
);
@@ -610,37 +601,3 @@ class FakeManagedProcesses implements ManagedProcessRegistry {
};
}
}
async function waitForClosedPort(port: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!(await canConnectToPort(port))) {
return;
}
await sleep(100);
}
throw new Error(`OpenCode helper server still accepts connections on port ${port}`);
}
function canConnectToPort(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.createConnection({ host: "127.0.0.1", port });
let settled = false;
const settle = (connected: boolean) => {
if (settled) {
return;
}
settled = true;
socket.destroy();
resolve(connected);
};
socket.once("connect", () => settle(true));
socket.once("error", () => settle(false));
socket.setTimeout(500, () => settle(false));
});
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -1,143 +0,0 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, test } from "vitest";
import { AgentRequests } from "./index.js";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});
async function fixture() {
const directory = await mkdtemp(path.join(tmpdir(), "agent-requests-"));
directories.push(directory);
return { directory, requests: new AgentRequests(directory) };
}
test("concurrent and reconstructed creates return the same durable agent", async () => {
const { requests, directory } = await fixture();
const agents = new Set<string>();
let creations = 0;
const input = {
key: "create-1",
request: { provider: "test", cwd: "/project" },
findAgent: async (id: string) => agents.has(id),
create: async (id: string) => {
creations++;
agents.add(id);
},
};
const ids = await Promise.all([requests.create(input), requests.create(input)]);
expect(ids[0]).toBe(ids[1]);
expect(await new AgentRequests(directory).create(input)).toBe(ids[0]);
expect(creations).toBe(1);
});
test("recovers creation when the agent was persisted before acknowledgement failed", async () => {
const { requests, directory } = await fixture();
const agents = new Set<string>();
const input = {
key: "lost-create",
request: {},
findAgent: async (id: string) => agents.has(id),
create: async (id: string) => {
agents.add(id);
throw new Error("acknowledgement lost");
},
};
await expect(requests.create(input)).rejects.toThrow("acknowledgement lost");
expect(await new AgentRequests(directory).create(input)).toBe([...agents][0]);
expect(agents.size).toBe(1);
});
test("reusing a create key with different configuration is a conflict", async () => {
const { requests } = await fixture();
const input = {
key: "key",
request: { model: "a" },
findAgent: async () => true,
create: async () => {},
};
await requests.create(input);
await expect(requests.create({ ...input, request: { model: "b" } })).rejects.toThrow(
"agent_request_key_conflict",
);
});
test("message retries survive reconstruction without submitting twice", async () => {
const { requests, directory } = await fixture();
let deliveries = 0;
const input = {
agentId: "agent",
messageId: "arrival",
request: { text: "hello" },
send: async () => {
deliveries++;
},
};
await Promise.all([requests.send(input), requests.send(input)]);
await new AgentRequests(directory).send(input);
expect(deliveries).toBe(1);
await requests.send({ ...input, agentId: "another" });
expect(deliveries).toBe(2);
});
test("ambiguous provider delivery is never blindly replayed after restart", async () => {
const { requests, directory } = await fixture();
let deliveries = 0;
const input = {
agentId: "agent",
messageId: "arrival",
request: {},
send: async () => {
deliveries++;
throw new Error("connection lost");
},
};
await expect(requests.send(input)).rejects.toThrow("connection lost");
await expect(new AgentRequests(directory).send(input)).rejects.toThrow(
"agent_request_outcome_unknown",
);
expect(deliveries).toBe(1);
});
test("a creation failure with no stored agent can be retried", async () => {
const { requests } = await fixture();
let available = false;
const input = {
key: "unavailable",
request: {},
findAgent: async () => false,
create: async () => {
if (!available) throw new Error("provider unavailable");
},
};
await expect(requests.create(input)).rejects.toThrow("provider unavailable");
available = true;
await expect(requests.create(input)).resolves.toEqual(expect.any(String));
});
test("failed local message preparation does not leave an ambiguous receipt", async () => {
const { requests, directory } = await fixture();
let available = false;
let sends = 0;
const input = {
agentId: "agent",
messageId: "message",
request: {},
prepare: async () => {
if (!available) throw new Error("load failed");
},
send: async () => {
sends++;
},
};
await expect(requests.send(input)).rejects.toThrow("load failed");
available = true;
await new AgentRequests(directory).send(input);
available = false;
await requests.send(input);
expect(sends).toBe(1);
});
@@ -1,138 +0,0 @@
import { createHash, randomUUID } from "node:crypto";
import { readFile, rm } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { writeJsonFileAtomic } from "../../atomic-file.js";
const ReceiptSchema = z.object({
fingerprint: z.string(),
agentId: z.string(),
state: z.enum(["pending", "completed"]),
});
type Receipt = z.infer<typeof ReceiptSchema>;
/** One daemon-owned request journal, shared by all of its socket sessions. */
export class AgentRequests {
private readonly pending = new Map<string, Promise<string>>();
constructor(private readonly directory: string) {}
create(input: {
key: string;
request: unknown;
findAgent: (agentId: string) => Promise<boolean>;
create: (agentId: string) => Promise<void>;
}): Promise<string> {
return this.execute(["create", input.key], input.request, {
agentId: randomUUID(),
recover: input.findAgent,
run: input.create,
retrySafe: async (agentId) => !(await input.findAgent(agentId)),
});
}
async send(input: {
agentId: string;
messageId: string;
request: unknown;
send: () => Promise<void>;
prepare?: () => Promise<void>;
}): Promise<void> {
await this.execute(["send", input.agentId, input.messageId], input.request, {
agentId: input.agentId,
// A provider call can take effect before the daemon records its outcome.
// Never repeat that call merely because a process died in this window.
recover: async () => false,
run: input.send,
prepare: input.prepare,
});
}
private execute(
identity: string[],
request: unknown,
operation: {
agentId: string;
recover: (agentId: string) => Promise<boolean>;
run: (agentId: string) => Promise<void>;
prepare?: (() => Promise<void>) | undefined;
retrySafe?: (agentId: string) => Promise<boolean>;
},
): Promise<string> {
const key = digest(identity);
const fingerprint = digest(request);
const previous = this.pending.get(key);
// Serialize even conflicting requests: each caller validates its own fingerprint.
const result = (previous ? previous.catch(() => undefined) : Promise.resolve()).then(() =>
this.executeOnce(key, fingerprint, operation),
);
this.pending.set(key, result);
void result
.finally(() => {
if (this.pending.get(key) === result) this.pending.delete(key);
})
.catch(() => undefined);
return result;
}
private async executeOnce(
key: string,
fingerprint: string,
operation: {
agentId: string;
recover: (agentId: string) => Promise<boolean>;
run: (agentId: string) => Promise<void>;
prepare?: (() => Promise<void>) | undefined;
retrySafe?: (agentId: string) => Promise<boolean>;
},
): Promise<string> {
const file = path.join(this.directory, `${key}.json`);
const existing = await readReceipt(file);
if (existing) {
if (existing.fingerprint !== fingerprint) throw new Error("agent_request_key_conflict");
if (existing.state === "completed") return existing.agentId;
if (!(await operation.recover(existing.agentId))) {
throw new Error("agent_request_outcome_unknown");
}
await writeJsonFileAtomic(file, { ...existing, state: "completed" });
return existing.agentId;
}
await operation.prepare?.();
const receipt: Receipt = { fingerprint, agentId: operation.agentId, state: "pending" };
await writeJsonFileAtomic(file, receipt);
try {
await operation.run(receipt.agentId);
} catch (error) {
// Keyed creation has no initial prompt. Once its normal cleanup finished,
// absence of an agent confirms that retrying cannot duplicate one.
if (await operation.retrySafe?.(receipt.agentId)) await rm(file, { force: true });
throw error;
}
await writeJsonFileAtomic(file, { ...receipt, state: "completed" });
return receipt.agentId;
}
}
async function readReceipt(file: string): Promise<Receipt | null> {
try {
return ReceiptSchema.parse(JSON.parse(await readFile(file, "utf8")));
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
throw error;
}
}
function digest(value: unknown): string {
return createHash("sha256")
.update(
JSON.stringify(value, (_key, candidate: unknown) => {
if (candidate !== null && typeof candidate === "object" && !Array.isArray(candidate)) {
return Object.fromEntries(
Object.entries(candidate).sort(([a], [b]) => a.localeCompare(b)),
);
}
return candidate;
}),
)
.digest("hex");
}
@@ -82,10 +82,11 @@ describe("AgentManager rewind", () => {
test("replaces the canonical epoch without replaying reconstructed parent rows", async () => {
const { manager, session, agentId } = await createRewindHarness();
session.history = Array.from({ length: 250 }, (_, index) => ({
const history = Array.from({ length: 250 }, (_, index) => ({
type: "assistant_message" as const,
text: `rewound ${index}`,
}));
session.history = history;
const epochBefore = manager.fetchTimeline(agentId, { limit: 0 }).epoch;
const events: string[] = [];
const unsubscribe = manager.subscribe((event) => events.push(event.type), {
@@ -97,7 +98,18 @@ describe("AgentManager rewind", () => {
const replacement = manager.fetchTimeline(agentId, { limit: 0 });
expect(replacement.epoch).not.toBe(epochBefore);
expect(replacement.rows).toHaveLength(250);
expect(replacement.rows).toEqual([
expect.objectContaining({
seq: 250,
seqStart: 1,
seqEnd: 250,
sourceSeqRanges: [{ startSeq: 1, endSeq: 250 }],
item: {
type: "assistant_message",
text: history.map((item) => item.text).join(""),
},
}),
]);
expect(events.filter((type) => type === "agent_stream")).toEqual([]);
});
@@ -0,0 +1,11 @@
/** Thrown when a prompt reaches a plugin session whose connection died with its plugin. */
export class StaleProviderSessionError extends Error {
constructor(sessionId: string) {
super(`Provider session ${sessionId} is stale after its plugin reloaded`);
this.name = "StaleProviderSessionError";
}
}
export function isStaleProviderSessionError(error: unknown): error is StaleProviderSessionError {
return error instanceof StaleProviderSessionError;
}
@@ -1,7 +1,8 @@
import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js";
import type { AgentTimelineRow } from "./agent-manager.js";
import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
export type TimelineProjectionMode = "canonical" | "projected";
export interface TimelineSeqRange {
@@ -19,6 +20,7 @@ export type TimelineLimitDirection = "tail" | "before" | "after";
export interface TimelineProjectionEntry {
item: AgentTimelineItem;
turnId?: string;
providerMessageId?: string;
timestamp: string;
seqStart: number;
seqEnd: number;
@@ -54,7 +56,7 @@ function appendSeqToRanges(ranges: TimelineSeqRange[], seq: number): TimelineSeq
}
if (seq <= last.endSeq + 1) {
last.endSeq = Math.max(last.endSeq, seq);
next[next.length - 1] = { ...last, endSeq: Math.max(last.endSeq, seq) };
return next;
}
@@ -114,15 +116,19 @@ function mergeToolCallItems(
}
function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): WorkingEntry[] {
return rows.map((row) => ({
item: row.item,
...(row.turnId ? { turnId: row.turnId } : {}),
timestamp: row.timestamp,
seqStart: row.seq,
seqEnd: row.seq,
sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }],
collapsed: [],
}));
return rows.map((row) => {
if ("seqStart" in row) return { ...(row as ProjectedTimelineRow) };
return {
item: row.item,
timestamp: row.timestamp,
...(row.turnId ? { turnId: row.turnId } : {}),
...(row.providerMessageId ? { providerMessageId: row.providerMessageId } : {}),
seqStart: row.seq,
seqEnd: row.seq,
sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }],
collapsed: [],
};
});
}
function mergeIdentityMetadata(
@@ -285,6 +291,54 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
return output;
}
/** A retained item. `seq` is its latest source event, not its display anchor. */
export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry;
/** Owns projected state; source events are consumed, never retained. */
export class TimelineProjection {
private readonly rows: ProjectedTimelineRow[] = [];
private readonly identities = new Map<string, number>();
append(row: AgentTimelineRow): void {
const entry = makeCanonicalEntries([row])[0];
const identity = timelineItemIdentity(row.item);
const index = identity === null ? undefined : this.identities.get(identity);
const existing = index === undefined ? undefined : this.rows[index];
const merged = existing ? mergeIdentityEntries(existing, entry) : null;
if (merged && index !== undefined) {
this.rows[index] = { ...merged, seq: merged.seqEnd };
return;
}
const previous = this.rows.at(-1);
const adjacent = previous
? mergeReasoningChunks(mergeAssistantChunks([previous, entry]))
: [entry];
if (previous && adjacent.length === 1) {
const mergedText = adjacent[0];
this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd };
return;
}
if (identity !== null) this.identities.set(identity, this.rows.length);
this.rows.push({ ...entry, seq: entry.seqEnd });
}
getRows(): ProjectedTimelineRow[] {
return this.rows.map((row) => ({ ...row }));
}
enrichSubmittedUserMessage(
clientMessageId: string,
providerMessageId: string,
): ProjectedTimelineRow | null {
const index = this.rows.findIndex(
(row) => row.item.type === "user_message" && row.item.clientMessageId === clientMessageId,
);
if (index < 0) return null;
this.rows[index] = { ...this.rows[index], providerMessageId };
return { ...this.rows[index] };
}
}
export function projectTimelineRows(input: {
rows: readonly AgentTimelineRow[];
mode: TimelineProjectionMode;
@@ -397,7 +451,11 @@ function getTimelineBounds(
if (!first || !last) {
return null;
}
return { minSeq: first.seq, maxSeq: last.seq };
const entries = makeCanonicalEntries(rows);
return {
minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity),
maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0),
};
}
function firstSourceSeqInRange(
@@ -420,7 +478,6 @@ interface ProjectedEntryCandidate {
function selectProjectedEntriesAfter(input: {
entries: readonly TimelineProjectionEntry[];
rows: readonly AgentTimelineRow[];
startSeq: number;
maxSeq: number;
limit: number;
@@ -445,16 +502,10 @@ function selectProjectedEntriesAfter(input: {
// Wide projected entries can include future, discontiguous source ranges. The
// page cursor advances only through source rows covered without a gap.
let endSeq = input.startSeq - 1;
let rangeIndex = 0;
for (const row of input.rows) {
if (row.seq < input.startSeq) continue;
if (row.seq > input.maxSeq || row.seq !== endSeq + 1) break;
while (selectedRanges[rangeIndex] && selectedRanges[rangeIndex].endSeq < row.seq) {
rangeIndex += 1;
}
const range = selectedRanges[rangeIndex];
if (!range || row.seq < range.startSeq || row.seq > range.endSeq) break;
endSeq = row.seq;
for (const range of selectedRanges) {
if (range.endSeq <= endSeq) continue;
if (range.startSeq > endSeq + 1) break;
endSeq = Math.min(range.endSeq, input.maxSeq);
}
return {
@@ -484,6 +535,27 @@ function selectProjectedEntriesBefore(input: {
};
}
function selectProjectedEntriesTail(
projectedAll: TimelineProjectionEntry[],
limit: number,
bounds: { minSeq: number; maxSeq: number },
): ProjectedTimelinePageSelection {
let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit);
// Include earlier display anchors with updates in the selected source window,
// so a tail certifies contiguous coverage even with interleaved tool updates.
for (let i = start - 1; i >= 0; i--) {
if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i;
}
const selected = projectedAll.slice(start);
return {
entries: selected,
startSeq: selected[0]?.seqStart ?? null,
endSeq: bounds.maxSeq,
hasOlder: start > 0,
hasNewer: false,
};
}
export function selectProjectedTimelinePage(input: {
rows: readonly AgentTimelineRow[];
bounds?: { minSeq: number; maxSeq: number };
@@ -535,18 +607,7 @@ export function selectProjectedTimelinePage(input: {
}
if (input.direction === "tail") {
const selected = selectTimelineWindowByProjectedLimit({
rows: input.rows,
direction: "tail",
limit,
});
return {
entries: selected.projectedEntries,
startSeq: selected.minSeq,
endSeq: selected.maxSeq,
hasOlder: selected.minSeq !== null && selected.minSeq > bounds.minSeq,
hasNewer: false,
};
return selectProjectedEntriesTail(projectedAll, limit, bounds);
}
if (input.direction === "after") {
@@ -554,7 +615,6 @@ export function selectProjectedTimelinePage(input: {
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
const selected = selectProjectedEntriesAfter({
entries: projectedAll,
rows: input.rows,
startSeq,
maxSeq: bounds.maxSeq,
limit,
@@ -21,6 +21,8 @@ const INBOUND_PERMISSION = {
"agent.timeline.search.request": "workspace.read",
"agent.timeline.list_prompts.request": "workspace.read",
"agent.timeline.append.request": "workspace.write",
"browser.host.register.request": ["workspace.write"],
"subscription.release.request": null,
"session.events.set_subscription.request": ["workspace.read", "daemon.read", "hub.execute"],
"agent.timeline.set_subscription.request": ["workspace.read", "hub.execute"],
agent_permission_response: "workspace.write",
@@ -61,6 +63,8 @@ const INBOUND_PERMISSION = {
client_heartbeat: "workspace.read",
close_items_request: "workspace.manage",
create_agent_request: ["workspace.write", "hub.execute"],
"agent.create.request": ["workspace.write", "hub.execute"],
"creation.subscribe.request": "workspace.read",
create_paseo_worktree_request: "workspace.manage",
create_terminal_request: "workspace.write",
"daemon.config.reload.request": "daemon.manage",
@@ -173,6 +177,7 @@ const INBOUND_PERMISSION = {
stash_list_request: "workspace.read",
stash_pop_request: "workspace.write",
stash_save_request: "workspace.write",
"checkout.diff.get.request": "workspace.read",
subscribe_checkout_diff_request: "workspace.read",
subscribe_terminal_request: "workspace.read",
subscribe_terminals_request: "workspace.read",
@@ -207,6 +212,10 @@ const INBOUND_PERMISSION = {
} as const satisfies Record<InboundOperation, PermissionRequirement>;
const OUTBOUND_PERMISSION = {
"agent.create.response": ["workspace.write", "hub.execute"],
"agent.create.update": ["workspace.write", "hub.execute"],
"workspace.create.update": "workspace.manage",
"creation.subscribe.response": "workspace.read",
activity_log: "workspace.read",
"agent.config.apply.response": ["workspace.write", "hub.execute"],
"agent.detach.response": "workspace.write",
@@ -224,6 +233,8 @@ const OUTBOUND_PERMISSION = {
"agent.timeline.list_prompts.response": "workspace.read",
"agent.timeline.append.response": "workspace.write",
"agent.timeline.replacement": ["workspace.read", "hub.execute"],
"browser.host.register.response": ["workspace.write"],
"subscription.release.response": null,
"session.events.set_subscription.response": ["workspace.read", "daemon.read", "hub.execute"],
"agent.timeline.set_subscription.response": ["workspace.read", "hub.execute"],
agent_archived: ["workspace.read", "hub.execute"],
@@ -387,6 +398,7 @@ const OUTBOUND_PERMISSION = {
stash_pop_response: "workspace.read",
stash_save_response: "workspace.read",
status: "daemon.read",
"checkout.diff.get.response": "workspace.read",
subscribe_checkout_diff_response: "workspace.read",
subscribe_terminal_response: "workspace.read",
"terminal.rename.response": "workspace.write",
+2 -2
View File
@@ -80,12 +80,12 @@ export function parseListenString(listen: string): ListenTarget {
throw new Error(`Invalid listen string: ${listen}`);
}
function formatListenTarget(listenTarget: ListenTarget | null): string | null {
export function formatListenTarget(listenTarget: ListenTarget | null): string | null {
if (!listenTarget) {
return null;
}
if (listenTarget.type === "tcp") {
return `${listenTarget.host}:${listenTarget.port}`;
return `${formatHostForHttpUrl(listenTarget.host)}:${listenTarget.port}`;
}
return listenTarget.path;
}
@@ -158,7 +158,10 @@ export class BrowserToolsBroker {
});
}
public receiveResponse(response: BrowserAutomationExecuteResponse): boolean {
public receiveResponse(
response: BrowserAutomationExecuteResponse,
clientIds?: readonly string[],
): boolean {
const parsed = BrowserAutomationExecuteResponseSchema.safeParse(response);
if (!parsed.success) {
const requestId = getBrowserAutomationResponseRequestId(response);
@@ -167,7 +170,7 @@ export class BrowserToolsBroker {
}
const pending = this.pending.get(requestId);
if (!pending) {
if (!pending || (clientIds && !clientIds.includes(pending.clientId))) {
return false;
}
@@ -184,7 +187,7 @@ export class BrowserToolsBroker {
}
const pending = this.pending.get(parsed.data.payload.requestId);
if (!pending) {
if (!pending || (clientIds && !clientIds.includes(pending.clientId))) {
return false;
}
@@ -0,0 +1,116 @@
import { expect, test } from "vitest";
import { CheckoutDiffCache } from "@server/server/checkout-diff-cache.js";
function createSequencedLoader<T>(first: Promise<T>, second: Promise<T>) {
let calls = 0;
return {
load: () => (++calls === 1 ? first : second),
get calls() {
return calls;
},
};
}
async function fillCompletedCache(cache: CheckoutDiffCache) {
for (let i = 0; i < 70; i++) {
await cache.read({
cwd: String(i),
compare: { mode: "base" },
load: async () => ({ diff: "" }),
});
}
}
test("forced reads after a mutation share a fresh build after the active build finishes", async () => {
const cache = new CheckoutDiffCache(() => 0);
const first = Promise.withResolvers<{ diff: string }>();
const second = Promise.withResolvers<{ diff: string }>();
const loader = createSequencedLoader(first.promise, second.promise);
const { load } = loader;
const pending = cache.read({ cwd: "repo", compare: { mode: "base" }, load });
await Promise.resolve();
const options = { force: true, reason: "repository-mutation" };
const fresh = cache.read({ cwd: "repo", compare: { mode: "base" }, ...options, load });
const another = cache.read({ cwd: "repo", compare: { mode: "base" }, ...options, load });
expect(loader.calls).toBe(1);
first.resolve({ diff: "before-mutation" });
expect(await pending).toEqual({ diff: "before-mutation" });
await Promise.resolve();
await Promise.resolve();
expect(loader.calls).toBe(2);
second.resolve({ diff: "after-mutation" });
expect(await fresh).toEqual({ diff: "after-mutation" });
expect(await another).toEqual({ diff: "after-mutation" });
expect(await cache.read({ cwd: "repo", compare: { mode: "base" }, load })).toEqual({
diff: "after-mutation",
});
expect(loader.calls).toBe(2);
});
test("reads after invalidation wait for fresh work without overlapping the original read", async () => {
const cache = new CheckoutDiffCache(() => 0);
const first = Promise.withResolvers<{ diff: string }>();
const second = Promise.withResolvers<{ diff: string }>();
const loader = createSequencedLoader(first.promise, second.promise);
const { load } = loader;
const read = () => cache.read({ cwd: "repo", compare: { mode: "base" }, load });
const pending = read();
await Promise.resolve();
cache.invalidate("repo", "base");
cache.invalidate("repo", "base");
const fresh = read();
const another = read();
expect(loader.calls).toBe(1);
first.resolve({ diff: "old" });
expect(await pending).toEqual({ diff: "old" });
await Promise.resolve();
await Promise.resolve();
expect(loader.calls).toBe(2);
second.resolve({ diff: "new" });
expect(await fresh).toEqual({ diff: "new" });
expect(await another).toEqual({ diff: "new" });
expect(await read()).toEqual({ diff: "new" });
expect(loader.calls).toBe(2);
});
test("edits during a read do not delay its caller or cache its outdated snapshot", async () => {
const cache = new CheckoutDiffCache(() => 0);
const deferred = Promise.withResolvers<{ diff: string }>();
let calls = 0;
const load = () => {
calls += 1;
return deferred.promise;
};
const pending = cache.read({ cwd: "repo", compare: { mode: "uncommitted" }, load });
await Promise.resolve();
cache.invalidate("repo", "uncommitted");
deferred.resolve({ diff: "snapshot" });
expect(await pending).toEqual({ diff: "snapshot" });
expect(calls).toBe(1);
expect(
await cache.read({
cwd: "repo",
compare: { mode: "uncommitted" },
load: async () => ({ diff: "fresh" }),
}),
).toEqual({ diff: "fresh" });
});
test("active reads survive completed-payload eviction and failed reads can retry", async () => {
const cache = new CheckoutDiffCache(() => 0);
const deferred = Promise.withResolvers<{ diff: string }>();
const load = () => deferred.promise;
const first = cache.read({ cwd: "active", compare: { mode: "uncommitted" }, load });
const rejected = expect(first).rejects.toThrow("read failed");
await fillCompletedCache(cache);
expect(cache.read({ cwd: "active", compare: { mode: "uncommitted" }, load })).toBe(first);
deferred.reject(new Error("read failed"));
await rejected;
expect(
await cache.read({
cwd: "active",
compare: { mode: "uncommitted" },
load: async () => ({ diff: "retry" }),
}),
).toEqual({ diff: "retry" });
});
@@ -0,0 +1,92 @@
import { LRUCache } from "lru-cache";
import type { CheckoutDiffCompare, CheckoutDiffResult } from "../utils/checkout-git.js";
interface ActiveRead {
promise: Promise<CheckoutDiffResult>;
invalidated: boolean;
}
interface CachedDiff {
value: CheckoutDiffResult;
loadedAt: number;
lastReadStarted: number;
}
interface CheckoutDiffReadInput {
cwd: string;
compare: CheckoutDiffCompare;
force?: boolean;
reason?: string;
load: () => Promise<CheckoutDiffResult>;
}
/** Completed payloads are bounded; active work survives invalidation and eviction. */
export class CheckoutDiffCache {
private readonly values = new LRUCache<string, CachedDiff>({
max: 64,
});
private readonly active = new Map<string, ActiveRead>();
constructor(private readonly now: () => number) {}
read(input: CheckoutDiffReadInput): Promise<CheckoutDiffResult> {
const { cwd, compare, force, reason, load } = input;
if (force && !reason) throw new Error("WorkspaceGitService forced read requires a reason");
const key = JSON.stringify([
cwd,
compare.mode,
compare.mode === "base" ? (compare.baseRef ?? null) : null,
compare.ignoreWhitespace === true,
compare.includeStructured === true,
]);
const cached = this.values.get(key);
const now = this.now();
if (
!force &&
cached &&
(now - cached.loadedAt <= 15_000 || now - cached.lastReadStarted < 2_000)
)
return Promise.resolve(cached.value);
const pending = this.active.get(key);
if (pending) {
if (force) {
pending.invalidated = true;
this.values.delete(key);
}
if (!pending.invalidated) return pending.promise;
// A caller arriving after a change needs a fresh read, once the old one
// finishes. The original caller can still receive its snapshot promptly.
// The older result cannot enter the cache. Drop force when retrying so
// callers waiting on that same result share the next fresh build.
const reload = () => this.read({ ...input, force: false });
return pending.promise.then(reload, reload);
}
if (cached) cached.lastReadStarted = now;
// Defer load until ownership is installed, including synchronous invalidations.
const read: ActiveRead = {
invalidated: false,
promise: Promise.resolve()
.then(load)
.then((value) => {
// Never repopulate the cache with a snapshot invalidated during its build.
if (!read.invalidated)
this.values.set(key, { value, loadedAt: this.now(), lastReadStarted: now });
return value;
})
.finally(() => this.active.delete(key)),
};
this.active.set(key, read);
return read.promise;
}
invalidate(cwd: string, mode: CheckoutDiffCompare["mode"]): void {
for (const key of new Set([...this.values.keys(), ...this.active.keys()])) {
const [cachedCwd, cachedMode] = JSON.parse(key) as string[];
if (cachedCwd !== cwd || cachedMode !== mode) continue;
this.values.delete(key);
const pending = this.active.get(key);
if (pending) pending.invalidated = true;
}
}
}
@@ -73,6 +73,12 @@ export class CheckoutDiffManager {
this.workspaceGitService = options.workspaceGitService;
}
async read(
params: Omit<CheckoutDiffSubscriptionRequest, "signal">,
): Promise<CheckoutDiffSnapshotPayload> {
return this.computeCheckoutDiffSnapshot(params.cwd, this.normalizeCompare(params.compare));
}
async subscribe(
params: CheckoutDiffSubscriptionRequest,
listener: (snapshot: CheckoutDiffSnapshotPayload) => void,
@@ -40,7 +40,7 @@ test("daemon resolves human and managed CLI workspace ownership", async () => {
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
await client.fetchAgents({ subscribe: {} });
// A bare run mints a fresh local workspace for the cwd, then the agent is
// stamped with that workspace's id.
@@ -0,0 +1,90 @@
// Daemon configuration inputs. General provider credentials and executable/runtime
// controls remain available to managed launches and their agent processes.
export const DAEMON_SETTING_ENV_KEYS = [
"MCP_DEBUG",
"OPENAI_STT_BASE_URL",
"OPENAI_TTS_BASE_URL",
"PASEO_ALLOWED_HOSTS",
"PASEO_APP_BASE_URL",
"PASEO_CORS_ORIGINS",
"PASEO_DICTATION_ENABLED",
"PASEO_DICTATION_LANGUAGE",
"PASEO_DICTATION_LOCAL_STT_MODEL",
"PASEO_DICTATION_STT_PROVIDER",
"PASEO_GIT_CONCURRENCY",
"PASEO_GIT_MAX_PROCESSES_PER_SECOND",
"PASEO_GIT_MAX_PROCESS_CONCURRENCY",
"PASEO_HOSTNAMES",
"PASEO_LISTEN",
"PASEO_LOCAL_MODELS_DIR",
"PASEO_LOG",
"PASEO_LOG_CONSOLE_FORMAT",
"PASEO_LOG_CONSOLE_LEVEL",
"PASEO_LOG_FILE_LEVEL",
"PASEO_LOG_FILE_PATH",
"PASEO_LOG_FILE_ROTATE_COUNT",
"PASEO_LOG_FILE_ROTATE_SIZE",
"PASEO_LOG_FORMAT",
"PASEO_LOG_LEVEL",
"PASEO_LOG_ROTATE_COUNT",
"PASEO_LOG_ROTATE_SIZE",
"PASEO_PASSWORD",
"PASEO_RELAY_ENABLED",
"PASEO_RELAY_ENDPOINT",
"PASEO_RELAY_PUBLIC_ENDPOINT",
"PASEO_RELAY_PUBLIC_USE_TLS",
"PASEO_RELAY_USE_TLS",
"PASEO_SERVICE_PROXY_ENABLED",
"PASEO_SERVICE_PROXY_LISTEN",
"PASEO_SERVICE_PROXY_PUBLIC_BASE_URL",
"PASEO_TRUSTED_PROXIES",
"PASEO_VOICE_LANGUAGE",
"PASEO_VOICE_LLM_PROVIDER",
"PASEO_VOICE_LOCAL_STT_MODEL",
"PASEO_VOICE_LOCAL_TTS_MODEL",
"PASEO_VOICE_LOCAL_TTS_SPEAKER_ID",
"PASEO_VOICE_LOCAL_TTS_SPEED",
"PASEO_VOICE_MODE_ENABLED",
"PASEO_VOICE_STT_PROVIDER",
"PASEO_VOICE_TTS_PROVIDER",
"PASEO_VOICE_TURN_DETECTION_PROVIDER",
"PASEO_WEB_UI_DIST_DIR",
"PASEO_WEB_UI_ENABLED",
"PORT",
"STT_CONFIDENCE_THRESHOLD",
"STT_MODEL",
"TTS_MODEL",
"TTS_VOICE",
] as const;
const CONFIG_CONTEXT_ENV_KEYS = [
"PASEO_NODE_ENV",
"PASEO_DESKTOP_MANAGED",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"OPENAI_STT_API_KEY",
"OPENAI_TTS_API_KEY",
] as const;
export function configurationEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return Object.fromEntries(
[...DAEMON_SETTING_ENV_KEYS, ...CONFIG_CONTEXT_ENV_KEYS].map((key) => [key, env[key]]),
);
}
export function daemonLaunchEnvironment(input: {
env: NodeJS.ProcessEnv;
home: string;
mode: "managed" | "deployment";
desktopManaged?: boolean;
}): NodeJS.ProcessEnv {
const env = { ...input.env };
if (input.mode === "managed") {
for (const key of DAEMON_SETTING_ENV_KEYS) delete env[key];
}
delete env.PASEO_HOST;
delete env.PASEO_DESKTOP_MANAGED;
env.PASEO_HOME = input.home;
if (input.desktopManaged) env.PASEO_DESKTOP_MANAGED = "1";
return env;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { configurationEnvironment } from "./config-environment.js";
import { existsSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -554,7 +555,7 @@ export function resolveConfigFromPersisted(
options?: ResolveConfigFromPersistedOptions,
): PaseoDaemonConfig {
const resolvedOptions = options ?? {};
const env = resolvedOptions.env ?? process.env;
const env = configurationEnvironment(resolvedOptions.env ?? process.env);
const cli = resolvedOptions.cli;
const relayEnabledFallback =
resolvedOptions.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined;
@@ -0,0 +1,286 @@
import { execFileSync } from "node:child_process";
import type { z } from "zod";
import { WebSocket } from "ws";
import { SessionInboundMessageSchema, WSOutboundMessageSchema } from "@getpaseo/protocol/messages";
import { randomUUID } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test, vi } from "vitest";
import pino from "pino";
import type { CreationSnapshot, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
test("creation progresses before agent readiness and continues after the disconnected Session is cleaned up", async () => {
const provider = deferred<void>();
const ready = deferred<CreationSnapshot>();
const disconnected = deferred<void>();
const cleaned = deferred<void>();
const directory = await mkdtemp(join(tmpdir(), "creation-wire-"));
let agents = 0;
let prompts = 0;
const daemon = await createTestPaseoDaemon({
logger: pino(
{ level: "trace" },
{
write(line) {
const { msg } = JSON.parse(line);
if (msg === "Client disconnected; waiting for reconnect") disconnected.resolve();
if (msg === "agent.session.lifecycle.cleanup") cleaned.resolve();
},
},
),
agentClients: createTestAgentClients({
beforeCreateSession: async () => {
agents++;
await provider.promise;
},
onStartTurn: () => {
prompts++;
},
}),
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.7.2",
clientId: "creation-subscriber",
});
const observer = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.7.2",
clientId: "creation-independent-observer",
});
const observerMessages: SessionOutboundMessage[] = [];
const unsubscribe = observer.subscribeRawMessages((message) => observerMessages.push(message));
const workspaceId = "wks_0123456789abcdef";
const agentId = randomUUID();
const request = {
idempotencyKey: "workspace-one",
workspaceId,
source: { kind: "directory" as const, path: directory },
agent: {
agentId,
provider: "codex",
cwd: directory,
initialPrompt: "Create once",
clientMessageId: "initial-one",
},
};
try {
await client.connect();
await observer.connect();
const first = client.createWorkspace({
...request,
onEvent: (snapshot) => {
if (snapshot.phase === "workspace_ready") ready.resolve(snapshot);
},
});
void first.catch(() => undefined);
const snapshot = await ready.promise;
expect(snapshot).toMatchObject({ workspaceId, agentId, phase: "workspace_ready" });
expect((await observer.fetchAgents()).entries).toHaveLength(0);
expect(
observerMessages.filter((message) => message.type === "workspace.create.update"),
).toHaveLength(0);
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
await client.close();
await disconnected.promise;
await vi.advanceTimersByTimeAsync(90_000);
await cleaned.promise;
vi.useRealTimers();
await expect(first).rejects.toThrow("closed");
// A disconnected form cannot prevent the provider or initial prompt from starting.
provider.resolve();
await expect.poll(() => prompts).toBe(1);
const replay = await observer.createWorkspace(request);
expect(replay.error).toBeNull();
expect(replay.workspace?.id).toBe(workspaceId);
expect(replay.agent?.id).toBe(agentId);
expect(replay.agent?.title).toBe("Create once");
expect(agents).toBe(1);
expect(prompts).toBe(1);
const conflict = await observer.createWorkspace({
...request,
idempotencyKey: "another-intent",
});
expect(conflict.error).toBe("workspace_id_conflict");
expect(agents).toBe(1);
} finally {
vi.useRealTimers();
provider.resolve();
unsubscribe();
await client.close();
await observer.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60000);
async function connectCreationPeer(port: number) {
const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`);
const frames: SessionOutboundMessage[] = [];
socket.on("message", (data) => {
const frame = WSOutboundMessageSchema.parse(JSON.parse(data.toString()));
if (frame.type === "session") frames.push(frame.message);
});
await new Promise<void>((resolve, reject) => {
socket.once("open", resolve);
socket.once("error", reject);
});
socket.send(
JSON.stringify({
type: "hello",
clientType: "browser",
clientId: randomUUID(),
protocolVersion: 1,
appVersion: "0.8.0",
}),
);
await expect
.poll(() => frames.some((m) => m.type === "status" && m.payload.status === "server_info"))
.toBe(true);
return {
close: () => socket.close(),
request: async (
message: z.input<typeof SessionInboundMessageSchema> & { requestId: string },
) => {
socket.send(JSON.stringify({ type: "session", message }));
const response = () =>
frames.find(
(m) =>
"payload" in m && "requestId" in m.payload && m.payload.requestId === message.requestId,
);
await expect.poll(response).toBeDefined();
return response()!;
},
};
}
test.each([false, true])(
"workspace identity does not depend on subscribing (first subscribe=%s)",
async (subscribe) => {
const directory = await mkdtemp(join(tmpdir(), "creation-identity-"));
const daemon = await createTestPaseoDaemon();
const peer = await connectCreationPeer(daemon.port);
try {
const request = {
type: "workspace.create.request" as const,
source: { kind: "directory" as const, path: directory },
idempotencyKey: "same-workspace",
};
const first = await peer.request({ ...request, requestId: "first", subscribe });
const replay = await peer.request({ ...request, requestId: "replay", subscribe: !subscribe });
if (first.type !== "workspace.create.response" || replay.type !== "workspace.create.response")
throw new Error("Expected workspace responses");
expect(first.payload.error).toBeNull();
expect(replay.payload.error).toBeNull();
expect(replay.payload.workspace?.id).toBe(first.payload.workspace?.id);
} finally {
peer.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
},
60000,
);
test.each(["create_agent_request", "agent.create.request"] as const)(
"legacy and modern agent RPCs share one creation identity (first %s)",
async (type) => {
const directory = await mkdtemp(join(tmpdir(), "creation-agent-identity-"));
let creations = 0;
const daemon = await createTestPaseoDaemon({
agentClients: createTestAgentClients({
beforeCreateSession: async () => {
creations++;
},
}),
});
const peer = await connectCreationPeer(daemon.port);
try {
const request = {
config: { provider: "codex", cwd: directory },
idempotencyKey: "same-agent",
};
const first = await peer.request({ ...request, type, requestId: "first" });
const replay = await peer.request({
...request,
type: type === "agent.create.request" ? "create_agent_request" : "agent.create.request",
requestId: "replay",
});
const createdAgent = (message: SessionOutboundMessage) => {
if (message.type === "agent.create.response") {
expect(message.payload.error).toBeNull();
return message.payload.agent;
}
if (message.type === "status" && message.payload.status === "agent_created")
return message.payload.agent;
throw new Error(`Unexpected creation result ${JSON.stringify(message)}`);
};
expect(createdAgent(replay)?.id).toBe(createdAgent(first)?.id);
const agentId = createdAgent(first)!.id;
await daemon.daemon.agentManager.setTitle(agentId, "Updated after creation");
await daemon.daemon.agentManager.flush();
const legacyReplay = await peer.request({
...request,
type: "create_agent_request",
requestId: "legacy-after-update",
});
expect(createdAgent(legacyReplay)?.title).toBe("Updated after creation");
expect(creations).toBe(1);
} finally {
peer.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
},
60000,
);
test("legacy keyed creation preserves checkout error codes", async () => {
const directory = await mkdtemp(join(tmpdir(), "creation-checkout-error-"));
execFileSync("git", ["init", "-b", "main", directory], { stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"--allow-empty",
"-m",
"initial",
],
{ cwd: directory, stdio: "pipe" },
);
const daemon = await createTestPaseoDaemon({ agentClients: createTestAgentClients() });
const peer = await connectCreationPeer(daemon.port);
try {
const result = await peer.request({
type: "create_agent_request",
requestId: "missing-branch",
idempotencyKey: "missing-branch",
config: { provider: "codex", cwd: directory },
worktree: { mode: "checkout-branch", branch: "does-not-exist" },
});
expect(result).toMatchObject({
type: "status",
payload: { status: "agent_create_failed", errorCode: "unknown_branch" },
});
} finally {
peer.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60000);
@@ -0,0 +1,324 @@
import pino from "pino";
import { createHash } from "node:crypto";
import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import type {
AgentSnapshotPayload,
CreationSnapshot,
WorkspaceDescriptorPayload,
} from "@getpaseo/protocol/messages";
import { CreationService, type CreationInput } from "./index.js";
const silentLogger = pino({ level: "silent" });
const directories: string[] = [];
afterEach(async () => {
await Promise.all(
directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
const workspace: WorkspaceDescriptorPayload = {
id: "wks_0123456789abcdef",
projectId: "project",
projectDisplayName: "Project",
projectRootPath: "/project",
workspaceDirectory: "/project/worktree",
projectKind: "git",
workspaceKind: "worktree",
name: "Worktree",
status: "running",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
scripts: [],
};
const agent: AgentSnapshotPayload = {
id: "00000000-0000-4000-8000-000000000001",
workspaceId: workspace.id,
provider: "codex",
cwd: "/project/worktree",
model: null,
createdAt: "2026-09-11T00:00:00Z",
updatedAt: "2026-09-11T00:00:00Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Start once",
labels: {},
};
async function fixture() {
const directory = await mkdtemp(join(tmpdir(), "creation-lifecycle-"));
directories.push(directory);
const service = new CreationService(directory, silentLogger);
const provider = deferred();
const ready = deferred();
const calls: string[] = [];
const input: CreationInput = {
kind: "workspace",
key: "submit-one",
request: { source: "/project", prompt: "Start once" },
workspaceId: workspace.id,
agentId: agent.id,
hasAgent: true,
hasPrompt: true,
exists: async () => false,
provision: async () => {
calls.push("workspace");
return { workspace };
},
createAgent: async (_id, _workspace, onReady) => {
await provider.promise;
calls.push("agent");
await onReady(agent);
calls.push("prompt");
return agent;
},
};
const updates: CreationSnapshot[] = [];
const observe = (snapshot: CreationSnapshot) => {
updates.push(snapshot);
if (snapshot.phase === "workspace_ready") ready.resolve();
};
return { directory, service, input, provider, ready, calls, updates, observe };
}
test("workspace readiness is observable before provider startup and duplicate requests join the same work", async () => {
const f = await fixture();
const first = f.service.create(f.input, f.observe);
await f.ready.promise;
expect(f.calls).toEqual(["workspace"]);
expect(f.updates.map((s) => s.phase)).toEqual(["accepted", "workspace_ready"]);
expect(f.updates[0]).toMatchObject({ workspaceId: workspace.id, agentId: agent.id });
const duplicate = f.service.create(f.input);
f.provider.resolve();
expect(await first).toMatchObject({ phase: "completed", error: null, workspace, agent });
expect(await duplicate).toEqual(await first);
expect(f.calls).toEqual(["workspace", "agent", "prompt"]);
expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(await first);
expect(f.calls).toEqual(["workspace", "agent", "prompt"]);
});
test("a failed agent startup retries only that stage with the reserved IDs", async () => {
const f = await fixture();
let attempts = 0;
f.input.createAgent = async (id, readyWorkspace, onReady) => {
attempts++;
expect(id).toBe(agent.id);
expect(readyWorkspace).toEqual(workspace);
if (attempts === 1) throw new Error("provider unavailable");
await onReady(agent);
return agent;
};
expect(await f.service.create(f.input)).toMatchObject({
phase: "failed",
failedStage: "agent",
outcomeUnknown: false,
workspace,
});
expect(await new CreationService(f.directory, silentLogger).create(f.input)).toMatchObject({
phase: "completed",
agent,
});
expect(f.calls).toEqual(["workspace"]);
expect(attempts).toBe(2);
});
test("an uncertain prompt outcome never resends or removes its workspace and agent", async () => {
const f = await fixture();
let prompts = 0;
f.input.createAgent = async (_id, _workspace, onReady) => {
await onReady(agent);
prompts++;
throw new Error("connection lost after provider accepted prompt");
};
const result = await f.service.create(f.input);
expect(result).toMatchObject({
phase: "failed",
failedStage: "prompt",
outcomeUnknown: true,
workspace,
agent,
});
expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result);
expect(prompts).toBe(1);
});
test("conflicting intents and resource IDs cannot start more work", async () => {
const f = await fixture();
const first = f.service.create(f.input, f.observe);
await f.ready.promise;
await expect(f.service.create({ ...f.input, request: { prompt: "changed" } })).rejects.toThrow(
"workspace_request_key_conflict",
);
await expect(f.service.create({ ...f.input, key: "another-intent" })).rejects.toThrow(
"workspace_id_conflict",
);
f.provider.resolve();
expect(await first).toMatchObject({ phase: "completed", error: null });
expect(f.calls).toEqual(["workspace", "agent", "prompt"]);
});
test("late subscribers recover readiness and removing an observer cannot stop creation", async () => {
const f = await fixture();
const first = f.service.create(f.input, f.observe);
await f.ready.promise;
const updates: CreationSnapshot[] = [];
const subscription = await f.service.subscribe("workspace", f.input.key, (snapshot) =>
updates.push(snapshot),
);
expect(subscription.snapshot).toMatchObject({ phase: "workspace_ready", workspace });
subscription.unsubscribe();
f.provider.resolve();
await first;
expect(updates).toEqual([]);
const completed = await new CreationService(f.directory, silentLogger).subscribe(
"workspace",
f.input.key,
() => {},
);
expect(completed.snapshot).toMatchObject({ phase: "completed", agent, workspace });
completed.unsubscribe();
});
test("omitted resource IDs are generated once and preserved on replay", async () => {
const f = await fixture();
f.input.workspaceId = undefined;
f.input.agentId = undefined;
f.input.provision = async (id) => ({ workspace: { ...workspace, id } });
f.input.createAgent = async (id, createdWorkspace, onReady) => {
const created = { ...agent, id, workspaceId: createdWorkspace!.id };
await onReady(created);
return created;
};
const result = await f.service.create(f.input);
expect(result.workspaceId).toMatch(/^wks_[a-f0-9]{16}$/);
expect(result.agentId).toMatch(
/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/,
);
expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result);
});
test("restart after a committed agent without a prompt completes without recreating the agent", async () => {
const f = await fixture();
const recoveredDirectory = await mkdtemp(join(tmpdir(), "creation-checkpoint-"));
directories.push(recoveredDirectory);
f.input.hasPrompt = false;
let starts = 0;
f.input.createAgent = async (_id, _workspace, onReady) => {
starts++;
await onReady(agent);
// Capture the durable state at the exact milestone a process could stop after.
await cp(f.directory, recoveredDirectory, { recursive: true });
return agent;
};
await f.service.create(f.input);
const recovered = await new CreationService(recoveredDirectory, silentLogger).create(f.input);
expect(recovered).toMatchObject({ phase: "completed", workspace, agent });
expect(starts).toBe(1);
});
test("partial provisioning without a registered workspace is not repeated", async () => {
const f = await fixture();
let provisions = 0;
f.input.provision = async () => {
provisions++;
await writeFile(join(f.directory, "partial-checkout"), "disk was changed");
throw new Error("Git failed after creating the checkout");
};
const result = await f.service.create(f.input);
expect(result).toMatchObject({ phase: "failed", failedStage: "workspace", outcomeUnknown: true });
expect(await new CreationService(f.directory, silentLogger).create(f.input)).toEqual(result);
expect(provisions).toBe(1);
});
test.each(["pending", "completed"])(
"imports a %s legacy agent receipt without creating another agent",
async (state) => {
const f = await fixture();
const legacyDirectory = join(f.directory, "legacy");
await mkdir(legacyDirectory);
const request = { config: { cwd: "/project", provider: "codex" } };
const hash = (value: unknown) =>
createHash("sha256").update(JSON.stringify(value)).digest("hex");
await writeFile(
join(legacyDirectory, `${hash(["create", "old-key"])}.json`),
JSON.stringify({
fingerprint: hash({ ...request, type: "create_agent_request" }),
state,
agentId: agent.id,
}),
);
const input: CreationInput = {
kind: "agent",
key: "old-key",
request,
hasAgent: true,
hasPrompt: false,
exists: async () => true,
readAgent: async () => agent,
createAgent: async () => {
throw new Error("Must not create again");
},
};
const service = new CreationService(f.directory, silentLogger, undefined, legacyDirectory);
await expect(
service.create({ ...input, request: { config: { ...request.config, cwd: "/changed" } } }),
).rejects.toThrow("agent_request_key_conflict");
expect(await service.create(input)).toMatchObject({ phase: "completed", agent });
expect(await new CreationService(f.directory, silentLogger).create(input)).toMatchObject({
phase: "completed",
agent,
});
},
);
test("observer failures are logged without failing creation", async () => {
const f = await fixture();
const logs: unknown[] = [];
const logger = pino(
{ level: "warn" },
{
write: (line) => {
logs.push(JSON.parse(line));
},
},
);
const service = new CreationService(f.directory, logger);
f.provider.resolve();
const result = await service.create(f.input, () => {
throw new Error("delivery failed");
});
expect(result.phase).toBe("completed");
expect(f.calls).toEqual(["workspace", "agent", "prompt"]);
expect(logs).toContainEqual(
expect.objectContaining({
level: 40,
kind: "workspace",
idempotencyKey: f.input.key,
phase: "workspace_ready",
err: expect.objectContaining({ message: "delivery failed" }),
}),
);
});
@@ -0,0 +1,373 @@
import type { Logger } from "pino";
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
import {
CreationSnapshotSchema,
type AgentSnapshotPayload,
type CreationSnapshot,
type WorkspaceDescriptorPayload,
} from "@getpaseo/protocol/messages";
import { writeFileAtomic } from "../atomic-file.js";
import { generateWorkspaceId } from "../workspace-registry-model.js";
type Observer = (snapshot: CreationSnapshot) => void;
interface CreationRequest {
key: string;
request: { [key: string]: unknown };
workspaceId?: string;
agentId?: string;
hasAgent: boolean;
hasPrompt: boolean;
exists: (kind: "workspace" | "agent", id: string) => Promise<boolean>;
provision?: (
workspaceId: string,
) => Promise<{ workspace: WorkspaceDescriptorPayload; setupSkippedReason?: string }>;
createAgent?: (
agentId: string,
workspace: WorkspaceDescriptorPayload | undefined,
onReady: (agent: AgentSnapshotPayload) => Promise<void>,
) => Promise<AgentSnapshotPayload>;
}
export type CreationInput = CreationRequest &
(
| { kind: "workspace" }
| { kind: "agent"; readAgent: (id: string) => Promise<AgentSnapshotPayload | null> }
);
const RecordSchema = z.object({
fingerprint: z.string(),
snapshot: CreationSnapshotSchema,
inFlight: z.enum(["workspace", "agent", "prompt"]).nullable(),
});
type Record = z.infer<typeof RecordSchema>;
/** Owns creation across socket lifetimes. Resource owners perform work; this module commits its milestones. */
export class CreationService {
private admission: Promise<unknown> = Promise.resolve();
private readonly active = new Map<string, Promise<CreationSnapshot>>();
private readonly receiptOperations = new Map<string, Promise<void>>();
private readonly observers = new Map<string, Set<Observer>>();
constructor(
private readonly directory: string,
private readonly logger: Logger,
private readonly validateCompleted: (
snapshot: CreationSnapshot,
) => Promise<void> = async () => {},
private readonly legacyDirectory?: string,
) {}
async create(input: CreationInput, observer?: Observer): Promise<CreationSnapshot> {
const identity = identityFor(input.kind, input.key);
const admitted = this.admission.then(async () => {
await mkdir(this.directory, { recursive: true });
const fingerprint = digest(input.request);
let record = await this.read(identity);
if (!record) record = await this.readLegacyAgent(input, fingerprint);
if (record && record.fingerprint !== fingerprint)
throw new Error(`${input.kind}_request_key_conflict`);
const running = this.active.get(identity);
if (running && record) return { completion: running, snapshot: record.snapshot };
if (record?.snapshot.phase === "completed") await this.validateCompleted(record.snapshot);
if (record?.snapshot.phase === "completed" || record?.snapshot.outcomeUnknown) {
return { completion: Promise.resolve(record.snapshot), snapshot: record.snapshot };
}
if (record?.inFlight) {
await this.publish(identity, record, {
phase: "failed",
error: `${input.kind}_request_outcome_unknown`,
failedStage: record.inFlight,
outcomeUnknown: true,
});
return { completion: Promise.resolve(record.snapshot), snapshot: record.snapshot };
}
if (!record) {
record = initialRecord(input, fingerprint);
// Persist identity before claiming resources. A failed admission can only retry these IDs.
await this.write(identity, record);
}
await this.claimResources(identity, record.snapshot, input);
if (record.snapshot.phase === "failed") {
await this.publish(identity, record, {
phase: record.snapshot.workspace ? "workspace_ready" : "accepted",
error: null,
errorCode: undefined,
failedStage: undefined,
outcomeUnknown: undefined,
});
}
const snapshot = record.snapshot;
// Defer the runner until admission has installed the initiating observer.
const completion = Promise.resolve().then(() => this.run(identity, record, input));
this.active.set(identity, completion);
void completion.finally(() => this.active.delete(identity)).catch(() => undefined);
return { completion, snapshot };
});
this.admission = admitted.then(
() => undefined,
() => undefined,
);
// Install before admission starts, so even a fast local provision cannot outrun observation.
const unsubscribe = observer ? this.observe(identity, observer) : () => {};
try {
const { completion, snapshot } = await admitted;
if (observer) this.notify(observer, snapshot);
return await completion;
} finally {
unsubscribe();
}
}
async subscribe(
kind: CreationSnapshot["kind"],
key: string,
observer: Observer,
): Promise<{ snapshot: CreationSnapshot | null; unsubscribe: () => void }> {
const identity = identityFor(kind, key);
const unsubscribe = this.observe(identity, observer);
try {
await this.admission;
const record = await this.read(identity);
if (record?.inFlight && !this.active.has(identity)) {
await this.publish(identity, record, {
phase: "failed",
failedStage: record.inFlight,
outcomeUnknown: true,
error: `${kind}_request_outcome_unknown`,
});
}
if (record?.snapshot.phase === "completed") await this.validateCompleted(record.snapshot);
return { snapshot: record?.snapshot ?? null, unsubscribe };
} catch (error) {
unsubscribe();
throw error;
}
}
private async run(
identity: string,
record: Record,
input: CreationInput,
): Promise<CreationSnapshot> {
try {
if (input.provision && !record.snapshot.workspace) {
record.inFlight = "workspace";
await this.write(identity, record);
const { workspace, setupSkippedReason } = await input.provision(
record.snapshot.workspaceId!,
);
record.inFlight = null;
await this.publish(identity, record, {
phase: "workspace_ready",
workspace,
workspaceId: workspace.id,
setupSkippedReason,
});
}
if (input.hasAgent && input.createAgent && !record.snapshot.agent) {
record.inFlight = "agent";
await this.write(identity, record);
const agent = await input.createAgent(
record.snapshot.agentId!,
record.snapshot.workspace,
async (readyAgent) => {
record.inFlight = input.hasPrompt ? "prompt" : null;
await this.publish(identity, record, {
phase: "agent_ready",
agent: readyAgent,
workspaceId: readyAgent.workspaceId ?? record.snapshot.workspaceId,
});
},
);
record.inFlight = null;
if (input.hasPrompt)
await this.publish(identity, record, { phase: "prompt_started", agent });
else record.snapshot = { ...record.snapshot, agent };
}
record.inFlight = null;
await this.publish(identity, record, { phase: "completed" });
} catch (error) {
const stage = record.inFlight ?? "agent";
const resourceId =
stage === "workspace" ? record.snapshot.workspaceId : record.snapshot.agentId;
const code =
error instanceof Error && "code" in error && typeof error.code === "string"
? error.code
: undefined;
const rejectedBeforeProvision = code === "directory_not_found" || code === "source_required";
const unknown =
stage === "prompt" ||
(stage === "workspace" && !rejectedBeforeProvision) ||
(resourceId !== null && (await input.exists(stage, resourceId)));
// A failed provider startup with no registered resource can retry under the same ID.
// A partially provisioned resource or an attempted prompt needs reconciliation.
if (!unknown) record.inFlight = null;
await this.publish(identity, record, {
phase: "failed",
error: error instanceof Error ? error.message : String(error),
errorCode: code,
failedStage: stage,
outcomeUnknown: unknown,
});
}
return record.snapshot;
}
// COMPAT(agentRequestReceipts): added in v0.8.0, remove after 2027-03-11 once old creation receipts can expire.
// Import the previous receipt format; it is never a second execution path.
private async readLegacyAgent(input: CreationInput, fingerprint: string): Promise<Record | null> {
if (input.kind !== "agent" || !this.legacyDirectory) return null;
const text = await readOptional(
join(this.legacyDirectory, `${digest(["create", input.key])}.json`),
);
if (text === null) return null;
const receipt = z
.object({
fingerprint: z.string(),
state: z.enum(["pending", "completed"]),
agentId: z.string(),
})
.parse(JSON.parse(text));
if (receipt.fingerprint !== digest({ type: "create_agent_request", ...input.request }))
throw new Error("agent_request_key_conflict");
const agent = await input.readAgent(receipt.agentId);
if (!agent && receipt.state === "completed")
throw new Error("Previously created agent no longer exists");
const record = initialRecord({ ...input, agentId: receipt.agentId }, fingerprint);
record.snapshot = {
...record.snapshot,
phase: agent ? "completed" : "failed",
agent: agent ?? undefined,
workspaceId: agent?.workspaceId ?? record.snapshot.workspaceId,
error: agent ? null : "agent_request_outcome_unknown",
...(agent ? {} : { failedStage: "agent", outcomeUnknown: true }),
};
await this.write(identityFor(input.kind, input.key), record);
return record;
}
private async claimResources(
identity: string,
snapshot: CreationSnapshot,
input: CreationInput,
): Promise<void> {
if (input.kind === "workspace" && snapshot.workspaceId)
await this.claim(identity, "workspace", snapshot.workspaceId, input.exists);
if (snapshot.agentId) await this.claim(identity, "agent", snapshot.agentId, input.exists);
}
private async claim(
identity: string,
kind: "workspace" | "agent",
id: string,
exists: CreationInput["exists"],
): Promise<void> {
const file = join(this.directory, `${digest([kind, id])}.claim`);
const owner = await readOptional(file);
if (owner !== null) {
if (owner !== identity) throw new Error(`${kind}_id_conflict`);
return;
}
if (await exists(kind, id)) throw new Error(`${kind}_id_conflict`);
await writeFile(file, identity, { flag: "wx", mode: 0o600 });
}
private observe(identity: string, observer: Observer): () => void {
const observers = this.observers.get(identity) ?? new Set<Observer>();
observers.add(observer);
this.observers.set(identity, observers);
return () => {
observers.delete(observer);
if (!observers.size) this.observers.delete(identity);
};
}
private notify(observer: Observer, snapshot: CreationSnapshot): void {
try {
observer(snapshot);
} catch (err) {
this.logger.warn(
{
err,
kind: snapshot.kind,
idempotencyKey: snapshot.idempotencyKey,
phase: snapshot.phase,
},
"Creation observer failed",
);
}
}
private async publish(
identity: string,
record: Record,
update: Partial<CreationSnapshot>,
): Promise<void> {
record.snapshot = { ...record.snapshot, ...update, revision: record.snapshot.revision + 1 };
await this.write(identity, record);
for (const observer of this.observers.get(identity) ?? [])
this.notify(observer, record.snapshot);
}
private write(identity: string, record: Record): Promise<void> {
// Serialize now: the runner mutates the record after queueing this write.
const contents = JSON.stringify(record, null, 2);
return this.accessReceipt(identity, () =>
writeFileAtomic(join(this.directory, `${identity}.json`), contents),
);
}
private read(identity: string): Promise<Record | null> {
return this.accessReceipt(identity, async () => {
const text = await readOptional(join(this.directory, `${identity}.json`));
return text === null ? null : RecordSchema.parse(JSON.parse(text));
});
}
private accessReceipt<T>(identity: string, access: () => Promise<T>): Promise<T> {
// Admission and the runner progress independently. Keep this owner from
// holding a receipt read open while atomically replacing the same file.
const previous = this.receiptOperations.get(identity) ?? Promise.resolve();
const result = previous.then(access);
const settled = result.then(
() => undefined,
() => undefined,
);
this.receiptOperations.set(identity, settled);
void settled.finally(() => {
if (this.receiptOperations.get(identity) === settled) this.receiptOperations.delete(identity);
});
return result;
}
}
function initialRecord(input: CreationInput, fingerprint: string): Record {
return {
fingerprint,
inFlight: null,
snapshot: {
kind: input.kind,
idempotencyKey: input.key,
revision: 0,
phase: "accepted",
error: null,
workspaceId: input.workspaceId ?? (input.kind === "workspace" ? generateWorkspaceId() : null),
agentId: input.hasAgent ? (input.agentId ?? randomUUID()) : null,
},
};
}
function identityFor(kind: string, key: string): string {
return digest([kind, key]);
}
function digest(input: unknown): string {
return createHash("sha256")
.update(
JSON.stringify(input, (_key, value: unknown) => {
if (value && typeof value === "object" && !Array.isArray(value))
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
return value;
}),
)
.digest("hex");
}
async function readOptional(file: string): Promise<string | null> {
try {
return await readFile(file, "utf8");
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
throw error;
}
}
@@ -121,7 +121,7 @@ test("createAgent without an initial prompt returns an idle snapshot", async ()
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "create-no-prompt" } });
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
provider: "codex",
@@ -185,7 +185,7 @@ test("createAgent with background initialPrompt returns a running snapshot befor
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "create-background-prompt" } });
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
provider: "codex",
@@ -415,7 +415,7 @@ test("createAgent fails when the initial turn cannot start", async () => {
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "create-start-failure" } });
await client.fetchAgents({ subscribe: {} });
await expect(
client.createAgent({
@@ -1325,7 +1325,7 @@ test("creates agent and exercises lifecycle", async () => {
const cwd = tmpCwd();
await ctx.client.fetchAgents({
subscribe: { subscriptionId: "daemon-client-lifecycle" },
subscribe: {},
});
const agentUpdatePromise = waitForSignal(15000, (resolve) => {
@@ -41,7 +41,7 @@ async function expectContextAfterWorkspaceRestore(legacyNativeArchive: boolean):
});
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "archive-qa" } });
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
config: {
provider: "codex",
@@ -130,7 +130,7 @@ describe("cancelAgent", () => {
const cwd = tmpCwd();
await ctx.client.fetchAgents({
subscribe: { subscriptionId: "agent-operations-cancel" },
subscribe: {},
});
// Create Codex agent
@@ -203,7 +203,7 @@ describe("setAgentMode", () => {
const cwd = tmpCwd();
await ctx.client.fetchAgents({
subscribe: { subscriptionId: "agent-operations-mode" },
subscribe: {},
});
// Create a Codex agent with default mode ("auto")
@@ -113,7 +113,7 @@ describe("daemon E2E - refresh rehydrates timeline from on-disk session", () =>
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "refresh-rehydrate-test" },
subscribe: {},
});
const imported = await client.importAgent({ provider: "claude", sessionId, cwd });
@@ -61,7 +61,7 @@ async function withNativeConversation(
});
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" });
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "native-reload-qa" } });
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({ provider, cwd, ...config });
process.stdout.write(`${provider}: model=${agent.model ?? "default"}\n`);
const manager = daemon.daemon.agentManager;
@@ -126,7 +126,7 @@ githubTest(
throw new Error("Failed to register both worktrees");
}
await context.client.fetchWorkspaces({
subscribe: { subscriptionId: "real-unrelated-merge" },
subscribe: {},
});
writeFileSync(path.join(merged.worktreePath, "feature.txt"), "merged\n");
@@ -77,12 +77,9 @@ test("pushes file-level checkout diff updates with deterministic path order", as
initGitRepo(cwd);
commitFile(cwd, "base.txt", "base\n");
const subscriptionId = "checkout-diff-e2e-subscription";
const initial = await ctx.client.subscribeCheckoutDiff(
cwd,
{ mode: "uncommitted" },
{ subscriptionId },
);
const subscription = ctx.client.observeCheckoutDiff(cwd, { mode: "uncommitted" });
const initial = await subscription.ready;
const subscriptionId = initial.subscriptionId;
expect(initial.error).toBeNull();
expect(initial.files).toEqual([]);
@@ -98,7 +95,7 @@ test("pushes file-level checkout diff updates with deterministic path order", as
expect(update.error).toBeNull();
expect(update.files.map((file) => file.path)).toEqual(["alpha.txt", "zeta.txt"]);
ctx.client.unsubscribeCheckoutDiff(subscriptionId);
await subscription.release();
} finally {
rmSync(cwd, { recursive: true, force: true });
}
@@ -114,12 +111,9 @@ test("pushes updates when subscribed from a subdirectory and files change outsid
const nestedDir = path.join(cwd, "nested", "dir");
mkdirSync(nestedDir, { recursive: true });
const subscriptionId = "checkout-diff-subdir-e2e-subscription";
const initial = await ctx.client.subscribeCheckoutDiff(
nestedDir,
{ mode: "uncommitted" },
{ subscriptionId },
);
const subscription = ctx.client.observeCheckoutDiff(nestedDir, { mode: "uncommitted" });
const initial = await subscription.ready;
const subscriptionId = initial.subscriptionId;
expect(initial.error).toBeNull();
expect(initial.files).toEqual([]);
@@ -133,7 +127,7 @@ test("pushes updates when subscribed from a subdirectory and files change outsid
expect(update.error).toBeNull();
expect(update.files.some((file) => file.path === "outside-subdir.txt")).toBe(true);
ctx.client.unsubscribeCheckoutDiff(subscriptionId);
await subscription.release();
} finally {
rmSync(cwd, { recursive: true, force: true });
}
@@ -146,15 +140,14 @@ test("keeps the socket usable after rejecting an oversized structured diff", asy
initGitRepo(cwd);
commitFile(cwd, "large-a.js", "const value = 0;\n");
commitFile(cwd, "large-b.js", "const value = 0;\n");
const denseExpression = `const value = ${"a+".repeat(450_000)}a;\n`;
// Short lines stay eligible for highlighting; a giant single line is skipped
// before token expansion and cannot exercise the structured-payload limit.
const denseExpression = `const value = ${"a+".repeat(100)}a;\n`.repeat(4_000);
writeFileSync(path.join(cwd, "large-a.js"), denseExpression);
writeFileSync(path.join(cwd, "large-b.js"), denseExpression);
const initial = await ctx.client.subscribeCheckoutDiff(
cwd,
{ mode: "uncommitted" },
{ subscriptionId: "oversized-checkout-diff" },
);
const subscription = ctx.client.observeCheckoutDiff(cwd, { mode: "uncommitted" });
const initial = await subscription.ready;
expect(initial).toMatchObject({
cwd,
@@ -165,6 +158,7 @@ test("keeps the socket usable after rejecting an oversized structured diff", asy
const status = await ctx.client.getCheckoutStatus(cwd);
expect(status).toMatchObject({ cwd, isGit: true });
await subscription.release();
} finally {
rmSync(cwd, { recursive: true, force: true });
}
@@ -45,7 +45,7 @@ describe("daemon E2E (real claude) - autonomous wake simple", () => {
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-autonomous-simple-real" },
subscribe: {},
});
const agent = await client.createAgent({
@@ -451,7 +451,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-autonomous-abc-a" },
subscribe: {},
});
const agent = await client.createAgent({
@@ -524,7 +524,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-autonomous-abc-b" },
subscribe: {},
});
const agent = await client.createAgent({
@@ -597,7 +597,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-autonomous-abc-c" },
subscribe: {},
});
const agent = await client.createAgent({
@@ -663,7 +663,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-autonomous-wake-real" },
subscribe: {},
});
const agent = await client.createAgent({
@@ -721,7 +721,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-autonomous-followup-real" },
subscribe: {},
});
const agent = await client.createAgent({
@@ -767,7 +767,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-hang-repro-real" },
subscribe: {},
});
const agent = await client.createAgent({
@@ -869,7 +869,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-background-repro-real" },
subscribe: {},
});
const agent = await client.createAgent({
@@ -941,7 +941,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-autonomous-race-stress-real" },
subscribe: {},
});
const agent = await client.createAgent({
@@ -1027,7 +1027,7 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-transcript-parity-race" },
subscribe: {},
});
const agent = await client.createAgent({
@@ -150,7 +150,7 @@ describe("daemon E2E (claude live usage)", () => {
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "claude-live-usage" } });
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
provider: "claude",
@@ -234,7 +234,7 @@ describe("daemon E2E (real claude) - rewind", () => {
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "claude-rewind-real" } });
await client.fetchAgents({ subscribe: {} });
harness = { client, daemon };
}, 30_000);
@@ -72,7 +72,7 @@ describe("daemon E2E (real claude) - thinking effort memory", () => {
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-thinking-memory-real" },
subscribe: {},
});
const modelList = await client.listProviderModels("claude", { cwd });
@@ -288,7 +288,7 @@ async function runScenario(modeId: "auto" | "auto-review") {
try {
writeMockCodexConfig(codexHome, mockServer.url);
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: `codex-auto-review-${modeId}` } });
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
provider: "codex",
@@ -116,7 +116,7 @@ describe("daemon E2E (real codex) - /goal command", () => {
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "primary" } });
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
cwd,
@@ -170,7 +170,7 @@ describe("daemon E2E (real codex) - /goal command", () => {
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "primary" } });
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
cwd,
@@ -247,7 +247,7 @@ describe("daemon E2E (real codex) - /goal command", () => {
try {
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "primary" } });
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
cwd,
@@ -164,7 +164,7 @@ describe("daemon E2E (real codex) - rewind", () => {
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "codex-rewind-real" } });
await client.fetchAgents({ subscribe: {} });
harness = { client, daemon };
}, 30_000);
@@ -108,7 +108,7 @@ test("archiving the last workspace leaves the project parent with no workspaces"
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
cleanupClients.add(client);
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "empty-project-agents" } });
await client.fetchAgents({ subscribe: {} });
const created = await client.createWorkspace({ source: { kind: "directory", path: repoRoot } });
expect(created.error).toBeNull();
@@ -96,7 +96,7 @@ beforeEach(async () => {
unsubscribe = ctx.client.subscribeRawMessages((message) => {
messages.push(message);
});
await ctx.client.fetchAgents({ subscribe: { subscriptionId: "live-preferences" } });
await ctx.client.fetchAgents({ subscribe: {} });
});
afterEach(async () => {
@@ -115,7 +115,7 @@ describe("mode-switch update propagation", () => {
await client2.connect();
const bootstrapResult = await client2.fetchAgents({
subscribe: { subscriptionId: "bootstrap-test" },
subscribe: {},
});
const found = bootstrapResult.entries.find((e) => e.agent.id === agent.id);
@@ -41,7 +41,7 @@ describe("daemon E2E (real claude) - model resolution on init", () => {
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "model-init-test" },
subscribe: {},
});
const modelsResult = await client.listProviderModels("claude", { cwd });
@@ -37,7 +37,7 @@ describe("daemon E2E (real claude) - runtime model reconciliation", () => {
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: "claude-runtime-model-reconcile" },
subscribe: {},
});
const modelsResult = await client.listProviderModels("claude", { cwd });
@@ -1,5 +1,4 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -69,7 +68,7 @@ async function createHarness(): Promise<Harness> {
});
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: `omp-real-${randomUUID()}` },
subscribe: {},
});
return { daemon, client, cwd, paseoHomeRoot, staticDir };
}
@@ -48,7 +48,7 @@ test("openProject on a nonexistent directory does not broadcast an upsert that i
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
cleanupClients.add(client);
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "missing-dir-agents" } });
await client.fetchAgents({ subscribe: {} });
const workspaceEvents: Array<{ kind: "upsert" | "remove"; workspaceId: string }> = [];
client.subscribe((event: DaemonEvent) => {
@@ -57,7 +57,7 @@ test("openProject on a nonexistent directory does not broadcast an upsert that i
}
workspaceEvents.push({ kind: event.payload.kind, workspaceId: event.workspaceId });
});
await client.fetchWorkspaces({ subscribe: { subscriptionId: "missing-dir-workspaces" } });
await client.fetchWorkspaces({ subscribe: {} });
const tempParent = await mkdtemp(path.join(os.tmpdir(), "paseo-open-project-"));
cleanupPaths.add(tempParent);
@@ -77,8 +77,8 @@ test("openProject expands tilde before creating the workspace", async () => {
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
cleanupClients.add(client);
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "tilde-project-agents" } });
await client.fetchWorkspaces({ subscribe: { subscriptionId: "tilde-project-workspaces" } });
await client.fetchAgents({ subscribe: {} });
await client.fetchWorkspaces({ subscribe: {} });
const home = process.env.HOME || os.homedir();
const workspacePath = await mkdtemp(path.join(home, ".paseo-open-project-"));
@@ -106,7 +106,7 @@ test("openProject preserves a worktree's exact-root project without rehoming it"
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
cleanupClients.add(client);
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "worktree-reclassification" } });
await client.fetchAgents({ subscribe: {} });
const response = await client.openProject(worktreeRoot);
const persistedProjects = await readRegistry<PersistedProjectRecord>(projectsPath);
@@ -23,7 +23,7 @@ async function createHarness(): Promise<{
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-custom-agents" } });
await client.fetchAgents({ subscribe: {} });
return { client, daemon };
}
@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -30,7 +29,7 @@ async function withConnectedOpenCodeDaemon(
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: `opencode-draft-features-${randomUUID()}` },
subscribe: {},
});
await run({ client });
} finally {
@@ -36,7 +36,7 @@ async function withConnectedOpenCodeDaemon(
try {
await client.connect();
await client.fetchAgents({
subscribe: { subscriptionId: `opencode-import-persistence-${randomUUID()}` },
subscribe: {},
});
await run({ client });
} finally {
@@ -29,7 +29,7 @@ async function createHarness(): Promise<{
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-init-prompt" } });
await client.fetchAgents({ subscribe: {} });
return { client, daemon };
}
@@ -23,7 +23,7 @@ async function createHarness(): Promise<{
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-invalid-mode" } });
await client.fetchAgents({ subscribe: {} });
return { client, daemon };
}
@@ -23,7 +23,7 @@ async function createHarness(): Promise<{
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-invalid-model" } });
await client.fetchAgents({ subscribe: {} });
return { client, daemon };
}
@@ -187,7 +187,7 @@ export async function createOpenCodeOmoRealRuntime(): Promise<OpenCodeOmoRealRun
});
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-omo-real" } });
await client.fetchAgents({ subscribe: {} });
return {
client,
@@ -23,7 +23,7 @@ async function createHarness(): Promise<{
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-real" } });
await client.fetchAgents({ subscribe: {} });
return { client, daemon };
}
@@ -234,7 +234,7 @@ describe("daemon E2E (real opencode) - rewind", () => {
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-rewind-real" } });
await client.fetchAgents({ subscribe: {} });
harness = { client, daemon };
}, 30_000);

Some files were not shown because too many files have changed in this diff Show More