mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
chore(chat): merge main while preserving stacked find features
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
+82
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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 });
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 });
|
||||
|
||||
+1
-1
@@ -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-"));
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user