Merge main into fix/usage-screen-provider-logins

This commit is contained in:
qichangzheng
2026-10-30 19:12:15 +08:00
1905 changed files with 138089 additions and 35552 deletions
@@ -1,17 +1,10 @@
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import { delimiter, resolve } from "node:path";
import type { ProcessEnvRecord } from "../server/paseo-env.js";
import { execCommand } from "../utils/spawn.js";
import which from "which";
import { windowsExecutableResolution } from "./windows.js";
type Which = (
command: string,
options: { all: true; path?: string; pathExt?: string },
) => Promise<string[]>;
const require = createRequire(import.meta.url);
const which = require("which") as Which;
const PROBE_TIMEOUT_MS = 2000;
function hasPathSeparator(value: string): boolean {
+10
View File
@@ -0,0 +1,10 @@
// `which` ships no types. Imported statically (not through createRequire) so the terminal
// worker bundle includes it: the worker runs from a copy that has only node-pty beside it.
// Listed in the tsconfig includes, since nothing imports a declaration file.
declare module "which" {
function which(
command: string,
options: { all: true; path?: string; pathExt?: string },
): Promise<string[]>;
export default which;
}
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { buildAgentForkContextAttachment, curateAgentActivity } from "./activity-curator.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
import { formatAgentMessage, projectAgentMessage } from "./agent-messages/index.js";
import { formatAgentMessage, projectAgentMessages } from "./agent-messages/index.js";
function toolCallItem(params: {
callId: string;
@@ -81,10 +81,10 @@ describe("curateAgentActivity", () => {
it("renders delivered agent messages with their event, sender, and body", () => {
const delivered = (source: Parameters<typeof formatAgentMessage>[0]["source"], text: string) =>
projectAgentMessage({
projectAgentMessages({
type: "user_message",
text: formatAgentMessage({ id: `msg-${text.length}`, source, text }),
})!;
})[0]!;
const timeline: AgentTimelineItem[] = [
delivered(
{ kind: "agent-message", agentId: "agt_lead", title: "CI lead" },
@@ -5,7 +5,7 @@ import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization";
import { buildAgentMessageDisplay } from "@getpaseo/protocol/paseo-tool-presentation/index";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import { projectTimelineRows } from "./timeline-projection.js";
import { projectTimelineRows, selectItemsByProjectedLimit } from "./timeline-projection.js";
const DEFAULT_MAX_ITEMS = 0;
const MAX_TOOL_INPUT_CHARS = 400;
@@ -210,6 +210,24 @@ export function curateAgentActivity(
: "No activity to display.";
}
export function summarizeAgentActivity(timeline: AgentTimelineItem[], limit?: number) {
const selection = selectItemsByProjectedLimit({
items: timeline,
direction: "tail",
limit: limit ?? 0,
});
const { totalProjected, shownProjected } = selection;
const noun = totalProjected === 1 ? "activity" : "activities";
const countHeader =
limit && shownProjected < totalProjected
? `Showing ${shownProjected} of ${totalProjected} ${noun} (limited to ${limit})`
: `Showing all ${totalProjected} ${noun}`;
return {
updateCount: timeline.length,
content: `${countHeader}\n\n${curateAgentActivity(selection.items)}`,
};
}
interface ForkCursorBoundary {
timelineEpoch: string;
cursor: { epoch: string; seq: number };
@@ -135,6 +135,7 @@ export async function ensureAgentLoaded(
labels: record.labels,
workspaceId: record.workspaceId,
owner: record.owner,
env: record.launchEnv,
});
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
}
@@ -1,9 +1,10 @@
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
import {
prepareAgentMessage,
formatAgentMessage,
formatSystemNotificationPrompt,
} from "./agent-messages/index.js";
import { expect, test, vi } from "vitest";
import { expect, test, vi, onTestFinished } from "vitest";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
@@ -879,6 +880,66 @@ test("retries provider history hydration after a stream failure", async () => {
}
});
test("notification batches share the run they start or join", async () => {
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
const workdir = mkdtempSync(join(tmpdir(), "agent-notification-turns-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
logger,
});
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const batch = (prefix: string) =>
["one", "two"]
.map((id) =>
formatAgentMessage({
id: `${prefix}-${id}`,
source: { kind: "agent-notification", agentId: "worker", event: "finished" },
text: "Worker finished",
}),
)
.join("\n\n");
const consume = async (prompt: string, clientMessageId: string) => {
for await (const _event of manager.streamAgent(agent.id, prompt, { clientMessageId })) {
}
};
try {
const first = consume("human", "human");
await manager.waitForAgentRunStart(agent.id);
await expect(
manager.steerAgentRun(agent.id, batch("joined"), { clientMessageId: "joined-one" }),
).resolves.toEqual({ status: "accepted" });
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-1" });
await first;
const second = consume(batch("idle"), "idle-one");
await manager.waitForAgentRunStart(agent.id);
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-2" });
await second;
expect(
manager.fetchTimeline(agent.id, { limit: 0 }).rows.map((row) => ({
turnId: row.turnId,
id: row.item.type === "tool_call" ? row.item.callId : row.item.type,
})),
).toEqual([
{ turnId: "active-turn-1", id: "user_message" },
{ turnId: "active-turn-1", id: "paseo-agent-message:joined-one" },
{ turnId: "active-turn-1", id: "paseo-agent-message:joined-two" },
{ turnId: "active-turn-2", id: "paseo-agent-message:idle-one" },
{ turnId: "active-turn-2", id: "paseo-agent-message:idle-two" },
]);
} finally {
await manager.closeAgent(agent.id);
rmSync(workdir, { recursive: true, force: true });
}
});
test("unavailable steer interrupts once and starts one replacement turn", async () => {
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
session.steerResult = "unavailable";
@@ -895,6 +956,131 @@ test("unavailable steer interrupts once and starts one replacement turn", async
}
});
test("steers a send that arrives while the previous turn is still starting", async () => {
const startHeld = deferred<void>();
class SlowStartSteeringSession extends SteeringTestSession {
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
await startHeld.promise;
return await super.startTurn(prompt);
}
}
const session = new SlowStartSteeringSession({
provider: "codex",
cwd: process.cwd(),
});
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-starting-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
const steerOptions = (clientMessageId: string) => ({
replaceRunning: true,
activeTurnBehavior: "steer" as const,
runOptions: { clientMessageId },
});
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
const second = startAgentRun(
manager,
agent.id,
"SECOND",
logger,
steerOptions("second-client"),
);
startHeld.resolve();
await expect(second).resolves.toEqual({ disposition: "steered" });
expect(session.startPrompts).toEqual(["FIRST"]);
expect(session.interruptCount).toBe(0);
expect(
manager
.fetchTimeline(agent.id, { limit: 0 })
.rows.filter((row) => row.item.type === "user_message")
.map((row) => ({
text: row.item.type === "user_message" ? row.item.text : null,
turnId: row.turnId,
})),
).toEqual([
{ text: "FIRST", turnId: "active-turn-1" },
{ text: "SECOND", turnId: "active-turn-1" },
]);
} finally {
startHeld.resolve();
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("starts its own turn when the previous turn fails to start under a steer send", async () => {
const startHeld = deferred<void>();
class FailingFirstStartSession extends SteeringTestSession {
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
if (prompt === "FIRST") {
this.startPrompts.push(prompt);
await startHeld.promise;
throw new Error("first start failed");
}
return await super.startTurn(prompt);
}
}
const session = new FailingFirstStartSession({
provider: "codex",
cwd: process.cwd(),
});
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-failed-start-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
logger,
});
let agentId: string | null = null;
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
agentId = agent.id;
const steerOptions = (clientMessageId: string) => ({
replaceRunning: true,
activeTurnBehavior: "steer" as const,
runOptions: { clientMessageId },
});
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
const second = startAgentRun(
manager,
agent.id,
"SECOND",
logger,
steerOptions("second-client"),
);
startHeld.resolve();
await expect(second).resolves.toEqual({ disposition: "turn_started" });
await vi.waitFor(() => expect(session.startPrompts).toEqual(["FIRST", "SECOND"]));
expect(session.interruptCount).toBe(0);
} finally {
startHeld.resolve();
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
rmSync(workdir, { recursive: true, force: true });
}
});
test("orders an accepted steer before output emitted while acknowledgement is pending", async () => {
const entered = deferred<void>();
const release = deferred<void>();
@@ -2868,6 +3054,92 @@ test("createAgent passes daemon launch env through the provider launch context",
});
});
test.each([
{ provider: "codex", hasHandle: true },
{ provider: "opencode", hasHandle: true },
{ provider: "codex", hasHandle: false },
{ provider: "opencode", hasHandle: false },
])(
"$provider preserves explicit launch env across reloads and storage-backed resumes (handle: $hasHandle)",
async ({ provider, hasHandle }) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-launch-env-"));
const storagePath = join(workdir, "agents");
class LaunchEnvClient extends TestAgentClient {
lastLaunchContext: AgentLaunchContext | undefined;
override async createSession(
config: AgentSessionConfig,
context?: AgentLaunchContext,
): Promise<AgentSession> {
this.lastLaunchContext = context;
const session = await super.createSession(config);
session.describePersistence = () => ({ provider, sessionId: session.id });
return session;
}
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
context?: AgentLaunchContext,
): Promise<AgentSession> {
this.lastLaunchContext = context;
const session = await super.resumeSession(handle, config, context);
session.describePersistence = () => ({ provider, sessionId: handle.sessionId });
return session;
}
}
const client = new LaunchEnvClient(provider);
let storage = new AgentStorage(storagePath, logger);
let manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
const env = { XDG_DATA_HOME: join(workdir, "agent-data"), CUSTOM_EMPTY: "" };
const expectedEnv = { ...env };
const agent = await manager.createAgent({ provider, cwd: workdir }, undefined, {
workspaceId: undefined,
env,
});
onTestFinished(async () => {
await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
});
env.XDG_DATA_HOME = "caller-mutated";
await manager.closeAgent(agent.id);
await manager.flush();
if (!hasHandle) {
const record = await storage.get(agent.id);
if (!record) throw new Error("Created agent was not persisted");
await storage.upsert({ ...record, persistence: null });
}
// Fresh storage and manager instances must read the environment from disk.
for (let restart = 0; restart < 2; restart += 1) {
storage = new AgentStorage(storagePath, logger);
manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
const resumed = await ensureAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
expect(client.lastLaunchContext).toEqual({
agentId: agent.id,
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
});
await manager.reloadAgentSession(agent.id);
expect(client.lastLaunchContext).toEqual({
agentId: agent.id,
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
});
expect(toAgentPayload(resumed)).not.toHaveProperty("launchEnv");
expect(toAgentPayload(resumed).persistence?.metadata).not.toHaveProperty("launchEnv");
await manager.closeAgent(agent.id);
await manager.flush();
}
},
);
test("createAgent passes persistSession to provider create options", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -7746,7 +8018,7 @@ test("workspace ownership does not turn an ordinary agent into a disposable help
expect(manager.listAgents().map((agent) => agent.id)).toEqual([agentId]);
expect(await storage.get(agentId)).not.toBeNull();
// Workspace ownership preserves ordinary agent lifecycle hooks.
expect(beforeHookCalls).toContain("agent.create");
expect(beforeHookCalls).toContain("agent.create.before");
});
test("global subscribers receive internal agent events only when they opt in", async () => {
@@ -7787,6 +8059,60 @@ test("global subscribers receive internal agent events only when they opt in", a
unsubscribeOptedIn();
});
test("agent removal notifications respect scoped subscriptions and internal visibility", () => {
const manager = new AgentManager({ clients: {}, logger });
const removedId = "00000000-0000-4000-8000-000000000901";
const internalId = "00000000-0000-4000-8000-000000000902";
const otherId = "00000000-0000-4000-8000-000000000903";
const publicEvents: AgentManagerEvent[] = [];
const allEvents: AgentManagerEvent[] = [];
const scopedEvents: AgentManagerEvent[] = [];
const internalEvents: AgentManagerEvent[] = [];
const unrelatedEvents: AgentManagerEvent[] = [];
const releases = [
manager.subscribe((event) => publicEvents.push(event), { replayState: false }),
manager.subscribe((event) => allEvents.push(event), {
replayState: false,
includeInternal: true,
}),
manager.subscribe((event) => scopedEvents.push(event), {
replayState: false,
agentId: removedId,
}),
manager.subscribe((event) => internalEvents.push(event), {
replayState: false,
agentId: internalId,
}),
manager.subscribe((event) => unrelatedEvents.push(event), {
replayState: false,
agentId: otherId,
}),
];
try {
manager.notifyAgentRemoved({ id: removedId, workspaceId: "workspace" });
manager.notifyAgentRemoved({ id: internalId, workspaceId: "workspace", internal: true });
const publicRemoval = {
type: "agent_removed",
agentId: removedId,
workspaceId: "workspace",
internal: false,
};
const internalRemoval = {
type: "agent_removed",
agentId: internalId,
workspaceId: "workspace",
internal: true,
};
expect(publicEvents).toEqual([publicRemoval]);
expect(allEvents).toEqual([publicRemoval, internalRemoval]);
expect(scopedEvents).toEqual([publicRemoval]);
expect(internalEvents).toEqual([internalRemoval]);
expect(unrelatedEvents).toEqual([]);
} finally {
for (const release of releases) release();
}
});
test("getAgent returns internal agents by ID", async () => {
const internalAgentId = "00000000-0000-4000-8000-000000000107";
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
@@ -10007,6 +10333,47 @@ test("closeAgent persists one final closed snapshot", async () => {
}
});
test("idle runtime cleanup honors a provider veto and resumes the saved conversation", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-cleanup-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let safe = false;
const client = new (class extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const session = await super.createSession(config);
session.canStopRuntime = async () => safe;
return session;
}
})();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
const collect = runtimeCleanup(manager);
try {
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const now = new Date(Date.now() + 61 * 60_000);
expect(await collect({ now })).toEqual({ collected: [], failures: [] });
safe = true;
expect(await collect({ now })).toEqual({ collected: [], failures: [] });
const later = new Date(now.getTime() + 61 * 60_000);
expect((await collect({ now: later })).collected).toEqual([
{ agentId: agent.id, provider: "codex", sessionId: agent.persistence?.sessionId },
]);
expect(manager.getAgent(agent.id)).toBeNull();
const resumed = await ensureAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
expect(resumed.id).toBe(agent.id);
expect(resumed.persistence).toEqual(agent.persistence);
await manager.runAgent(agent.id, "Continue the saved conversation");
} finally {
await manager.closeAgent(manager.listAgents()[0]?.id ?? "missing").catch(() => undefined);
await storage.flush();
rmSync(workdir, { recursive: true, force: true });
}
});
test("idle agents remain resident until an explicit lifecycle action closes them", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-residency-"));
let closeCount = 0;
@@ -10951,12 +11318,12 @@ test("tryRunOutOfBand gives providers the command another agent sent, not its en
try {
const fromAgent = prepareAgentMessage("/handled", { kind: "agent-message", agentId: "sender" });
expect(manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true);
expect(await manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true);
expect(seenPrompts).toEqual(["/handled"]);
// A human who pastes an envelope gets it quoted, and it stays a plain prompt.
const pasted = prepareAgentMessage(String(fromAgent.prompt));
expect(manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false);
expect(await manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false);
expect(seenPrompts.at(-1)).toBe(pasted.prompt);
} finally {
await manager.closeAgent(agent.id);
@@ -11299,7 +11666,7 @@ test("listImportableSessions searches every provider result before global rankin
]);
});
test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => {
test("system-injected prompts appear as trigger tools in the live timeline", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -11328,13 +11695,16 @@ test("user_message events wrapping a paseo-system envelope are not added to the
await manager.runAgent(snapshot.id, { text: "do something" });
const timeline = manager.getTimeline(snapshot.id);
expect(timeline).toContainEqual(
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
);
const userMessages = timeline.filter((item) => item.type === "user_message");
expect(userMessages).toHaveLength(1);
expect(userMessages[0].text).toBe("plain user message");
});
test("user_message events wrapping a paseo-system envelope are not restored during history replay", async () => {
test("system-injected prompts appear as trigger tools during history replay", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-history-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -11369,6 +11739,9 @@ test("user_message events wrapping a paseo-system envelope are not restored duri
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true });
const timeline = manager.getTimeline(snapshot.id);
expect(timeline).toContainEqual(
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
);
const userMessages = timeline.filter((item) => item.type === "user_message");
expect(userMessages).toHaveLength(1);
@@ -11892,3 +12265,367 @@ test.each([false, true])(
}
},
);
test.each([
{
event: {
type: "turn_completed",
provider: "codex",
turnId: "hook-turn",
} satisfies AgentStreamEvent,
outcome: { kind: "completed" },
},
{
event: {
type: "turn_failed",
provider: "codex",
turnId: "hook-turn",
error: "Failed",
code: "test",
} satisfies AgentStreamEvent,
outcome: { kind: "failed", error: { message: "Failed", code: "test" } },
},
{
event: {
type: "turn_canceled",
provider: "codex",
turnId: "hook-turn",
reason: "Stopped",
} satisfies AgentStreamEvent,
outcome: { kind: "canceled", reason: "Stopped" },
},
])(
"lifecycle hooks receive no conversation for $event.type and stream dispatch never reads timeline items",
async ({ event: terminalEvent, outcome }) => {
class CountingTimelineStore extends InMemoryAgentTimelineStore {
itemReads = 0;
override getItems(agentId: string): AgentTimelineItem[] {
this.itemReads += 1;
return super.getItems(agentId);
}
}
const workdir = mkdtempSync(join(tmpdir(), "agent-hook-payload-"));
const client = new SessionRecordingAgentClient();
const timelineStore = new CountingTimelineStore();
const events: Array<{ name: string; event: unknown }> = [];
const manager = new AgentManager({
clients: { codex: client },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
timelineStore,
pluginLifecycle: {
before: async (_name, request) => request,
emit: (name, event) => {
events.push({ name, event });
},
},
});
onTestFinished(async () => {
await manager.closeAgent(manager.listAgents()[0]!.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
});
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: "A long conversation".repeat(10_000),
});
expect(manager.getTimeline(agent.id)).toHaveLength(1);
const readsBeforeDispatch = timelineStore.itemReads;
const session = client.sessions[0]!;
session.pushEvent({ type: "turn_started", provider: "codex", turnId: "hook-turn" });
await expect
.poll(() => events.filter(({ name }) => name === "agent.turn.before").length)
.toBe(1);
expect(timelineStore.itemReads).toBe(readsBeforeDispatch);
session.pushEvent(terminalEvent);
await expect
.poll(() => events.filter(({ name }) => name === "agent.turn.after"))
.toEqual([
{
name: "agent.turn.after",
event: {
agent: {
id: agent.id,
workspaceId: null,
parentAgentId: null,
provider: "codex",
cwd: workdir,
title: null,
},
turnId: "hook-turn",
outcome,
timeline: [],
},
},
]);
expect(timelineStore.itemReads).toBe(readsBeforeDispatch);
},
);
test.each(["live", "history", "accepted"] as const)(
"batched agent entries retain their identities through %s ingestion",
async (ingestion) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-batch-provenance-"));
const entries = [
{
id: "report",
source: { kind: "agent-message" as const, agentId: "worker", serverId: "remote-host" },
text: "Result <ready>",
},
{
id: "finish",
source: {
kind: "agent-notification" as const,
agentId: "worker",
serverId: "remote-host",
event: "finished" as const,
},
text: "Worker finished",
},
];
const prompt = entries.map(formatAgentMessage).join("\n\n");
const item: AgentTimelineItem = {
type: "user_message",
text: prompt,
clientMessageId: "report",
messageId: "provider-batch",
};
const codex = fakeCodexEmitting(
ingestion === "history" ? { historyItems: [item] } : { turnItems: [item] },
);
const manager = new AgentManager({ clients: { codex }, logger });
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
if (ingestion === "history")
await manager.hydrateTimelineFromProvider(agent.id, { force: true });
else if (ingestion === "accepted") {
for await (const _event of manager.streamAgent(agent.id, prompt, {
clientMessageId: "report",
})) {
}
} else await manager.runAgent(agent.id, { text: "start" });
expect(
manager
.getTimeline(agent.id)
.filter((row) => row.type === "tool_call")
.map((row) => ({ callId: row.callId, agentMessage: row.agentMessage })),
).toEqual([
{
callId: "paseo-agent-message:report",
agentMessage: {
event: "message",
sender: { id: "worker", serverId: "remote-host" },
text: "Result <ready>",
},
},
{
callId: "paseo-agent-message:finish",
agentMessage: {
event: "finished",
sender: { id: "worker", serverId: "remote-host" },
text: "Worker finished",
},
},
]);
expect(manager.getTimeline(agent.id).filter((row) => row.type === "user_message")).toEqual(
[],
);
expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull();
} finally {
await manager.closeAgent(agent.id);
rmSync(workdir, { recursive: true, force: true });
}
},
);
test("reads the last durable assistant response without loading a stored-only child", async () => {
const store = new RecordingTimelineStore();
await store.appendCommitted("stored-child", {
type: "assistant_message",
text: "Partial work saved before restart",
});
const manager = new AgentManager({ clients: {}, durableTimelineStore: store, logger });
expect(manager.getAgent("stored-child")).toBeNull();
expect(await manager.getLastAssistantMessage("stored-child")).toBe(
"Partial work saved before restart",
);
expect(manager.getAgent("stored-child")).toBeNull();
});
function runtimeCleanup(manager: AgentManager) {
let now = Date.now();
let protectedCaller = (_agentId: string) => false;
const cleanup = createIdleRuntimeCleanup({
enabled: true,
runtimes: () => manager.getRuntimeCleanupCandidates(),
logger,
now: () => now,
isProtected: (agentId) => protectedCaller(agentId),
schedule: () => () => {},
});
onTestFinished(() => cleanup.close());
return (options: { now: Date; isProtected?: (agentId: string) => boolean }) => {
now = options.now.getTime();
protectedCaller = options.isProtected ?? (() => false);
return cleanup.sweep();
};
}
function idleCleanupHarness() {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-protection-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const commands: AgentPromptInput[] = [];
const prompts: AgentPromptInput[] = [];
const recordPrompts = (session: AgentSession) => {
session.tryHandleOutOfBand = (prompt) =>
prompt === "/maintenance"
? {
run: async () => {
commands.push(prompt);
},
}
: null;
const start = session.startTurn.bind(session);
session.startTurn = (prompt, options) => {
prompts.push(prompt);
return start(prompt, options);
};
return session;
};
const sessions: AgentSession[] = [];
const probes: Array<(() => Promise<boolean>) | undefined> = [];
const closeEntered = deferred<void>();
let closeGate: Promise<void> | undefined;
let closes = 0;
const client = new (class extends TestAgentClient {
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
const session = await super.resumeSession(handle, config);
session.describePersistence = () => handle;
return recordPrompts(session);
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const index = sessions.length;
const session: AgentSession = await super.createSession(config);
session.canStopRuntime = async () => probes[index]?.() ?? false;
session.close = async () => {
closes++;
closeEntered.resolve();
await closeGate;
};
sessions.push(session);
return recordPrompts(session);
}
})();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
onTestFinished(async () => {
closeGate = undefined;
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
await storage.flush();
rmSync(workdir, { recursive: true, force: true });
});
return {
commands,
sweep: runtimeCleanup(manager),
manager,
storage,
sessions,
probes,
prompts,
closeEntered,
get closes() {
return closes;
},
holdClose: (gate: Promise<void>) => {
closeGate = gate;
},
create: (labels?: Record<string, string>) =>
manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
labels,
}),
later: (minutes: number) => new Date(Date.now() + minutes * 60_000),
};
}
test("idle cleanup rechecks new prompt activity after an asynchronous safety probe", async () => {
const h = idleCleanupHarness();
const agent = await h.create();
const entered = deferred<void>();
const probe = deferred<boolean>();
h.probes[0] = () => {
entered.resolve();
return probe.promise;
};
const sweep = h.sweep({ now: h.later(61) });
await entered.promise;
await h.manager.runAgent(agent.id, "arrived during safety query");
probe.resolve(true);
expect((await sweep).collected).toEqual([]);
expect(h.closes).toBe(0);
});
test("idle cleanup retains a parent whose idle managed child owns background work", async () => {
const h = idleCleanupHarness();
const parent = await h.create();
await h.create({ [PARENT_AGENT_ID_LABEL]: parent.id });
h.probes[0] = async () => true;
h.probes[1] = async () => false;
expect((await h.sweep({ now: h.later(61) })).collected).toEqual([]);
expect(h.closes).toBe(0);
});
test("idle cleanup rechecks queued deliveries admitted during the safety probe", async () => {
const h = idleCleanupHarness();
await h.create();
let queued = false;
h.probes[0] = async () => {
queued = true;
return true;
};
expect((await h.sweep({ now: h.later(61), isProtected: () => queued })).collected).toEqual([]);
expect(h.closes).toBe(0);
});
test("a prompt racing idle cleanup waits for close and resumes exactly once", async () => {
const h = idleCleanupHarness();
const agent = await h.create();
h.probes[0] = async () => true;
const gate = deferred<void>();
h.holdClose(gate.promise);
const sweep = h.sweep({ now: h.later(61) });
await h.closeEntered.promise;
const run = h.manager.runAgent(agent.id, "arrived while closing");
gate.resolve();
expect((await sweep).collected).toHaveLength(1);
const result = await run;
expect(result.sessionId).toBe(agent.persistence?.sessionId);
expect(h.closes).toBe(1);
expect(h.prompts).toEqual(["arrived while closing"]);
});
test("an out-of-band command racing idle cleanup waits for the resumed runtime", async () => {
const h = idleCleanupHarness();
const agent = await h.create();
h.probes[0] = async () => true;
const gate = deferred<void>();
h.holdClose(gate.promise);
const sweep = h.sweep({ now: h.later(61) });
await h.closeEntered.promise;
const command = startAgentRun(h.manager, agent.id, "/maintenance", logger);
await new Promise<void>((resolve) => setImmediate(resolve));
const deliveredWhileClosing = [...h.commands];
gate.resolve();
await sweep;
expect(deliveredWhileClosing).toEqual([]);
expect(await command).toEqual({ disposition: "out_of_band" });
expect(h.commands).toEqual(["/maintenance"]);
expect(h.prompts).toEqual([]);
});
+383 -121
View File
@@ -1,3 +1,7 @@
import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js";
import type { AgentToolResult } from "@getpaseo/plugin/server";
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { projectTimelineRows } from "./timeline-projection.js";
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
@@ -79,9 +83,13 @@ import {
type PendingForegroundRun,
} from "./agent-run-state.js";
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
import { parseAgentMessage, projectAgentMessage } from "./agent-messages/index.js";
import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js";
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
import {
PASEO_MCP_SERVER_NAME,
stripInternalPaseoMcpServer,
withRuntimePaseoMcpServer,
} from "./runtime-mcp-config.js";
import { resolveCreateAgentTitles } from "./create-agent-title.js";
import type { PaseoToolCatalogFactory } from "./tools/types.js";
import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js";
@@ -228,6 +236,7 @@ export type {
export type AgentManagerEvent =
| { type: "agent_state"; agent: ManagedAgent }
| { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean }
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
| { type: "timeline_replacement"; agentId: string; epoch: string }
| {
@@ -326,10 +335,12 @@ export interface AgentManagerOptions {
registry?: AgentStorage;
onAgentAttention?: AgentAttentionCallback;
onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
timelineStore?: InMemoryAgentTimelineStore;
durableTimelineStore?: AgentTimelineStore;
terminalManager?: TerminalManager | null;
mcpBaseUrl?: string;
mcpAuthToken?: string;
pluginAgentTools?: PluginAgentToolsRuntime;
paseoToolsEnabled?: boolean;
paseoToolCatalogFactory?: PaseoToolCatalogFactory;
resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined;
@@ -411,6 +422,8 @@ interface ManagedAgentBase {
owner?: AgentOwner;
capabilities: AgentCapabilityFlags;
config: AgentSessionConfig;
/** Explicit launch overrides, before per-opening hooks and daemon-owned variables. */
launchEnv?: Record<string, string>;
runtimeInfo?: AgentRuntimeInfo;
createdAt: Date;
updatedAt: Date;
@@ -553,18 +566,20 @@ interface AgentMetadataPatch {
const SYSTEM_ERROR_PREFIX = "[System Error]";
function attachPersistenceCwd(
function normalizePersistenceForConfig(
handle: AgentPersistenceHandle | null,
cwd: string,
config: AgentSessionConfig,
): AgentPersistenceHandle | null {
if (!handle) {
return null;
}
const { mcpServers: _launchMcpServers, ...metadata } = handle.metadata ?? {};
return {
...handle,
metadata: {
...handle.metadata,
cwd,
...metadata,
cwd: config.cwd,
...(config.mcpServers ? { mcpServers: config.mcpServers } : {}),
},
};
}
@@ -657,13 +672,13 @@ function buildExplicitTimelineSeedForRegister(
function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): AgentTimelineRow[] {
const rows: AgentTimelineRow[] = [];
for (const entry of entries) {
const item = projectAgentMessage(entry.item);
if (!item) continue;
rows.push({
seq: rows.length + 1,
timestamp: entry.timestamp ?? new Date().toISOString(),
item: limitAgentTimelineItemContent(item),
});
for (const item of projectAgentMessages(entry.item)) {
rows.push({
seq: rows.length + 1,
timestamp: entry.timestamp ?? new Date().toISOString(),
item: limitAgentTimelineItemContent(item),
});
}
}
return rows;
}
@@ -741,7 +756,7 @@ export class AgentManager {
private readonly providerEnabled = new Map<AgentProvider, boolean>();
private readonly providerDefinitions = new Map<AgentProvider, ProviderEnabledFlag>();
private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore = new InMemoryAgentTimelineStore();
private readonly timelineStore: InMemoryAgentTimelineStore;
private readonly providerSubagents = new ProviderSubagentStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
private readonly sessionEventTails = new Map<string, Promise<void>>();
@@ -757,10 +772,16 @@ export class AgentManager {
private readonly agentRegistrationTasks = new Set<Promise<void>>();
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
private readonly reloadedSessionCloses = new WeakMap<AgentSession, Promise<void>>();
private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>();
private readonly collectingRuntimes = new Set<string>();
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null;
private readonly mcpAuthToken: string | null;
private sessionOpenGeneration = 0;
private readonly sessionOpenGenerations = new Map<string, number>();
private readonly attachedPluginTools = new Map<string, AttachedAgentTool[]>();
private readonly pluginAgentTools: PluginAgentToolsRuntime | undefined;
private paseoToolsEnabled = true;
private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null;
private readonly paseoToolPolicies = new Map<string, ProviderPaseoToolsPolicy | undefined>();
@@ -778,7 +799,9 @@ export class AgentManager {
constructor(options: AgentManagerOptions) {
this.pluginLifecycle = options.pluginLifecycle;
this.idFactory = options?.idFactory ?? (() => randomUUID());
this.pluginAgentTools = options.pluginAgentTools;
this.timelineStore = options.timelineStore ?? new InMemoryAgentTimelineStore();
this.idFactory = options.idFactory ?? (() => randomUUID());
this.registry = options?.registry;
this.durableTimelineStore = options?.durableTimelineStore;
this.onAgentAttention = options?.onAgentAttention;
@@ -881,6 +904,62 @@ export class AgentManager {
this.paseoToolCatalogFactory = factory;
}
invalidatePluginSessions(): void {
this.sessionOpenGeneration++;
}
async reloadStalePluginSession(agentId: string): Promise<void> {
if (!this.needsPluginSessionReload(agentId)) return;
await this.trackAgentRegistrationOperation(
this.runLifecycleMutation(agentId, async () => {
if (!this.needsPluginSessionReload(agentId)) return;
await this.reloadAgentSessionInternal(agentId);
}),
);
}
private needsPluginSessionReload(agentId: string): boolean {
const agent = this.getAgent(agentId);
return (
agent !== null &&
agent.lifecycle !== "closed" &&
this.sessionOpenGenerations.get(agentId) !== this.sessionOpenGeneration &&
!this.hasInFlightRun(agentId)
);
}
getPluginTools(agentId: string): readonly AttachedAgentTool[] {
return (this.attachedPluginTools.get(agentId) ?? []).filter((tool) =>
this.pluginAgentTools?.isToolPluginAvailable(tool.pluginId, tool.pluginInstanceId),
);
}
async executePluginTool(
tool: AttachedAgentTool,
agentId: string,
input: unknown,
signal?: AbortSignal,
): Promise<AgentToolResult> {
const agent = this.getAgent(agentId);
if (!agent || agent.lifecycle === "closed") throw new Error(`Agent is not live: ${agentId}`);
if (!this.pluginAgentTools) throw new Error("Plugin tools are unavailable");
const record = await this.registry?.get(agentId);
return this.pluginAgentTools.callTool(
tool.pluginId,
tool.pluginInstanceId,
describeHookAgent({ ...agent, title: record?.title ?? agent.config.title }),
tool.name,
input,
signal,
);
}
private detachPluginTools(agentId: string): void {
this.attachedPluginTools.delete(agentId);
this.sessionOpenGenerations.delete(agentId);
this.pluginAgentTools?.detachTools(agentId);
}
getPaseoToolPolicy(agentId: string): ProviderPaseoToolsPolicy | undefined {
return this.paseoToolPolicies.get(agentId);
}
@@ -1282,7 +1361,7 @@ export class AgentManager {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
if (this.pluginLifecycle && !config.internal) {
const request = await this.pluginLifecycle.before("agent.create", {
const request = await this.pluginLifecycle.before("agent.create.before", {
config,
env: options.env,
});
@@ -1306,21 +1385,28 @@ export class AgentManager {
storedConfig.cwd,
paseoToolPolicy,
options?.env,
{ reason: "create", purpose: "interactive", workspaceId: options.workspaceId ?? null },
{
reason: "create",
purpose: "interactive",
workspaceId: options.workspaceId ?? null,
title: options.initialTitle ?? storedConfig.title ?? null,
labels: options.labels ?? {},
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const createOptions = this.buildCreateSessionOptions(options);
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
await this.requireExternalMcpSupport(session, storedConfig);
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
labels: options.labels,
initialTitle: options.initialTitle,
workspaceId: options.workspaceId,
owner: options.owner,
launchEnv: options.env ? { ...options.env } : undefined,
historyPrimed: true,
});
if (!agent.internal) {
this.pluginLifecycle?.emit("agent.created", {
this.pluginLifecycle?.emit("agent.create.after", {
agent: describeHookAgent({ ...agent, title: agent.config.title }),
});
}
@@ -1405,10 +1491,11 @@ export class AgentManager {
: resumeOptions;
const purpose = currentResumeOptions?.purpose ?? "interactive";
const launchEnv = record?.launchEnv;
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
mergedConfig,
resolvedAgentId,
{ purpose },
{ purpose, env: launchEnv },
);
const client = this.requireClient(handle.provider);
const available = await client.isAvailable();
@@ -1423,11 +1510,13 @@ export class AgentManager {
client,
storedConfig.cwd,
paseoToolPolicy,
undefined,
launchEnv,
{
reason: "resume",
purpose,
workspaceId: options?.workspaceId ?? null,
title: record?.title ?? mergedConfig.title ?? null,
labels: record?.labels ?? options?.labels ?? {},
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
@@ -1437,9 +1526,10 @@ export class AgentManager {
launchContext,
currentResumeOptions,
);
await this.requireExternalMcpSupport(session, storedConfig);
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, {
...options,
launchEnv,
persistence: handle,
restoring: true,
});
@@ -1485,7 +1575,12 @@ export class AgentManager {
storedConfig.cwd,
paseoToolPolicy,
undefined,
{ reason: "import", purpose: "interactive", workspaceId: input.workspaceId },
{
reason: "import",
purpose: "interactive",
workspaceId: input.workspaceId,
labels: input.labels ?? {},
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const imported = await client.importSession(
@@ -1495,6 +1590,7 @@ export class AgentManager {
},
{ config: providerLaunchConfig, storedConfig, launchContext },
);
await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext);
let handedToRegistration = false;
try {
const importedConfig = await this.normalizeConfig(
@@ -1571,6 +1667,7 @@ export class AgentManager {
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
refreshConfig,
agentId,
{ env: existing.launchEnv },
);
const hadPreviousPaseoToolPolicy = this.paseoToolPolicies.has(agentId);
const previousPaseoToolPolicy = this.paseoToolPolicies.get(agentId);
@@ -1579,15 +1676,21 @@ export class AgentManager {
client,
storedConfig.cwd,
paseoToolPolicy,
undefined,
{ reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId },
existing.launchEnv,
{
reason: "refresh",
purpose: "interactive",
workspaceId: existing.workspaceId,
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
labels: existing.labels,
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
if (
Object.keys(storedConfig.mcpServers ?? {}).length > 0 &&
Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 &&
existing.session.capabilities.supportsMcpServers !== true
) {
throw new Error(`Provider '${provider}' does not support MCP servers`);
throw this.unsupportedMcpError(providerLaunchConfig, launchContext);
}
let session: AgentSession | undefined;
@@ -1606,7 +1709,7 @@ export class AgentManager {
session = handle
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
: await client.createSession(providerLaunchConfig, launchContext);
await this.requireExternalMcpSupport(session, storedConfig);
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
this.assertAcceptingAgentRegistrations();
if (rehydrateFromDisk) {
@@ -1624,6 +1727,7 @@ export class AgentManager {
labels: existing.labels,
workspaceId: existing.workspaceId,
owner: existing.owner,
launchEnv: existing.launchEnv,
createdAt: existing.createdAt,
updatedAt: existing.updatedAt,
lastUserMessageAt: existing.lastUserMessageAt,
@@ -1644,6 +1748,7 @@ export class AgentManager {
throw error;
} finally {
if (!handedToRegistration) {
this.detachPluginTools(agentId);
if (hadPreviousPaseoToolPolicy) {
this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy);
} else {
@@ -1745,6 +1850,64 @@ export class AgentManager {
});
}
/** Capture runtime capabilities without handing lifecycle ownership to the collector. */
getRuntimeCleanupCandidates(): RuntimeCleanupCandidate[] {
if (!this.registry) return [];
const candidates: RuntimeCleanupCandidate[] = [];
for (const agent of this.agents.values()) {
const updatedAt = agent.updatedAt;
let identity = this.runtimeCleanupIdentities.get(agent.session);
if (!identity) {
identity = Object.freeze({});
this.runtimeCleanupIdentities.set(agent.session, identity);
}
const isEligible = () =>
this.agents.get(agent.id) === agent &&
agent.updatedAt === updatedAt &&
this.runtimeHasNoPendingWork(agent);
candidates.push({
identity,
agentId: agent.id,
parentAgentId: getParentAgentIdFromLabels(agent.labels) ?? undefined,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? null,
lastActivityAt: updatedAt.getTime(),
isEligible,
canStopRuntime: agent.session.canStopRuntime?.bind(agent.session),
closeIfEligible: async (revalidate) => {
if (!agent.persistence?.sessionId) return false;
// Block prompt admission before entering the close lane. A skipped close also
// releases this barrier, so the waiting prompt keeps the existing runtime.
this.collectingRuntimes.add(agent.id);
try {
return await this.runLifecycleMutation(agent.id, () =>
this.closeAgentRuntime(agent.id, () => isEligible() && revalidate()),
);
} finally {
this.collectingRuntimes.delete(agent.id);
}
},
});
}
return candidates;
}
private runtimeHasNoPendingWork(agent: LiveManagedAgent): boolean {
return (
agent.lifecycle === "idle" &&
!agent.internal &&
agent.activeForegroundTurnId === null &&
!this.runs.hasRun(agent.id) &&
!agent.pendingReplacement &&
agent.pendingPermissions.size === 0 &&
agent.inFlightPermissionResponses.size === 0 &&
(this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) &&
!this.foregroundMutationTails.has(agent.id) &&
!this.sessionEventTails.has(agent.id) &&
!this.providerSubagents.list(agent.id).some((child) => child.status === "running")
);
}
closeAgent(agentId: string): Promise<void> {
const existing = this.inFlightAgentCloses.get(agentId);
if (existing) {
@@ -1765,7 +1928,7 @@ export class AgentManager {
return close;
}
private async closeAgentRuntime(agentId: string): Promise<void> {
private async closeAgentRuntime(agentId: string, canClose?: () => boolean): Promise<boolean> {
const agent = this.requireAgent(agentId);
this.logger.trace(
{
@@ -1780,6 +1943,7 @@ export class AgentManager {
"agent.manager.close.start",
);
await this.drainSessionEvents(agentId);
if (canClose && !canClose()) return false;
// 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();
@@ -1792,6 +1956,7 @@ export class AgentManager {
} catch (error) {
persistError = error;
}
this.detachPluginTools(agentId);
this.emitClosedAgent(closedAgent, { persist: false });
this.logger.trace(
{
@@ -1805,6 +1970,7 @@ export class AgentManager {
if (persistError !== undefined) {
throw persistError;
}
return true;
}
private cancelRunningProviderSubagents(parentAgentId: string): void {
@@ -1956,7 +2122,7 @@ export class AgentManager {
const archivedRecord = buildArchivedAgentRecord(record, options);
await this.requireRegistry().upsert(archivedRecord);
if (!record.archivedAt && !record.internal) {
this.pluginLifecycle?.emit("agent.archived", {
this.pluginLifecycle?.emit("agent.archive.after", {
agent: describeHookAgent(archivedRecord),
archivedAt: archivedRecord.archivedAt,
});
@@ -2457,7 +2623,19 @@ export class AgentManager {
* emitted by the handler flow through dispatchStream so they persist and
* broadcast like normal timeline events.
*/
tryRunOutOfBand(agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions): boolean {
async tryRunOutOfBand(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<boolean> {
if (this.collectingRuntimes.has(agentId) || !this.agents.has(agentId)) {
await this.waitForAgentClose(agentId);
await ensureAgentLoaded(agentId, {
agentManager: this,
agentStorage: this.requireRegistry(),
logger: this.logger,
});
}
const agent = this.requireSessionAgent(agentId);
// Providers parse slash commands from the raw text, so a command another agent sent
// must reach them without its sender envelope.
@@ -2468,6 +2646,7 @@ export class AgentManager {
if (!handler) {
return false;
}
this.touchUpdatedAt(agent);
if (options?.clientMessageId) {
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted");
this.emitState(agent);
@@ -2487,7 +2666,7 @@ export class AgentManager {
}
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
};
void (async () => {
void this.runForegroundMutation(agentId, async () => {
try {
await handler.run({ emit: dispatch });
} catch (error) {
@@ -2498,7 +2677,7 @@ export class AgentManager {
item: { type: "assistant_message", text: `[Error] ${text}` },
});
}
})();
});
return true;
}
@@ -2556,7 +2735,7 @@ export class AgentManager {
throw error;
}
if (isStaleProviderSessionError(error)) {
pendingRun.start = { status: "failed", error: error.message };
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: error.message });
agent.pendingReplacement = false;
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
this.runs.settleForegroundRun(agentId, pendingRun.token);
@@ -2564,29 +2743,49 @@ export class AgentManager {
}
agent.pendingReplacement = false;
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
pendingRun.start = { status: "failed", error: errorMsg };
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
if (options?.clientMessageId) {
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
messageId: options.clientMessageId,
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: errorMsg });
try {
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
if (options?.clientMessageId) {
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
messageId: options.clientMessageId,
});
}
await this.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
error: errorMsg,
});
this.finalizeForegroundTurn(agent);
} finally {
// A steer waiting on this start is released only once the run is cleared.
this.runs.settleForegroundRun(agentId, pendingRun.token);
}
await this.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
error: errorMsg,
});
this.finalizeForegroundTurn(agent);
this.runs.settleForegroundRun(agentId, pendingRun.token);
throw error;
}
}
private async *streamAfterRuntimeCollection(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions,
): AsyncGenerator<AgentStreamEvent> {
await this.waitForAgentClose(agentId);
await ensureAgentLoaded(agentId, {
agentManager: this,
agentStorage: this.requireRegistry(),
logger: this.logger,
});
yield* this.streamAgent(agentId, prompt, options);
}
streamAgent(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions,
): AsyncGenerator<AgentStreamEvent> {
if (this.collectingRuntimes.has(agentId) || (!this.agents.has(agentId) && this.registry))
return this.streamAfterRuntimeCollection(agentId, prompt, options);
const existingAgent = this.requireSessionAgent(agentId);
this.logger.trace(
{
@@ -2638,7 +2837,7 @@ export class AgentManager {
agent.pendingReplacement = false;
}
const turnStartedAt = new Date();
pendingRun.start = { status: "started", turnId };
this.runs.settleForegroundStart(pendingRun, { status: "started", turnId });
agent.activeForegroundTurnId = turnId;
this.openActiveTurn(agent, turnId, turnStartedAt);
agent.lifecycle = "running";
@@ -2741,7 +2940,10 @@ export class AgentManager {
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
: null);
if (persistenceHandle) {
mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd);
mutableAgent.persistence = normalizePersistenceForConfig(
persistenceHandle,
mutableAgent.config,
);
}
this.logger.trace(
{
@@ -2844,6 +3046,9 @@ export class AgentManager {
prompt: AgentPromptInput,
options?: AgentSteerOptions,
): Promise<ActiveTurnSteerDispatchResult> {
// A turn that is still starting has no id to steer yet. Replacing it would drop its
// prompt, so wait until it is accepted (or fails) and steer the turn it became.
await this.runs.waitForForegroundStart(agentId);
const agent = this.requireSessionAgent(agentId);
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
if (!expectedTurnId) {
@@ -3315,16 +3520,23 @@ export class AgentManager {
await this.deleteCommittedTimeline(agentId);
}
notifyAgentRemoved(agent: Pick<StoredAgentRecord, "id" | "workspaceId" | "internal">): void {
this.dispatch({
type: "agent_removed",
agentId: agent.id,
workspaceId: agent.workspaceId,
internal: agent.internal === true,
});
}
async deleteCommittedTimeline(agentId: string): Promise<void> {
await this.durableTimelineStore?.deleteAgent(agentId);
}
async getLastAssistantMessage(agentId: string): Promise<string | null> {
const agent = this.agents.get(agentId);
if (!agent) {
return null;
if (!this.timelineStore.has(agentId)) {
return (await this.durableTimelineStore?.getLastAssistantMessage(agentId)) ?? null;
}
return await this.getLastAssistantMessageFromStores(agentId);
}
@@ -3575,6 +3787,7 @@ export class AgentManager {
publishWhenReady?: boolean;
workspaceId?: string;
owner?: AgentOwner;
launchEnv?: Record<string, string>;
},
): Promise<ManagedAgent> {
let registered = false;
@@ -3654,6 +3867,8 @@ export class AgentManager {
this.assertAgentRegistrationActive(managed);
this.emitState(managed, { persist: false });
this.subscribeToSession(managed);
await this.drainSessionEvents(managed.id);
this.assertAgentRegistrationActive(managed);
return { ...managed };
} catch (error) {
if (!registered) {
@@ -3685,16 +3900,23 @@ export class AgentManager {
private async requireExternalMcpSupport(
session: AgentSession,
storedConfig: AgentSessionConfig,
launchConfig: AgentSessionConfig,
launchContext: AgentLaunchContext,
): Promise<void> {
if (
Object.keys(storedConfig.mcpServers ?? {}).length === 0 ||
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 ||
session.capabilities.supportsMcpServers === true
) {
return;
}
await this.closeUnregisteredSession(session);
throw new Error(`Provider '${storedConfig.provider}' does not support MCP servers`);
throw this.unsupportedMcpError(launchConfig, launchContext);
}
private unsupportedMcpError(config: AgentSessionConfig, context: AgentLaunchContext): Error {
const owners = [...new Set(Object.values(context.mcpServerOwners ?? {}))];
const source = owners.length ? ` (plugin ${owners.join(", ")})` : "";
return new Error(`Provider '${config.provider}' does not support MCP servers${source}`);
}
private async initializeAgentTimelineForRegister(params: {
@@ -3751,6 +3973,7 @@ export class AgentManager {
persistence?: AgentPersistenceHandle;
workspaceId?: string;
owner?: AgentOwner;
launchEnv?: Record<string, string>;
}
| undefined;
}): ActiveManagedAgent {
@@ -3761,6 +3984,7 @@ export class AgentManager {
cwd: config.cwd,
workspaceId: options?.workspaceId,
owner: options?.owner,
launchEnv: options?.launchEnv,
session,
capabilities: session.capabilities,
config,
@@ -3780,9 +4004,9 @@ export class AgentManager {
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
finalizedForegroundTurnIds: new Set<string>(),
unsubscribeSession: null,
persistence: attachPersistenceCwd(
persistence: normalizePersistenceForConfig(
options?.persistence ?? session.describePersistence(),
config.cwd,
config,
),
historyPrimed: options?.historyPrimed ?? durableTimelineHasRows,
lastUserMessageAt: options?.lastUserMessageAt ?? null,
@@ -3845,6 +4069,7 @@ export class AgentManager {
private discardRetainedAgentState(agentId: string): void {
this.timelineStore.delete(agentId);
this.paseoToolPolicies.delete(agentId);
this.detachPluginTools(agentId);
for (const event of this.providerSubagents.deleteParent(agentId)) {
this.dispatch({ type: "provider_subagent", event });
}
@@ -3853,7 +4078,7 @@ export class AgentManager {
private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void {
this.emitState(agent, options);
if (!agent.internal) {
this.pluginLifecycle?.emit("agent.closed", {
this.pluginLifecycle?.emit("agent.close.after", {
agent: describeHookAgent({ ...agent, title: agent.config.title }),
});
}
@@ -4085,9 +4310,9 @@ export class AgentManager {
newInfo.modeId !== agent.runtimeInfo?.modeId;
agent.runtimeInfo = newInfo;
if (!agent.persistence && newInfo.sessionId) {
agent.persistence = attachPersistenceCwd(
agent.persistence = normalizePersistenceForConfig(
{ provider: agent.provider, sessionId: newInfo.sessionId },
agent.cwd,
agent.config,
);
}
// Emit state if runtimeInfo changed so clients get the updated model
@@ -4132,9 +4357,9 @@ export class AgentManager {
for await (const rawEvent of agent.session.streamHistory()) {
const event = limitAgentStreamEventContent(rawEvent);
if (event.type === "timeline") {
const item = projectAgentMessage(event.item);
if (!item) continue;
historyEvents.push({ ...event, item });
for (const item of projectAgentMessages(event.item)) {
historyEvents.push({ ...event, item });
}
} else if (event.type === "provider_subagent") {
providerSubagentEvents.push(event);
}
@@ -4199,9 +4424,9 @@ export class AgentManager {
if (event.type !== "timeline") {
continue;
}
const item = projectAgentMessage(event.item);
if (!item) continue;
historyEvents.push({ ...event, item });
for (const item of projectAgentMessages(event.item)) {
historyEvents.push({ ...event, item });
}
}
} catch (error) {
this.logger.warn({ err: error, agentId: agent.id }, "Failed to hydrate provider history");
@@ -4445,9 +4670,9 @@ export class AgentManager {
case "model_changed":
agent.runtimeInfo = event.runtimeInfo;
if (!agent.persistence && event.runtimeInfo.sessionId) {
agent.persistence = attachPersistenceCwd(
agent.persistence = normalizePersistenceForConfig(
{ provider: agent.provider, sessionId: event.runtimeInfo.sessionId },
agent.cwd,
agent.config,
);
}
this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId);
@@ -4521,7 +4746,7 @@ export class AgentManager {
private refreshSessionPersistence(agent: ActiveManagedAgent): void {
const handle = agent.session.describePersistence();
if (handle) {
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
agent.persistence = normalizePersistenceForConfig(handle, agent.config);
}
}
@@ -4533,42 +4758,32 @@ export class AgentManager {
}): Promise<void> {
const { agent, event, options, flags } = params;
const item = projectAgentMessage(event.item);
if (!item) {
flags.shouldDispatchEvent = false;
flags.shouldNotifyWaiters = false;
return;
}
event.item = item;
if (
event.item.type === "user_message" &&
event.item.clientMessageId &&
this.reconcileSubmittedPromptEcho(agent, event.item, event.turnId)
) {
flags.shouldDispatchEvent = false;
flags.shouldNotifyWaiters = false;
return;
}
if (options?.fromHistory) {
this.recordTimeline(
agent.id,
event.item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
flags.shouldDispatchEvent = false;
flags.shouldNotifyWaiters = false;
return;
}
this.recordAndDispatchTimelineItem(agent.id, event.item, event.provider, event.turnId);
if (event.item.type === "user_message") {
agent.lastUserMessageAt = new Date();
this.emitState(agent);
}
flags.shouldDispatchEvent = false;
flags.shouldNotifyWaiters = true;
flags.shouldNotifyWaiters = false;
for (const item of projectAgentMessages(event.item)) {
if (
item.type === "user_message" &&
item.clientMessageId &&
this.reconcileSubmittedPromptEcho(agent, item, event.turnId)
)
continue;
if (options?.fromHistory) {
this.recordTimeline(
agent.id,
item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
continue;
}
this.recordAndDispatchTimelineItem(agent.id, item, event.provider, event.turnId);
if (item.type === "user_message") {
agent.lastUserMessageAt = new Date();
this.emitState(agent);
}
flags.shouldNotifyWaiters = true;
}
}
private onStreamTurnCompleted(params: {
@@ -4814,16 +5029,17 @@ export class AgentManager {
outcome: "accepted" | "rejected",
options?: { messageId?: string; providerMessageId?: string; turnId?: string },
): void {
const item = projectAgentMessage({
const items = projectAgentMessages({
type: "user_message",
text: submittedPromptText(prompt),
clientMessageId,
...(options?.messageId ? { messageId: options.messageId } : {}),
});
if (!item) return;
// Human attempts stay in history on rejection; delivery notifications require acceptance.
if (outcome === "rejected" && item.type !== "user_message") return;
this.recordSubmittedPromptItem(agent, item, options);
for (const item of items) {
// Human attempts stay in history on rejection; delivery notifications require acceptance.
if (outcome === "rejected" && item.type !== "user_message") continue;
this.recordSubmittedPromptItem(agent, item, options);
}
}
private recordSubmittedPromptItem(
@@ -5162,7 +5378,6 @@ export class AgentManager {
this.pluginLifecycle,
describeHookAgent({ ...agent, title: agent.config.title }),
event,
this.timelineStore.getItems(agentId),
);
}
}
@@ -5171,7 +5386,7 @@ export class AgentManager {
for (const subscriber of this.subscribers) {
if (
subscriber.agentId &&
event.type === "agent_stream" &&
(event.type === "agent_stream" || event.type === "agent_removed") &&
subscriber.agentId !== event.agentId
) {
continue;
@@ -5207,6 +5422,7 @@ export class AgentManager {
private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean {
if (event.type === "agent_state") return event.agent.internal === true;
if (event.type === "agent_removed") return event.internal;
if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true;
if (event.type !== "provider_subagent") return false;
const parentAgentId =
@@ -5341,10 +5557,17 @@ export class AgentManager {
reason: PluginSessionOpenRequest["reason"];
purpose: PluginSessionOpenRequest["purpose"];
workspaceId?: string | null;
title?: string | null;
labels?: Record<string, string>;
},
): Promise<AgentLaunchContext> {
// A plugin changing during this open must still make the resulting session stale.
const generation = this.sessionOpenGeneration;
this.detachPluginTools(agentId);
let mcpServers: AgentSessionConfig["mcpServers"];
let mcpServerOwners: Record<string, string> | undefined;
if (this.pluginLifecycle) {
const request: PluginSessionOpenRequest = {
const request = {
agentId,
provider: client.provider,
cwd,
@@ -5352,29 +5575,49 @@ export class AgentManager {
reason: opening?.reason ?? "resume",
purpose: opening?.purpose ?? "interactive",
env: { ...env },
title: opening?.title ?? null,
labels: opening?.labels ?? {},
tools: [],
mcpServers: {},
};
const transformed = await this.pluginLifecycle.before("agent.session_open", request);
const transformed = await this.pluginLifecycle.before("agent.open.before", request);
env = transformed.env;
if (request.purpose === "interactive") {
this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]);
mcpServers = transformed.mcpServers;
mcpServerOwners = transformed.mcpServerOwners;
}
}
const context: AgentLaunchContext = {
agentId,
mcpServers,
mcpServerOwners,
env: {
...env,
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
},
};
if (
const useNativeTools =
this.paseoToolsEnabled &&
isPaseoToolPolicyEnabled(paseoToolPolicy) &&
client.capabilities.supportsNativePaseoTools &&
this.paseoToolCatalogFactory
client.capabilities.supportsNativePaseoTools;
if (
this.paseoToolCatalogFactory &&
(useNativeTools || this.getPluginTools(agentId).length > 0)
) {
context.paseoTools = await this.paseoToolCatalogFactory({
callerAgentId: agentId,
paseoToolPolicy,
});
try {
const catalog = await this.paseoToolCatalogFactory({
callerAgentId: agentId,
paseoToolPolicy,
});
if (useNativeTools) context.paseoTools = catalog;
} catch (error) {
this.detachPluginTools(agentId);
throw error;
}
}
this.sessionOpenGenerations.set(agentId, generation);
return context;
}
@@ -5382,7 +5625,26 @@ export class AgentManager {
launchConfig: AgentSessionConfig,
launchContext: AgentLaunchContext,
): AgentSessionConfig {
return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig;
const savedServerNames = new Set(
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}),
);
for (const name of Object.keys(launchContext.mcpServers ?? {})) {
const owner = launchContext.mcpServerOwners?.[name];
if (name === PASEO_MCP_SERVER_NAME || savedServerNames.has(name)) {
const existingOwner = savedServerNames.has(name)
? "agent saved configuration"
: "Paseo internal MCP server";
throw new Error(`MCP server '${name}' from plugin ${owner} collides with ${existingOwner}`);
}
}
const merged =
Object.keys(launchContext.mcpServers ?? {}).length > 0
? {
...launchConfig,
mcpServers: { ...launchConfig.mcpServers, ...launchContext.mcpServers },
}
: launchConfig;
return launchContext.paseoTools ? stripInternalPaseoMcpServer(merged) : merged;
}
private async requireAvailableClient(options: { provider: AgentProvider }): Promise<AgentClient> {
@@ -787,7 +787,7 @@ describe("agent MCP end-to-end (offline)", () => {
}
}, 30_000);
test("create_agent with worktree is async and boots terminals only after setup success", async () => {
test("workspace creation owns async setup and terminal bootstrap before agent attachment", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-worktree-repo-"));
@@ -843,21 +843,30 @@ describe("agent MCP end-to-end (offline)", () => {
stdio: "pipe",
});
const result = await withTimeout({
const workspaceResult = await withTimeout({
promise: client.callTool({
name: "create_agent",
name: "create_workspace",
args: {
cwd: repoRoot,
title: "MCP worktree setup terminals",
provider: "claude/claude-test-model",
mode: "bypassPermissions",
initialPrompt: "say done and stop",
worktreeName: "mcp-worktree-setup-test",
isolation: "worktree",
path: repoRoot,
worktreeSlug: "mcp-worktree-setup-test",
baseBranch: "main",
},
}),
timeoutMs: 2500,
label: "create_agent should not block on setup",
label: "create_workspace should not block on setup",
});
const workspace = getStructuredContent(workspaceResult);
if (typeof workspace?.workspaceId !== "string") throw new Error("Expected created workspace");
const result = await client.callTool({
name: "create_agent",
args: {
workspaceId: workspace.workspaceId,
title: "MCP worktree setup terminals",
provider: "claude/claude-test-model",
settings: { modeId: "bypassPermissions" },
initialPrompt: "say done and stop",
},
});
const payload = getStructuredContent(result);
@@ -0,0 +1,315 @@
import { expect, test } from "vitest";
import { createAgentMessageDelivery, parseAgentMessages } from "./index.js";
import type { AgentPromptInput } from "../agent-sdk-types.js";
function createClock() {
let now = 0;
const jobs = new Map<() => void, number>();
return {
schedule(delayMs: number, callback: () => void) {
jobs.set(callback, now + delayMs);
return () => {
jobs.delete(callback);
};
},
advance(ms: number) {
now += ms;
for (const [callback, deadline] of jobs) {
if (deadline > now) continue;
jobs.delete(callback);
callback();
}
},
};
}
test("a queued child report and completion reach the recipient in one steer after two quiet seconds", async () => {
const clock = createClock();
const sent: Array<{ recipientId: string; prompt: AgentPromptInput }> = [];
const delivery = createAgentMessageDelivery({
schedule: clock.schedule,
dispatch: async (batch) => {
sent.push(batch);
return "steered";
},
onError: (error) => {
throw error;
},
});
const receipt = delivery.enqueue({
recipientId: "parent",
messageId: "report",
prompt: "Results <ready>",
source: { kind: "agent-message", agentId: "worker" },
});
expect(receipt).toMatchObject({ status: "queued", messageId: "report" });
expect(sent).toEqual([]);
clock.advance(1000);
delivery.enqueue({
recipientId: "parent",
messageId: "finish",
prompt: "Worker finished",
source: { kind: "agent-notification", agentId: "worker", event: "finished" },
});
clock.advance(1999);
expect(sent).toEqual([]);
clock.advance(1);
await delivery.close();
expect(
sent.map((batch) => ({
recipientId: batch.recipientId,
messages: parseAgentMessages(String(batch.prompt)),
})),
).toEqual([
{
recipientId: "parent",
messages: [
{
id: "report",
source: { kind: "agent-message", agentId: "worker" },
text: "Results <ready>",
},
{
id: "finish",
source: { kind: "agent-notification", agentId: "worker", event: "finished" },
text: "Worker finished",
},
],
},
]);
});
test("recipient timers are independent, later messages form another batch, and close flushes pending work", async () => {
const clock = createClock();
const sent: string[] = [];
const delivery = createAgentMessageDelivery({
schedule: clock.schedule,
dispatch: async (request) => {
sent.push(`${request.recipientId}:${request.messageIds.join(",")}`);
return "steered";
},
onError: (error) => {
throw error;
},
});
const source = { kind: "agent-message" as const, agentId: "worker" };
const a = delivery.enqueue({ recipientId: "a", messageId: "first", source, prompt: "one" });
clock.advance(1000);
const b = delivery.enqueue({ recipientId: "b", messageId: "independent", source, prompt: "two" });
clock.advance(1000);
await a.dispatched;
expect(sent).toEqual(["a:first"]);
delivery.enqueue({ recipientId: "a", messageId: "later", source, prompt: "three" });
clock.advance(1000);
await b.dispatched;
expect(sent).toEqual(["a:first", "b:independent"]);
await delivery.close();
expect(sent).toEqual(["a:first", "b:independent", "a:later"]);
expect(() => delivery.enqueue({ recipientId: "a", source, prompt: "closed" })).toThrow("closed");
});
test("explicit interrupt and conflicting session modes retain ordered dispatch boundaries", async () => {
const sent: Array<{
activeTurnBehavior: string;
sessionMode?: string;
messageIds: readonly string[];
}> = [];
const delivery = createAgentMessageDelivery({
dispatch: async (request) => {
sent.push(request);
request.onDispatch("steered");
return "steered";
},
onError: (error) => {
throw error;
},
});
const source = { kind: "agent-message" as const, agentId: "worker" };
delivery.enqueue({
recipientId: "parent",
source,
prompt: "interrupt",
messageId: "interrupt",
activeTurnBehavior: "interrupt",
});
delivery.enqueue({
recipientId: "parent",
source,
prompt: "review",
messageId: "review",
sessionMode: "plan",
});
delivery.enqueue({
recipientId: "parent",
source,
prompt: "execute",
messageId: "execute",
sessionMode: "full-access",
});
delivery.enqueue({
recipientId: "parent",
source: { kind: "agent-notification", agentId: "worker", event: "finished" },
prompt: "done",
messageId: "finished",
});
await delivery.close();
expect(
sent.map(({ activeTurnBehavior, sessionMode, messageIds }) => ({
activeTurnBehavior,
sessionMode,
messageIds,
})),
).toEqual([
{ activeTurnBehavior: "interrupt", sessionMode: undefined, messageIds: ["interrupt"] },
{ activeTurnBehavior: "steer", sessionMode: "plan", messageIds: ["review"] },
{
activeTurnBehavior: "steer",
sessionMode: "full-access",
messageIds: ["execute", "finished"],
},
]);
});
test("dispatch failures reject waits before and after flush and do not poison later messages", async () => {
const clock = createClock();
const failed = new Error("provider rejected");
const errors: unknown[] = [];
const delivery = createAgentMessageDelivery({
schedule: clock.schedule,
dispatch: async (request) => {
if (request.messageIds.includes("bad")) throw failed;
return "steered";
},
onError: (error) => {
errors.push(error);
},
});
const message = {
recipientId: "parent",
source: { kind: "agent-message" as const, agentId: "worker" },
prompt: "report",
};
const bad = delivery.enqueue({ ...message, messageId: "bad" });
const before = expect(
delivery.waitForDispatch("parent", new AbortController().signal),
).rejects.toBe(failed);
clock.advance(2000);
const after = expect(
delivery.waitForDispatch("parent", new AbortController().signal),
).rejects.toBe(failed);
await Promise.all([before, after, expect(bad.dispatched).rejects.toBe(failed)]);
const next = delivery.enqueue({ ...message, messageId: "next" });
await delivery.close();
await expect(next.dispatched).resolves.toBe("steered");
expect(errors).toEqual([failed]);
});
test("canceling a completion wait leaves queued delivery intact", async () => {
const clock = createClock();
const delivery = createAgentMessageDelivery({
schedule: clock.schedule,
dispatch: async () => "steered",
onError: (error) => {
throw error;
},
});
const receipt = delivery.enqueue({
recipientId: "parent",
source: { kind: "agent-message", agentId: "worker" },
prompt: "report",
});
const controller = new AbortController();
const wait = delivery.waitForDispatch("parent", controller.signal);
controller.abort();
await expect(wait).rejects.toMatchObject({ name: "AbortError" });
await delivery.close();
await expect(receipt.dispatched).resolves.toBe("steered");
});
test("batches retain each sender's attachment context and image order", async () => {
let prompt: AgentPromptInput = "";
const delivery = createAgentMessageDelivery({
dispatch: async (request) => {
prompt = request.prompt;
return "steered";
},
onError: (error) => {
throw error;
},
});
const firstImage = { type: "image" as const, data: "Zmlyc3Q=", mimeType: "image/png" };
const secondImage = { type: "image" as const, data: "c2Vjb25k", mimeType: "image/png" };
delivery.enqueue({
recipientId: "parent",
messageId: "first",
source: { kind: "agent-message", agentId: "one" },
prompt: [{ type: "text", text: "First" }, firstImage],
});
delivery.enqueue({
recipientId: "parent",
messageId: "second",
source: { kind: "agent-message", agentId: "two" },
prompt: [
{ type: "text", text: "Second" },
{
type: "github_issue",
mimeType: "application/github-issue",
number: 42,
title: "Context",
url: "https://example.com/42",
body: "Attached",
},
secondImage,
],
});
await delivery.close();
expect(prompt).toEqual([
{
type: "text",
text: '<paseo-system version="1" kind="agent-message" source-agent-id="one" message-id="first">\nFirst\n</paseo-system>',
},
firstImage,
{
type: "text",
text: '<paseo-system version="1" kind="agent-message" source-agent-id="two" message-id="second">\nSecond\n\nGitHub Issue #42: Context\nhttps://example.com/42\n\nAttached\n</paseo-system>',
},
secondImage,
]);
expect(
parseAgentMessages(
(prompt as Exclude<AgentPromptInput, string>)
.flatMap((block) => (block.type === "text" ? [block.text] : []))
.join("\n"),
)?.map((message) => message.source?.agentId),
).toEqual(["one", "two"]);
});
test("runtime safety sees queued messages until provider dispatch settles", async () => {
const clock = createClock();
let release!: () => void;
const dispatched = new Promise<void>((resolve) => {
release = resolve;
});
const delivery = createAgentMessageDelivery({
schedule: clock.schedule,
dispatch: async () => {
await dispatched;
return "steered";
},
onError: (error) => {
throw error;
},
});
expect(delivery.hasPending("parent")).toBe(false);
delivery.enqueue({
recipientId: "parent",
prompt: "finished",
source: { kind: "agent-message", agentId: "child" },
});
expect(delivery.hasPending("parent")).toBe(true);
clock.advance(2000);
expect(delivery.hasPending("parent")).toBe(true);
release();
await delivery.close();
expect(delivery.hasPending("parent")).toBe(false);
});
@@ -0,0 +1,192 @@
import type { PromptDispatchDisposition } from "../agent-prompt.js";
import { randomUUID } from "node:crypto";
import type { AgentPromptInput, AgentPromptContentBlock } from "../agent-sdk-types.js";
import { prepareAgentMessage, type AgentPromptSource } from "./envelope.js";
interface Message {
recipientId: string;
messageId?: string;
source: AgentPromptSource;
prompt: AgentPromptInput;
sessionMode?: string;
unarchive?: boolean;
clearPendingPermissions?: boolean;
activeTurnBehavior?: "steer" | "interrupt";
beforeDispatch?: () => void;
onDispatch?: (disposition: PromptDispatchDisposition) => void;
}
interface Receipt {
status: "queued";
messageId: string;
dispatched: Promise<PromptDispatchDisposition>;
}
interface Admitted extends Message {
receipt: Receipt;
resolve(disposition: PromptDispatchDisposition): void;
reject(error: unknown): void;
}
interface DispatchRequest {
recipientId: string;
messageIds: readonly string[];
prompt: AgentPromptInput;
activeTurnBehavior: "steer" | "interrupt";
sessionMode?: string;
clearPendingPermissions: boolean;
unarchive: boolean;
onDispatch(disposition: PromptDispatchDisposition): void;
}
interface Dependencies {
dispatch(request: DispatchRequest): Promise<PromptDispatchDisposition>;
onError(error: unknown, request: DispatchRequest): void;
/** Return a cancellation function. Production uses the daemon's event loop. */
schedule?: (delayMs: number, callback: () => void) => () => void;
}
export interface AgentMessageDelivery {
enqueue(message: Message): Receipt;
/** Queued messages and provider dispatches still owned by this recipient. */
hasPending(recipientId: string): boolean;
/** Stop admission, flush pending messages, and await provider dispatch acknowledgments. */
close(): Promise<void>;
/** Existing completion waits include agent messages admitted before the wait. */
waitForDispatch(recipientId: string, signal: AbortSignal): Promise<void>;
}
/** Recipient-owned agent delivery. Human prompts never enter this module. */
export function createAgentMessageDelivery(dependencies: Dependencies): AgentMessageDelivery {
const schedule =
dependencies.schedule ??
((delayMs, callback) => {
const timer = setTimeout(callback, delayMs);
return () => clearTimeout(timer);
});
const pending = new Map<string, { messages: Admitted[]; cancel(): void }>();
const dispatching = new Map<string, Promise<void>>();
let closed = false;
let closing: Promise<void> | undefined;
function dispatch(recipientId: string, messages: Admitted[]): void {
const prompts = messages.map(
(message) =>
prepareAgentMessage(message.prompt, message.source, message.receipt.messageId).prompt,
);
const prompt: AgentPromptInput = prompts.every((entry) => typeof entry === "string")
? prompts.join("\n\n")
: prompts.flatMap((entry): AgentPromptContentBlock[] =>
typeof entry === "string" ? [{ type: "text", text: entry }] : entry,
);
const request: DispatchRequest = {
recipientId,
messageIds: messages.map((message) => message.receipt.messageId),
prompt,
activeTurnBehavior: messages[0]!.activeTurnBehavior ?? "steer",
sessionMode: messages.find((message) => message.sessionMode)?.sessionMode,
clearPendingPermissions: messages.some((message) => message.clearPendingPermissions),
unarchive: messages.some((message) => message.unarchive !== false),
onDispatch: (disposition) => {
for (const message of messages) {
try {
message.onDispatch?.(disposition);
} catch (error) {
dependencies.onError(error, request);
}
}
},
};
const previous = dispatching.get(recipientId) ?? Promise.resolve();
const dispatched = previous
.catch(() => undefined)
.then(async () => {
try {
for (const message of messages) message.beforeDispatch?.();
const disposition = await dependencies.dispatch(request);
for (const message of messages) message.resolve(disposition);
return undefined;
} catch (error) {
for (const message of messages) message.reject(error);
dependencies.onError(error, request);
throw error;
}
});
dispatching.set(recipientId, dispatched);
void dispatched
.finally(() => {
if (dispatching.get(recipientId) === dispatched) dispatching.delete(recipientId);
})
.catch(() => undefined);
}
function flush(recipientId: string): void {
const entry = pending.get(recipientId);
if (!entry) return;
pending.delete(recipientId);
entry.cancel();
let group: Admitted[] = [];
let mode: string | undefined;
for (const message of entry.messages) {
// Explicitly conflicting send options require separate, ordered dispatches.
if (
group.length &&
((message.activeTurnBehavior ?? "steer") !== (group[0]!.activeTurnBehavior ?? "steer") ||
(mode && message.sessionMode && mode !== message.sessionMode))
) {
dispatch(recipientId, group);
group = [];
mode = undefined;
}
group.push(message);
mode ??= message.sessionMode;
}
if (group.length) dispatch(recipientId, group);
}
return {
hasPending: (recipientId) => pending.has(recipientId) || dispatching.has(recipientId),
enqueue(message) {
if (closed) throw new Error("Agent message delivery is closed");
let resolve!: Admitted["resolve"];
let reject!: Admitted["reject"];
const dispatched = new Promise<PromptDispatchDisposition>((accepted, failed) => {
resolve = accepted;
reject = failed;
});
void dispatched.catch(() => undefined);
const receipt: Receipt = {
status: "queued",
messageId: message.messageId ?? randomUUID(),
dispatched,
};
const entry = pending.get(message.recipientId) ?? { messages: [], cancel: () => {} };
entry.cancel();
entry.messages.push({ ...message, receipt, resolve, reject });
entry.cancel = schedule(2000, () => flush(message.recipientId));
pending.set(message.recipientId, entry);
return receipt;
},
async waitForDispatch(recipientId, signal) {
signal.throwIfAborted();
const admitted =
pending.get(recipientId)?.messages.map((message) => message.receipt.dispatched) ?? [];
let aborted!: () => void;
const cancellation = new Promise<never>((_resolve, reject) => {
aborted = () => reject(new DOMException("Wait aborted", "AbortError"));
signal.addEventListener("abort", aborted, { once: true });
});
try {
await Promise.race([
Promise.all([dispatching.get(recipientId), ...admitted]),
cancellation,
]);
} finally {
signal.removeEventListener("abort", aborted);
}
},
close() {
if (closing) return closing;
closed = true;
for (const recipientId of pending.keys()) flush(recipientId);
// Each failure is reported and rejects its receipts; shutdown still drains other recipients.
closing = Promise.allSettled(dispatching.values()).then(() => undefined);
return closing;
},
};
}
@@ -0,0 +1,230 @@
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { createHash, randomUUID } from "node:crypto";
import { z } from "zod";
import type {
AgentPromptContentBlock,
AgentPromptInput,
AgentTimelineItem,
} from "../agent-sdk-types.js";
const agentIdSchema = z.string().min(1);
const sourceSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("agent-message"),
agentId: agentIdSchema,
serverId: z.string().min(1).optional(),
title: z.string().min(1).optional(),
}),
z.object({
kind: z.literal("agent-notification"),
agentId: agentIdSchema,
serverId: z.string().min(1).optional(),
title: z.string().min(1).optional(),
event: z.enum(["finished", "errored", "permission-required", "closed"]),
}),
]);
export type AgentPromptSource = z.infer<typeof sourceSchema>;
interface AgentMessage {
id: string;
source: AgentPromptSource | null;
text: string;
}
function escapeXml(value: string): string {
return value.replace(/[&<>"']/g, (character) => {
switch (character) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
default:
return "&apos;";
}
});
}
function unescapeXml(value: string): string | null {
if (/[<>]|&(?!amp;|lt;|gt;|quot;|apos;)/.test(value)) return null;
return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, entity: string) => {
switch (entity) {
case "amp":
return "&";
case "lt":
return "<";
case "gt":
return ">";
case "quot":
return '"';
default:
return "'";
}
});
}
export function formatAgentMessage(message: AgentMessage): string {
const { source } = message;
const attributes = source
? `kind="${source.kind}" source-agent-id="${escapeXml(source.agentId)}"`
: 'kind="user-message"';
const server = source?.serverId ? ` source-server-id="${escapeXml(source.serverId)}"` : "";
const title = source?.title ? ` source-agent-title="${escapeXml(source.title)}"` : "";
const event = source?.kind === "agent-notification" ? ` event="${source.event}"` : "";
return `<paseo-system version="1" ${attributes} message-id="${escapeXml(message.id)}"${server}${title}${event}>\n${escapeXml(message.text)}\n</paseo-system>`;
}
export function parseAgentMessage(text: string): AgentMessage | null {
const envelope = /^<paseo-system((?:\s+[a-z-]+="[^"<>]*")+)>\n([\s\S]*)\n<\/paseo-system>$/.exec(
text,
);
if (!envelope) return null;
const attributes = new Map<string, string>();
for (const match of envelope[1].matchAll(/\s+([a-z-]+)="([^"<>]*)"/g)) {
const value = unescapeXml(match[2]);
if (value === null || attributes.has(match[1])) return null;
attributes.set(match[1], value);
}
if (attributes.get("version") !== "1") return null;
const id = attributes.get("message-id");
const body = unescapeXml(envelope[2]);
if (!id || body === null) return null;
if (attributes.get("kind") === "user-message") {
if (attributes.has("source-agent-id") || attributes.has("event")) return null;
return { id, source: null, text: body };
}
const source = sourceSchema.safeParse({
kind: attributes.get("kind"),
agentId: attributes.get("source-agent-id"),
serverId: attributes.get("source-server-id"),
event: attributes.get("event"),
title: attributes.get("source-agent-title"),
});
if (!source.success) return null;
return { id, source: source.data, text: body };
}
/** Accept only a complete sequence of envelopes; prose around them stays user text. */
export function parseAgentMessages(text: string): AgentMessage[] | null {
// Bodies are XML escaped, so a literal closing tag unambiguously ends an entry.
const parts = text.split(/(?<=<\/paseo-system>)\n+(?=<paseo-system )/);
const messages = parts.map(parseAgentMessage);
return messages.every((message) => message !== null) ? messages : null;
}
function renderMessageText(block: AgentPromptContentBlock): string[] {
if (block.type === "image") return [];
if (block.type === "text") return [block.text];
return [renderPromptAttachmentAsText(block)];
}
export function prepareAgentMessage(
prompt: AgentPromptInput,
source?: AgentPromptSource,
id?: string,
): { prompt: AgentPromptInput; messageId?: string } {
if (!source) {
const submittedText =
typeof prompt === "string"
? prompt
: prompt
.flatMap((block) =>
block.type === "text" && !("mimeType" in block) ? [block.text] : [],
)
.join("\n")
.trim();
// Quote reserved envelopes only when they arrived through the human send path.
// This provenance must survive provider history, not only the accepted local row.
if (!parseAgentMessages(submittedText) && !isSystemInjectedEnvelope(submittedText)) {
return { prompt, messageId: id };
}
}
const messageId = id ?? randomUUID();
const text = typeof prompt === "string" ? prompt : prompt.flatMap(renderMessageText).join("\n\n");
// Render textual attachments before wrapping, so provider replay still contains one
// complete envelope instead of an envelope followed by provider-rendered context.
const envelope = formatAgentMessage({ id: messageId, source: source ?? null, text });
return {
messageId,
prompt:
typeof prompt === "string"
? envelope
: [{ type: "text", text: envelope }, ...prompt.filter((block) => block.type === "image")],
};
}
/** The same projection owns provider echoes, acceptance, import, and replay. */
export function projectAgentMessages(item: AgentTimelineItem): AgentTimelineItem[] {
if (item.type === "user_message") {
const messages = parseAgentMessages(item.text);
if (messages) {
return messages.flatMap((message) => {
const projected = projectAgentMessage({ ...item, text: formatAgentMessage(message) });
return projected ? [projected] : [];
});
}
}
const projected = projectAgentMessage(item);
return projected ? [projected] : [];
}
function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null {
if (item.type !== "user_message") return item;
const message = parseAgentMessage(item.text);
if (!message) {
// Schedule bodies include their run ID, keeping identity stable across acceptance
// and provider replay even when the provider assigns its own message ID.
if (!isSystemInjectedEnvelope(item.text)) return item;
const text = item.text.slice("<paseo-system>\n".length, -"\n</paseo-system>".length);
return {
type: "tool_call",
callId: `paseo-system:${createHash("sha256").update(item.text).digest("hex")}`,
name: "heartbeat",
status: "completed",
error: null,
detail: { type: "plain_text", text, icon: "sparkles" },
};
}
const { source } = message;
if (!source) {
return { ...item, text: message.text, clientMessageId: message.id };
}
return {
type: "tool_call",
callId: `paseo-agent-message:${message.id}`,
agentMessage: {
event: source.kind === "agent-message" ? "message" : source.event,
sender: {
id: source.agentId,
...(source.serverId ? { serverId: source.serverId } : {}),
...(source.title ? { title: source.title } : {}),
},
text: message.text,
},
// COMPAT(agentMessageToolEnvelope): added in v0.11.1; remove after 2027-04-08
// once clients can accept a dedicated timeline variant. Older clients still
// parse this existing shape and can expand the delivered body.
name: "agent_message",
status: "completed",
error: null,
detail: { type: "plain_text", text: message.text, icon: "bot" },
};
}
/**
* Wrap a body in <paseo-system>…</paseo-system> so the receiving agent
* recognizes the prompt as system-injected context — not a user turn.
* Used by schedule fires; agent messages use the versioned envelope.
*/
export function formatSystemNotificationPrompt(reason: string): string {
return `<paseo-system>\n${reason}\n</paseo-system>`;
}
const SYSTEM_ENVELOPE_PATTERN = /^<paseo-system>\n[\s\S]*\n<\/paseo-system>$/;
export function isSystemInjectedEnvelope(text: string): boolean {
return SYSTEM_ENVELOPE_PATTERN.test(text);
}
@@ -1,191 +1,11 @@
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import type {
AgentPromptContentBlock,
AgentPromptInput,
AgentTimelineItem,
} from "../agent-sdk-types.js";
const agentIdSchema = z.string().min(1);
const sourceSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("agent-message"),
agentId: agentIdSchema,
title: z.string().min(1).optional(),
}),
z.object({
kind: z.literal("agent-notification"),
agentId: agentIdSchema,
title: z.string().min(1).optional(),
event: z.enum(["finished", "errored", "permission-required", "closed"]),
}),
]);
export type AgentPromptSource = z.infer<typeof sourceSchema>;
interface AgentMessage {
id: string;
source: AgentPromptSource | null;
text: string;
}
function escapeXml(value: string): string {
return value.replace(/[&<>"']/g, (character) => {
switch (character) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
default:
return "&apos;";
}
});
}
function unescapeXml(value: string): string | null {
if (/[<>]|&(?!amp;|lt;|gt;|quot;|apos;)/.test(value)) return null;
return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, entity: string) => {
switch (entity) {
case "amp":
return "&";
case "lt":
return "<";
case "gt":
return ">";
case "quot":
return '"';
default:
return "'";
}
});
}
export function formatAgentMessage(message: AgentMessage): string {
const { source } = message;
const attributes = source
? `kind="${source.kind}" source-agent-id="${escapeXml(source.agentId)}"`
: 'kind="user-message"';
const title = source?.title ? ` source-agent-title="${escapeXml(source.title)}"` : "";
const event = source?.kind === "agent-notification" ? ` event="${source.event}"` : "";
return `<paseo-system version="1" ${attributes} message-id="${escapeXml(message.id)}"${title}${event}>\n${escapeXml(message.text)}\n</paseo-system>`;
}
export function parseAgentMessage(text: string): AgentMessage | null {
const envelope = /^<paseo-system((?:\s+[a-z-]+="[^"<>]*")+)>\n([\s\S]*)\n<\/paseo-system>$/.exec(
text,
);
if (!envelope) return null;
const attributes = new Map<string, string>();
for (const match of envelope[1].matchAll(/\s+([a-z-]+)="([^"<>]*)"/g)) {
const value = unescapeXml(match[2]);
if (value === null || attributes.has(match[1])) return null;
attributes.set(match[1], value);
}
if (attributes.get("version") !== "1") return null;
const id = attributes.get("message-id");
const body = unescapeXml(envelope[2]);
if (!id || body === null) return null;
if (attributes.get("kind") === "user-message") {
if (attributes.has("source-agent-id") || attributes.has("event")) return null;
return { id, source: null, text: body };
}
const source = sourceSchema.safeParse({
kind: attributes.get("kind"),
agentId: attributes.get("source-agent-id"),
event: attributes.get("event"),
title: attributes.get("source-agent-title"),
});
if (!source.success) return null;
return { id, source: source.data, text: body };
}
function renderMessageText(block: AgentPromptContentBlock): string[] {
if (block.type === "image") return [];
if (block.type === "text") return [block.text];
return [renderPromptAttachmentAsText(block)];
}
export function prepareAgentMessage(
prompt: AgentPromptInput,
source?: AgentPromptSource,
id?: string,
): { prompt: AgentPromptInput; messageId?: string } {
if (!source) {
const submittedText =
typeof prompt === "string"
? prompt
: prompt
.flatMap((block) =>
block.type === "text" && !("mimeType" in block) ? [block.text] : [],
)
.join("\n")
.trim();
// Quote reserved envelopes only when they arrived through the human send path.
// This provenance must survive provider history, not only the accepted local row.
if (!parseAgentMessage(submittedText) && !isSystemInjectedEnvelope(submittedText)) {
return { prompt, messageId: id };
}
}
const messageId = id ?? randomUUID();
const text = typeof prompt === "string" ? prompt : prompt.flatMap(renderMessageText).join("\n\n");
// Render textual attachments before wrapping, so provider replay still contains one
// complete envelope instead of an envelope followed by provider-rendered context.
const envelope = formatAgentMessage({ id: messageId, source: source ?? null, text });
return {
messageId,
prompt:
typeof prompt === "string"
? envelope
: [{ type: "text", text: envelope }, ...prompt.filter((block) => block.type === "image")],
};
}
/** The same projection owns provider echoes, acceptance, import, and replay. */
export function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null {
if (item.type !== "user_message") return item;
const message = parseAgentMessage(item.text);
if (!message) {
// COMPAT(legacySystemEnvelope): added in v0.11.1; remove after 2027-04-08 once
// legacy notification history is retired. Schedules still use this envelope.
return isSystemInjectedEnvelope(item.text) ? null : item;
}
const { source } = message;
if (!source) {
return { ...item, text: message.text, clientMessageId: message.id };
}
return {
type: "tool_call",
callId: `paseo-agent-message:${message.id}`,
agentMessage: {
event: source.kind === "agent-message" ? "message" : source.event,
sender: { id: source.agentId, ...(source.title ? { title: source.title } : {}) },
text: message.text,
},
// COMPAT(agentMessageToolEnvelope): added in v0.11.1; remove after 2027-04-08
// once clients can accept a dedicated timeline variant. Older clients still
// parse this existing shape and can expand the delivered body.
name: "agent_message",
status: "completed",
error: null,
detail: { type: "plain_text", text: message.text, icon: "bot" },
};
}
/**
* Wrap a body in <paseo-system>…</paseo-system> so the receiving agent
* recognizes the prompt as system-injected context — not a user turn.
* Used by schedule fires; agent messages use the versioned envelope.
*/
export function formatSystemNotificationPrompt(reason: string): string {
return `<paseo-system>\n${reason}\n</paseo-system>`;
}
const SYSTEM_ENVELOPE_PATTERN = /^<paseo-system>\n[\s\S]*\n<\/paseo-system>$/;
export function isSystemInjectedEnvelope(text: string): boolean {
return SYSTEM_ENVELOPE_PATTERN.test(text);
}
export {
formatAgentMessage,
parseAgentMessage,
parseAgentMessages,
prepareAgentMessage,
projectAgentMessages,
formatSystemNotificationPrompt,
isSystemInjectedEnvelope,
type AgentPromptSource,
} from "./envelope.js";
export { createAgentMessageDelivery, type AgentMessageDelivery } from "./delivery.js";
@@ -1,3 +1,4 @@
import { isSameOrDescendantPath } from "../path-utils.js";
import type {
AgentListItemPayload,
AgentSnapshotPayload,
@@ -30,6 +31,13 @@ interface ProjectionOptions {
title?: string | null;
createdAt?: string;
internal?: boolean;
/**
* Archive timestamp, supplied by a caller holding the stored record. A live
* managed agent cannot know this: an archived agent is resumed back into
* memory to serve a history read, so being in the manager is not evidence
* that it is active. Storage stays the single owner.
*/
archivedAt?: string | null;
}
interface RecentProviderSessionProjectionOptions {
@@ -87,6 +95,7 @@ export function toStoredAgentRecord(
lastStatus: agent.lifecycle,
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null,
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
runtimeInfo,
features: normalizeFeatures(agent.features),
persistence,
@@ -139,6 +148,7 @@ export function toAgentPayload(
persistence: projectPersistenceHandleForWire(agent.persistence),
title: options?.title ?? null,
labels: agent.labels,
archivedAt: options?.archivedAt ?? null,
...(agent.internal ? { internal: true } : {}),
};
@@ -278,6 +288,66 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt
};
}
function parseTimestamp(value: string | null | undefined): number {
if (!value) {
return 0;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
function resolveAgentListActivityTime(agent: AgentListItemPayload): number {
return Math.max(
parseTimestamp(agent.updatedAt),
parseTimestamp(agent.lastUserMessageAt),
parseTimestamp(agent.attentionTimestamp),
parseTimestamp(agent.archivedAt),
parseTimestamp(agent.createdAt),
);
}
function compareAgentListItems(a: AgentListItemPayload, b: AgentListItemPayload): number {
const attentionDelta =
Number(b.requiresAttention ?? false) - Number(a.requiresAttention ?? false);
if (attentionDelta !== 0) {
return attentionDelta;
}
const statusOrder = {
running: 0,
initializing: 1,
idle: 2,
error: 3,
closed: 4,
} as Record<string, number>;
const statusDelta = (statusOrder[a.status] ?? 999) - (statusOrder[b.status] ?? 999);
if (statusDelta !== 0) {
return statusDelta;
}
return resolveAgentListActivityTime(b) - resolveAgentListActivityTime(a);
}
export function selectAgentListItems(
agents: readonly AgentSnapshotPayload[],
options: {
cwd?: string;
statuses?: AgentSnapshotPayload["status"][];
sinceHours?: number;
limit?: number;
} = {},
): AgentListItemPayload[] {
const statusFilter = options.statuses?.length ? new Set(options.statuses) : null;
const sinceMs = Date.now() - (options.sinceHours ?? 48) * 60 * 60 * 1000;
return agents
.map(toAgentListItemPayload)
.filter((agent) => !options.cwd || isSameOrDescendantPath(options.cwd, agent.cwd))
.filter((agent) => !statusFilter || statusFilter.has(agent.status))
.filter((agent) => !agent.archivedAt || resolveAgentListActivityTime(agent) >= sinceMs)
.sort(compareAgentListItems)
.slice(0, options.limit ?? 50);
}
export function toRecentProviderSessionDescriptorPayload(
session: ImportableProviderSession & { provider: string },
options: RecentProviderSessionProjectionOptions,
@@ -4,10 +4,9 @@ import {
parseAgentMessage,
formatAgentMessage,
prepareAgentMessage,
projectAgentMessage,
projectAgentMessages,
} from "./agent-messages/index.js";
import { expect, it, test, vi } from "vitest";
import pino, { type Logger } from "pino";
import { expect, test, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -15,9 +14,7 @@ import { join } from "node:path";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import { setupFinishNotification, waitForAgentRunStartWithTimeout } from "./agent-prompt.js";
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
import { waitForAgentRunStartWithTimeout } from "./agent-prompt.js";
import type {
AgentClient,
AgentRunResult,
@@ -25,543 +22,12 @@ import type {
AgentStreamEvent,
} from "./agent-sdk-types.js";
interface CapturedLogger {
logger: Logger;
records: Array<Record<string, unknown>>;
nextRecord: Promise<void>;
}
function createCapturedLogger(): CapturedLogger {
const records: Array<Record<string, unknown>> = [];
let resolveNextRecord!: () => void;
const nextRecord = new Promise<void>((resolve) => {
resolveNextRecord = resolve;
});
const logger = pino(
{ level: "error" },
{
write(line: string) {
records.push(JSON.parse(line) as Record<string, unknown>);
resolveNextRecord();
},
},
);
return { logger, records, nextRecord };
}
interface FinishNotificationScenarioOptions {
childLastAssistantMessage?: string | null;
childParentAgentId?: string | null;
requireParentOwnership?: boolean;
parentPromptError?: Error;
logger?: Logger;
}
interface FinishNotificationScenario {
startWatchingChild(): void;
requestChildPermission(requestId?: string): void;
resolveChildPermission(requestId?: string): void;
resolveChildPermissionFromState(requestId?: string): void;
resolveChildPermissionWhileIdle(requestId?: string): void;
finishChild(): void;
finishChildAndReadParentPrompt(): Promise<string>;
closeChildAndReadParentPrompt(): Promise<string>;
parentPrompts(): string[];
steerAttemptCount(): number;
wasParentPrompted(): boolean;
}
function createFinishNotificationScenario(
options?: FinishNotificationScenarioOptions,
): FinishNotificationScenario {
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
let resolveParentPrompt: ((prompt: string) => void) | null = null;
let parentPrompted = false;
let steerAttemptCount = 0;
const parentPrompts: string[] = [];
const childAgent: ManagedAgent = Object.create(null);
Reflect.set(childAgent, "id", "child-agent");
Reflect.set(childAgent, "lifecycle", "idle");
Reflect.set(childAgent, "config", { title: "Child Agent" });
Reflect.set(childAgent, "pendingPermissions", new Map());
const callerAgent: ManagedAgent = Object.create(null);
Reflect.set(callerAgent, "id", "caller-agent");
Reflect.set(callerAgent, "lifecycle", "idle");
Reflect.set(callerAgent, "config", { title: "Caller Agent" });
const agentManager = new AgentManager({ clients: {}, logger: createTestLogger() });
Reflect.set(agentManager, "getAgent", (agentId: string) => {
if (agentId === "child-agent") {
return childAgent;
}
if (agentId === "caller-agent") {
return callerAgent;
}
return null;
});
Reflect.set(agentManager, "subscribe", (callback: (event: AgentManagerEvent) => void) => {
subscriber = callback;
return () => {
subscriber = null;
};
});
Reflect.set(agentManager, "getLastAssistantMessage", async () => {
return options?.childLastAssistantMessage ?? null;
});
Reflect.set(agentManager, "tryRunOutOfBand", () => false);
Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError));
Reflect.set(agentManager, "steerOrReplaceActiveTurn", async () => {
steerAttemptCount += 1;
return { status: "inactive" };
});
Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => {
parentPrompted = true;
parentPrompts.push(prompt);
resolveParentPrompt?.(prompt);
return (async function* noop() {})();
});
Reflect.set(agentManager, "replaceAgentRun", async (_agentId: string, prompt: string) => {
resolveParentPrompt?.(prompt);
throw options?.parentPromptError;
});
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
Reflect.set(agentStorage, "get", async (agentId: string) => {
if (agentId === "child-agent") {
const parentAgentId =
options?.childParentAgentId === undefined ? "caller-agent" : options.childParentAgentId;
return {
title: "Child Agent",
labels: parentAgentId ? { "paseo.parent-agent-id": parentAgentId } : {},
};
}
return null;
});
return {
startWatchingChild() {
setupFinishNotification({
agentManager,
agentStorage,
childAgentId: "child-agent",
callerAgentId: "caller-agent",
requireParentOwnership: options?.requireParentOwnership,
logger: options?.logger ?? createTestLogger(),
});
},
requestChildPermission(requestId = "permission-1") {
childAgent.lifecycle = "running";
childAgent.pendingPermissions.set(requestId, {
id: requestId,
provider: "claude",
kind: "tool",
name: "Run command",
description: "Write the QA sentinel",
input: {
file_path: "/tmp/permission-qa.txt",
content: "PASEO_PERMISSION_NOTIFY_QA_OK\n",
},
});
subscriber?.({
type: "agent_state",
agent: childAgent,
});
subscriber?.({
type: "agent_stream",
agentId: "child-agent",
event: {
type: "permission_requested",
provider: "codex",
request: childAgent.pendingPermissions.get(requestId)!,
},
});
},
resolveChildPermission(requestId = "permission-1") {
childAgent.pendingPermissions.delete(requestId);
subscriber?.({
type: "agent_stream",
agentId: "child-agent",
event: {
type: "permission_resolved",
provider: "codex",
requestId,
resolution: { behavior: "allow" },
},
});
},
resolveChildPermissionFromState(requestId = "permission-1") {
childAgent.pendingPermissions.delete(requestId);
subscriber?.({ type: "agent_state", agent: childAgent });
},
resolveChildPermissionWhileIdle(requestId = "permission-1") {
childAgent.pendingPermissions.delete(requestId);
childAgent.lifecycle = "idle";
subscriber?.({ type: "agent_state", agent: childAgent });
subscriber?.({
type: "agent_stream",
agentId: "child-agent",
event: {
type: "permission_resolved",
provider: "codex",
requestId,
resolution: { behavior: "allow" },
},
});
},
finishChild() {
childAgent.lifecycle = "running";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
childAgent.lifecycle = "idle";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
subscriber?.({
type: "agent_stream",
agentId: "child-agent",
event: { type: "turn_completed", provider: "codex" },
});
},
async finishChildAndReadParentPrompt() {
const parentPrompt = new Promise<string>((resolve) => {
resolveParentPrompt = resolve;
});
this.finishChild();
return parentPrompt;
},
async closeChildAndReadParentPrompt() {
const parentPrompt = new Promise<string>((resolve) => {
resolveParentPrompt = resolve;
});
childAgent.lifecycle = "running";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
childAgent.lifecycle = "closed";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
return parentPrompt;
},
parentPrompts() {
return parentPrompts;
},
steerAttemptCount() {
return steerAttemptCount;
},
wasParentPrompted() {
return parentPrompted;
},
};
}
test("isSystemInjectedEnvelope matches the envelope formatSystemNotificationPrompt produces", () => {
expect(isSystemInjectedEnvelope(formatSystemNotificationPrompt("child finished"))).toBe(true);
expect(isSystemInjectedEnvelope("hello world")).toBe(false);
});
test("finish notifications tell the parent the child's last assistant message", async () => {
const scenario = createFinishNotificationScenario({
childLastAssistantMessage: "Implemented the cleanup and all checks pass.",
});
scenario.startWatchingChild();
const parentPrompt = await scenario.finishChildAndReadParentPrompt();
expect(parseAgentMessage(parentPrompt)).toEqual({
id: expect.any(String),
source: {
kind: "agent-notification",
agentId: "child-agent",
title: "Child Agent",
event: "finished",
},
text: "Agent child-agent (Child Agent) finished.\n\n<agent-response>\nImplemented the cleanup and all checks pass.\n</agent-response>",
});
expect(scenario.steerAttemptCount()).toBe(1);
});
test("finish notifications truncate oversized child responses", async () => {
const included = "x".repeat(4000);
const omitted = "TAIL-MARKER".repeat(50);
const scenario = createFinishNotificationScenario({
childLastAssistantMessage: included + omitted,
});
scenario.startWatchingChild();
const parentPrompt = await scenario.finishChildAndReadParentPrompt();
expect(parentPrompt).toContain(included);
expect(parentPrompt).toContain(
`[truncated ${omitted.length} chars; use get_agent_activity for the full response]`,
);
expect(parentPrompt).not.toContain("TAIL-MARKER");
});
test("closing a watched child notifies the caller", async () => {
const scenario = createFinishNotificationScenario();
scenario.startWatchingChild();
const parentPrompt = await scenario.closeChildAndReadParentPrompt();
expect(parseAgentMessage(parentPrompt)).toEqual({
id: expect.any(String),
source: {
kind: "agent-notification",
agentId: "child-agent",
title: "Child Agent",
event: "closed",
},
text: "Agent child-agent (Child Agent) was closed.",
});
});
test("finish notifications survive permission responses", async () => {
const scenario = createFinishNotificationScenario();
scenario.startWatchingChild();
scenario.requestChildPermission();
await vi.waitFor(() => {
expect(scenario.parentPrompts()).toHaveLength(1);
});
expect(scenario.parentPrompts()[0]).toContain("needs permission.");
const permissionPayload = parseAgentMessage(scenario.parentPrompts()[0])!.text.match(
/<permission-request>\n([\s\S]+?)\n<\/permission-request>/,
)?.[1];
expect(permissionPayload).toBeDefined();
expect(JSON.parse(permissionPayload!)).toEqual({
agentId: "child-agent",
requestId: "permission-1",
request: {
id: "permission-1",
provider: "claude",
kind: "tool",
name: "Run command",
description: "Write the QA sentinel",
input: {
file_path: "/tmp/permission-qa.txt",
content: "PASEO_PERMISSION_NOTIFY_QA_OK\n",
},
},
});
scenario.resolveChildPermission();
scenario.finishChild();
await vi.waitFor(() => {
expect(scenario.parentPrompts()).toHaveLength(2);
});
expect(scenario.parentPrompts()[1]).toContain("finished.");
});
test("an idle permission resolution waits for the resumed run to finish", async () => {
const scenario = createFinishNotificationScenario();
scenario.startWatchingChild();
scenario.requestChildPermission();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1));
scenario.resolveChildPermissionWhileIdle();
scenario.requestChildPermission("permission-2");
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2));
expect(scenario.parentPrompts().every((prompt) => prompt.includes("needs permission."))).toBe(
true,
);
scenario.resolveChildPermission("permission-2");
scenario.finishChild();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3));
expect(scenario.parentPrompts()[2]).toContain("finished.");
});
test("finish notifications report every concurrently pending permission", async () => {
const scenario = createFinishNotificationScenario();
scenario.startWatchingChild();
scenario.requestChildPermission("permission-1");
scenario.requestChildPermission("permission-2");
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2));
expect(
scenario.parentPrompts().map((prompt) => {
const payload = parseAgentMessage(prompt)!.text.match(
/<permission-request>\n([\s\S]+?)\n<\/permission-request>/,
)?.[1];
return JSON.parse(payload!).requestId;
}),
).toEqual(["permission-1", "permission-2"]);
scenario.resolveChildPermission("permission-1");
scenario.resolveChildPermission("permission-2");
scenario.finishChild();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3));
expect(scenario.parentPrompts()[2]).toContain("finished.");
});
test("finish notifications survive repeated permission cycles", async () => {
const scenario = createFinishNotificationScenario();
scenario.startWatchingChild();
scenario.requestChildPermission();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1));
scenario.resolveChildPermissionFromState();
scenario.requestChildPermission();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2));
scenario.resolveChildPermission();
scenario.finishChild();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3));
expect(
scenario.parentPrompts().map((prompt) => prompt.match(/(needs permission|finished)\./)?.[1]),
).toEqual(["needs permission", "needs permission", "finished"]);
});
test("detaching a child ends its parent-owned finish notification", async () => {
const scenario = createFinishNotificationScenario({
childParentAgentId: null,
requireParentOwnership: true,
});
scenario.startWatchingChild();
scenario.finishChild();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(scenario.wasParentPrompted()).toBe(false);
});
test("follow-up finish notifications do not require a parent relationship", async () => {
const scenario = createFinishNotificationScenario({ childParentAgentId: "another-agent" });
scenario.startWatchingChild();
const parentPrompt = await scenario.finishChildAndReadParentPrompt();
expect(parentPrompt).toContain("Agent child-agent (Child Agent) finished.");
});
test("finish notifications log a rejected parent prompt without an unhandled rejection", async () => {
const captured = createCapturedLogger();
const scenario = createFinishNotificationScenario({
parentPromptError: new Error("parent provider rejected replacement"),
logger: captured.logger,
});
scenario.startWatchingChild();
await scenario.finishChildAndReadParentPrompt();
await captured.nextRecord;
expect(captured.records).toEqual([
expect.objectContaining({
msg: "Failed to notify caller agent",
childAgentId: "child-agent",
callerAgentId: "caller-agent",
reason: "finished",
err: expect.objectContaining({ message: "parent provider rejected replacement" }),
}),
]);
});
it("does not notify archived callers", async () => {
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
const childAgent: ManagedAgent = Object.create(null);
Reflect.set(childAgent, "id", "child-agent");
Reflect.set(childAgent, "lifecycle", "idle");
Reflect.set(childAgent, "config", { title: "Child Agent" });
Reflect.set(childAgent, "pendingPermissions", new Map());
const callerAgent: ManagedAgent = Object.create(null);
Reflect.set(callerAgent, "id", "caller-agent");
Reflect.set(callerAgent, "lifecycle", "idle");
Reflect.set(callerAgent, "config", { title: "Caller Agent" });
const streamAgentSpy = vi.fn(() => (async function* noop() {})());
const replaceAgentRunSpy = vi.fn(() => (async function* noop() {})());
const agentManager = new AgentManager({ clients: {}, logger: createTestLogger() });
Reflect.set(
agentManager,
"getAgent",
vi.fn((agentId: string) => {
if (agentId === "child-agent") {
return childAgent;
}
if (agentId === "caller-agent") {
return callerAgent;
}
return null;
}),
);
Reflect.set(
agentManager,
"subscribe",
vi.fn((callback: (event: AgentManagerEvent) => void) => {
subscriber = callback;
return () => {
subscriber = null;
};
}),
);
Reflect.set(agentManager, "hasInFlightRun", vi.fn().mockReturnValue(false));
Reflect.set(agentManager, "streamAgent", streamAgentSpy);
Reflect.set(agentManager, "replaceAgentRun", replaceAgentRunSpy);
const agentStorageGetSpy = vi.fn(async (agentId: string) =>
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
);
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
Reflect.set(agentStorage, "get", agentStorageGetSpy);
setupFinishNotification({
agentManager,
agentStorage,
childAgentId: "child-agent",
callerAgentId: "caller-agent",
logger: createTestLogger(),
});
expect(subscriber).not.toBeNull();
childAgent.lifecycle = "running";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
childAgent.lifecycle = "idle";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
subscriber?.({
type: "agent_stream",
agentId: "child-agent",
event: { type: "turn_completed", provider: "codex" },
});
await vi.waitFor(() => {
expect(agentStorageGetSpy).toHaveBeenCalledWith("caller-agent");
});
expect(streamAgentSpy).not.toHaveBeenCalled();
expect(replaceAgentRunSpy).not.toHaveBeenCalled();
});
// Deliberately independent literals rather than the production constants these tests
// guard: deriving the boundaries from AGENT_RUN_START_TIMEOUT_MS would keep the tests
// green if that constant were shortened back under a provider's startup budget.
// Independent of the production constant so shortening the budget breaks this test.
const EXPECTED_RUN_START_BUDGET_MS = 60_000;
// The slowest provider startup budget the run-start wait has to sit outside of today
// (OpenCode's OPENCODE_SERVER_STARTUP_TIMEOUT_MS).
@@ -808,6 +274,36 @@ test("agent envelopes round-trip opaque sender IDs and XML-sensitive messages",
expect(parseAgentMessage(encoded.replace('version="1"', 'version="1" version="1"'))).toBeNull();
});
test("a delivered batch projects each message and completion separately in order", () => {
const entries = [
{
id: "report",
source: { kind: "agent-message" as const, agentId: "remote::worker", title: "Worker" },
text: "Result <ready>",
},
{
id: "finished",
source: {
kind: "agent-notification" as const,
agentId: "remote::worker",
event: "finished" as const,
},
text: "Finished & ready",
},
];
const text = entries.map(formatAgentMessage).join("\n\n");
expect(projectAgentMessages({ type: "user_message", text })).toEqual(
entries.flatMap((entry) =>
projectAgentMessages({ type: "user_message", text: formatAgentMessage(entry) }),
),
);
// Human submissions containing the same envelopes must survive replay as human text.
const pasted = prepareAgentMessage(text, undefined, "human-batch");
expect(projectAgentMessages({ type: "user_message", text: String(pasted.prompt) })).toEqual([
{ type: "user_message", text, clientMessageId: "human-batch" },
]);
});
test("agent envelope includes rendered attachment context and preserves images", () => {
const image = { type: "image" as const, data: "aW1hZ2U=", mimeType: "image/png" };
const attachment = {
@@ -849,11 +345,11 @@ test("a human pasting an agent envelope stays a human message through provider r
});
const delivery = prepareAgentMessage(pasted, undefined, "human-submission");
expect(
projectAgentMessage({
projectAgentMessages({
type: "user_message",
text: String(delivery.prompt),
messageId: "provider-echo",
}),
})[0],
).toEqual({
type: "user_message",
text: pasted,
@@ -861,3 +357,21 @@ test("a human pasting an agent envelope stays a human message through provider r
clientMessageId: "human-submission",
});
});
test("schedule prompts remain visible as completed tool calls on replay", () => {
const text = formatSystemNotificationPrompt("Heartbeat: check progress");
expect(projectAgentMessages({ type: "user_message", messageId: "fire-1", text })).toEqual([
expect.objectContaining({
type: "tool_call",
status: "completed",
detail: { type: "plain_text", text: "Heartbeat: check progress", icon: "sparkles" },
}),
]);
});
test("schedule trigger identity survives the provider assigning a message ID", () => {
const text = formatSystemNotificationPrompt("Schedule fired (id=heartbeat, run=run-1).");
expect(projectAgentMessages({ type: "user_message", text })).toEqual(
projectAgentMessages({ type: "user_message", text, messageId: "provider-message" }),
);
});
+22 -328
View File
@@ -1,16 +1,11 @@
import { prepareAgentMessage, type AgentPromptSource } from "./agent-messages/index.js";
import type { Logger } from "pino";
import type {
AgentPermissionRequest,
AgentPromptInput,
AgentRunOptions,
} from "./agent-sdk-types.js";
import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js";
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";
export type AgentUnarchiveController = Pick<AgentManager, "notifyAgentState" | "unarchiveSnapshot">;
@@ -20,6 +15,7 @@ export type AgentRunController = Pick<
| "getAgent"
| "tryRunOutOfBand"
| "hasInFlightRun"
| "reloadStalePluginSession"
| "replaceAgentRun"
| "steerOrReplaceActiveTurn"
| "streamAgent"
@@ -115,11 +111,12 @@ export async function startAgentRun(
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
// in-flight turn — replaceAgentRun would interrupt the running turn. The
// intercept lives at this layer so it covers every prompt entrypoint.
if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
if (await agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
options?.onDispatch?.("out_of_band");
return { disposition: "out_of_band" };
}
try {
await agentManager.reloadStalePluginSession(agentId);
return await startAgentRunInner(agentManager, agentId, prompt, logger, options);
} catch (error) {
if (!isStaleProviderSessionError(error)) throw error;
@@ -292,7 +289,7 @@ async function resolvePromptSource(
manager: Pick<AgentManager, "getAgent">,
storage: AgentStorage,
): Promise<AgentPromptSource | undefined> {
if (!source) return undefined;
if (!source || source.title) return source;
const title = (
manager.getAgent(source.agentId)?.config.title ?? (await storage.get(source.agentId))?.title
)?.trim();
@@ -312,6 +309,21 @@ async function resolvePromptSource(
*/
export async function sendPromptToAgent(
params: SendPromptToAgentParams,
): Promise<{ disposition: PromptDispatchDisposition }> {
const source = await resolvePromptSource(params.source, params.agentManager, params.agentStorage);
const delivery = prepareAgentMessage(params.prompt, source, params.messageId);
return sendPreparedPromptToAgent({
...params,
prompt: delivery.prompt,
runOptions: delivery.messageId
? { ...params.runOptions, clientMessageId: delivery.messageId }
: params.runOptions,
});
}
/** Dispatch daemon-prepared context without reclassifying it as human input. */
export async function sendPreparedPromptToAgent(
params: Omit<SendPromptToAgentParams, "source" | "messageId">,
): Promise<{ disposition: PromptDispatchDisposition }> {
const unarchive = params.unarchive ?? true;
@@ -345,18 +357,12 @@ export async function sendPromptToAgent(
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
}
const source = await resolvePromptSource(params.source, params.agentManager, params.agentStorage);
const delivery = prepareAgentMessage(params.prompt, source, params.messageId);
const runOptions = delivery.messageId
? { ...params.runOptions, clientMessageId: delivery.messageId }
: params.runOptions;
return await startAgentRun(params.agentManager, params.agentId, delivery.prompt, params.logger, {
return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, {
replaceRunning: true,
onDispatch: params.onDispatch,
activeTurnBehavior: params.activeTurnBehavior,
clearPendingPermissions: params.clearPendingPermissions,
runOptions,
runOptions: params.runOptions,
});
}
@@ -395,315 +401,3 @@ export async function startCreatedAgentInitialPrompt(
}
return refreshedSnapshot;
}
export interface SetupFinishNotificationParams {
agentManager: AgentManager;
agentStorage: AgentStorage;
childAgentId: string;
callerAgentId: string;
requireParentOwnership?: boolean;
/** Retain pre-dispatch outcomes only when dispatch keeps the existing turn. */
dispatchResult?: Promise<PromptDispatchDisposition | false>;
/** A replacement must observe its new start, not the displaced running snapshot. */
waitForTurnStart?: boolean;
logger: Logger;
}
type FinishNotificationReason =
| "finished"
| "errored"
| "needs permission"
| "was closed"
| "was canceled";
const finishNotificationEvents = {
finished: "finished",
errored: "errored",
"needs permission": "permission-required",
"was closed": "closed",
// Use the existing failure event so older clients still parse agent-message metadata.
"was canceled": "errored",
} as const;
const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000;
interface FinishNotificationBodyInput {
childAgentId: string;
title: string;
reason: FinishNotificationReason;
lastAssistantMessage: string | null;
permissionRequest?: AgentPermissionRequest;
}
function formatFinishNotificationBody(params: FinishNotificationBodyInput): string {
const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`;
const sections = [statusLine];
if (params.reason === "needs permission" && params.permissionRequest) {
sections.push(
"Respond with `respond_to_permission` using the `agentId` and `requestId` below.",
`<permission-request>\n${JSON.stringify(
{
agentId: params.childAgentId,
requestId: params.permissionRequest.id,
request: params.permissionRequest,
},
null,
2,
)}\n</permission-request>`,
);
}
let lastAssistantMessage = params.lastAssistantMessage?.trim();
if (lastAssistantMessage) {
if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) {
const omitted = lastAssistantMessage.length - FINISH_NOTIFICATION_MESSAGE_LIMIT;
lastAssistantMessage = `${lastAssistantMessage.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`;
}
sections.push(`<agent-response>\n${lastAssistantMessage}\n</agent-response>`);
}
return sections.join("\n\n");
}
interface NotifySafelyOptions {
terminal?: boolean;
permissionRequest?: AgentPermissionRequest;
}
// Pending follow-ups coexist until acknowledgement. A successful dispatch replaces
// every earlier observation for the caller/child pair; a failure removes only itself.
interface ArmedFinishNotification {
stop(): void;
supersedeWhen(accepted: Promise<boolean>): void;
}
const armedFinishNotifications = new WeakMap<
AgentManager,
Map<string, Set<ArmedFinishNotification>>
>();
export function setupFinishNotification(params: SetupFinishNotificationParams): () => void {
const {
agentManager,
agentStorage,
childAgentId,
callerAgentId,
requireParentOwnership = false,
waitForTurnStart = false,
logger,
} = params;
let hasSeenRunning = false;
let hasSeenTurn = false;
let stopped = false;
const notifiedPermissionRequestIds = new Set<string>();
let unsubscribe: (() => void) | null = null;
let notificationQueue = Promise.resolve();
const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map();
armedFinishNotifications.set(agentManager, armedByManager);
const armedKey = JSON.stringify([childAgentId, callerAgentId]);
const registrations = armedByManager.get(armedKey) ?? new Set<ArmedFinishNotification>();
armedByManager.set(armedKey, registrations);
const previous = [...registrations];
let superseded = Promise.resolve(false);
let dispatchPending = Boolean(params.dispatchResult);
const registration: ArmedFinishNotification = {
stop,
supersedeWhen(accepted) {
superseded = superseded.then(
async (alreadySuperseded) => alreadySuperseded || (await accepted),
);
},
};
registrations.add(registration);
if (params.dispatchResult) {
const dispatchAccepted = params.dispatchResult.then((result) => result !== false);
for (const observer of previous) observer.supersedeWhen(dispatchAccepted);
void params.dispatchResult.then((accepted) => {
dispatchPending = false;
if (accepted) {
for (const observer of previous) observer.stop();
if (stopped || accepted === "turn_started") stop();
} else {
stop();
}
return undefined;
});
} else {
for (const observer of previous) {
observer.supersedeWhen(Promise.resolve(true));
observer.stop();
}
}
function stop(): void {
stopped = true;
unsubscribe?.();
// Pending observations remain visible to overlapping sends until acknowledgement.
if (!dispatchPending) {
registrations.delete(registration);
if (registrations.size === 0 && armedByManager.get(armedKey) === registrations) {
armedByManager.delete(armedKey);
}
}
}
async function notify(
reason: FinishNotificationReason,
permissionRequest?: AgentPermissionRequest,
): Promise<void> {
if (params.dispatchResult) {
const result = await params.dispatchResult;
if (result === false || result === "turn_started") return;
}
if (await superseded) return;
const callerRecord = await agentStorage.get(callerAgentId);
if (callerRecord?.archivedAt) {
return;
}
const record = await agentStorage.get(childAgentId);
if (requireParentOwnership && getParentAgentIdFromLabels(record?.labels) !== callerAgentId) {
return;
}
const title = record?.title ?? childAgentId;
const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId);
const body = formatFinishNotificationBody({
childAgentId,
title,
reason,
lastAssistantMessage,
permissionRequest,
});
await sendPromptToAgent({
agentManager,
agentStorage,
agentId: callerAgentId,
prompt: body,
source: {
kind: "agent-notification",
agentId: childAgentId,
event: finishNotificationEvents[reason],
},
activeTurnBehavior: "steer",
unarchive: false,
logger,
});
}
function notifySafely(reason: FinishNotificationReason, options: NotifySafelyOptions = {}): void {
if (stopped) return;
if (options.terminal ?? true) stop();
notificationQueue = notificationQueue
.then(() => notify(reason, options.permissionRequest))
.catch((error) => {
logger.error(
{ err: error, childAgentId, callerAgentId, reason },
"Failed to notify caller agent",
);
});
}
function observeState(agent: ManagedAgent): void {
for (const requestId of notifiedPermissionRequestIds) {
if (!agent.pendingPermissions.has(requestId)) {
notifiedPermissionRequestIds.delete(requestId);
}
}
if (agent.lifecycle === "running") {
if (waitForTurnStart && !hasSeenTurn) return;
hasSeenTurn = true;
if (agent.pendingPermissions.size === 0) {
hasSeenRunning = true;
}
return;
}
// Prior terminal state is not this task's outcome. A failure before turn start
// is reported by the dispatch acknowledgement instead of a finish callback.
if (agent.lifecycle === "error" && hasSeenTurn) {
notifySafely("errored");
return;
}
// Idle is published before the terminal stream event for both completion
// and cancellation. Let that event identify the outcome instead.
if (agent.lifecycle === "closed") {
notifySafely("was closed");
return;
}
return;
}
unsubscribe = agentManager.subscribe(
(event) => {
if (stopped) {
return;
}
if (event.type === "agent_state") {
observeState(event.agent);
return;
}
if (event.type === "timeline_replacement") {
return;
}
if (event.event.type === "turn_started") {
hasSeenTurn = true;
hasSeenRunning = true;
return;
}
if (event.event.type === "turn_completed" || event.event.type === "turn_canceled") {
// Replacing a turn keeps the child running through the displaced turn's
// cancellation. The task settles only when the child becomes idle.
if (agentManager.getAgent(childAgentId)?.lifecycle !== "idle") return;
if (event.event.type === "turn_completed" && hasSeenRunning) {
notifySafely("finished");
} else if (event.event.type === "turn_canceled" && hasSeenTurn) {
notifySafely("was canceled");
}
return;
}
if (event.event.type === "permission_requested") {
// A permission pause is an intermediate checkpoint. Forget the run
// observed before it so an idle state during follow-up startup cannot
// masquerade as the final completion.
hasSeenRunning = false;
if (!notifiedPermissionRequestIds.has(event.event.request.id)) {
notifiedPermissionRequestIds.add(event.event.request.id);
notifySafely("needs permission", {
terminal: false,
permissionRequest: event.event.request,
});
}
return;
}
if (event.event.type === "permission_resolved") {
notifiedPermissionRequestIds.delete(event.event.requestId);
const childAgent = agentManager.getAgent(childAgentId);
if (childAgent?.pendingPermissions.size === 0) {
hasSeenRunning = childAgent.lifecycle === "running";
}
}
},
{ agentId: childAgentId, replayState: false },
);
// Check if the child is already running (catches the case where
// the lifecycle flipped before our subscribe call was processed).
// Do NOT treat an immediate "idle" as "finished" — the agent may
// not have started yet (streamAgent sets a pending run before
// transitioning to "running").
const childSnapshot = agentManager.getAgent(childAgentId);
if (!childSnapshot || childSnapshot.lifecycle === "closed") {
stop();
return stop;
}
if (childSnapshot.lifecycle === "running" && !waitForTurnStart) {
hasSeenTurn = true;
hasSeenRunning = true;
}
return stop;
}
@@ -254,6 +254,57 @@ describe("generateStructuredAgentResponseWithFallback", () => {
expect(manager.checkedProviders).toEqual(["claude", "codex"]);
});
it("logs the provider, model and error of every failed attempt", async () => {
const warnings: Array<{ obj: object; msg?: string }> = [];
const runnerError = new Error("connection refused");
const manager = createManager([
{ provider: "local-llm", available: true, error: null },
{ provider: "opencode", available: false, error: "not installed" },
]);
await expect(
generateStructuredAgentResponseWithFallback({
manager,
cwd: "/tmp/project",
prompt: "Return JSON",
schema,
schemaName: "BranchName",
providers: [
{ provider: "local-llm", model: "qwen-local" },
{ provider: "opencode", model: "minimax-m3" },
],
logger: {
info: () => undefined,
warn: (obj, msg) => warnings.push({ obj, msg }),
},
runner: async () => {
throw runnerError;
},
}),
).rejects.toBeInstanceOf(StructuredAgentFallbackError);
expect(warnings).toEqual([
{
obj: {
err: runnerError,
provider: "local-llm",
model: "qwen-local",
schemaName: "BranchName",
},
msg: "Structured generation: provider failed",
},
{
obj: {
provider: "opencode",
model: "minimax-m3",
schemaName: "BranchName",
reason: "not installed",
},
msg: "Structured generation: skipping unavailable provider",
},
]);
});
it("throws a fallback error when all providers are unavailable or fail", async () => {
const manager = createManager([
{ provider: "claude", available: false, error: "missing auth" },
@@ -475,7 +475,7 @@ export async function generateStructuredAgentResponseWithFallback<T>(
});
logger?.warn(
{ err: error, provider: candidate.provider, model: candidate.model, schemaName },
"Structured generation: provider failed, trying next",
"Structured generation: provider failed",
);
}
}
@@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";
import { AgentRunState } from "./agent-run-state.js";
function track(promise: Promise<void>): { readonly resolved: boolean } {
const state = { resolved: false };
void (async () => {
await promise;
state.resolved = true;
})();
return state;
}
async function flushMicrotasks(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
describe("waitForForegroundStart", () => {
test("resolves once the starting turn has an id", async () => {
const runs = new AgentRunState();
const run = runs.createPendingRun("agent");
const wait = track(runs.waitForForegroundStart("agent"));
await flushMicrotasks();
expect(wait.resolved).toBe(false);
runs.settleForegroundStart(run, { status: "started", turnId: "turn-1" });
await flushMicrotasks();
expect(wait.resolved).toBe(true);
});
test("waits for a failed start's run to be cleared", async () => {
const runs = new AgentRunState();
const run = runs.createPendingRun("agent");
const wait = track(runs.waitForForegroundStart("agent"));
runs.settleForegroundStart(run, { status: "failed", error: "start failed" });
await flushMicrotasks();
expect(wait.resolved).toBe(false);
expect(runs.hasPendingRun("agent")).toBe(true);
runs.settleForegroundRun("agent", run.token);
await flushMicrotasks();
expect(wait.resolved).toBe(true);
});
test("resolves at once when no turn is starting", async () => {
const runs = new AgentRunState();
await expect(runs.waitForForegroundStart("agent")).resolves.toBeUndefined();
});
});
@@ -10,14 +10,18 @@ export interface ForegroundTurnWaiter {
resolveSettled: () => void;
}
type ForegroundRunStart =
| { status: "pending" }
| { status: "started"; turnId: string }
| { status: "failed"; error: string };
export interface PendingForegroundRun {
token: string;
kind: "foreground";
stagedEvents: AgentStreamEvent[];
start:
| { status: "pending" }
| { status: "started"; turnId: string }
| { status: "failed"; error: string };
start: ForegroundRunStart;
turnStartedPromise: Promise<void>;
resolveTurnStarted: () => void;
settled: boolean;
settledPromise: Promise<void>;
resolveSettled: () => void;
@@ -54,6 +58,28 @@ export class AgentRunState {
return run?.kind === "foreground" ? run : null;
}
settleForegroundStart(
run: PendingForegroundRun,
start: Exclude<ForegroundRunStart, { status: "pending" }>,
): void {
run.start = start;
if (start.status === "started") {
run.resolveTurnStarted();
}
}
/**
* Resolves once the agent's foreground run has a turn id or has ended. A failed start
* resolves only once its run is cleared, so callers never see a failed run still tracked.
*/
async waitForForegroundStart(agentId: string): Promise<void> {
const run = this.getPendingRun(agentId);
if (run?.start.status !== "pending") {
return;
}
await Promise.race([run.turnStartedPromise, run.settledPromise]);
}
hasPendingRun(agentId: string): boolean {
return this.getPendingRun(agentId) !== null;
}
@@ -277,10 +303,16 @@ export class ForegroundTurnStream {
}
function createPendingForegroundRun(): PendingForegroundRun {
let resolveTurnStarted!: () => void;
const turnStartedPromise = new Promise<void>((resolvePromise) => {
resolveTurnStarted = resolvePromise;
});
return {
...createTrackedRunState(),
kind: "foreground",
start: { status: "pending" },
turnStartedPromise,
resolveTurnStarted,
stagedEvents: [],
};
}
@@ -616,6 +616,9 @@ export interface AgentSessionConfig {
}
export interface AgentLaunchContext {
/** Launch-only plugin MCP servers and their owners; never persisted. */
mcpServers?: Record<string, McpServerConfig>;
mcpServerOwners?: Record<string, string>;
agentId?: string;
env?: Record<string, string>;
/**
@@ -704,6 +707,10 @@ export interface AgentSession {
* still uncertain.
*/
interrupt(): Promise<void>;
/** True only when releasing this runtime loses no background work or pending replies.
* An optional additional veto: missing methods use daemon baseline eligibility;
* implemented checks that reject or return false keep the runtime resident. */
canStopRuntime?(): Promise<boolean>;
/** Release live runtime resources without archiving or deleting the durable native session. */
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
@@ -54,8 +54,10 @@ const STORED_AGENT_SCHEMA = z.object({
title: z.string().nullable().optional(),
labels: z.record(z.string(), z.string()).default({}),
lastStatus: AgentStatusSchema.default("closed"),
resumeOnStartup: z.boolean().optional(),
lastModeId: z.string().nullable().optional(),
config: SERIALIZABLE_CONFIG_SCHEMA,
launchEnv: z.record(z.string(), z.string()).optional(),
runtimeInfo: z
.object({
provider: z.string(),
@@ -259,6 +261,8 @@ export class AgentStorage {
if (existing && existing.archivedAt !== undefined) {
record.archivedAt = existing.archivedAt;
}
record.resumeOnStartup =
existing?.resumeOnStartup && agent.lifecycle !== "idle" && agent.lifecycle !== "error";
return record;
});
}
@@ -3,6 +3,29 @@ import { describe, expect, test } from "vitest";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
describe("agent timeline content", () => {
test.each([false, true])(
"preserves the exact UTF-16 prefix for oversized shell output (failed=%s)",
(failed) => {
const text = "x".repeat(64 * 1024 - 1) + "🙂";
const item = limitAgentTimelineItemContent({
type: "tool_call",
callId: "large-shell",
name: "Bash",
status: failed ? "failed" : "completed",
detail: { type: "shell", command: "echo large", output: text },
error: failed ? { content: text } : null,
});
expect(item).toEqual({
type: "tool_call",
callId: "large-shell",
name: "Bash",
status: failed ? "failed" : "completed",
detail: { type: "shell", command: "echo large", output: text.slice(0, 64 * 1024) },
error: failed ? { content: text.slice(0, 64 * 1024) } : null,
});
},
);
test("marks truncated plain-text tool content within the content budget", () => {
const oversizedInput = "x".repeat(64 * 1024 + 1);
@@ -11,6 +11,12 @@ export function assertPluginTimelineDataSize(data: JsonValue): void {
}
}
/** A sliced string can keep the entire provider output alive. Copy only the retained UTF-16
* units, including an unmatched surrogate at the existing character limit. */
function retainTextPrefix(text: string, length: number): string {
return Buffer.from(text.slice(0, length), "utf16le").toString("utf16le");
}
function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
if (
item.type !== "tool_call" ||
@@ -28,7 +34,7 @@ function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
...item,
error: {
...item.error,
content: item.error.content.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
content: retainTextPrefix(item.error.content, TOOL_CALL_CONTENT_MAX_LENGTH),
},
};
}
@@ -47,7 +53,8 @@ function limitPlainText(item: AgentTimelineItem): AgentTimelineItem {
...item,
detail: {
...item.detail,
text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice,
text:
retainTextPrefix(item.detail.text, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice,
},
};
}
@@ -69,7 +76,7 @@ export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTim
...item,
detail: {
...item.detail,
output: item.detail.output.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
output: retainTextPrefix(item.detail.output, TOOL_CALL_CONTENT_MAX_LENGTH),
},
};
}
@@ -17,6 +17,7 @@ export interface AgentTimelineCursor {
export type AgentTimelineFetchDirection = "tail" | "before" | "after";
export interface AgentTimelineFetchOptions {
wholeTurns?: boolean;
direction?: AgentTimelineFetchDirection;
cursor?: AgentTimelineCursor;
/**
@@ -1,7 +1,45 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
describe("InMemoryAgentTimelineStore", () => {
it("resets stale forward cursors to complete available turns", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent", { epoch: "current" });
for (let index = 0; index < 60; index++)
store.append(
"agent",
{
type: "tool_call",
callId: `call-${index}`,
name: "shell",
status: "completed",
error: null,
detail: { type: "unknown", input: null, output: null },
},
{ turnId: "run" },
);
const page = store.fetch("agent", {
direction: "after",
cursor: { epoch: "old", seq: 10 },
limit: 40,
wholeTurns: true,
});
expect(page.rows).toHaveLength(60);
expect(page).toMatchObject({
reset: true,
staleCursor: true,
startSeq: 1,
endSeq: 60,
hasOlder: false,
hasNewer: false,
});
});
it("clamps an overshooting before cursor into the bounded tail window", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent-1", {
@@ -177,7 +215,10 @@ describe("projected sequence ownership", () => {
store.append("a", { type: "assistant_message", text: "Answer" });
store.append("a", { ...tool, status: "completed" });
store.initialize("b", { rows: store.getRows("a") });
expect(store.getLastItem("b")).toMatchObject({ type: "tool_call", status: "completed" });
expect(store.getLastAssistantMessage("b")).toBe("Answer");
expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4);
expect(store.getLastItem("b")).toEqual({ type: "user_message", text: "next" });
expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]);
});
it("returns the last projected assistant message without joining different messages", () => {
@@ -211,3 +252,178 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]);
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
});
async function measureTimelineTail(wholeTurns: boolean) {
const fixture = fileURLToPath(
new URL("./test-utils/timeline-responsiveness.ts", import.meta.url),
);
const { stdout, stderr } = await promisify(execFile)(
process.execPath,
["--expose-gc", "--import", "tsx", fixture, String(wholeTurns)],
{ timeout: 15_000 },
);
process.stderr.write(stderr);
return JSON.parse(stdout) as {
result: ReturnType<InMemoryAgentTimelineStore["fetch"]>;
blockingMs: number;
};
}
it("excludes unrelated test-runner CPU from the timeline fetch budget", async () => {
let unrelatedWork: ReturnType<typeof setImmediate>;
const burnCpu = () => {
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
process,
"threadCpuUsage",
);
const start = threadCpuUsage();
let elapsed: NodeJS.CpuUsage;
do {
elapsed = threadCpuUsage(start);
} while (elapsed.user + elapsed.system < 80_000);
unrelatedWork = setImmediate(burnCpu);
};
unrelatedWork = setImmediate(burnCpu);
try {
const { result: tail, blockingMs } = await measureTimelineTail(false);
expect(tail.rows).toHaveLength(200);
expect(tail.startSeq).toBe(99_801);
expect(tail.endSeq).toBe(100_000);
expect(blockingMs).toBeLessThan(50);
} finally {
clearImmediate(unrelatedWork);
}
});
it.each([false, true])(
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
async (wholeTurns) => {
const { result: tail, blockingMs } = await measureTimelineTail(wholeTurns);
expect(tail.rows).toHaveLength(200);
expect(tail.startSeq).toBe(99_801);
expect(tail.endSeq).toBe(100_000);
expect(blockingMs).toBeLessThan(50);
},
);
it("matches the previous reprojecting selector on seeded randomized timelines", () => {
let seed = 0x76aabbcc;
const random = (bound: number) => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return Math.floor((seed / 0x1_0000_0000) * bound);
};
for (let scenario = 0; scenario < 100; scenario++) {
const store = new InMemoryAgentTimelineStore();
store.initialize("a", { epoch: "e" });
for (let seq = 1; seq <= 100; seq++) {
const identity = String(random(8));
const text = String(seq);
const items: AgentTimelineItem[] = [
{ type: "user_message", text },
{ type: "assistant_message", text },
{ type: "assistant_message", text, messageId: identity },
{ type: "reasoning", text },
{
type: "tool_call",
callId: identity,
name: "shell",
status: "running",
error: null,
detail: { type: "plain_text", label: text },
},
{
type: "plugin",
id: identity,
pluginId: "test",
kind: "state",
version: 1,
data: { text },
},
];
store.append("a", items[random(items.length)], { turnId: String(random(3)) });
}
// Include rehydration of already-projected rows, with non-monotonic seqEnd.
store.initialize("b", { rows: store.getRows("a"), epoch: "e" });
for (const agentId of ["a", "b"]) {
assertSelectionMatchesReference(store, agentId);
store.append(agentId, { type: "assistant_message", text: "later" });
assertSelectionMatchesReference(store, agentId);
}
}
});
function assertSelectionMatchesReference(store: InMemoryAgentTimelineStore, agentId: string): void {
for (const direction of ["tail", "before", "after"] as const) {
for (const limit of [0, 1, 7, 200]) {
for (const cursorSeq of [0, 1, 25, 50, 99, 100, 150]) {
const actual = store.fetch(agentId, {
direction,
limit,
cursor: { epoch: "e", seq: cursorSeq },
});
const expected = selectReferenceProjectedTimelinePage({
rows: store.getRows(agentId),
bounds: actual.window,
direction,
limit,
cursorSeq,
});
expect({
...expected,
entries: undefined,
rows: expected.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)),
}).toEqual({
entries: undefined,
rows: actual.rows,
startSeq: actual.startSeq,
endSeq: actual.endSeq,
hasOlder: actual.hasOlder,
hasNewer: actual.hasNewer,
});
}
}
}
}
it("continues a scroll across appends and lifecycle collapse without losing source coverage", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("a", { epoch: "e" });
const tool: AgentTimelineItem = {
type: "tool_call",
callId: "t",
name: "shell",
status: "running",
error: null,
detail: { type: "plain_text", label: "work" },
};
store.append("a", tool);
for (let seq = 2; seq <= 5; seq++) store.append("a", { type: "user_message", text: String(seq) });
const first = store.fetch("a", { direction: "after", cursor: { epoch: "e", seq: 0 }, limit: 2 });
expect(first.endSeq).toBe(2);
store.append("a", { ...tool, status: "completed" });
store.append("a", { type: "assistant_message", text: "Done" });
expect(first.rows[0].item).toMatchObject({ status: "running" });
const second = store.fetch("a", {
direction: "after",
cursor: { epoch: "e", seq: first.endSeq! },
limit: 2,
});
expect(second.rows.map((row) => row.seqStart)).toEqual([3, 4]);
expect(second.endSeq).toBe(4);
const third = store.fetch("a", {
direction: "after",
cursor: { epoch: "e", seq: second.endSeq! },
limit: 2,
});
expect(third.rows.map((row) => row.seqStart)).toEqual([1, 5]);
expect(third.rows[0].item).toMatchObject({ status: "completed" });
expect(third.endSeq).toBe(6);
const last = store.fetch("a", {
direction: "after",
cursor: { epoch: "e", seq: third.endSeq! },
limit: 2,
});
expect(last.rows.map((row) => row.item)).toEqual([{ type: "assistant_message", text: "Done" }]);
expect(last.endSeq).toBe(7);
expect(last.hasNewer).toBe(false);
});
@@ -1,9 +1,5 @@
import { randomUUID } from "node:crypto";
import {
TimelineProjection,
selectProjectedTimelinePage,
type ProjectedTimelineRow,
} from "./timeline-projection.js";
import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type {
AgentTimelineFetchOptions,
@@ -43,8 +39,7 @@ export class InMemoryAgentTimelineStore {
? options.rows.map(cloneRow)
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
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);
const projection = new TimelineProjection(rows);
this.states.set(agentId, {
epoch: options?.epoch ?? randomUUID(),
projection,
@@ -97,22 +92,22 @@ export class InMemoryAgentTimelineStore {
const state = this.requireState(agentId);
const direction = options?.direction ?? "tail";
const cursor = options?.cursor;
const rows = state.projection.getRows();
const hasRows = state.minSeq !== 0;
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 &&
hasRows &&
cursor.seq < state.minSeq - 1;
const reset = staleCursor || gap;
const page = selectProjectedTimelinePage({
rows,
const page = state.projection.selectPage({
bounds: window,
direction: reset ? "tail" : direction,
cursorSeq: cursor?.seq,
limit: options?.limit ?? DEFAULT_TIMELINE_FETCH_LIMIT,
wholeTurns: options?.wholeTurns,
});
return {
epoch: state.epoch,
@@ -150,14 +145,11 @@ export class InMemoryAgentTimelineStore {
getLastItem(agentId: string): AgentTimelineItem | null {
const state = this.requireState(agentId);
return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null;
return state.projection.getLastItem(state.nextSeq - 1);
}
getLastAssistantMessage(agentId: string): string | null {
const row = this.requireState(agentId)
.projection.getRows()
.findLast((candidate) => candidate.item.type === "assistant_message");
return row?.item.type === "assistant_message" ? row.item.text : null;
return this.requireState(agentId).projection.getLastAssistantMessage();
}
private requireState(agentId: string): AgentTimelineState {
@@ -0,0 +1,100 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { z } from "zod";
import {
AgentListItemPayloadSchema,
AgentSnapshotPayloadSchema,
} from "@getpaseo/protocol/messages";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
import { ClaudeAgentClient } from "./providers/claude/agent.js";
// Opt in to the same workflow with the installed providers instead of test clients.
const real = process.env.PASEO_ARCHIVE_REAL === "1";
const StatusSchema = z.object({ snapshot: AgentSnapshotPayloadSchema });
const ListSchema = z.object({ agents: z.array(AgentListItemPayloadSchema) });
test.each(["codex", "claude"] as const)(
"archived %s history remains archived across MCP discovery",
async (provider) => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "archive-discovery-"));
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: real
? {
[provider]:
provider === "codex"
? new CodexAppServerAgentClient(logger)
: new ClaudeAgentClient({ logger }),
}
: createTestAgentClients(),
pluginsEnabled: false,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const mcp = new Client({ name: "archive-discovery-test", version: "1.0.0" });
const call = async (name: string, args: Record<string, unknown>) => {
const result = await mcp.callTool({ name, arguments: args });
if (result.isError) throw new Error(JSON.stringify(result.content));
return result.structuredContent;
};
const list = async (includeArchived: boolean) =>
ListSchema.parse(await call("list_agents", { cwd, includeArchived })).agents;
const status = async (agentId: string) =>
StatusSchema.parse(await call("get_agent_status", { agentId })).snapshot;
try {
await client.connect();
await mcp.connect(
new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${daemon.port}/mcp/agents`)),
);
const realModel = provider === "codex" ? "gpt-5.6-sol" : "haiku";
const agent = await client.createAgent({
config: {
provider,
cwd,
model: real ? realModel : undefined,
modeId: provider === "codex" ? "full-access" : "bypassPermissions",
},
});
await daemon.daemon.agentManager.runAgent(
agent.id,
"Reply with ARCHIVE_OK. Do not use tools.",
);
expect((await list(false)).map((item) => item.id)).toContain(agent.id);
await call("archive_agent", { agentId: agent.id });
const archivedAt = (await status(agent.id)).archivedAt;
expect(archivedAt).toEqual(expect.any(String));
expect((await list(false)).map((item) => item.id)).not.toContain(agent.id);
const timeline = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
expect(timeline.entries.length).toBeGreaterThan(0);
expect.soft((await status(agent.id)).archivedAt).toBe(archivedAt);
expect.soft((await list(false)).map((item) => item.id)).not.toContain(agent.id);
expect
.soft((await list(true)).find((item) => item.id === agent.id)?.archivedAt)
.toBe(archivedAt);
const ordinary = await client.fetchAgents({ filter: { includeArchived: false } });
expect.soft(ordinary.entries.map((entry) => entry.agent.id)).not.toContain(agent.id);
const inclusive = await client.fetchAgents({ filter: { includeArchived: true } });
expect
.soft(inclusive.entries.find((entry) => entry.agent.id === agent.id)?.agent.archivedAt)
.toBe(archivedAt);
await client.refreshAgent(agent.id);
expect((await status(agent.id)).archivedAt).toBeNull();
expect((await list(false)).map((item) => item.id)).toContain(agent.id);
} finally {
await mcp.close();
await client.close();
await daemon.close();
await rm(cwd, { recursive: true, force: true });
}
},
120_000,
);
@@ -1,3 +1,4 @@
import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js";
import { randomUUID } from "node:crypto";
import type pino from "pino";
@@ -7,11 +8,13 @@ import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-se
import type {
CreatePaseoWorktreeWorkflowFn,
CreatePaseoWorktreeWorkflowResult,
CreatePaseoWorktreeSetupContinuationInput,
NormalizedGitOptions,
} from "../worktree-session.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import type { FirstAgentContext, SessionOutboundMessage } from "../messages.js";
import type {
FirstAgentContext,
CreateAgentWorktreeTarget,
SessionOutboundMessage,
} from "../messages.js";
import type { AgentManager, AgentSubscriber, SubscribeOptions } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
@@ -57,33 +60,39 @@ export class CreateAgentLifecycleDispatch {
async createWorktreeForRequest(input: {
cwd: string;
target: NormalizedGitOptions | null;
target: CreateAgentWorktreeTarget | undefined;
firstAgentContext: FirstAgentContext;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
background?: boolean;
callerWorkspaceId?: string;
}): Promise<CreatePaseoWorktreeWorkflowResult | null> {
const target = input.target;
if (!target?.createWorktree) return null;
if (!target) return null;
const placement: CreatePaseoWorktreeInput = { cwd: input.cwd };
switch (target.mode) {
case "branch-off":
placement.action = "branch-off";
placement.worktreeSlug = target.newBranch;
placement.refName = target.base;
break;
case "checkout-branch":
placement.action = "checkout";
placement.refName = target.branch;
break;
case "checkout-pr":
placement.githubPrNumber = target.prNumber;
break;
}
const base = target.mode === "branch-off" ? target.base : undefined;
return this.dependencies.createPaseoWorktreeWorkflow(
{
cwd: input.cwd,
...placement,
background: input.background,
callerWorkspaceId: input.callerWorkspaceId,
firstAgentContext: input.firstAgentContext,
worktreeSlug: target.worktreeSlug,
refName: target.refName,
action: target.action,
checkoutSource: target.checkoutSource,
githubPrNumber: target.githubPrNumber,
runSetup: false,
paseoHome: this.dependencies.paseoHome,
worktreesRoot: this.dependencies.worktreesRoot,
},
{
...(target.baseBranch ? { resolveDefaultBranch: async () => target.baseBranch! } : {}),
setupContinuation: input.setupContinuation,
},
base ? { resolveDefaultBranch: async () => base } : undefined,
);
}
@@ -11,7 +11,6 @@ import { AgentStorage } from "../agent-storage.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
import { createAgentCommand } from "./create.js";
import type { ManagedAgent } from "../agent-manager.js";
import type { AgentSessionConfig } from "../agent-sdk-types.js";
const logger = createTestLogger();
@@ -53,7 +52,6 @@ function fakeWorktreeCreator(args: { repoRoot: string; createdWorkspaceId: strin
workspace: { workspaceId: args.createdWorkspaceId, cwd: workspaceCwd },
repoRoot: args.repoRoot,
created: true,
setupContinuation: { kind: "agent" as const, startAfterAgentCreate: () => {} },
}) as unknown as CreatePaseoWorktreeWorkflowResult;
}
@@ -71,6 +69,7 @@ test("session create forwards clientMessageId to the initial prompt run options"
getAgent: vi.fn(() => snapshot),
tryRunOutOfBand: vi.fn(() => false),
hasInFlightRun: vi.fn(() => false),
reloadStalePluginSession: async () => {},
streamAgent,
waitForAgentRunStart: vi.fn(async () => undefined),
} as unknown as Parameters<typeof createAgentCommand>[0]["agentManager"],
@@ -88,7 +87,6 @@ test("session create forwards clientMessageId to the initial prompt run options"
labels: {},
provisionalTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
});
expect(streamAgent).toHaveBeenCalledWith("agent-1", "hello from create", {
@@ -125,7 +123,6 @@ test("session create validates the requested mode against the provider's modes",
labels: {},
provisionalTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
}),
).rejects.toThrow("Invalid mode 'plan'");
@@ -169,7 +166,6 @@ test("session create applies the resolved mode from the provider create config",
labels: {},
provisionalTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
});
expect(createAgent).toHaveBeenCalledWith(
@@ -247,7 +243,6 @@ test("session create stamps the requested workspaceId when no worktree setup run
labels: {},
provisionalTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
},
);
@@ -258,41 +253,6 @@ test("session create stamps the requested workspaceId when no worktree setup run
}
});
test("session create stamps the new worktree's workspaceId when a setup continuation runs", async () => {
const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const agentManager = createRealAgentManager(storage);
try {
const { snapshot } = await createAgentCommand(
{
agentManager,
agentStorage: storage,
logger,
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
},
{
kind: "session",
config: { provider: "codex", cwd: workdir },
workspaceId: "ws-source",
labels: {},
provisionalTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({
sessionConfig: config,
setupContinuation: { kind: "agent", startAfterAgentCreate: () => {} },
createdWorkspaceId: "ws-new-worktree",
}),
},
);
const stored = await storage.get(snapshot.id);
expect(stored?.workspaceId).toBe("ws-new-worktree");
} finally {
await removeRealAgentManagerWorkdir({ agentManager, storage, workdir });
}
});
test("mcp create stamps the new worktree's workspaceId, not the parent's", async () => {
const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
@@ -309,7 +269,6 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async
labels: {},
provisionalTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
},
);
@@ -416,7 +375,6 @@ test("session create keeps the prompt title after the initial prompt settles", a
labels: {},
provisionalTitle: title,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
},
);
@@ -454,7 +412,6 @@ test("session create keeps an explicit title after the initial prompt settles",
labels: {},
provisionalTitle: title,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
},
);
@@ -492,7 +449,6 @@ test("session create with an internal config skips provider session persistence"
labels: {},
provisionalTitle: null,
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config: AgentSessionConfig) => ({ sessionConfig: config }),
};
await createAgentCommand(dependencies, {
@@ -1,3 +1,4 @@
import type { FinishSubscriptions } from "../finish-subscriptions/index.js";
import type { AgentPromptSource } from "../agent-messages/index.js";
import type { Logger } from "pino";
@@ -6,37 +7,24 @@ import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js";
import { expandUserPath, resolvePathFromBase } from "../../path-utils.js";
import { toWorktreeRequestError } from "../../worktree-errors.js";
import type {
AgentWorktreeSetupContinuation,
CreatePaseoWorktreeSetupContinuationInput,
CreatePaseoWorktreeWorkflowFn,
CreatePaseoWorktreeWorkflowResult,
} from "../../worktree-session.js";
import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js";
import type { AgentAttachment, FirstAgentContext } from "../../messages.js";
import type { AgentManager, CreateAgentOptions, ManagedAgent } from "../agent-manager.js";
import type { AgentPromptInput, AgentRunOptions, AgentSessionConfig } from "../agent-sdk-types.js";
import type { AgentStorage } from "../agent-storage.js";
import type { AgentOwner } from "../agent-owner.js";
import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js";
import { startCreatedAgentInitialPrompt } from "../agent-prompt.js";
import { resolveCreateAgentTitles } from "../create-agent-title.js";
import { buildAgentPrompt } from "../prompt-attachments.js";
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "../timeline-append.js";
import { resolveCreateAgentIntent } from "./intent.js";
export interface CreateAgentSessionWorktreeResult {
sessionConfig: AgentSessionConfig;
setupContinuation?: AgentWorktreeSetupContinuation;
// Set when this build created a fresh worktree workspace. The agent must be
// stamped with it so workspaceId-scoped archive can find the agent later.
createdWorkspaceId?: string;
}
export interface CreateAgentCommandDependencies {
finishSubscriptions?: FinishSubscriptions;
agentManager: AgentManager;
agentStorage: AgentStorage;
logger: Logger;
@@ -61,24 +49,16 @@ export interface CreateAgentFromSessionInput {
agentId?: string;
config: AgentSessionConfig;
workspaceId: string;
worktreeName?: string;
initialPrompt?: string;
source?: AgentPromptSource;
clientMessageId?: string;
outputSchema?: Record<string, unknown>;
images?: Array<{ data: string; mimeType: string }>;
attachments?: AgentAttachment[];
git?: GitSetupOptions;
labels: Record<string, string>;
env?: Record<string, string>;
provisionalTitle: string | null;
firstAgentContext: FirstAgentContext;
buildSessionConfig: (
config: AgentSessionConfig,
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
firstAgentContext?: FirstAgentContext,
) => Promise<CreateAgentSessionWorktreeResult>;
}
export interface CreateAgentFromMcpInput {
@@ -111,6 +91,7 @@ export interface CreateAgentFromMcpInput {
allowCustomCwd?: boolean;
childAgentDefaultLabels?: Record<string, string>;
} | null;
worktree?: {
worktreeName?: string;
branchName?: string;
@@ -167,7 +148,6 @@ interface ResolvedCreateAgent {
prompt?: AgentPromptInput;
source?: AgentPromptSource;
runOptions?: AgentRunOptions;
setupContinuation?: AgentWorktreeSetupContinuation;
promptFailure: CreateAgentPromptFailureMode;
promptLogger?: Logger;
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
@@ -196,29 +176,29 @@ export async function createAgentCommand(
if (input.kind === "mcp") {
input.onCreated?.({ agentId: snapshot.id, createdWorktree: resolved.createdWorktree ?? null });
}
const stopNotification =
const notification =
input.kind === "mcp" &&
input.notifyOnFinish &&
input.callerAgentId &&
(resolved.prompt !== undefined || resolved.setupContinuation !== undefined)
? setupFinishNotification({
agentManager: dependencies.agentManager,
agentStorage: dependencies.agentStorage,
childAgentId: snapshot.id,
callerAgentId: input.callerAgentId,
requireParentOwnership: true,
logger: dependencies.logger,
})
resolved.prompt !== undefined
? await requireFinishSubscriptions(dependencies).register(
{
childAgentId: snapshot.id,
callerAgentId: input.callerAgentId,
requireParentOwnership: !(input.detached ?? false),
},
true,
)
: undefined;
resolved.setupContinuation?.startAfterAgentCreate({
agentId: snapshot.id,
});
if (resolved.prompt !== undefined) {
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch((error) => {
stopNotification?.();
throw error;
});
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch(
async (error) => {
await notification?.reject();
throw error;
},
);
notification?.accept();
initialPromptStarted = sendResult.started;
liveSnapshot = sendResult.liveSnapshot;
initialPromptError = sendResult.error ?? null;
@@ -238,29 +218,7 @@ async function resolveSessionCreateAgent(
input: CreateAgentFromSessionInput,
): Promise<ResolvedCreateAgent> {
const trimmedPrompt = input.initialPrompt?.trim();
const {
sessionConfig: builtSessionConfig,
setupContinuation,
createdWorkspaceId,
} = await input.buildSessionConfig(
input.config,
input.git,
input.worktreeName,
input.firstAgentContext,
);
// Validate the requested mode against the provider's modes for the resolved
// cwd. The app remembers mode preferences globally, so a saved mode can be
// stale for a workspace whose provider config no longer defines it — reject
// it here instead of letting the provider fail mid-turn.
//
// This runs after buildSessionConfig, which may already have created a
// worktree and/or workspace record — cwd (required to resolve modes) is
// only known once that step completes. If validation throws, any
// worktree/workspace buildSessionConfig created is the caller's
// responsibility to clean up (session.ts's handleCreateAgentRequest does
// this for the worktree path via cleanupCreatedWorktreeAfterFailedAgentCreate;
// this is a pre-existing gap for directory-only workspace creates, not
// introduced by this validation).
const builtSessionConfig = input.config;
const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({
cwd: builtSessionConfig.cwd,
provider: builtSessionConfig.provider,
@@ -284,7 +242,7 @@ async function resolveSessionCreateAgent(
...(clientMessageId ? { clientMessageId } : {}),
}
: undefined;
const workspaceId = setupContinuation ? createdWorkspaceId : input.workspaceId;
const workspaceId = input.workspaceId;
return {
config: sessionConfig,
@@ -296,15 +254,11 @@ async function resolveSessionCreateAgent(
// Paseo never persists an internal agent, so nothing could resume its
// provider session either. Same as the daemon's own ephemeral helpers.
...(sessionConfig.internal ? { persistSession: false } : {}),
// A legacy git/worktreeName worktree creates a fresh workspace, so the
// agent belongs to that workspace, not the source one. createdWorkspaceId
// is the freshly created worktree's workspace.
workspaceId: requireResolvedWorkspaceId(workspaceId),
},
prompt: hasPromptContent ? prompt : undefined,
source: input.source,
runOptions,
setupContinuation,
// Registration commits creation. First-turn failures belong to the created agent.
promptFailure: "return-error",
promptLogger: dependencies.logger.child({
@@ -323,18 +277,16 @@ async function resolveMcpCreateAgent(
? requireParentAgent(dependencies.agentManager, input.callerAgentId)
: null;
const cwd = resolveMcpInitialCwd(input, parentAgent);
const { resolvedCwd, setupContinuation, createdWorkspaceId, createdWorktree } =
await resolveMcpCwd({
dependencies,
cwd,
worktree: input.worktree,
initialPrompt: input.initialPrompt ?? "",
callerWorkspaceId: parentAgent?.workspaceId,
});
const { resolvedCwd, createdWorkspaceId, createdWorktree } = await resolveMcpCwd({
dependencies,
cwd,
worktree: input.worktree,
initialPrompt: input.initialPrompt ?? "",
callerWorkspaceId: parentAgent?.workspaceId,
});
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
const intent = await resolveCreateAgentIntent({
explicitWorkspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId,
explicitWorkspaceId: createdWorkspaceId ?? input.workspaceId,
caller: parentAgent
? { id: parentAgent.id, cwd: parentAgent.cwd, workspaceId: parentAgent.workspaceId }
: null,
@@ -383,7 +335,6 @@ async function resolveMcpCreateAgent(
source: input.callerAgentId
? { kind: "agent-message", agentId: input.callerAgentId }
: undefined,
setupContinuation,
createdWorktree,
promptFailure: input.promptFailure ?? "log",
};
@@ -535,6 +486,28 @@ function resolveChildAgentCwd(params: {
return resolvePathFromBase(params.parentCwd, requestedCwd);
}
interface CreateMcpWorktreeOptions {
input: CreatePaseoWorktreeInput;
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
}
async function createMcpWorktree(
options: CreateMcpWorktreeOptions,
): Promise<CreatePaseoWorktreeWorkflowResult> {
try {
if (!options.createPaseoWorktree) {
throw new Error("Paseo worktree service is not configured");
}
return await options.createPaseoWorktree(
options.input,
options.resolveDefaultBranch ? { resolveDefaultBranch: options.resolveDefaultBranch } : {},
);
} catch (error) {
throw toWorktreeRequestError(error);
}
}
async function resolveMcpCwd(params: {
dependencies: CreateAgentCommandDependencies;
cwd: string;
@@ -543,7 +516,6 @@ async function resolveMcpCwd(params: {
worktree: CreateAgentFromMcpInput["worktree"];
}): Promise<{
resolvedCwd: string;
setupContinuation?: AgentWorktreeSetupContinuation;
createdWorkspaceId?: string;
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
}> {
@@ -577,59 +549,23 @@ async function resolveMcpCwd(params: {
action: worktree.action,
githubPrNumber: worktree.githubPrNumber,
firstAgentContext: { prompt: params.initialPrompt },
runSetup: false,
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot,
},
createPaseoWorktree: dependencies.createPaseoWorktree,
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
setupContinuation: {
kind: "agent",
terminalManager: dependencies.terminalManager ?? null,
appendTimelineItem: ({ agentId, item }) =>
appendTimelineItemIfAgentKnown({
agentManager: dependencies.agentManager,
agentId,
item,
}),
emitLiveTimelineItem: ({ agentId, item }) =>
emitLiveTimelineItemIfAgentKnown({
agentManager: dependencies.agentManager,
agentId,
item,
}),
logger: dependencies.logger,
},
});
return {
resolvedCwd: createdWorktree.workspace.cwd,
setupContinuation: createdWorktree.setupContinuation,
createdWorkspaceId: createdWorktree.workspace.workspaceId,
createdWorktree,
};
}
interface CreateMcpWorktreeOptions {
input: CreatePaseoWorktreeInput;
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
}
async function createMcpWorktree(
options: CreateMcpWorktreeOptions,
): Promise<CreatePaseoWorktreeWorkflowResult> {
try {
if (!options.createPaseoWorktree) {
throw new Error("Paseo worktree service is not configured");
}
return await options.createPaseoWorktree(options.input, {
...(options.resolveDefaultBranch
? { resolveDefaultBranch: options.resolveDefaultBranch }
: {}),
...(options.setupContinuation ? { setupContinuation: options.setupContinuation } : {}),
});
} catch (error) {
throw toWorktreeRequestError(error);
}
function requireFinishSubscriptions(
dependencies: CreateAgentCommandDependencies,
): FinishSubscriptions {
if (!dependencies.finishSubscriptions) throw new Error("Finish subscriptions are not configured");
return dependencies.finishSubscriptions;
}
+25 -3
View File
@@ -7,8 +7,8 @@ import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
export class AgentDirectory {
constructor(
private readonly deps: {
manager: AgentManager;
storage: AgentStorage;
manager: Pick<AgentManager, "listAgents">;
storage: Pick<AgentStorage, "list">;
workspaces: Pick<WorkspaceRegistry, "get" | "list">;
projectLive(agent: ManagedAgent): Promise<AgentSnapshotPayload>;
projectStored(record: StoredAgentRecord): AgentSnapshotPayload;
@@ -17,6 +17,26 @@ export class AgentDirectory {
},
) {}
/** Human prompt recency includes archived/unavailable agents without projecting their UI payloads. */
async workspaceLastUserMessageTimes(): Promise<Map<string, string>> {
const live = this.deps.manager.listAgents();
const liveIds = new Set(live.map((agent) => agent.id));
const times = new Map<string, string>();
const record = (workspaceId: string | undefined, at: string | null | undefined) => {
if (!workspaceId || !at) return;
const previous = times.get(workspaceId);
if (!previous || at > previous) times.set(workspaceId, at);
};
for (const agent of await this.deps.storage.list()) {
if (!agent.internal && !liveIds.has(agent.id))
record(agent.workspaceId, agent.lastUserMessageAt);
}
for (const agent of live) {
if (!agent.internal) record(agent.workspaceId, agent.lastUserMessageAt?.toISOString());
}
return times;
}
async includes(
agent: Pick<AgentSnapshotPayload, "workspaceId" | "internal">,
includeBackground = false,
@@ -60,6 +80,8 @@ export class AgentDirectory {
const active = await Promise.all(
live.filter(matches).map((agent) => this.deps.projectLive(agent)),
);
return [...active, ...stored].filter((agent) => this.deps.isProviderVisible(agent.provider));
return [...active, ...stored]
.filter(matches)
.filter((agent) => this.deps.isProviderVisible(agent.provider));
}
}
@@ -0,0 +1,499 @@
import { createIdleRuntimeCleanup } from "../idle-runtime-cleanup/index.js";
import { HostObservationError } from "../host-operations/index.js";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client";
import type { AgentMessageDelivery } from "../agent-messages/index.js";
import { buildStoredAgentPayload } from "../agent-projections.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import {
createFinishSubscriptions,
createJsonFinishSubscriptionStore,
type FinishObservation,
type FinishSubscriptions,
} from "./index.js";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
class Observation implements FinishObservation {
waits: Array<{ signal: AbortSignal; result: ReturnType<typeof deferred<WaitForFinishResult>> }> =
[];
permission = deferred<void>();
opening: Promise<void> = Promise.resolve();
openedSignals: AbortSignal[] = [];
async open(_record: unknown, signal: AbortSignal) {
this.openedSignals.push(signal);
await this.opening;
return { next: () => this.wait(signal), close: async () => {} };
}
async wait(signal: AbortSignal) {
signal.throwIfAborted();
const result = deferred<WaitForFinishResult>();
signal.addEventListener("abort", () => result.reject(new Error("Stopped")), { once: true });
this.waits.push({ signal, result });
return result.promise;
}
afterPermission() {
return this.permission.promise;
}
}
class Delivery implements AgentMessageDelivery {
prompts: string[] = [];
acknowledgments: Array<ReturnType<typeof deferred<"steered">>> = [];
enqueue(message: Parameters<AgentMessageDelivery["enqueue"]>[0]) {
this.prompts.push(String(message.prompt));
const acknowledgment = deferred<"steered">();
this.acknowledgments.push(acknowledgment);
return {
status: "queued" as const,
messageId: "notification",
dispatched: acknowledgment.promise,
};
}
async close() {}
async waitForDispatch() {}
}
const homes: string[] = [];
const services: FinishSubscriptions[] = [];
afterEach(async () => {
for (const service of services.splice(0)) await service.close();
await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true })));
});
async function scenario(directory?: string, removalFailures = 0, retryDelayMs = 1) {
const home = directory ?? (await mkdtemp(join(tmpdir(), "finish-recovery-")));
if (!directory) homes.push(home);
const durableStore = createJsonFinishSubscriptionStore(home);
const store = {
...durableStore,
async remove(id: string) {
if (removalFailures > 0) {
removalFailures--;
throw new Error("Subscription removal unavailable");
}
await durableStore.remove(id);
},
};
const observation = new Observation();
const delivery = new Delivery();
let active = true;
const service = createFinishSubscriptions({
store,
messages: () => delivery,
open: observation.open.bind(observation),
callerIsActive: async () => active,
serverId: "origin",
logger: createTestLogger(),
retryDelayMs,
});
services.push(service);
await service.start();
return {
home,
store,
observation,
delivery,
service,
archive: () => {
active = false;
},
};
}
const intent = { callerAgentId: "parent", childAgentId: "child" };
function outcome(status: "idle" | "running" | "closed" | "error" = "closed"): WaitForFinishResult {
return {
status: status === "error" ? "error" : "idle",
final: buildStoredAgentPayload(
{
id: "child",
provider: "codex",
cwd: "/tmp",
title: "Child",
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
labels: { "paseo.parent-agent-id": "parent" },
lastStatus: status,
},
["codex"],
),
lastMessage: "Partial work saved",
error: null,
};
}
async function until(check: () => boolean) {
await expect.poll(check).toBe(true);
}
test("retains intent until parent dispatch acknowledgment, then restart sends nothing", async () => {
const first = await scenario();
await first.service.register(intent);
await until(() => first.observation.waits.length === 1);
first.observation.waits[0]!.result.resolve(outcome());
await until(() => first.delivery.prompts.length === 1);
expect(first.delivery.prompts[0]).toContain("was closed");
expect(first.delivery.prompts[0]).toContain("Partial work saved");
expect(await first.store.list()).toHaveLength(1);
first.delivery.acknowledgments[0]!.resolve("steered");
await expect.poll(async () => (await first.store.list()).length).toBe(0);
await first.service.close();
const restarted = await scenario(first.home);
expect(restarted.observation.waits).toHaveLength(0);
});
test("restart coalesces predecessor and queued candidate into one settled notification", async () => {
const first = await scenario();
await first.service.register(intent);
await first.service.register(intent, true);
expect(await first.store.list()).toHaveLength(2);
await first.service.close();
const restarted = await scenario(first.home);
await until(() => restarted.observation.waits.length === 1);
restarted.observation.waits[0]!.result.resolve(outcome());
await until(() => restarted.delivery.prompts.length === 1);
restarted.delivery.acknowledgments[0]!.resolve("steered");
await expect.poll(async () => (await restarted.store.list()).length).toBe(0);
expect(restarted.delivery.prompts).toHaveLength(1);
});
test("rejected queued follow-up preserves the predecessor and its buffered finish", async () => {
const state = await scenario();
await state.service.register(intent);
await until(() => state.observation.waits.length === 1);
const pending = await state.service.register(intent, true);
state.observation.waits[0]!.result.resolve(outcome("idle"));
await Promise.resolve();
expect(state.delivery.prompts).toHaveLength(0);
await pending.reject();
await until(() => state.delivery.prompts.length === 1);
expect(await state.store.list()).toHaveLength(1);
});
test("accepted replacement ignores old idle and stale acknowledgment cannot delete new intent", async () => {
const state = await scenario();
await state.service.register(intent);
await until(() => state.observation.waits.length === 1);
state.observation.waits[0]!.result.resolve(outcome("idle"));
await until(() => state.delivery.prompts.length === 1);
const next = await state.service.register(intent, true);
next.accept();
await until(() => state.observation.waits.length === 2);
state.delivery.acknowledgments[0]!.resolve("steered");
await expect.poll(async () => (await state.store.list()).length).toBe(1);
state.observation.waits[1]!.result.resolve(outcome("idle"));
await until(() => state.delivery.prompts.length === 2);
});
test("shutdown before dispatch acknowledgment leaves intent for recovery", async () => {
const state = await scenario();
await state.service.register(intent);
await until(() => state.observation.waits.length === 1);
state.observation.waits[0]!.result.resolve(outcome());
await until(() => state.delivery.prompts.length === 1);
await state.service.close();
state.delivery.acknowledgments[0]!.resolve("steered");
const restarted = await scenario(state.home);
await until(() => restarted.observation.waits.length === 1);
restarted.observation.waits[0]!.result.resolve(outcome());
await until(() => restarted.delivery.prompts.length === 1);
});
test("remote recovery before three failed observations still reports child completion", async () => {
const first = await scenario();
await first.service.register({ ...intent, serverId: "remote" });
await first.service.close();
const state = await scenario(first.home);
await until(() => state.observation.waits.length === 1);
state.observation.waits[0]!.result.reject(new HostObservationError("unreachable"));
await until(() => state.observation.waits.length === 2);
expect(await state.store.list()).toHaveLength(1);
expect(state.delivery.prompts).toHaveLength(0);
state.observation.waits[1]!.result.resolve(outcome("idle"));
await until(() => state.delivery.prompts.length === 1);
expect(state.delivery.prompts[0]).toContain("Server: remote");
});
test("archived parent suppresses delivery and removes obsolete intent", async () => {
const state = await scenario();
await state.service.register(intent);
await until(() => state.observation.waits.length === 1);
state.archive();
state.observation.waits[0]!.result.resolve(outcome());
await expect.poll(async () => (await state.store.list()).length).toBe(0);
expect(state.delivery.prompts).toHaveLength(0);
});
test("permission checkpoint retains intent and completion reports after permission clears", async () => {
const state = await scenario();
await state.service.register(intent);
await until(() => state.observation.waits.length === 1);
const result = outcome("running");
result.status = "permission";
result.final!.pendingPermissions = [
{
id: "permission",
name: "Write",
input: { path: "file" },
kind: "tool",
} as (typeof result.final.pendingPermissions)[number],
];
state.observation.waits[0]!.result.resolve(result);
await until(() => state.delivery.prompts.length === 1);
expect(state.delivery.prompts[0]).toContain("respond_to_permission");
expect(await state.store.list()).toHaveLength(1);
state.delivery.acknowledgments[0]!.resolve("steered");
state.observation.permission.resolve();
await until(() => state.observation.waits.length === 2);
state.observation.waits[1]!.result.resolve(outcome("idle"));
await until(() => state.delivery.prompts.length === 2);
});
test("coalesced unconditional follow-up survives detachment of a parent-owned creation", async () => {
const first = await scenario();
await first.service.register({ ...intent, requireParentOwnership: true });
await first.service.register(intent, true);
await first.service.close();
const restarted = await scenario(first.home);
await until(() => restarted.observation.waits.length === 1);
const detached = outcome();
detached.final!.labels = {};
restarted.observation.waits[0]!.result.resolve(detached);
await until(() => restarted.delivery.prompts.length === 1);
});
test("detached child suppresses a parent-owned creation subscription", async () => {
const state = await scenario();
await state.service.register({ ...intent, requireParentOwnership: true });
await until(() => state.observation.waits.length === 1);
const detached = outcome();
detached.final!.labels = {};
state.observation.waits[0]!.result.resolve(detached);
await expect.poll(async () => (await state.store.list()).length).toBe(0);
expect(state.delivery.prompts).toHaveLength(0);
});
test("dispatch failure retains intent and retries the ordinary finish notification", async () => {
const state = await scenario();
await state.service.register(intent);
await until(() => state.observation.waits.length === 1);
state.observation.waits[0]!.result.resolve(outcome());
await until(() => state.delivery.prompts.length === 1);
state.delivery.acknowledgments[0]!.reject(new Error("Provider unavailable"));
await until(() => state.delivery.prompts.length === 2);
expect(await state.store.list()).toHaveLength(1);
expect(state.observation.waits).toHaveLength(1);
state.delivery.acknowledgments[1]!.resolve("steered");
await expect.poll(async () => (await state.store.list()).length).toBe(0);
});
test("error and missing-child notifications never claim successful completion", async () => {
const state = await scenario();
await state.service.register(intent);
await until(() => state.observation.waits.length === 1);
state.observation.waits[0]!.result.resolve(outcome("error"));
await until(() => state.delivery.prompts.length === 1);
expect(state.delivery.prompts[0]).toContain("errored");
await state.service.register({ ...intent, childAgentId: "missing-child" });
await until(() => state.observation.waits.length === 2);
state.observation.waits[1]!.result.resolve({
status: "error",
final: null,
error: "Agent not found: missing-child",
lastMessage: null,
});
await until(() => state.delivery.prompts.length === 2);
expect(state.delivery.prompts[1]).toContain("is unavailable");
expect(state.delivery.prompts[1]).not.toContain("finished");
});
test("finish notification limits a long last response and points to activity for the remainder", async () => {
const state = await scenario();
await state.service.register(intent);
await until(() => state.observation.waits.length === 1);
const result = outcome("idle");
result.lastMessage = "x".repeat(4100);
state.observation.waits[0]!.result.resolve(result);
await until(() => state.delivery.prompts.length === 1);
expect(state.delivery.prompts[0]).toContain("x".repeat(4000));
expect(state.delivery.prompts[0]).not.toContain("x".repeat(4001));
expect(state.delivery.prompts[0]).toContain("get_agent_activity");
});
test("three failed remote observations consume intent only after unavailable-notification acknowledgment", async () => {
const state = await scenario();
await state.service.register({ ...intent, serverId: "remote" });
for (let attempt = 0; attempt < 3; attempt++) {
await until(() => state.observation.waits.length === attempt + 1);
expect(state.delivery.prompts).toEqual([]);
state.observation.waits[attempt]!.result.reject(new HostObservationError("unreachable"));
}
await until(() => state.delivery.prompts.length === 1);
expect(state.delivery.prompts[0]).toContain(
"Could not observe child agent child on host remote after three failed attempts.",
);
expect(state.delivery.prompts[0]).toContain("Status of child agent child (child) is unknown.");
expect(state.delivery.prompts[0]).not.toContain("finished.");
expect(await state.store.list()).toHaveLength(1);
expect(state.observation.waits).toHaveLength(3);
state.delivery.acknowledgments[0]!.resolve("steered");
await expect.poll(async () => (await state.store.list()).length).toBe(0);
await state.service.close();
const restarted = await scenario(state.home);
expect(restarted.observation.waits).toEqual([]);
expect(restarted.delivery.prompts).toEqual([]);
});
test("connecting does not spend the remote failure budget and recovery before three failures still completes", async () => {
const state = await scenario();
await state.service.register({ ...intent, serverId: "remote" });
for (let attempt = 0; attempt < 4; attempt++) {
await until(() => state.observation.waits.length === attempt + 1);
expect(state.delivery.prompts).toEqual([]);
state.observation.waits[attempt]!.result.reject(new HostObservationError("connecting"));
}
for (let attempt = 4; attempt < 6; attempt++) {
await until(() => state.observation.waits.length === attempt + 1);
expect(state.delivery.prompts).toEqual([]);
state.observation.waits[attempt]!.result.reject(new HostObservationError("unreachable"));
}
await until(() => state.observation.waits.length === 7);
expect(state.delivery.prompts).toEqual([]);
state.observation.waits[6]!.result.resolve(outcome("idle"));
await until(() => state.delivery.prompts.length === 1);
expect(state.delivery.prompts[0]).toContain("finished.");
});
test("removed-host notice retries parent dispatch without observing again or deleting unacknowledged intent", async () => {
const state = await scenario();
await state.service.register({ ...intent, serverId: "remote" });
await until(() => state.observation.waits.length === 1);
state.observation.waits[0]!.result.reject(new HostObservationError("removed"));
await until(() => state.delivery.prompts.length === 1);
expect(state.delivery.prompts[0]).toContain("Host remote was removed or is unavailable.");
expect(state.delivery.prompts[0]).toContain("Status of child agent child (child) is unknown.");
state.delivery.acknowledgments[0]!.reject(new Error("Parent provider unavailable"));
await until(() => state.delivery.prompts.length === 2);
expect(state.delivery.prompts[1]).toBe(state.delivery.prompts[0]);
expect(state.observation.waits).toHaveLength(1);
expect(await state.store.list()).toHaveLength(1);
state.delivery.acknowledgments[1]!.resolve("steered");
await expect.poll(async () => (await state.store.list()).length).toBe(0);
});
test("failed removal after dispatch acknowledgment retries only storage, preserving the accepted outcome", async () => {
const state = await scenario(undefined, 2, 1000);
await state.service.register(intent);
await until(() => state.observation.waits.length === 1);
state.observation.waits[0]!.result.resolve(outcome("idle"));
await until(() => state.delivery.prompts.length === 1);
state.delivery.acknowledgments[0]!.resolve("steered");
await expect.poll(async () => (await state.store.list()).length, { timeout: 10000 }).toBe(0);
expect(state.delivery.prompts).toHaveLength(1);
expect(state.delivery.prompts[0]).toContain("finished.");
expect(state.observation.waits).toHaveLength(1);
});
test("shutdown during observation setup owns rejection of the already-canceled next result", async () => {
const state = await scenario();
const opening = deferred<void>();
state.observation.opening = opening.promise;
await state.service.register(intent);
await until(() => state.observation.openedSignals.length === 1);
const closing = state.service.close();
await until(() => state.observation.openedSignals[0]!.aborted);
opening.resolve();
await closing;
expect(await state.store.list()).toHaveLength(1);
expect(state.delivery.prompts).toEqual([]);
});
test("remote finish intent protects its caller through registration, observation and delivery", async () => {
const s = await scenario();
let time = 61 * 60_000;
let collected = false;
const runtime = {
identity: {},
agentId: "parent",
parentAgentId: undefined,
provider: "test",
sessionId: "parent",
lastActivityAt: 0,
isEligible: () => !collected,
closeIfEligible: async (revalidate: () => boolean) => {
if (!revalidate()) return false;
collected = true;
return true;
},
};
const cleanup = createIdleRuntimeCleanup({
enabled: true,
logger: createTestLogger(),
now: () => time,
runtimes: () => (collected ? [] : [runtime]),
isProtected: (agentId) => s.service.hasPendingForCaller(agentId),
schedule: () => () => {},
});
const savedPut = s.store.put.bind(s.store);
const writing = deferred<void>();
s.store.put = async (record) => {
await writing.promise;
await savedPut(record);
};
const registration = s.service.register({ ...intent, serverId: "remote" }, true);
const protectedDuringRegistration = s.service.hasPendingForCaller?.("parent") ?? false;
writing.resolve();
const accepted = await registration;
expect(protectedDuringRegistration).toBe(true);
expect((await cleanup.sweep()).collected).toEqual([]);
expect(s.service.hasPendingForCaller("parent")).toBe(true);
accepted.accept();
await until(() => s.observation.waits.length === 1);
expect(s.service.hasPendingForCaller("parent")).toBe(true);
s.observation.waits[0]!.result.resolve(outcome());
await until(() => s.delivery.prompts.length === 1);
expect(s.service.hasPendingForCaller("parent")).toBe(true);
s.delivery.acknowledgments[0]!.resolve("steered");
await until(() => !s.service.hasPendingForCaller("parent"));
expect(await s.store.list()).toEqual([]);
time = 62 * 60_000;
expect((await cleanup.sweep()).collected).toEqual([]);
time = 122 * 60_000;
expect((await cleanup.sweep()).collected).toHaveLength(1);
await cleanup.close();
});
test("finish recovery protects callers before durable intent has loaded", async () => {
const s = await scenario();
await s.service.register({ ...intent, serverId: "remote" });
await s.service.close();
const loading = deferred<void>();
const recovered = createFinishSubscriptions({
store: {
...s.store,
list: async () => {
await loading.promise;
return s.store.list();
},
},
messages: () => s.delivery,
open: s.observation.open.bind(s.observation),
callerIsActive: async () => true,
serverId: "origin",
logger: createTestLogger(),
});
services.push(recovered);
expect(recovered.hasPendingForCaller("parent")).toBe(true);
const starting = recovered.start();
expect(recovered.hasPendingForCaller("parent")).toBe(true);
loading.resolve();
await starting;
expect(recovered.hasPendingForCaller("parent")).toBe(true);
expect(recovered.hasPendingForCaller("unrelated")).toBe(false);
});
@@ -0,0 +1,381 @@
import { setTimeout as pause } from "node:timers/promises";
import { HostObservationError } from "../host-operations/index.js";
import { randomUUID } from "node:crypto";
import type { Logger } from "pino";
import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client";
import type { FinishObservationResult } from "./internal/observation-buffer.js";
import type { AgentMessageDelivery } from "../agent-messages/index.js";
import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels";
import {
formatFinishNotificationBody,
finishNotificationEvents,
type FinishNotificationReason,
} from "./internal/notification.js";
export { createJsonFinishSubscriptionStore } from "./internal/json-store.js";
export interface FinishSubscription {
id: string;
callerAgentId: string;
childAgentId: string;
serverId?: string;
/** Creation subscriptions stop reporting after the child is detached. */
requireParentOwnership?: boolean;
}
export interface FinishSubscriptionStore {
list(): Promise<FinishSubscription[]>;
put(subscription: FinishSubscription): Promise<void>;
remove(id: string): Promise<void>;
}
export interface FinishObservationSession {
next(): Promise<FinishObservationResult>;
close(): Promise<void>;
}
export interface FinishObservation {
open(subscription: FinishSubscription, signal: AbortSignal): Promise<FinishObservationSession>;
}
interface Dependencies extends FinishObservation {
store: FinishSubscriptionStore;
messages(): AgentMessageDelivery;
callerIsActive(agentId: string): Promise<boolean>;
serverId: string;
logger: Logger;
retryDelayMs?: number;
}
export interface FinishRegistration {
/** The prompt was accepted. Replaces preceding registrations for this pair. */
accept(): void;
/** Rejected dispatch preserves preceding registrations. */
reject(): Promise<void>;
}
export interface FinishSubscriptions {
start(): Promise<void>;
/** Pending notification intent, including registration, recovery and unacknowledged delivery. */
hasPendingForCaller(agentId: string): boolean;
register(
input: Omit<FinishSubscription, "id">,
pendingDispatch?: boolean,
): Promise<FinishRegistration>;
close(): Promise<void>;
}
interface Watch {
records: FinishSubscription[];
controller: AbortController;
decision: Promise<boolean>;
decide(accepted: boolean): void;
pending: boolean;
task?: Promise<void>;
}
function key(record: FinishSubscription): string {
return JSON.stringify([record.callerAgentId, record.childAgentId, record.serverId]);
}
/** Owns durable intent, live observation, supersession, and dispatch acknowledgment. */
export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptions {
const tasks = new Set<Promise<void>>();
const watches = new Map<string, Set<Watch>>();
let closed = false;
let recovering = true;
const registering = new Map<string, number>();
let mutations = Promise.resolve();
function serialize<T>(action: () => Promise<T>): Promise<T> {
const result = mutations.then(action);
mutations = result.then(
() => undefined,
() => undefined,
);
return result;
}
function attach(records: FinishSubscription[], pending: boolean): Watch {
let decide!: Watch["decide"];
const decision = new Promise<boolean>((resolve) => {
decide = resolve;
});
const watch: Watch = { records, controller: new AbortController(), pending, decision, decide };
const group = watches.get(key(records[0]!)) ?? new Set<Watch>();
group.add(watch);
watches.set(key(records[0]!), group);
if (!pending) decide(true);
watch.task = run(watch, group).catch((error) =>
deps.logger.error({ err: error }, "Finish subscription failed"),
);
tasks.add(watch.task);
void watch.task.then(() => tasks.delete(watch.task!));
return watch;
}
function stop(watch: Watch, group: Set<Watch>): void {
watch.controller.abort();
watch.decide(false);
group.delete(watch);
if (!group.size && watches.get(key(watch.records[0]!)) === group)
watches.delete(key(watch.records[0]!));
}
async function remove(watch: Watch): Promise<void> {
// Only IDs captured by this observer are eligible; newer registrations survive.
for (const record of watch.records) await deps.store.remove(record.id);
}
async function delay(signal: AbortSignal, attempt: number): Promise<void> {
try {
await pause(
Math.min((deps.retryDelayMs ?? 1000) * 2 ** Math.min(attempt, 3), 5000),
undefined,
{ signal },
);
} catch (error) {
if (!signal.aborted) throw error;
}
}
async function abortable<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
let abort!: () => void;
const cancellation = new Promise<never>((_resolve, reject) => {
abort = () => reject(signal.reason);
if (signal.aborted) abort();
else signal.addEventListener("abort", abort, { once: true });
});
try {
return await Promise.race([operation, cancellation]);
} finally {
signal.removeEventListener("abort", abort);
}
}
async function decisions(group: Set<Watch>): Promise<void> {
// New candidates may arrive while an older dispatch decision is pending.
while (true) {
if (closed) return;
const pending = [...group].filter((watch) => watch.pending);
if (!pending.length) return;
await Promise.all(pending.map((watch) => watch.decision));
}
}
async function suppressed(
record: FinishSubscription,
result?: WaitForFinishResult,
): Promise<boolean> {
if (!(await deps.callerIsActive(record.callerAgentId))) return true;
if (!record.requireParentOwnership || !result?.final) return false;
const parent = getParentAddressFromLabels(
result.final.labels,
record.serverId ?? deps.serverId,
);
return parent?.agentId !== record.callerAgentId || parent.serverId !== deps.serverId;
}
function reasonFor(result: FinishObservationResult): FinishNotificationReason {
if (result.reason === "host unreachable" || result.reason === "host removed")
return result.reason;
if (!result.final) return "is unavailable";
if (result.final.status === "closed") return "was closed";
if (result.status === "error") return "errored";
if (result.status === "permission") return "needs permission";
return result.reason ?? "finished";
}
async function deliver(
record: FinishSubscription,
result: FinishObservationResult,
permissions: Set<string>,
signal: AbortSignal,
): Promise<boolean> {
const requests = result.final?.pendingPermissions ?? [];
const reason = reasonFor(result);
for (const id of permissions)
if (!requests.some((request) => request.id === id)) permissions.delete(id);
const acknowledgments: Promise<unknown>[] = [];
for (const request of reason === "needs permission" ? requests : [undefined]) {
if (signal.aborted) return false;
if (request && permissions.has(request.id)) continue;
const title = result.final?.title ?? record.childAgentId;
const receipt = deps.messages().enqueue({
recipientId: record.callerAgentId,
prompt: formatFinishNotificationBody({
childAgentId: record.childAgentId,
serverId: record.serverId ?? deps.serverId,
title,
reason,
lastAssistantMessage: result.lastMessage,
permissionRequest: request,
}),
source: {
kind: "agent-notification",
agentId: record.childAgentId,
serverId: record.serverId ?? deps.serverId,
title,
event: finishNotificationEvents[reason],
},
activeTurnBehavior: "steer",
unarchive: false,
});
acknowledgments.push(
receipt.dispatched.then(() => {
if (request) permissions.add(request.id);
return undefined;
}),
);
}
await abortable(Promise.all(acknowledgments), signal);
if (signal.aborted) return false;
if (reason !== "needs permission") return true;
return false;
}
async function run(watch: Watch, group: Set<Watch>): Promise<void> {
const signal = watch.controller.signal;
if (!(await watch.decision) || signal.aborted) return;
const record = {
...watch.records[0]!,
requireParentOwnership: watch.records.every(
(intent) => intent.requireParentOwnership === true,
),
};
const permissions = new Set<string>();
let observation: FinishObservationSession | undefined;
let pending: FinishObservationResult | undefined;
let acknowledged = false;
let remoteFailures = 0;
let retries = 0;
let warned = false;
async function observeNext(): Promise<FinishObservationResult | undefined> {
try {
observation ??= await deps.open(record, signal);
const result = await abortable(observation.next(), signal);
remoteFailures = 0;
return result;
} catch (error) {
await observation?.close();
observation = undefined;
signal.throwIfAborted();
if (!(error instanceof HostObservationError) || !record.serverId) throw error;
if (error.reason === "connecting") return undefined;
if (error.reason === "unreachable" && ++remoteFailures < 3) return undefined;
// Host visibility ends this subscription; it says nothing about child completion.
return {
status: "error",
final: null,
lastMessage: null,
error: null,
reason: error.reason === "removed" ? "host removed" : "host unreachable",
};
}
}
try {
while (!signal.aborted) {
try {
if (await suppressed(record)) {
await remove(watch);
stop(watch, group);
return;
}
if (!pending) pending = await observeNext();
if (!pending) {
await delay(signal, retries++);
continue;
}
if (signal.aborted) return;
await decisions(group);
if (signal.aborted) return;
if (await suppressed(record, pending)) {
await remove(watch);
stop(watch, group);
return;
}
if (!acknowledged) acknowledged = await deliver(record, pending, permissions, signal);
if (acknowledged) {
// A removal failure retries only the commit, never the already accepted callback.
await remove(watch);
stop(watch, group);
return;
}
pending = undefined;
retries = 0;
warned = false;
} catch (error) {
if (signal.aborted) return;
// Delivery and storage failures retain the captured outcome and durable intent.
// They do not spend the remote-observation budget or change the child's outcome.
if (!warned) {
deps.logger.warn(
{ err: error, subscription: record.id },
"Retrying finish subscription",
);
warned = true;
}
await delay(signal, retries++);
}
}
} finally {
await observation?.close();
}
}
return {
hasPendingForCaller(agentId) {
if (recovering || registering.has(agentId)) return true;
for (const group of watches.values()) {
for (const watch of group) if (watch.records[0]!.callerAgentId === agentId) return true;
}
return false;
},
start: () =>
serialize(async () => {
const groups = new Map<string, FinishSubscription[]>();
for (const record of await deps.store.list()) {
const records = groups.get(key(record)) ?? [];
records.push(record);
groups.set(key(record), records);
}
for (const records of groups.values()) attach(records, false);
recovering = false;
}),
register: (input, pendingDispatch = false) => {
registering.set(input.callerAgentId, (registering.get(input.callerAgentId) ?? 0) + 1);
return serialize(async () => {
if (closed) throw new Error("Finish subscriptions are closed");
const record: FinishSubscription = {
...input,
serverId: input.serverId === deps.serverId ? undefined : input.serverId,
id: randomUUID(),
};
await deps.store.put(record);
const group = watches.get(key(record)) ?? new Set<Watch>();
const predecessors = [...group];
const watch = attach([record], true);
const actualGroup = watches.get(key(record))!;
const registration: FinishRegistration = {
accept() {
if (!watch.pending || closed) return;
watch.pending = false;
for (const prior of predecessors) stop(prior, actualGroup);
watch.decide(true);
void serialize(async () => {
for (const prior of predecessors) await remove(prior);
}).catch((error) =>
deps.logger.error({ err: error }, "Failed to remove superseded finish subscriptions"),
);
},
reject: () =>
serialize(async () => {
await remove(watch);
stop(watch, actualGroup);
}),
};
if (!pendingDispatch) registration.accept();
return registration;
}).finally(() => {
const remaining = registering.get(input.callerAgentId)! - 1;
if (remaining) registering.set(input.callerAgentId, remaining);
else registering.delete(input.callerAgentId);
});
},
async close() {
closed = true;
await mutations;
for (const group of watches.values())
for (const watch of group) {
watch.controller.abort();
watch.decide(false);
}
await mutations;
await Promise.all(tasks);
await deps.store.list();
// Dispatch may still be pending. The delivery owner drains it after observers stop.
},
};
}
export { createFinishObservation } from "./internal/observation.js";
@@ -0,0 +1,51 @@
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { createJsonFinishSubscriptionStore } from "../index.js";
const homes: string[] = [];
afterEach(async () => {
await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true })));
});
async function temporaryHome() {
const result = await mkdtemp(join(tmpdir(), "finish-subscriptions-"));
homes.push(result);
return result;
}
const subscription = {
id: "subscription",
callerAgentId: "parent",
childAgentId: "child",
serverId: "remote",
};
test("acknowledged concurrent writes survive reopening and removal", async () => {
const directory = await temporaryHome();
const store = createJsonFinishSubscriptionStore(directory);
await Promise.all([store.put(subscription), store.put({ ...subscription, id: "second" })]);
const restarted = createJsonFinishSubscriptionStore(directory);
expect(await restarted.list()).toEqual([subscription, { ...subscription, id: "second" }]);
await restarted.remove(subscription.id);
expect(await createJsonFinishSubscriptionStore(directory).list()).toEqual([
{ ...subscription, id: "second" },
]);
});
test("write failure rejects registration and does not publish an unwritten record", async () => {
const directory = await temporaryHome();
const store = createJsonFinishSubscriptionStore(directory);
expect(await store.list()).toEqual([]);
await mkdir(join(directory, "finish-subscriptions.json"));
await expect(store.put(subscription)).rejects.toThrow();
expect(await store.list()).toEqual([]);
});
test("invalid persisted records fail visibly", async () => {
const directory = await temporaryHome();
await writeFile(
join(directory, "finish-subscriptions.json"),
JSON.stringify([{ callerAgentId: 42 }]),
);
await expect(createJsonFinishSubscriptionStore(directory).list()).rejects.toThrow();
});
@@ -0,0 +1,58 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { z } from "zod";
import { writeJsonFileAtomic } from "../../../atomic-file.js";
import type { FinishSubscription, FinishSubscriptionStore } from "../index.js";
const recordsSchema = z.array(
z.object({
id: z.string().min(1),
callerAgentId: z.string().min(1),
childAgentId: z.string().min(1),
serverId: z.string().min(1).optional(),
requireParentOwnership: z.boolean().optional(),
}),
);
export function createJsonFinishSubscriptionStore(paseoHome: string): FinishSubscriptionStore {
const file = join(paseoHome, "finish-subscriptions.json");
let records: FinishSubscription[] | undefined;
let pending = Promise.resolve();
async function load(): Promise<FinishSubscription[]> {
if (records) return records;
try {
records = recordsSchema.parse(JSON.parse(await readFile(file, "utf8")));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
records = [];
}
return records;
}
function serialized<T>(action: () => Promise<T>): Promise<T> {
const result = pending.then(action);
pending = result.then(
() => undefined,
() => undefined,
);
return result;
}
async function mutate(
update: (current: FinishSubscription[]) => FinishSubscription[],
): Promise<void> {
const next = update(await load());
await writeJsonFileAtomic(file, next);
records = next;
}
return {
list: () => serialized(async () => (await load()).map((record) => Object.assign({}, record))),
put: (subscription) =>
serialized(() =>
mutate((current) => [
...current.filter((record) => record.id !== subscription.id),
{ ...subscription },
]),
),
remove: (id) =>
serialized(() => mutate((current) => current.filter((record) => record.id !== id))),
};
}
@@ -0,0 +1,70 @@
import type { AgentPermissionRequest } from "../../agent-sdk-types.js";
export type FinishNotificationReason =
| "host unreachable"
| "host removed"
| "finished"
| "errored"
| "needs permission"
| "is unavailable"
| "was closed"
| "was canceled";
export const finishNotificationEvents = {
"host unreachable": "errored",
"host removed": "errored",
finished: "finished",
errored: "errored",
"needs permission": "permission-required",
"was closed": "closed",
"is unavailable": "closed",
// Use the existing failure event so older clients still parse agent-message metadata.
"was canceled": "errored",
} as const;
const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000;
interface FinishNotificationBodyInput {
childAgentId: string;
serverId?: string;
title: string;
reason: FinishNotificationReason;
lastAssistantMessage: string | null;
permissionRequest?: AgentPermissionRequest;
}
export function formatFinishNotificationBody(params: FinishNotificationBodyInput): string {
if (params.reason === "host unreachable" || params.reason === "host removed") {
const host =
params.reason === "host removed"
? `Host ${params.serverId} was removed or is unavailable.`
: `Could not observe child agent ${params.childAgentId} on host ${params.serverId} after three failed attempts.`;
return `${host}\n\nStatus of child agent ${params.childAgentId} (${params.title}) is unknown.\n\nServer: ${params.serverId}\n\nThis finish subscription has ended. Reassess the delegated work; reconnecting will not resume notifications.`;
}
const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`;
const sections = [statusLine];
if (params.serverId) sections.push(`Server: ${params.serverId}`);
if (params.reason === "needs permission" && params.permissionRequest) {
sections.push(
"Respond with `respond_to_permission` using the `agentId` and `requestId` below.",
`<permission-request>\n${JSON.stringify(
{
agentId: params.childAgentId,
...(params.serverId ? { serverId: params.serverId } : {}),
requestId: params.permissionRequest.id,
request: params.permissionRequest,
},
null,
2,
)}\n</permission-request>`,
);
}
let lastAssistantMessage = params.lastAssistantMessage?.trim();
if (lastAssistantMessage) {
if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) {
const omitted = lastAssistantMessage.length - FINISH_NOTIFICATION_MESSAGE_LIMIT;
lastAssistantMessage = `${lastAssistantMessage.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`;
}
sections.push(`<agent-response>\n${lastAssistantMessage}\n</agent-response>`);
}
return sections.join("\n\n");
}
@@ -0,0 +1,83 @@
import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client";
export type FinishObservationResult = WaitForFinishResult & {
reason?: "was canceled" | "host unreachable" | "host removed";
};
/** Coalesces duplicate checkpoints and retains one terminal result while delivery is pending. */
export function createObservationBuffer(signal: AbortSignal) {
const delivered = new Set<string>();
const permissions = new Map<string, FinishObservationResult>();
const resolvedBeforeInitial = new Set<string>();
let initialPending = true;
let terminal: FinishObservationResult | undefined;
let failure: unknown;
let wake: (() => void) | undefined;
const abort = () => {
failure = signal.reason;
wake?.();
};
signal.addEventListener("abort", abort, { once: true });
function push(result: FinishObservationResult): void {
if (result.status === "timeout") return;
if (result.status === "permission") {
for (const request of result.final?.pendingPermissions ?? [])
if (!delivered.has(request.id)) permissions.set(request.id, result);
} else terminal = result;
wake?.();
}
return {
pushInitial(result: FinishObservationResult) {
initialPending = false;
if (result.status === "permission" && result.final) {
const pendingPermissions = result.final.pendingPermissions.filter(
(request) => !resolvedBeforeInitial.has(request.id),
);
if (pendingPermissions.length)
push({ ...result, final: { ...result.final, pendingPermissions } });
} else if (
!terminal &&
result.final?.status !== "running" &&
result.final?.status !== "initializing"
)
push(result);
resolvedBeforeInitial.clear();
},
push,
resolved(id: string) {
if (initialPending) resolvedBeforeInitial.add(id);
delivered.delete(id);
permissions.delete(id);
},
fail(error: unknown) {
failure = error;
wake?.();
},
async next(): Promise<FinishObservationResult> {
while (true) {
signal.throwIfAborted();
const first = permissions.entries().next().value;
if (first) {
for (const request of first[1].final?.pendingPermissions ?? []) {
delivered.add(request.id);
permissions.delete(request.id);
}
return first[1];
}
if (terminal) {
const result = terminal;
terminal = undefined;
return result;
}
if (failure) throw failure;
await new Promise<void>((resolve) => {
wake = resolve;
});
wake = undefined;
}
},
dispose() {
signal.removeEventListener("abort", abort);
},
};
}
@@ -0,0 +1,217 @@
import { expect, test } from "vitest";
import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import {
HostObservationError,
createHostObservationResolver,
type ToolHosts,
type HostObservation,
} from "../../host-operations/index.js";
import { buildStoredAgentPayload } from "../../agent-projections.js";
import { openFinishObservation } from "./observation-stream.js";
class Host implements HostObservation {
handler?: Parameters<HostObservation["observe"]>[1];
released = false;
failure?: (error: unknown) => void;
baseline = Promise.withResolvers<WaitForFinishResult>();
async wait() {
return this.baseline.promise;
}
async observe(
_id: string,
handler: Parameters<HostObservation["observe"]>[1],
_signal: AbortSignal,
onError: (error: unknown) => void,
) {
this.handler = handler;
this.failure = onError;
return async () => {
this.released = true;
};
}
emit(status: "idle" | "running", event?: AgentStreamEvent) {
this.handler!(
buildStoredAgentPayload(
{
id: "child",
cwd: "/tmp",
provider: "codex",
labels: {},
lastStatus: status,
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
},
["codex"],
),
event,
"Last response",
);
}
}
function permission(id: string): AgentStreamEvent {
return {
type: "permission_requested",
provider: "codex",
request: { id, provider: "codex", name: "Write", kind: "tool", input: { path: id } },
};
}
async function scenario() {
const host = new Host();
const controller = new AbortController();
const observation = await openFinishObservation(
{ use: async (_server, action) => action(host) },
{ id: "watch", callerAgentId: "parent", childAgentId: "child" },
controller.signal,
);
return { host, observation, controller };
}
test("permission resolution followed by transient idle waits for resumed work to settle", async () => {
const { host, observation } = await scenario();
try {
host.emit("running", permission("first"));
expect((await observation.next()).status).toBe("permission");
host.emit("idle", { type: "permission_resolved", provider: "codex", requestId: "first" });
let settled = false;
const next = observation.next().then((result) => {
settled = true;
return result;
});
await Promise.resolve();
expect(settled).toBe(false);
host.emit("running", { type: "turn_started", provider: "codex" });
await Promise.resolve();
expect(settled).toBe(false);
host.emit("idle", { type: "turn_completed", provider: "codex" });
expect(await next).toMatchObject({ status: "idle", lastMessage: "Last response" });
} finally {
await observation.close();
}
expect(host.released).toBe(true);
});
test("second checkpoint and terminal outcome remain buffered while the first callback is pending", async () => {
const { host, observation } = await scenario();
try {
host.emit("running", permission("first"));
expect((await observation.next()).status).toBe("permission");
// The recipient has not acknowledged the first callback; no next() is in flight.
host.emit("running", permission("second"));
host.emit("idle", { type: "turn_completed", provider: "codex" });
expect((await observation.next()).final?.pendingPermissions[0]?.id).toBe("second");
expect((await observation.next()).status).toBe("idle");
} finally {
await observation.close();
}
});
test("abort releases continuous observation even with a pending initial wait", async () => {
const { host, observation, controller } = await scenario();
const next = observation.next();
controller.abort();
await expect(next).rejects.toThrow();
await observation.close();
expect(host.released).toBe(true);
});
test("late initial permission snapshot cannot resurrect an already resolved request", async () => {
const { host, observation } = await scenario();
try {
const requestEvent = permission("resolved");
host.emit("running", requestEvent);
await observation.next();
host.emit("idle", { type: "permission_resolved", provider: "codex", requestId: "resolved" });
const final = buildStoredAgentPayload(
{
id: "child",
cwd: "/tmp",
provider: "codex",
labels: {},
lastStatus: "running",
createdAt: "2026-01-01",
updatedAt: "2026-01-01",
},
["codex"],
);
if (requestEvent.type !== "permission_requested") throw new Error("Expected permission");
final.pendingPermissions = [requestEvent.request];
host.baseline.resolve({ status: "permission", final, lastMessage: null, error: null });
await host.baseline.promise;
await Promise.resolve();
host.emit("idle", { type: "turn_completed", provider: "codex" });
expect((await observation.next()).status).toBe("idle");
} finally {
await observation.close();
}
});
test("lookup failure uses its error channel and never fabricates a missing-child result", async () => {
const { host, observation } = await scenario();
try {
host.failure!(new Error("Route disconnected"));
await expect(observation.next()).rejects.toThrow("Route disconnected");
} finally {
await observation.close();
}
});
test("a terminal event observed after newer work starts waits for that running work", async () => {
const { host, observation } = await scenario();
try {
host.emit("running", { type: "turn_completed", provider: "codex" });
let settled = false;
const next = observation.next().then((result) => {
settled = true;
return result;
});
await Promise.resolve();
expect(settled).toBe(false);
host.emit("idle", { type: "turn_completed", provider: "codex" });
expect((await next).status).toBe("idle");
} finally {
await observation.close();
}
});
test.each([
{ status: "connecting", reason: "connecting" },
{ status: "offline", reason: "unreachable" },
{ status: "disabled", reason: "unreachable" },
{ status: "removed", reason: "removed" },
] as const)(
"remote observation classifies $status from the authoritative directory, ignoring arbitrary error codes",
async ({ status, reason }) => {
const row = {
registrationId: "registration",
serverId: "remote",
label: null,
source: { id: "owner", key: "remote" },
metadata: {},
enabled: status !== "disabled",
status: status === "removed" ? ("offline" as const) : status,
targetDescription: "Remote",
lastOnlineAt: null,
nextRetryAt: null,
error: null,
};
const hosts: ToolHosts = {
directory: async () => ({
self: { serverId: "origin", label: "Origin" },
hosts: status === "removed" ? [] : [row],
}),
async use() {
throw Object.assign(new Error("Untrusted remote error"), { code: "host_unknown" });
},
};
const local = new Host();
const resolver = createHostObservationResolver({
serverId: "origin",
local,
hosts: () => hosts,
});
const error = await resolver.use("remote", async () => {}).catch((failure: unknown) => failure);
expect(error).toBeInstanceOf(HostObservationError);
expect(error).toMatchObject({ reason });
},
);
@@ -0,0 +1,121 @@
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { HostObservation } from "../../host-operations/index.js";
import type { FinishSubscription, FinishObservationSession } from "../index.js";
import { createObservationBuffer } from "./observation-buffer.js";
interface ObservationScope {
use<T>(
serverId: string | undefined,
action: (host: HostObservation) => Promise<T>,
signal?: AbortSignal,
): Promise<T>;
}
function isTerminal(snapshot: AgentSnapshotPayload, event?: AgentStreamEvent): boolean {
if (snapshot.status === "closed" || snapshot.status === "error") return true;
return (
snapshot.status === "idle" &&
(event?.type === "turn_completed" || event?.type === "turn_canceled")
);
}
function observeState(
buffer: ReturnType<typeof createObservationBuffer>,
snapshot: AgentSnapshotPayload | null,
event?: AgentStreamEvent,
lastMessage: string | null = null,
): void {
if (!snapshot) {
buffer.push({ status: "idle", final: null, lastMessage: null, error: null });
return;
}
if (event?.type === "permission_resolved") {
buffer.resolved(event.requestId);
return;
}
if (event?.type === "turn_started") return;
if (event?.type === "permission_requested") {
buffer.push({
status: "permission",
final: { ...snapshot, pendingPermissions: [event.request] },
lastMessage,
error: null,
});
} else if (isTerminal(snapshot, event)) {
buffer.push({
status: snapshot.status === "error" ? "error" : "idle",
final: snapshot,
lastMessage,
error: snapshot.lastError ?? null,
...(event?.type === "turn_canceled" ? { reason: "was canceled" as const } : {}),
});
}
}
/** One continuous source feeds a bounded buffer while the caller awaits dispatch. */
export async function openFinishObservation(
resolver: ObservationScope,
record: FinishSubscription,
signal: AbortSignal,
): Promise<FinishObservationSession> {
signal.throwIfAborted();
const controller = new AbortController();
const lifetime = AbortSignal.any([signal, controller.signal]);
const buffer = createObservationBuffer(lifetime);
let opened!: () => void;
let failed!: (error: unknown) => void;
const ready = new Promise<void>((resolve, reject) => {
opened = resolve;
failed = reject;
});
const route = resolver
.use(
record.serverId,
async (host) => {
const release = await host.observe(
record.childAgentId,
(snapshot, event, lastMessage) => observeState(buffer, snapshot, event, lastMessage),
lifetime,
(error) => buffer.fail(error),
);
opened();
// The ordinary wait supplies the initial settled snapshot and covers queued admission.
// Continuous observation buffers checkpoints and terminal events throughout delivery.
void host
.wait(record.childAgentId, lifetime)
.then((result) => {
buffer.pushInitial(result);
return undefined;
})
.catch((error) => buffer.fail(error));
try {
if (!lifetime.aborted)
await new Promise<void>((resolve) =>
lifetime.addEventListener("abort", () => resolve(), { once: true }),
);
} finally {
await release();
}
},
lifetime,
)
.catch((error) => {
failed(error);
buffer.fail(error);
});
try {
await ready;
} catch (error) {
controller.abort();
buffer.dispose();
await route;
throw error;
}
return {
next: () => buffer.next(),
async close() {
controller.abort();
buffer.dispose();
await route;
},
};
}
@@ -0,0 +1,107 @@
import type { Logger } from "pino";
import type { AgentManager } from "../../agent-manager.js";
import type { AgentStorage } from "../../agent-storage.js";
import type { AgentMessageDelivery } from "../../agent-messages/index.js";
import { buildStoredAgentPayload, toAgentPayload } from "../../agent-projections.js";
import { serializeSnapshotWithMetadata } from "../../mcp-shared.js";
import { createHostObservationResolver, type ToolHosts } from "../../host-operations/index.js";
import { openFinishObservation } from "./observation-stream.js";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import type { FinishObservation } from "../index.js";
export function createFinishObservation(input: {
agentManager: AgentManager;
agentStorage: AgentStorage;
/** A child chosen for restart recovery reports its state only after its resume was sent. */
waitForResume(agentId: string, signal: AbortSignal): Promise<void>;
messages(): AgentMessageDelivery;
hosts(): ToolHosts;
serverId: string;
logger: Logger;
}): FinishObservation {
const { agentManager, agentStorage } = input;
const resolver = createHostObservationResolver({
serverId: input.serverId,
hosts: input.hosts,
local: {
async wait(agentId, signal) {
await input.waitForResume(agentId, signal);
await input.messages().waitForDispatch(agentId, signal);
signal.throwIfAborted();
const live = agentManager.getAgent(agentId);
if (!live) {
const stored = await agentStorage.get(agentId);
return {
status: stored?.lastStatus === "error" ? "error" : "idle",
final: stored ? buildStoredAgentPayload(stored, [stored.provider]) : null,
lastMessage: await agentManager.getLastAssistantMessage(agentId),
error: stored?.lastError ?? null,
};
}
const result = await agentManager.waitForAgentEvent(agentId, {
signal,
waitForActive: true,
});
const current = agentManager.getAgent(agentId);
const status = result.permission ? "permission" : result.status;
return {
status: status === "permission" || status === "error" ? status : "idle",
final: current
? await serializeSnapshotWithMetadata(agentStorage, current, input.logger)
: null,
lastMessage: result.lastMessage,
error: current?.lastError ?? null,
};
},
async observe(agentId, handler, signal, onError) {
await input.waitForResume(agentId, signal);
const stored = await agentStorage.get(agentId);
let updates = Promise.resolve();
const publish = (snapshot: AgentSnapshotPayload | null, event?: AgentStreamEvent) => {
updates = updates
.then(async () =>
handler(snapshot, event, await agentManager.getLastAssistantMessage(agentId)),
)
.catch(onError);
};
signal.throwIfAborted();
const unsubscribe = agentManager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.lifecycle !== "idle")
publish(toAgentPayload(event.agent, { title: stored?.title }));
else if (
event.type === "agent_stream" &&
[
"permission_requested",
"permission_resolved",
"turn_started",
"turn_completed",
"turn_canceled",
"turn_failed",
].includes(event.event.type)
) {
const current = agentManager.getAgent(agentId);
if (current) publish(toAgentPayload(current, { title: stored?.title }), event.event);
}
},
{ agentId, replayState: false },
);
const current = agentManager.getAgent(agentId);
if (current)
publish(toAgentPayload(current, { title: stored?.title }), {
type: "turn_started",
provider: current.provider,
});
else publish(stored ? buildStoredAgentPayload(stored, [stored.provider]) : null);
const stop = () => unsubscribe();
signal.addEventListener("abort", stop, { once: true });
return async () => {
signal.removeEventListener("abort", stop);
unsubscribe();
};
},
},
});
return { open: (record, signal) => openFinishObservation(resolver, record, signal) };
}
@@ -0,0 +1,510 @@
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
import type { ActiveTurnBehavior, AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import {
DaemonClient,
DaemonConnectionError,
type FetchWorkspacesEntry,
type WaitForFinishResult,
} from "@getpaseo/client/internal/daemon-client";
import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types";
import type { HostDirectory } from "@getpaseo/protocol/hosts";
import { OWNER_PERMISSIONS } from "../../authorization/index.js";
import type { ConnectedHosts } from "../../connected-hosts/index.js";
import { resolveRequiredProviderModel } from "../mcp-shared.js";
import { selectAgentListItems } from "../agent-projections.js";
import { summarizeAgentActivity } from "../activity-curator.js";
import { expandUserPath } from "../../path-utils.js";
import type { PaseoToolExecutionContext, PaseoToolResult } from "../tools/types.js";
export class HostOperationRejectedError extends Error {
readonly code: unknown;
constructor(error: unknown) {
super(error instanceof Error ? error.message : String(error), { cause: error });
this.name = "HostOperationRejectedError";
this.code = error instanceof Error && "code" in error ? error.code : undefined;
}
}
function isAmbiguousPromptFailure(error: unknown): boolean {
if (error instanceof DaemonConnectionError) return true;
return error instanceof Error && error.message.startsWith("Timeout waiting for message");
}
export type HostOperation = (
input: Record<string, unknown>,
context: PaseoToolExecutionContext,
) => Promise<PaseoToolResult>;
export interface HostOperations {
createAgent: HostOperation;
createWorkspace: HostOperation;
listWorkspaces: HostOperation;
sendPrompt: HostOperation;
getStatus: HostOperation;
getActivity: HostOperation;
listAgents: HostOperation;
}
/** Connection authority at the host boundary, independent of child lifecycle. */
export class HostObservationError extends Error {
constructor(
readonly reason: "connecting" | "unreachable" | "removed",
cause?: unknown,
) {
super(`Host observation ${reason}`, { cause });
this.name = "HostObservationError";
}
}
export interface HostObservation {
wait(agentId: string, signal: AbortSignal): Promise<WaitForFinishResult>;
observe(
agentId: string,
handler: (
snapshot: AgentSnapshotPayload | null,
event?: AgentStreamEvent,
lastMessage?: string | null,
) => void,
signal: AbortSignal,
onError: (error: unknown) => void,
): Promise<() => Promise<void>>;
}
export interface ToolHosts {
directory(): Promise<HostDirectory>;
use<T>(
serverId: string,
action: (client: DaemonClient) => Promise<T>,
signal?: AbortSignal,
): Promise<T>;
}
/** Daemon MCP has the daemon's authority, bounded by the destination's admission of its link. */
export function createToolHosts(hosts: ConnectedHosts): ToolHosts {
return {
directory: () => hosts.list(),
async use(serverId, action, signal) {
const lifetime = new AbortController();
let routeFailure: unknown;
const transportFactory: DaemonTransportFactory = () => {
const opens = new Set<() => void>();
const closes = new Set<(event?: unknown) => void>();
const errors = new Set<(event: unknown) => void>();
const messages = new Set<(data: unknown, binary: boolean) => void>();
let closed = false;
let route: Awaited<ReturnType<ConnectedHosts["openRoute"]>> | undefined;
void hosts
.openRoute({
serverId,
scopeId: randomUUID(),
sourceId: "mcp",
originSessionId: "mcp",
permissions: OWNER_PERMISSIONS,
signal: signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal,
frame: (data) => {
for (const listener of messages) listener(data, typeof data !== "string");
},
ended: (code, message) => {
for (const listener of errors)
listener(Object.assign(new Error(message), { code, serverId }));
for (const listener of closes) listener();
},
})
.then((opened) => {
route = opened;
if (closed) opened.close();
else for (const listener of opens) listener();
return undefined;
})
.catch((error) => {
routeFailure = error;
for (const listener of errors) listener(error);
for (const listener of closes) listener(error);
});
return {
send: (data) => {
if (!route) throw new Error("Host route is not open");
route.send(data instanceof ArrayBuffer ? new Uint8Array(data) : data);
},
close: () => {
closed = true;
route?.close();
},
onOpen: (listener) => {
opens.add(listener);
return () => opens.delete(listener);
},
onClose: (listener) => {
closes.add(listener);
return () => closes.delete(listener);
},
onError: (listener) => {
errors.add(listener);
return () => errors.delete(listener);
},
onMessage: (listener) => {
messages.add(listener);
return () => messages.delete(listener);
},
};
};
const client = new DaemonClient({
url: "ws://host-route/ws",
clientId: randomUUID(),
transportFactory,
reconnect: { enabled: false },
});
const stop = () => void client.close();
signal?.addEventListener("abort", stop, { once: true });
try {
signal?.throwIfAborted();
await client.connect().catch((error) => {
// The action has not run; no application request could have been transmitted.
throw new HostOperationRejectedError(routeFailure ?? error);
});
return await action(client);
} finally {
signal?.removeEventListener("abort", stop);
lifetime.abort();
await client.close();
}
},
};
}
const remoteCreate = z
.object({
provider: z.string().min(1),
title: z.string().trim().min(1).max(60),
initialPrompt: z.string().min(1),
workspaceId: z.string().min(1),
labels: z.record(z.string(), z.string()).optional(),
notifyOnFinish: z.boolean().optional(),
idempotencyKey: z.string().optional(),
settings: z
.object({
modeId: z.string().optional(),
thinkingOptionId: z.string().optional(),
features: z.record(z.string(), z.unknown()).optional(),
})
.optional(),
})
.strict();
const result = (structuredContent: unknown): PaseoToolResult => ({
content: [],
structuredContent,
});
export function createHostResolver(input: {
serverId: string;
local: HostOperations;
hosts: () => ToolHosts;
callerAgentId?: string;
lockedCwd?: string;
}) {
return {
directory: () => input.hosts().directory(),
async use<T>(
serverId: string | undefined,
action: (host: HostOperations) => Promise<T>,
): Promise<T> {
const destination = serverId ?? input.serverId;
if (destination === input.serverId) return action(input.local);
if (input.lockedCwd)
throw new Error("This caller is restricted to its local working directory");
return input.hosts().use(destination, async (client) =>
action({
async createAgent(args) {
const parsed = remoteCreate.parse(args);
const { provider, model } = resolveRequiredProviderModel(parsed.provider);
const workspace = (await client.fetchWorkspaces({})).entries.find(
(row) => row.id === parsed.workspaceId,
);
if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`);
const labels = {
...parsed.labels,
...(input.callerAgentId
? parentAddressLabels({ agentId: input.callerAgentId, serverId: input.serverId })
: {}),
};
const snapshot = await client.createAgent({
config: {
provider,
model,
cwd: workspace.workspaceDirectory,
title: parsed.title,
modeId: parsed.settings?.modeId,
thinkingOptionId: parsed.settings?.thinkingOptionId,
featureValues: parsed.settings?.features,
},
workspaceId: parsed.workspaceId,
labels,
initialPrompt: parsed.initialPrompt,
idempotencyKey: parsed.idempotencyKey,
});
return result({
agentId: snapshot.id,
type: snapshot.provider,
status: snapshot.status,
cwd: snapshot.cwd,
workspaceId: snapshot.workspaceId,
currentModeId: snapshot.currentModeId,
availableModes: snapshot.availableModes,
});
},
async sendPrompt(args) {
const agentId = args.agentId as string;
// Human sends retain the established mode RPC on older connected hosts.
// Agent sends apply their requested mode when the recipient dispatches the batch.
if (!input.callerAgentId && args.sessionMode !== undefined) {
await client.setAgentMode(agentId, args.sessionMode as string);
}
const agent = (
await client.fetchAgent(agentId).catch((error) => {
// Snapshot preflight precedes sendMessage, so failure is definitely unsent.
throw new HostOperationRejectedError(error);
})
)?.agent;
if (!agent)
throw new HostOperationRejectedError(new Error(`Agent ${agentId} not found`));
const receipt = await client
.sendMessage(agentId, args.prompt as string, {
sessionMode: input.callerAgentId
? (args.sessionMode as string | undefined)
: undefined,
sourceAgentId: input.callerAgentId,
sourceAgentServerId: input.callerAgentId ? input.serverId : undefined,
activeTurnBehavior:
(args.activeTurnBehavior as ActiveTurnBehavior | undefined) ??
(input.callerAgentId ? "steer" : "interrupt"),
})
.catch((error) => {
if (isAmbiguousPromptFailure(error)) throw error;
throw new HostOperationRejectedError(error);
});
return result({
success: true,
...receipt,
status: agent.status,
guidance:
receipt.deliveryStatus === "queued"
? "Queued for delivery after two quiet seconds. You will get notified when the prompted agent finishes, errors, or needs permission unless notifyOnFinish is false."
: "Dispatch acknowledged. Observe completion using get_agent_status/get_agent_activity or SDK/RPC subscriptions and wait APIs.",
});
},
async getStatus(args) {
const agent = (await client.fetchAgent(args.agentId as string))?.agent;
if (!agent) throw new Error(`Agent ${args.agentId} not found`);
return result({ status: agent.status, snapshot: agent });
},
async getActivity(args) {
const agentId = args.agentId as string;
const limit = args.limit as number | undefined;
const timeline = await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: limit ?? 0,
});
const agent = (await client.fetchAgent(agentId))?.agent;
if (!agent) throw new Error(`Agent ${agentId} not found`);
return result({
agentId,
...summarizeAgentActivity(
timeline.entries.map((entry) => entry.item),
limit,
),
currentModeId: agent.currentModeId,
});
},
async listAgents(args) {
const { entries } = await client.fetchAgents({
includeBackground: args.includeBackground === true,
filter: { includeArchived: args.includeArchived === true },
});
return result({
agents: selectAgentListItems(
entries.map((entry) => entry.agent),
{
cwd: (args.cwd as string | undefined)?.trim()
? expandUserPath(args.cwd as string)
: undefined,
statuses: args.statuses as AgentSnapshotPayload["status"][] | undefined,
sinceHours: args.sinceHours as number | undefined,
limit: args.limit as number | undefined,
},
),
});
},
async listWorkspaces(args) {
const { entries } = await client.fetchWorkspaces({
includeBackground: args.includeBackground === true,
});
return result({ workspaces: entries.map(workspaceSummary) });
},
async createWorkspace(args) {
const isolation = z.enum(["local", "worktree"]).parse(args.isolation);
const path = z.string().min(1).parse(args.path);
const response = await client.createWorkspace({
source:
isolation === "local"
? { kind: "directory", path, projectId: args.projectId as string | undefined }
: {
kind: "worktree",
cwd: path,
projectId: args.projectId as string | undefined,
action:
args.mode === "checkout-branch" || args.mode === "checkout-pr"
? "checkout"
: "branch-off",
baseBranch: args.baseBranch as string | undefined,
branchName: args.branchName as string | undefined,
refName: args.branch as string | undefined,
githubPrNumber: args.prNumber as number | undefined,
worktreeSlug: args.worktreeSlug as string | undefined,
},
title: args.title as string | undefined,
background: args.background as boolean | undefined,
});
if (response.error || !response.workspace)
throw new Error(response.error ?? "Workspace creation failed");
return result(workspaceSummary(response.workspace));
},
}),
);
},
};
}
function workspaceSummary(workspace: FetchWorkspacesEntry) {
return {
workspaceId: workspace.id,
projectId: workspace.projectId,
cwd: workspace.workspaceDirectory,
isolation: workspace.workspaceKind === "worktree" ? "worktree" : "local",
kind: workspace.workspaceKind === "checkout" ? "local_checkout" : workspace.workspaceKind,
title: workspace.title,
background: workspace.background,
};
}
function remoteObservation(client: DaemonClient): HostObservation {
return {
async observe(agentId, handler, signal, onError) {
const observation = client.observeTimeline([agentId], { signal });
const refresh = async (event?: AgentStreamEvent) => {
let snapshot = (await client.fetchAgent(agentId))?.agent ?? null;
let lastMessage: string | null = null;
if (
snapshot &&
(snapshot.status === "idle" ||
snapshot.status === "closed" ||
snapshot.status === "error" ||
snapshot.pendingPermissions.length > 0)
) {
// A new turn may start between the snapshot and the current-response read.
// Bound that wait and refresh state instead of attributing the old event to it.
const current = await client.waitForFinish(agentId, 100);
if (current.status === "timeout")
snapshot = (await client.fetchAgent(agentId))?.agent ?? null;
else {
if (!current.final && current.error && current.error !== `Agent not found: ${agentId}`)
throw new Error(current.error);
snapshot = current.final;
lastMessage = current.lastMessage;
}
}
handler(snapshot, event, lastMessage);
};
let updates = Promise.resolve();
const unsubscribe = observation.subscribe({
snapshot: () => {},
update: (message) => {
if (message.type !== "agent_stream" || message.payload.agentId !== agentId) return;
const event = message.payload.event;
if (
event.type === "permission_requested" ||
event.type === "permission_resolved" ||
event.type === "turn_started" ||
event.type === "turn_completed" ||
event.type === "turn_canceled" ||
event.type === "turn_failed"
)
updates = updates.then(() => refresh(event)).catch(onError);
},
error: onError,
});
try {
await observation.ready;
await refresh();
} catch (error) {
unsubscribe();
await observation.release();
throw error;
}
return async () => {
unsubscribe();
await observation.release();
};
},
wait: (agentId, signal) => {
const close = () => void client.close();
signal.addEventListener("abort", close, { once: true });
return client
.waitForFinish(agentId, 0)
.then((outcome) => {
if (!outcome.final && outcome.error && outcome.error !== `Agent not found: ${agentId}`)
throw new Error(outcome.error);
return outcome;
})
.finally(() => signal.removeEventListener("abort", close));
},
};
}
/** Shares routed status observation with daemon services independently of MCP execution. */
export function createHostObservationResolver(input: {
serverId: string;
local: HostObservation;
hosts(): ToolHosts;
}) {
return {
async use<T>(
serverId: string | undefined,
action: (host: HostObservation) => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
if (!serverId || serverId === input.serverId) return action(input.local);
const hosts = input.hosts();
async function classify(error: unknown): Promise<HostObservationError> {
// This is the origin's authoritative registration directory, not a destination RPC error.
const row = (await hosts.directory()).hosts.find((host) => host.serverId === serverId);
if (!row) return new HostObservationError("removed", error);
if (row.status === "connecting") return new HostObservationError("connecting", error);
return new HostObservationError("unreachable", error);
}
async function fail(error: unknown): Promise<never> {
throw await classify(error);
}
try {
return await hosts.use(
serverId,
(client) => {
const remote = remoteObservation(client);
return action({
wait: (agentId, lifetime) => remote.wait(agentId, lifetime).catch(fail),
observe(agentId, handler, lifetime, onError) {
async function report(error: unknown): Promise<void> {
try {
onError(await classify(error));
} catch (failure) {
onError(failure);
}
}
return remote.observe(agentId, handler, lifetime, report).catch(fail);
},
});
},
signal,
);
} catch (error) {
if (error instanceof HostObservationError) throw error;
return fail(error);
}
},
};
}
@@ -0,0 +1,294 @@
import { expect, onTestFinished, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { createIdleRuntimeCleanup, type RuntimeCleanupCandidate } from "./index.js";
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((yes) => {
resolve = yes;
});
return { promise, resolve };
}
class Runtime {
readonly identity = {};
busy = false;
closed = false;
resumable = true;
activity = 0;
parentAgentId: string | undefined;
probe: (() => Promise<boolean>) | undefined;
admitClose: (() => Promise<void>) | undefined;
finishClose: (() => Promise<void>) | undefined;
constructor(readonly id = "agent") {}
capture(): RuntimeCleanupCandidate {
const activity = this.activity;
return {
identity: this.identity,
agentId: this.id,
parentAgentId: this.parentAgentId,
provider: "test",
sessionId: this.resumable ? this.id : null,
lastActivityAt: activity,
isEligible: () => !this.closed && !this.busy && this.activity === activity,
canStopRuntime: this.probe,
closeIfEligible: async (eligible) => {
await this.admitClose?.();
if (!eligible()) return false;
this.closed = true;
await this.finishClose?.();
return true;
},
};
}
}
function scenario(enabled = true) {
const runtime = new Runtime();
const runtimes = [runtime];
let time = 0;
let protectedCaller = false;
let tick!: () => void;
const cleanup = createIdleRuntimeCleanup({
enabled,
logger: createTestLogger(),
now: () => time,
runtimes: () => runtimes.filter((item) => !item.closed).map((item) => item.capture()),
isProtected: () => protectedCaller,
schedule: (callback, interval) => {
expect(interval).toBe(60_000);
tick = callback;
return () => {};
},
});
onTestFinished(() => cleanup.close());
return {
runtime,
runtimes,
cleanup,
tick: () => tick(),
protect: (value: boolean) => {
protectedCaller = value;
},
sweep: (minutes: number) => {
time = minutes * 60_000;
return cleanup.sweep();
},
};
}
test("a runtime without a provider check is collected at exactly one hour", async () => {
const s = scenario();
expect((await s.sweep(59)).collected).toEqual([]);
expect((await s.sweep(60)).collected).toEqual([
{ agentId: "agent", provider: "test", sessionId: "agent" },
]);
});
test("activity restarts the single one-hour idle period", async () => {
const s = scenario();
await s.sweep(59);
s.runtime.activity = 59 * 60_000;
expect((await s.sweep(118)).collected).toEqual([]);
expect((await s.sweep(119)).collected).toHaveLength(1);
});
test.each(["foreground", "finish subscription"])(
"a positive provider check cannot override %s",
async (blocker) => {
const s = scenario();
let calls = 0;
s.runtime.probe = async () => {
calls++;
return true;
};
if (blocker === "foreground") s.runtime.busy = true;
else s.protect(true);
expect((await s.sweep(61)).collected).toEqual([]);
expect(calls).toBe(0);
s.runtime.busy = false;
s.protect(false);
expect((await s.sweep(62)).collected).toEqual([]);
expect((await s.sweep(121)).collected).toEqual([]);
expect((await s.sweep(122)).collected).toHaveLength(1);
},
);
test.each(["veto", "error"])(
"a supported provider %s retains the runtime until a fresh idle hour passes",
async (decision) => {
const s = scenario();
s.runtime.probe = async () => {
if (decision === "error") throw new Error("Probe failed");
return false;
};
const blocked = await s.sweep(61);
expect(blocked.collected).toEqual([]);
expect(blocked.failures).toHaveLength(decision === "error" ? 1 : 0);
await s.sweep(119);
s.runtime.probe = async () => true;
expect((await s.sweep(120)).collected).toEqual([]);
expect((await s.sweep(179)).collected).toEqual([]);
expect((await s.sweep(180)).collected).toHaveLength(1);
},
);
test("an idle managed descendant with provider-held work vetoes its parent", async () => {
const s = scenario();
const child = new Runtime("child");
child.parentAgentId = s.runtime.id;
child.probe = async () => false;
s.runtimes.push(child);
expect((await s.sweep(61)).collected).toEqual([]);
child.probe = async () => true;
expect((await s.sweep(62)).collected).toEqual([]);
expect((await s.sweep(122)).collected).toHaveLength(2);
});
test("new caller protection during an async provider probe prevents stale collection", async () => {
const s = scenario();
const probe = deferred<boolean>();
s.runtime.probe = () => probe.promise;
const collecting = s.sweep(61);
s.protect(true);
probe.resolve(true);
expect((await collecting).collected).toEqual([]);
expect(s.runtime.closed).toBe(false);
s.protect(false);
expect((await s.sweep(62)).collected).toEqual([]);
expect((await s.sweep(122)).collected).toHaveLength(1);
});
test("a supported provider timeout retains ownership even if it later returns true", async () => {
const s = scenario();
const probe = deferred<boolean>();
s.runtime.probe = () => probe.promise;
const result = await s.sweep(61);
expect(result.collected).toEqual([]);
expect(result.failures[0]?.error).toEqual(new Error("Runtime safety probe timed out for agent"));
probe.resolve(true);
expect((await s.sweep(62)).collected).toEqual([]);
expect(s.runtime.closed).toBe(false);
}, 15_000);
test("the host opt-out disables explicit sweeps and scheduled ticks", async () => {
const s = scenario(false);
let calls = 0;
s.runtime.probe = async () => {
calls++;
return true;
};
expect((await s.sweep(61)).collected).toEqual([]);
s.tick();
await s.cleanup.close();
expect(calls).toBe(0);
expect(s.runtime.closed).toBe(false);
});
test("a managed descendant without persistence still protects its parent while working", async () => {
const s = scenario();
const child = new Runtime("initializing-child");
child.parentAgentId = s.runtime.id;
child.resumable = false;
child.busy = true;
s.runtimes.push(child);
expect((await s.sweep(61)).collected).toEqual([]);
child.busy = false;
expect((await s.sweep(62)).collected).toEqual([]);
expect((await s.sweep(122)).collected).toEqual([
{ agentId: "agent", provider: "test", sessionId: "agent" },
]);
expect(child.closed).toBe(false);
});
test("the live host switch retains eligible runtimes and re-enables with their existing idle clocks", async () => {
const s = scenario();
s.cleanup.setEnabled(false);
expect((await s.sweep(61)).collected).toEqual([]);
expect(s.runtime.closed).toBe(false);
s.cleanup.setEnabled(true);
s.tick();
expect((await s.cleanup.sweep()).collected).toHaveLength(1);
});
test.each([false, true])(
"disabling during a probe invalidates the sweep (re-enable: %s)",
async (reEnable) => {
const s = scenario();
const probe = deferred<boolean>();
s.runtime.probe = () => probe.promise;
const collecting = s.sweep(61);
s.cleanup.setEnabled(false);
if (reEnable) s.cleanup.setEnabled(true);
probe.resolve(true);
expect((await collecting).collected).toEqual([]);
expect(s.runtime.closed).toBe(false);
s.cleanup.setEnabled(true);
expect((await s.sweep(62)).collected).toHaveLength(1);
},
);
test.each([false, true])(
"disabling while waiting for lifecycle admission prevents closure (re-enable: %s)",
async (reEnable) => {
const s = scenario();
const admitted = deferred<void>();
const waiting = deferred<void>();
s.runtime.admitClose = () => {
waiting.resolve();
return admitted.promise;
};
const collecting = s.sweep(61);
await waiting.promise;
s.cleanup.setEnabled(false);
if (reEnable) s.cleanup.setEnabled(true);
admitted.resolve();
expect((await collecting).collected).toEqual([]);
expect(s.runtime.closed).toBe(false);
s.cleanup.setEnabled(true);
expect((await s.sweep(62)).collected).toHaveLength(1);
},
);
test.each(["veto", "error"])(
"an invalidated provider %s does not reset the idle clock",
async (outcome) => {
const s = scenario();
const probe = deferred<boolean>();
s.runtime.probe = async () => {
await probe.promise;
if (outcome === "error") throw new Error("Old probe failed");
return false;
};
const collecting = s.sweep(61);
s.cleanup.setEnabled(false);
s.cleanup.setEnabled(true);
probe.resolve(false);
expect(await collecting).toEqual({ collected: [], failures: [] });
s.runtime.probe = async () => true;
expect((await s.sweep(62)).collected).toHaveLength(1);
},
);
test("disabling lets a started close finish but prevents the next closure in that sweep", async () => {
const s = scenario();
const sibling = new Runtime("sibling");
s.runtimes.push(sibling);
const closing = deferred<void>();
const finished = deferred<void>();
s.runtime.finishClose = () => {
closing.resolve();
return finished.promise;
};
const collecting = s.sweep(61);
await closing.promise;
expect(s.runtime.closed).toBe(true);
s.cleanup.setEnabled(false);
finished.resolve();
expect((await collecting).collected).toEqual([
{ agentId: "agent", provider: "test", sessionId: "agent" },
]);
expect(sibling.closed).toBe(false);
s.cleanup.setEnabled(true);
expect((await s.sweep(62)).collected).toEqual([
{ agentId: "sibling", provider: "test", sessionId: "sibling" },
]);
});
@@ -0,0 +1,167 @@
import type { Logger } from "pino";
import { withTimeout } from "../../../utils/promise-timeout.js";
/** A captured runtime capability. Its lifecycle owner revalidates before releasing it. */
export interface RuntimeCleanupCandidate {
readonly identity: object;
readonly agentId: string;
readonly parentAgentId: string | undefined;
readonly provider: string;
readonly sessionId: string | null;
readonly lastActivityAt: number;
isEligible(): boolean;
canStopRuntime?(): Promise<boolean>;
closeIfEligible(revalidate: () => boolean): Promise<boolean>;
}
interface Options {
enabled: boolean;
runtimes(): readonly RuntimeCleanupCandidate[];
isProtected?(agentId: string): boolean;
logger: Pick<Logger, "debug" | "info" | "warn">;
now?: () => number;
schedule?: (tick: () => void, intervalMs: number) => () => void;
}
interface CollectionEntry {
agentId: string;
provider: string;
sessionId: string;
}
interface CollectionResult {
collected: CollectionEntry[];
failures: Array<{ agentId: string; error: unknown }>;
}
const IDLE_PERIOD_MS = 60 * 60_000;
/** Owns idle clocks, baseline eligibility, optional provider vetoes and the sweep lifetime. */
export function createIdleRuntimeCleanup(options: Options) {
const protectedAt = new WeakMap<object, { at: number; protected: boolean }>();
let collection: Promise<CollectionResult> | null = null;
let stopped = false;
let enabled = options.enabled;
let generation = 0;
const isProtected = options.isProtected ?? (() => false);
const now = options.now ?? Date.now;
function familyOf(root: RuntimeCleanupCandidate, runtimes: readonly RuntimeCleanupCandidate[]) {
const family = new Map([[root.agentId, root]]);
for (const parent of family.values()) {
for (const child of runtimes) {
if (child.parentAgentId === parent.agentId) family.set(child.agentId, child);
}
}
return [...family.values()];
}
function baselineAllows(family: readonly RuntimeCleanupCandidate[]) {
return family.every((runtime) => runtime.isEligible() && !isProtected(runtime.agentId));
}
async function providersAllow(
family: readonly RuntimeCleanupCandidate[],
isCurrent: () => boolean,
) {
for (const runtime of family) {
if (!isCurrent()) return false;
if (!runtime.canStopRuntime) continue;
const allowed = await withTimeout(
runtime.canStopRuntime(),
10_000,
`Runtime safety probe timed out for ${runtime.agentId}`,
);
if (allowed !== true) return false;
}
return true;
}
async function collect(): Promise<CollectionResult> {
const result: CollectionResult = { collected: [], failures: [] };
const sweepGeneration = generation;
const isCurrent = () => enabled && !stopped && generation === sweepGeneration;
if (!isCurrent()) return result;
const timestamp = now();
const runtimes = options.runtimes();
for (const runtime of runtimes) {
if (!isCurrent()) break;
if (!runtime.sessionId) continue;
const family = familyOf(runtime, runtimes);
try {
const allowed = baselineAllows(family) && (await providersAllow(family, isCurrent));
if (!isCurrent()) break;
if (!allowed) {
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
options.logger.debug(
{ agentId: runtime.agentId },
"Idle runtime retained by a baseline or provider veto",
);
continue;
}
const previous = protectedAt.get(runtime.identity);
if (previous?.protected)
protectedAt.set(runtime.identity, { at: timestamp, protected: false });
const idleSince = Math.max(
runtime.lastActivityAt,
protectedAt.get(runtime.identity)?.at ?? 0,
);
if (timestamp - idleSince < IDLE_PERIOD_MS) continue;
const revalidate = () => {
const currentFamily = familyOf(runtime, options.runtimes());
return (
isCurrent() &&
currentFamily.length === family.length &&
currentFamily.every((current) =>
family.some((captured) => captured.identity === current.identity),
) &&
baselineAllows(family)
);
};
if (await runtime.closeIfEligible(revalidate)) {
const entry = {
agentId: runtime.agentId,
provider: runtime.provider,
sessionId: runtime.sessionId,
};
result.collected.push(entry);
options.logger.info(entry, "Collected idle agent runtime");
} else if (isCurrent()) {
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
}
} catch (error) {
if (!isCurrent()) break;
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
result.failures.push({ agentId: runtime.agentId, error });
options.logger.warn(
{ agentId: runtime.agentId, err: error },
"Idle runtime cleanup failed",
);
}
}
return result;
}
function sweep(): Promise<CollectionResult> {
if (collection) return collection;
collection = collect().finally(() => {
collection = null;
});
return collection;
}
const schedule =
options.schedule ??
((tick, intervalMs) => {
const timer = setInterval(tick, intervalMs);
timer.unref();
return () => clearInterval(timer);
});
const cancel = schedule(() => {
void sweep().catch((error) => options.logger.warn({ err: error }, "Idle runtime sweep failed"));
}, 60_000);
return {
sweep,
setEnabled(value: boolean) {
if (enabled === value) return;
enabled = value;
generation++;
},
async close() {
stopped = true;
cancel();
await collection;
},
};
}
@@ -313,6 +313,124 @@ afterAll(async () => {
});
describe("Suite A: Core Fixes", () => {
test.each([
{ relationship: "detached", reparent: false, notifications: 1 },
{ relationship: "subagent", reparent: false, notifications: 1 },
{ relationship: "subagent", reparent: true, notifications: 0 },
] as const)(
"create_agent $relationship (reparent=$reparent) sends $notifications finish notification(s)",
async ({ relationship, reparent, notifications }) => {
let releaseChild!: () => void;
let childReachedGate!: () => void;
const heldTurn = new Promise<void>((resolve) => {
releaseChild = resolve;
});
const childAtGate = new Promise<void>((resolve) => {
childReachedGate = resolve;
});
const callerPrompts: string[] = [];
const daemon = await createTestPaseoDaemon({
agentClients: createTestAgentClients({
onStartTurn(prompt, config) {
if (config.title === "Notification caller" && typeof prompt === "string")
callerPrompts.push(prompt);
},
async beforeTurnComplete(prompt) {
if (
typeof prompt === "string" &&
parseAgentMessage(prompt).text === "Hold notification child"
) {
childReachedGate();
await heldTurn;
}
},
}),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
let mcp: McpClient | undefined;
try {
await client.connect();
const caller = await client.createAgent({
provider: "claude",
cwd: daemon.paseoHome,
title: "Notification caller",
modeId: "bypassPermissions",
});
mcp = await createMcpClient(
buildExpectedAgentMcpUrl({
host: "127.0.0.1",
port: daemon.port,
agentId: caller.id,
}),
);
const created = await callToolStructured(mcp, "create_agent", {
relationship: { kind: relationship },
workspace: { kind: "current" },
provider: "codex/gpt-5.4",
title: "Notification child",
initialPrompt: "Hold notification child",
notifyOnFinish: true,
settings: { modeId: "full-access" },
});
const childId = str(created.agentId);
await childAtGate;
expect(daemon.daemon.agentManager.getAgent(childId)?.labels?.[PARENT_AGENT_ID_LABEL]).toBe(
relationship === "subagent" ? caller.id : undefined,
);
if (reparent) {
const other = await client.createAgent({
provider: "claude",
cwd: daemon.paseoHome,
title: "Other parent",
modeId: "bypassPermissions",
});
await daemon.daemon.agentManager.setLabels(childId, {
[PARENT_AGENT_ID_LABEL]: other.id,
});
}
releaseChild();
await waitFor({
timeoutMs: 10000,
label: "child completion",
check: () =>
daemon.daemon.agentManager.getAgent(childId)?.lifecycle === "idle" ? true : null,
});
if (notifications) {
await waitFor({
timeoutMs: 10000,
label: "caller finish notification",
check: () => (callerPrompts.length > 0 ? true : null),
});
expect(parseAgentMessage(callerPrompts[0]).source).toEqual({
kind: "agent-notification",
agentId: childId,
title: "Notification child",
event: "finished",
});
expect(parseAgentMessage(callerPrompts[0]).text).toContain(
"Notification child) finished.",
);
await waitFor({
timeoutMs: 10000,
label: "caller notification drained",
check: () =>
daemon.daemon.agentManager.getAgent(caller.id)?.lifecycle === "idle" ? true : null,
});
} else {
// Allow the terminal event's asynchronous storage reads and notification dispatch to drain.
await new Promise((resolve) => setTimeout(resolve, 100));
}
expect(callerPrompts).toHaveLength(notifications);
} finally {
releaseChild();
await mcp?.close();
await client.close();
await daemon.close();
}
},
30000,
);
test.each([
["Stop", "cancelAgent"],
["Reload", "refreshAgent"],
File diff suppressed because it is too large Load Diff
+17 -8
View File
@@ -5,7 +5,7 @@ import type { AgentPermissionRequest } from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
import { curateAgentActivity } from "./activity-curator.js";
import { selectItemsByProjectedLimit } from "./timeline-projection.js";
import type { AgentStorage } from "./agent-storage.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import { serializeAgentSnapshot } from "../messages.js";
import { StoredScheduleSchema } from "@getpaseo/protocol/schedule/types";
import type { AgentProvider } from "./agent-sdk-types.js";
@@ -202,27 +202,36 @@ export function sanitizePermissionRequest(
return sanitized;
}
export async function resolveAgentTitle(
async function resolveAgentStorageRecord(
agentStorage: AgentStorage,
agentId: string,
logger: Logger,
): Promise<string | null> {
): Promise<StoredAgentRecord | null> {
try {
const record = await agentStorage.get(agentId);
return record?.title ?? null;
return (await agentStorage.get(agentId)) ?? null;
} catch (error) {
logger.error({ err: error, agentId }, "Failed to load agent title");
logger.error({ err: error, agentId }, "Failed to load agent record");
return null;
}
}
// Storage owns the archive timestamp, and a live snapshot cannot carry it: an
// archived agent is resumed back into memory to serve a history read (see the
// `purpose: "history"` resume in agent-loading), so presence in the manager is
// not evidence that the agent is active. Merge the stored record here, the same
// way the WebSocket path does in Session.enrichAgentPayload, rather than
// copying the value onto the managed agent, where it would be a second source
// of truth that unarchive has to remember to clear.
export async function serializeSnapshotWithMetadata(
agentStorage: AgentStorage,
snapshot: ManagedAgent,
logger: Logger,
) {
const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
return serializeAgentSnapshot(snapshot, { title });
const record = await resolveAgentStorageRecord(agentStorage, snapshot.id, logger);
return serializeAgentSnapshot(snapshot, {
title: record?.title ?? null,
archivedAt: record?.archivedAt ?? null,
});
}
export function parseDurationString(input: string): number {
@@ -99,16 +99,17 @@ afterEach(() => {
});
describe("mutable provider config owner", () => {
test("publishes provider changes after commit and emits nothing on rollback", () => {
test("publishes provider changes after commit and emits nothing on rollback", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, mutableConfig({ version: 1 }));
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: { codex: { enabled: true } },
providerOverrides: CONTROLLED_PROVIDERS,
extraClients: { codex: controlledCodexClient(async () => catalog("initial")) },
});
const cwd = path.resolve("/tmp/provider-config-owner");
manager.getSnapshot(cwd);
await manager.refreshSnapshotForCwd({ cwd });
const events: string[] = [];
manager.on("change", ({ current }) => events.push(current.cwd));
let agentManagerState = manager.getAgentManagerProviderState();
@@ -125,7 +126,7 @@ describe("mutable provider config owner", () => {
});
try {
store.patch({ providers: { codex: { enabled: false } } });
await store.patch({ providers: { codex: { enabled: false } } });
expect(events).toEqual([cwd]);
expect(agentManagerState.providerDefinitions.codex).toMatchObject({ enabled: false });
@@ -133,7 +134,7 @@ describe("mutable provider config owner", () => {
const unsubscribeFailure = store.onApply(() => {
throw new Error("later owner failed");
});
expect(() => store.patch({ providers: { codex: { enabled: true } } })).toThrow(
await expect(store.patch({ providers: { codex: { enabled: true } } })).rejects.toThrow(
"later owner failed",
);
unsubscribeFailure();
@@ -150,7 +151,7 @@ describe("mutable provider config owner", () => {
}
});
test("does no provider work for unrelated CORS, Git, and app URL reloads", () => {
test("does no provider work for unrelated CORS, Git, and app URL reloads", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
tempDirs.push(paseoHome);
const initial: PersistedConfig = {
@@ -174,9 +175,10 @@ describe("mutable provider config owner", () => {
});
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: { codex: { enabled: true } },
providerOverrides: CONTROLLED_PROVIDERS,
extraClients: { codex: controlledCodexClient(async () => catalog("initial")) },
});
manager.getSnapshot("/tmp/provider-config-owner");
await manager.refreshSnapshotForCwd({ cwd: "/tmp/provider-config-owner" });
const events: string[] = [];
manager.on("change", ({ current }) => events.push(current.cwd));
let registryUpdates = 0;
@@ -200,7 +202,7 @@ describe("mutable provider config owner", () => {
};
writeFileSync(configPath, `${JSON.stringify(reloaded, null, 2)}\n`, "utf-8");
expect(store.reload().appliedPaths).toEqual([
expect((await store.reload()).appliedPaths).toEqual([
"app.baseUrl",
"daemon.cors.allowedOrigins",
"daemon.git.maxProcessConcurrency",
@@ -245,7 +247,7 @@ describe("mutable provider config owner", () => {
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1));
expect(events).toEqual([]);
store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } });
await store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } });
expect(events).toEqual(["loading:none"]);
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(2));
@@ -311,9 +313,9 @@ describe("mutable provider config owner", () => {
expect(events).toEqual([]);
const before = manager.getSnapshot(cwd);
expect(() =>
await expect(
store.patch({ providers: { codex: { enabled: true, label: "Rejected Codex" } } }),
).toThrow("later owner failed");
).rejects.toThrow("later owner failed");
expect(events).toEqual([]);
expect(observedDuringApply).toEqual([{ label: "Codex", snapshot: before }]);
expect(observedDuringApply[0]!.snapshot).toBe(before);
@@ -32,7 +32,7 @@ class FakePermissionAgentManager {
return this.permissionResult;
}
tryRunOutOfBand(): boolean {
async tryRunOutOfBand(): Promise<boolean> {
return this.outOfBandHandled;
}
@@ -40,6 +40,8 @@ class FakePermissionAgentManager {
return undefined;
}
async reloadStalePluginSession(): Promise<void> {}
hasInFlightRun(): boolean {
return this.hasRunInFlight;
}
@@ -1,4 +1,11 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { AgentStorage } from "./agent-storage.js";
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
import { relative } from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import type {
ProviderConnection,
ProviderLaunch,
@@ -20,6 +27,21 @@ import {
StaleProviderSessionError,
} from "./stale-provider-session-error.js";
test("long concurrent plugin and child streams retain text-sized history and release it on archive", () => {
const fixture = fileURLToPath(new URL("./test-utils/plugin-stream-memory.ts", import.meta.url));
const output = execFileSync(
process.execPath,
["--expose-gc", "--max-old-space-size=512", "--import", "tsx", fixture],
{
encoding: "utf8",
timeout: 20_000,
},
);
const memory = JSON.parse(output) as { retained: number; closed: number };
expect(memory.retained).toBeLessThan(24 * 1024 * 1024);
expect(memory.closed).toBeLessThan(6 * 1024 * 1024);
});
const CAPABILITIES = [
"prompt.message",
"session.configure",
@@ -287,6 +309,99 @@ function expectNestedChildren(events: AgentStreamEvent[]) {
}
describe("PluginAgentClientRegistry", () => {
test("history keeps the tool's first position when completion follows later text", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: client.provider, cwd: "/tmp" });
const opened = harness.inputs.find((input) => input.type === "session.open");
if (opened?.type !== "session.open") throw new Error("missing session open");
const tool = {
type: "tool_call" as const,
id: "tool-position",
callId: "call-position",
name: "read",
detail: { type: "unknown" as const, input: null, output: null },
error: null,
};
try {
harness.emit({
type: "timeline.item",
sessionId: opened.sessionId,
item: { ...tool, status: "running" },
});
harness.emit({
type: "timeline.item",
sessionId: opened.sessionId,
item: { type: "assistant_message", id: "later-text", text: "Working" },
});
harness.emit({
type: "timeline.item",
sessionId: opened.sessionId,
item: { ...tool, status: "completed" },
});
const items = [];
for await (const event of session.streamHistory())
if (event.type === "timeline") items.push(event.item);
expect(items).toEqual([
{
type: "tool_call",
callId: "call-position",
name: "read",
detail: tool.detail,
error: null,
status: "completed",
},
{ type: "assistant_message", messageId: "later-text", text: "Working" },
]);
} finally {
await session.close();
await registry.shutdown();
}
});
test("detached text deltas preserve split surrogates and history replays one complete message", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: client.provider, cwd: "/tmp" });
const opened = harness.inputs.find((input) => input.type === "session.open");
if (opened?.type !== "session.open") throw new Error("missing session open");
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const prefix = "long prefix ".repeat(10);
const first = prefix + "a\uD83D";
const complete = first + "\uDE00b";
try {
for (const text of [first, complete, complete]) {
harness.emit({
type: "timeline.item",
sessionId: opened.sessionId,
item: { type: "assistant_message", id: "split-message", text },
});
}
const deltas = events.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message"
? [event.item.text]
: [],
);
expect(deltas).toEqual([first, "\uDE00b"]);
expect(deltas.join("")).toBe(prefix + "a😀b");
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) history.push(event);
expect(
history.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item] : [],
),
).toEqual([{ type: "assistant_message", messageId: "split-message", text: complete }]);
} finally {
await session.close();
await registry.shutdown();
}
});
test("stores only agent options while the plugin receives merged defaults", async () => {
const logger = createTestLogger();
const harness = createProviderHarness();
@@ -305,7 +420,7 @@ describe("PluginAgentClientRegistry", () => {
});
const own = { nested: { replace: "agent" } };
const agent = await manager.createAgent(
{ provider: "plugin-direct", cwd: "/tmp", providerOptions: own },
{ provider: "plugin-direct", cwd: tmpdir(), providerOptions: own },
undefined,
{ workspaceId: undefined },
);
@@ -481,6 +596,137 @@ describe("PluginAgentClientRegistry", () => {
}
});
test.each(["completed", "canceled", "failed"] as const)(
"reports a reused plugin child as running until its next turn is %s",
async (outcome) => {
const logger = createTestLogger();
const harness = createProviderHarness();
const plugins = new PluginAgentClientRegistry(logger);
plugins.replace([harness.registration]);
const registry = buildProviderRegistry(logger, { pluginProviders: plugins.definitions() });
const manager = new AgentManager({
logger,
clients: { "plugin-direct": registry["plugin-direct"].createClient(logger) },
providerDefinitions: { "plugin-direct": registry["plugin-direct"] },
});
try {
const agent = await manager.createAgent(
{ provider: "plugin-direct", cwd: tmpdir() },
undefined,
{ workspaceId: undefined },
);
const open = harness.inputs.find((input) => input.type === "session.open")!;
harness.emit({
type: "session.opened",
sessionId: "reusable-child",
parentSessionId: open.sessionId,
capabilities: [],
restoration: "parent",
title: "Plugin child",
cwd: tmpdir(),
});
harness.emit({
type: "session.turn",
sessionId: "reusable-child",
turnId: "first-turn",
state: "started",
});
harness.emit({
type: "session.turn",
sessionId: "reusable-child",
turnId: "first-turn",
state: "completed",
});
await nextTurn();
const [child] = manager.listProviderSubagents(agent.id);
expect(child).toMatchObject({
id: "reusable-child",
title: "Plugin child",
status: "completed",
});
harness.emit({
type: "session.turn",
sessionId: "reusable-child",
turnId: "second-turn",
state: "started",
});
await nextTurn();
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
{ id: child.id, title: child.title, status: "running" },
]);
harness.emit({
type: "session.turn",
sessionId: "reusable-child",
turnId: "second-turn",
state: outcome,
});
await nextTurn();
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
{ id: child.id, title: child.title, status: outcome },
]);
} finally {
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
await plugins.shutdown();
}
},
);
test.each(["started", "completed", "canceled", "failed"] as const)(
"replays a reused plugin child's latest turn state: %s",
async (state) => {
const harness = createProviderHarness({
openChildren(rootSessionId, emit) {
emit({
type: "session.opened",
sessionId: "reusable-child",
parentSessionId: rootSessionId,
capabilities: [],
restoration: "parent",
cwd: "/workspace",
});
for (const [turnId, turnState] of [
["first-turn", "started"],
["first-turn", "completed"],
["second-turn", "started"],
] as const) {
emit({ type: "session.turn", sessionId: "reusable-child", turnId, state: turnState });
}
if (state !== "started") {
emit({
type: "session.turn",
sessionId: "reusable-child",
turnId: "second-turn",
state,
});
}
},
});
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
try {
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: "/workspace",
});
const statuses = [];
for await (const event of session.streamHistory()) {
if (
event.type === "provider_subagent" &&
event.event.type === "upsert" &&
event.event.id === "reusable-child"
) {
statuses.push(event.event.status);
}
}
expect(statuses.at(-1)).toBe(state === "started" ? "running" : state);
} finally {
await registry.shutdown();
}
},
);
test("gates persistence operations on negotiated provider capabilities", async () => {
const harness = createProviderHarness({ capabilities: ["session.persistence"] });
const registry = new PluginAgentClientRegistry(createTestLogger());
@@ -1234,3 +1480,110 @@ test("config-required plugin membership is independent of enablement and other p
await plugins.shutdown();
}
});
test.each([true, false, "error", "wrong-session", "missing"] as const)(
"plugin runtime safety query: %s",
async (answer) => {
const harness = createProviderHarness({
capabilities:
answer === "missing" ? CAPABILITIES : [...CAPABILITIES, "session.can_stop_runtime"],
handleInput: async (input, emit) => {
if (input.type !== "session.can_stop_runtime") return false;
if (answer === "error")
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "Probe failed" },
});
else
emit({
type: "session.can_stop_runtime",
requestId: input.requestId,
sessionId: answer === "wrong-session" ? "child-1" : input.sessionId,
canStop: answer === true,
});
return true;
},
});
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: harness.registration.id, cwd: "/tmp" });
try {
if (answer === "error" || answer === "wrong-session") {
await expect(session.canStopRuntime!()).rejects.toThrow(
answer === "error" ? "Probe failed" : "another session",
);
} else if (answer === "missing") expect(session.canStopRuntime).toBeUndefined();
else expect(await session.canStopRuntime!()).toBe(answer === true);
const requests = harness.inputs.filter((input) => input.type === "session.can_stop_runtime");
expect(requests).toHaveLength(answer === "missing" ? 0 : 1);
if (answer !== "missing")
expect(requests[0]).toMatchObject({
sessionId: harness.inputs.find((input) => input.type === "session.open")?.sessionId,
});
} finally {
await session.close();
await registry.shutdown();
}
},
);
test("plugin without a runtime safety capability uses baseline collection without a query", async () => {
const home = await mkdtemp(join(tmpdir(), "plugin-baseline-cleanup-"));
const logger = createTestLogger();
const storage = new AgentStorage(join(home, "agents"), logger);
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(logger);
registry.replace([harness.registration]);
const manager = new AgentManager({ clients: registry.clients(), registry: storage, logger });
const agent = await manager.createAgent(
{ provider: harness.registration.id, cwd: home },
undefined,
{ workspaceId: undefined },
);
const cleanup = createIdleRuntimeCleanup({
enabled: true,
logger,
runtimes: () => manager.getRuntimeCleanupCandidates(),
now: () => agent.updatedAt.getTime() + 60 * 60_000,
schedule: () => () => {},
});
try {
expect(agent.session?.canStopRuntime).toBeUndefined();
expect((await cleanup.sweep()).collected).toHaveLength(1);
expect(manager.getAgent(agent.id)).toBeNull();
expect(harness.inputs.filter((input) => input.type === "session.can_stop_runtime")).toEqual([]);
} finally {
await cleanup.close();
await Promise.all(manager.listAgents().map((item) => manager.closeAgent(item.id)));
await registry.shutdown();
await storage.flush();
await rm(home, { recursive: true, force: true });
}
});
test("plugin provider import launches with runtime MCP servers and returns only saved configuration", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const storedConfig = { provider: harness.registration.id, cwd: "/project" };
const company = { type: "http", url: "https://tools.example.com/mcp" } as const;
try {
const imported = await client.importSession!(
{ providerHandleId: 'plugin:{"version":1,"data":{"token":"root"}}', cwd: "/project" },
{ config: { ...storedConfig, mcpServers: { company } }, storedConfig },
);
expect(harness.inputs).toContainEqual(
expect.objectContaining({
type: "session.open",
config: expect.objectContaining({ mcpServers: { company } }),
}),
);
expect(imported.config).toEqual(storedConfig);
await imported.session.close();
} finally {
await registry.shutdown();
}
});
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { PluginSessionHistory } from "./plugin-session-history.js";
import { resolve as resolvePath } from "node:path";
import type { Logger } from "pino";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
@@ -113,9 +114,15 @@ function providerError(error: ProviderError): Error {
function isProviderRequestReply(
event: ProviderEvent,
): event is Extract<ProviderEvent, { type: "request.completed" | "catalog" | "sessions" }> {
): event is Extract<
ProviderEvent,
{ type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" }
> {
return (
event.type === "request.completed" || event.type === "catalog" || event.type === "sessions"
event.type === "request.completed" ||
event.type === "catalog" ||
event.type === "sessions" ||
event.type === "session.can_stop_runtime"
);
}
@@ -564,10 +571,21 @@ class ProviderRuntime {
}
private finishRequest(
event: Extract<ProviderEvent, { type: "request.completed" | "catalog" | "sessions" }>,
event: Extract<
ProviderEvent,
{ type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" }
>,
): void {
const request = this.requests.get(event.requestId);
if (!request) return;
if (
event.type === "session.can_stop_runtime" &&
this.providerSessions.get(event.sessionId)?.id !== request.sessionId
) {
this.requests.delete(event.requestId);
request.reject(new Error("Runtime safety response belongs to another session"));
return;
}
this.requests.delete(event.requestId);
request.resolve(event);
}
@@ -575,6 +593,7 @@ class ProviderRuntime {
class ProviderRuntimeSession {
readonly history: ProviderEvent[] = [];
private historyClaimed = false;
private readonly listeners = new Set<(event: ProviderEvent) => void>();
private capabilities: readonly string[] = [];
private openRequest: { requestId: string; deferred: Deferred<void> } | null = null;
@@ -605,6 +624,10 @@ class ProviderRuntimeSession {
}
onEvent(listener: (event: ProviderEvent) => void): () => void {
// This buffer bridges session.open and adapter attachment. Once replayed,
// the adapter owns history; retaining live cumulative snapshots is quadratic.
this.historyClaimed = true;
this.history.length = 0;
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
@@ -635,6 +658,27 @@ class ProviderRuntimeSession {
}
}
async canStopRuntime(): Promise<boolean> {
if (
this.terminal ||
this.prompts.size ||
this.activeTurnIds.size ||
!this.capabilities.includes("session.can_stop_runtime")
)
return false;
const event = await this.runtime.complete(
{
type: "session.can_stop_runtime",
requestId: randomUUID(),
sessionId: this.providerSessionId,
},
AbortSignal.timeout(10_000),
);
if (event.type !== "session.can_stop_runtime")
throw new Error("Invalid runtime safety response");
return event.canStop;
}
async configure(changes: ProviderConfigChanges): Promise<void> {
const input: Extract<ProviderInput, { type: "session.configure" }> = {
type: "session.configure",
@@ -675,6 +719,7 @@ class ProviderRuntimeSession {
}
async close(): Promise<void> {
this.history.length = 0;
if (this.restoration === "parent") {
this.runtime.removeSession(this.id, this.providerSessionId);
return;
@@ -788,7 +833,7 @@ class ProviderRuntimeSession {
if (event.type === "session.persistence" && this.restoration === "core") {
this.persistence = event.persistence;
}
this.history.push(event);
if (!this.historyClaimed) this.history.push(event);
for (const listener of this.listeners) listener(event);
}
}
@@ -1014,7 +1059,7 @@ class PluginAgentClient implements AgentClient {
if (!persistence) throw new Error(`Plugin provider '${this.provider}' did not persist import`);
return {
session,
config: { ...context.config, provider: this.provider, cwd: input.cwd },
config: { ...context.storedConfig, provider: this.provider, cwd: input.cwd },
persistence,
timeline: session.timelineHistory(),
providerSubagentEvents: session.subagentHistory(),
@@ -1090,17 +1135,30 @@ class PluginAgentClient implements AgentClient {
}
}
interface TimelineTextSnapshot {
type: "assistant_message" | "reasoning" | "user_message";
text: string;
}
function isTextSnapshotItem(
item: ProviderTimelineItem,
): item is Extract<ProviderTimelineItem, { type: TimelineTextSnapshot["type"] }> {
return (
item.type === "assistant_message" || item.type === "reasoning" || item.type === "user_message"
);
}
class PluginAgentSession implements AgentSession {
readonly initialTimeline: ImportedProviderSession["timeline"];
private readonly listeners = new Set<(event: AgentStreamEvent) => void>();
private readonly history: AgentStreamEvent[] = [];
private readonly history = new PluginSessionHistory();
private readonly pendingPermissions = new Map<string, AgentPermissionRequest>();
private readonly permissionResponses = new Map<string, AgentPermissionResponse>();
private readonly revertTokens = new Map<string, ProviderTimelineItem["revertToken"]>();
private readonly timelineSnapshots = new Map<string, ProviderTimelineItem>();
private readonly timelineSnapshots = new Map<string, TimelineTextSnapshot>();
private readonly subagentIdsBySession = new Map<string, string | null>();
private readonly childUnsubscribes = new Map<string, () => void>();
private readonly childSnapshots = new Map<string, Map<string, ProviderTimelineItem>>();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
private unsubscribe: (() => void) | null = null;
private currentTurnId: string | null = null;
private closed = false;
@@ -1198,7 +1256,12 @@ class PluginAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
for (const event of this.history) yield event;
for (const event of this.history.read(this.provider)) yield event;
}
get canStopRuntime(): (() => Promise<boolean>) | undefined {
if (!this.bridge.negotiatedCapabilities.includes("session.can_stop_runtime")) return undefined;
return () => this.bridge.canStopRuntime();
}
async getRuntimeInfo() {
@@ -1261,6 +1324,12 @@ class PluginAgentSession implements AgentSession {
for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe();
this.childUnsubscribes.clear();
this.subagentIdsBySession.clear();
this.history.clear();
this.timelineSnapshots.clear();
this.childSnapshots.clear();
this.revertTokens.clear();
this.pendingPermissions.clear();
this.permissionResponses.clear();
this.listeners.clear();
this.onClose();
await this.bridge.close();
@@ -1301,16 +1370,20 @@ class PluginAgentSession implements AgentSession {
}
timelineHistory(): ImportedProviderSession["timeline"] {
return this.history.flatMap((event) =>
event.type === "timeline" ? [{ item: event.item, timestamp: event.timestamp }] : [],
);
return this.history
.read(this.provider)
.flatMap((event) =>
event.type === "timeline" ? [{ item: event.item, timestamp: event.timestamp }] : [],
);
}
subagentHistory(): NonNullable<ImportedProviderSession["providerSubagentEvents"]> {
return this.history.filter(
(event): event is Extract<AgentStreamEvent, { type: "provider_subagent" }> =>
event.type === "provider_subagent",
);
return this.history
.read(this.provider)
.filter(
(event): event is Extract<AgentStreamEvent, { type: "provider_subagent" }> =>
event.type === "provider_subagent",
);
}
attachChild(
@@ -1339,7 +1412,7 @@ class PluginAgentSession implements AgentSession {
cwd: opened.cwd,
},
});
const snapshots = new Map<string, ProviderTimelineItem>();
const snapshots = new Map<string, TimelineTextSnapshot>();
this.childSnapshots.set(childId, snapshots);
for (const event of child.history) this.acceptChildEvent(childId, event, snapshots);
this.childUnsubscribes.set(
@@ -1525,7 +1598,7 @@ class PluginAgentSession implements AgentSession {
private acceptChildEvent(
childId: string,
event: ProviderEvent,
snapshots: Map<string, ProviderTimelineItem>,
snapshots: Map<string, TimelineTextSnapshot>,
): void {
if (event.type === "timeline.item") {
const item = mapTimelineItem(event.item, snapshots);
@@ -1538,11 +1611,15 @@ class PluginAgentSession implements AgentSession {
}
return;
}
if (event.type === "session.turn" && event.state !== "started") {
if (event.type === "session.turn") {
this.publish({
type: "provider_subagent",
provider: this.provider,
event: { type: "upsert", id: childId, status: event.state },
event: {
type: "upsert",
id: childId,
status: event.state === "started" ? "running" : event.state,
},
});
return;
}
@@ -1674,24 +1751,24 @@ function mapPromptContent(prompt: AgentPromptInput): ProviderContent[] {
function mapTimelineItem(
item: ProviderTimelineItem,
snapshots: Map<string, ProviderTimelineItem>,
snapshots: Map<string, TimelineTextSnapshot>,
revertTokens?: Map<string, ProviderTimelineItem["revertToken"]>,
): AgentTimelineItem | null {
const previous = snapshots.get(item.id);
snapshots.set(item.id, item);
const { id, revertToken, ...withoutIdentity } = item;
if (revertToken !== undefined && revertTokens) {
revertTokens.set(id, revertToken);
if (item.type === "user_message" || item.type === "assistant_message") {
revertTokens.set(item.messageId ?? id, revertToken);
}
if (isTextSnapshotItem(item)) {
snapshots.set(item.id, { type: item.type, text: item.text });
}
const { id, revertToken: _revertToken, ...withoutIdentity } = item;
rememberRevertToken(item, revertTokens);
if (item.type === "assistant_message" || item.type === "reasoning") {
const previousText = previous?.type === item.type ? previous.text : "";
const text = item.text.startsWith(previousText)
const suffix = item.text.startsWith(previousText)
? item.text.slice(previousText.length)
: item.text;
// Match OpenCode's detachedSuffix: a sliced delta otherwise keeps its entire
// cumulative IPC snapshot alive in the projected timeline. Preserve split surrogates.
const text = Buffer.from(suffix, "utf16le").toString("utf16le");
if (text.length === 0) return null;
return item.type === "assistant_message"
? { type: "assistant_message", text, messageId: item.messageId ?? id }
@@ -1719,6 +1796,17 @@ function mapTimelineItem(
return withoutIdentity as AgentTimelineItem;
}
function rememberRevertToken(
item: ProviderTimelineItem,
tokens?: Map<string, ProviderTimelineItem["revertToken"]>,
): void {
if (item.revertToken === undefined || !tokens) return;
tokens.set(item.id, item.revertToken);
if (item.type === "user_message" || item.type === "assistant_message") {
tokens.set(item.messageId ?? item.id, item.revertToken);
}
}
function persistenceHandle(
provider: string,
persistence: ProviderPersistence,
@@ -0,0 +1,66 @@
import { TimelineProjection } from "./timeline-projection.js";
import type { AgentStreamEvent } from "./agent-sdk-types.js";
// Replay canonical items, rather than retaining every streamed delta or tool state.
// Sequence numbers here only preserve adapter replay order, not wire cursors.
export class PluginSessionHistory {
private readonly timelines = new Map<
string,
{ projection: TimelineProjection; seq: number; positions: Map<number, number> }
>();
private readonly events: Array<{ seq: number; event: AgentStreamEvent }> = [];
private seq = 0;
push(event: AgentStreamEvent): void {
const seq = ++this.seq;
const child =
event.type === "provider_subagent" && event.event.type === "timeline" ? event.event : null;
if (event.type !== "timeline" && !child) {
this.events.push({ seq, event });
return;
}
const key = child?.id ?? "";
let projection = this.timelines.get(key);
if (!projection) {
projection = { projection: new TimelineProjection(), seq: 0, positions: new Map() };
this.timelines.set(key, projection);
}
const timeline = event.type === "timeline" ? event : child!;
const row = projection.projection.append({
seq: ++projection.seq,
item: timeline.item,
timestamp: timeline.timestamp ?? "",
...(event.type === "timeline" && event.turnId ? { turnId: event.turnId } : {}),
});
if (!projection.positions.has(row.seqStart)) projection.positions.set(row.seqStart, seq);
}
read(provider: string): AgentStreamEvent[] {
const events = [...this.events];
for (const [id, projection] of this.timelines) {
for (const row of projection.projection.getRows()) {
const timestamp = row.timestamp ? { timestamp: row.timestamp } : {};
const event: AgentStreamEvent = id
? {
type: "provider_subagent",
provider,
event: { type: "timeline", id, item: row.item, ...timestamp },
}
: {
type: "timeline",
provider,
item: row.item,
...timestamp,
...(row.turnId ? { turnId: row.turnId } : {}),
};
events.push({ seq: projection.positions.get(row.seqStart)!, event });
}
}
return events.sort((a, b) => a.seq - b.seq).map(({ event }) => event);
}
clear(): void {
this.timelines.clear();
this.events.length = 0;
}
}
@@ -69,13 +69,13 @@ describe("prompt attachments", () => {
).toContain("GitLab MR !123: Fix race in worktree setup");
});
it("renders review attachments with compact file, line, comment, and context details", () => {
it.each(["base", "uncommitted", "all"] as const)("renders %s reviews", (mode) => {
expect(
renderPromptAttachmentAsText({
type: "review",
mimeType: "application/paseo-review",
cwd: "/tmp/repo",
mode: "base",
mode,
baseRef: "main",
comments: [
{
@@ -111,7 +111,7 @@ describe("prompt attachments", () => {
}),
).toBe(
[
"Paseo review attachment (base)",
`Paseo review attachment (${mode})`,
"CWD: /tmp/repo",
"Base: main",
"",
@@ -469,6 +469,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
interrupt: () => inner.interrupt(),
close: () => inner.close(),
canStopRuntime: inner.canStopRuntime?.bind(inner),
listCommands: inner.listCommands?.bind(inner),
setModel: inner.setModel?.bind(inner),
setThinkingOption: inner.setThinkingOption?.bind(inner),
@@ -9,7 +9,7 @@ import type { PaseoToolCatalog } from "./tools/types.js";
export interface AgentProviderRuntime {
snapshotManager: ProviderSnapshotManager;
setPaseoToolCatalog(catalog: PaseoToolCatalog | null): void;
setPaseoToolCatalog(catalog: Pick<PaseoToolCatalog, "tools"> | null): void;
shutdown(): Promise<void>;
}
@@ -35,6 +35,7 @@ import {
resolveACPModelSelection,
summarizeACPRequestError,
} from "./acp-agent.js";
import { createIdleRuntimeCleanup } from "../idle-runtime-cleanup/index.js";
import type { ProcessTerminator, TreeKillTarget } from "../../../utils/tree-kill.js";
import {
COPILOT_AGENT_FEATURE_OPTION,
@@ -810,6 +811,46 @@ describe("ACPAgentSession terminal tools", () => {
truncated: false,
});
});
test("trims large terminal output without rescanning the retained suffix", async () => {
const session = createSession();
const terminal = await session.createTerminal({
sessionId: "session-1",
command: process.execPath,
args: ["-e", 'process.stdout.write("x".repeat(1024 * 1024))'],
cwd: process.cwd(),
outputByteLimit: 1024,
});
await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId });
await expect(
session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }),
).resolves.toEqual({
output: "x".repeat(1024),
truncated: true,
exitStatus: { exitCode: 0, signal: null },
});
}, 1000);
test("trims terminal output at a UTF-8 character boundary", async () => {
const session = createSession();
const terminal = await session.createTerminal({
sessionId: "session-1",
command: process.execPath,
args: ["-e", 'process.stdout.write("😀ab")'],
cwd: process.cwd(),
outputByteLimit: 3,
});
await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId });
await expect(
session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }),
).resolves.toEqual({
output: "ab",
truncated: true,
exitStatus: { exitCode: 0, signal: null },
});
});
});
describe("mapACPUsage", () => {
@@ -4424,6 +4465,161 @@ new AgentSideConnection(
);
`;
describe("ACP idle runtime cleanup", () => {
test("pending permissions veto cleanup until answered", async () => {
const session = createSession();
try {
expect(await session.canStopRuntime()).toBe(true);
const permission = session.requestPermission({
sessionId: "session-1",
toolCall: { toolCallId: "tool-1", title: "Run command", status: "pending" },
options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
});
expect(await session.canStopRuntime()).toBe(false);
await session.respondToPermission(session.getPendingPermissions()[0]!.id, {
behavior: "allow",
});
await permission;
expect(await session.canStopRuntime()).toBe(true);
} finally {
await session.close();
}
});
test("a failed terminal spawn does not pin the runtime or discard its diagnostic", async () => {
const session = createSession();
try {
const terminal = await session.createTerminal({
sessionId: "session-1",
command: path.join(tmpdir(), "paseo-no-such-command", "missing"),
});
await expect(
session.waitForTerminalExit({ sessionId: "session-1", ...terminal }),
).rejects.toThrow("ENOENT");
expect(await session.canStopRuntime()).toBe(true);
expect(
(await session.terminalOutput({ sessionId: "session-1", ...terminal })).output,
).toContain("ENOENT");
await session.releaseTerminal({ sessionId: "session-1", ...terminal });
expect(await session.canStopRuntime()).toBe(true);
} finally {
await session.close();
}
});
test("retains a quiet terminal after a finished turn, then waits a fresh idle hour after exit", async () => {
const dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-idle-"));
const agentScript = path.join(dir, "agent.mjs");
await writeFile(agentScript, PERMISSIVE_MODEL_ACP_AGENT);
const session = new ACPAgentSession(
{ provider: "test-acp", cwd: dir },
{
provider: "test-acp",
logger: createTestLogger(),
defaultCommand: [process.execPath, agentScript],
defaultModes: [],
capabilities: { supportsStreaming: true, supportsSessionPersistence: true },
launchEnv: {
ACP_SDK_URL: pathToFileURL(
createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"),
).href,
},
},
);
const hour = 60 * 60_000;
let now = 0;
let closed = false;
const candidate = {
identity: session,
agentId: "test-agent",
parentAgentId: undefined,
provider: session.provider,
sessionId: "permissive-session",
lastActivityAt: 0,
isEligible: () => !closed,
canStopRuntime: (session as AgentSession).canStopRuntime?.bind(session),
async closeIfEligible(revalidate: () => boolean) {
if (!revalidate()) return false;
await session.close();
closed = true;
return true;
},
};
const cleanup = createIdleRuntimeCleanup({
enabled: true,
now: () => now,
runtimes: () => (closed ? [] : [candidate]),
schedule: () => () => {},
logger: createTestLogger(),
});
try {
await session.initializeNewSession();
const completed = new Promise<void>((resolve) =>
session.subscribe((event) => {
if (event.type === "turn_completed") resolve();
}),
);
let activeTurnSafety: Promise<boolean> | undefined;
session.subscribe((event) => {
if (event.type === "turn_started") activeTurnSafety = session.canStopRuntime();
});
await session.startTurn("Finish this foreground turn");
await expect(activeTurnSafety).resolves.toBe(false);
await completed;
expect(await session.canStopRuntime()).toBe(true);
await session.sessionUpdate({
sessionId: candidate.sessionId,
update: {
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "buffered" },
},
});
expect(await session.canStopRuntime()).toBe(false);
await session.sessionUpdate({
sessionId: candidate.sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "flush" },
},
});
expect(await session.canStopRuntime()).toBe(true);
const terminal = await session.createTerminal({
sessionId: candidate.sessionId,
command: process.execPath,
args: ["-e", "setInterval(() => {}, 1000)"],
});
now = 2 * hour;
expect((await cleanup.sweep()).collected).toEqual([]);
expect(closed).toBe(false);
expect(await session.terminalOutput({ sessionId: candidate.sessionId, ...terminal })).toEqual(
{
output: "",
truncated: false,
exitStatus: undefined,
},
);
await session.killTerminal({ sessionId: candidate.sessionId, ...terminal });
await session.waitForTerminalExit({ sessionId: candidate.sessionId, ...terminal });
expect((await cleanup.sweep()).collected).toEqual([]);
now += hour - 1;
expect((await cleanup.sweep()).collected).toEqual([]);
now += 1;
expect((await cleanup.sweep()).collected).toEqual([
{
agentId: candidate.agentId,
provider: candidate.provider,
sessionId: candidate.sessionId,
},
]);
expect(closed).toBe(true);
} finally {
await cleanup.close();
await session.close();
await rm(dir, { recursive: true, force: true });
}
});
});
describe("custom ACP provider with configured models", () => {
let dir: string;
let agentScript: string;
@@ -649,6 +649,7 @@ interface TerminalExit {
}
interface TerminalEntry {
running: boolean;
id: string;
child: ChildProcess;
output: string;
@@ -2554,6 +2555,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
}
async canStopRuntime(): Promise<boolean> {
return (
this.activeForegroundTurnId === null &&
this.pendingPermissions.size === 0 &&
this.pendingUserMessage === null &&
!Array.from(this.terminalEntries.values()).some((terminal) => terminal.running)
);
}
async close(): Promise<void> {
if (this.closed) {
return;
@@ -2589,12 +2599,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
}
const terminalTerminations = Array.from(this.terminalEntries.values(), (terminal) =>
this.terminateProcess(terminal.child, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
}),
);
const terminalTerminations = Array.from(this.terminalEntries.values())
.filter((terminal) => terminal.running)
.map((terminal) =>
this.terminateProcess(terminal.child, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
}),
);
await Promise.all(terminalTerminations);
this.terminalEntries.clear();
@@ -2796,6 +2808,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
waitForExit.catch(() => undefined);
const entry: TerminalEntry = {
running: true,
id: terminalId,
child,
output: "",
@@ -2816,10 +2829,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
child.once("error", (error) => {
const spawnError = error instanceof Error ? error : new Error(String(error));
appendTerminalOutput(entry, `${spawnError.message}\n`);
// A failed spawn has no exit event. Other process errors do not prove exit.
if (child.pid === undefined) entry.running = false;
rejectExit(spawnError);
});
child.once("exit", (code, signal) => {
const exit = { exitCode: code, signal };
entry.running = false;
entry.exit = exit;
resolveExit(exit);
});
@@ -2844,7 +2860,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
async releaseTerminal(params: { sessionId: string; terminalId: string }): Promise<void> {
const entry = this.getTerminalEntry(params.terminalId);
if (!entry.exit) {
if (entry.running) {
await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 });
}
this.terminalEntries.delete(params.terminalId);
@@ -2852,7 +2868,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
async killTerminal(params: KillTerminalRequest): Promise<Record<string, never>> {
const entry = this.getTerminalEntry(params.terminalId);
if (!entry.exit) {
if (entry.running) {
await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 });
}
return {};
@@ -4077,10 +4093,16 @@ function appendTerminalOutput(entry: TerminalEntry, chunk: string): void {
if (!limit) {
return;
}
while (Buffer.byteLength(entry.output, "utf8") > limit && entry.output.length > 0) {
entry.output = entry.output.slice(1);
entry.truncated = true;
const output = Buffer.from(entry.output, "utf8");
if (output.length <= limit) {
return;
}
let start = output.length - limit;
while (start < output.length && (output[start] & 0xc0) === 0x80) {
start += 1;
}
entry.output = output.subarray(start).toString("utf8");
entry.truncated = true;
}
function readRecord(value: unknown): Record<string, unknown> | null {
@@ -0,0 +1,92 @@
import { expect, test } from "vitest";
import type { CanUseTool, PermissionMode, Query } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentPermissionRequest } from "../../agent-sdk-types.js";
import { ClaudeAgentClient } from "./agent.js";
// The injected SDK adapter supplies requests through Claude's permission callback.
async function createPlanApproval(mode: PermissionMode) {
let requestPermission: CanUseTool | undefined;
let sdkMode: PermissionMode = mode;
const query = Object.assign((async function* () {})(), {
async setPermissionMode(next: PermissionMode) {
sdkMode = next;
},
close() {},
});
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => "/test/claude",
queryFactory: ({ options }) => {
requestPermission = options.canUseTool;
return query as Query;
},
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
modeId: mode,
});
let request: AgentPermissionRequest | undefined;
session.subscribe((event) => {
if (event.type === "permission_requested") request = event.request;
});
await session.setMode("plan");
if (!requestPermission) throw new Error("SDK permission callback was not installed");
const resolution = requestPermission(
"ExitPlanMode",
{ plan: "Implement the plan" },
{
signal: new AbortController().signal,
toolUseID: "plan-approval",
},
);
if (!request) throw new Error("No plan approval request was emitted");
return { session, request, resolution, sdkMode: () => sdkMode };
}
test.each([
["auto", "Implement with Auto mode"],
["bypassPermissions", "Implement with Bypass"],
] as const)("plan approval offers an explicit return to %s", async (mode, label) => {
const plan = await createPlanApproval(mode);
try {
expect(plan.request.actions).toContainEqual(
expect.objectContaining({
id: "implement_resume",
label,
behavior: "allow",
intent: "implement_resume",
}),
);
} finally {
await plan.session.respondToPermission(plan.request.id, { behavior: "deny" });
await plan.resolution;
await plan.session.close();
}
});
test.each([
["auto", "implement_resume", "auto"],
["bypassPermissions", "implement_resume", "bypassPermissions"],
["auto", "implement", "acceptEdits"],
["bypassPermissions", "implement", "acceptEdits"],
["default", "implement_resume", "acceptEdits"],
["acceptEdits", "implement_resume", "acceptEdits"],
] as const)("approving from %s with %s enters %s", async (mode, action, expected) => {
const plan = await createPlanApproval(mode);
try {
await plan.session.respondToPermission(plan.request.id, {
behavior: "allow",
selectedActionId: action,
});
await expect(plan.resolution).resolves.toMatchObject({ behavior: "allow" });
expect(plan.sdkMode()).toBe(expected);
expect(await plan.session.getCurrentMode()).toBe(expected);
if (mode === "default" || mode === "acceptEdits") {
expect(plan.request.actions?.some((entry) => entry.id === "implement_resume")).toBe(false);
}
} finally {
await plan.session.close();
}
});
@@ -1,3 +1,5 @@
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
import type { Query, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -706,3 +708,137 @@ describe("background Claude subagents", () => {
},
);
});
test("Claude runtime safety protects silent shells, monitors and pending completion notifications", () => {
const safety = new ClaudeRuntimeSafety();
const observe = (message: unknown) => safety.observe(message as SDKMessage);
observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" });
expect(safety.canStopRuntime()).toBe(true);
observe({
type: "system",
subtype: "background_tasks_changed",
tasks: [
{ task_id: "quiet-shell", task_type: "local_bash", description: "sleep 7200" },
{ task_id: "monitor", task_type: "local_bash", description: "watch build" },
{ task_id: "native-child", task_type: "local_agent", description: "research" },
],
});
expect(safety.canStopRuntime()).toBe(false);
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
expect(safety.canStopRuntime()).toBe(false);
for (const task_id of ["quiet-shell", "monitor"])
observe({ type: "system", subtype: "task_notification", task_id });
expect(safety.canStopRuntime()).toBe(false);
observe({ type: "system", subtype: "task_notification", task_id: "native-child" });
expect(safety.canStopRuntime()).toBe(true);
safety.reset();
observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" });
expect(safety.canStopRuntime()).toBe(false);
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
expect(safety.canStopRuntime()).toBe(true);
});
test("Claude session runtime safety observes pumped background work and autonomous replies", async () => {
let wake: (() => void) | undefined;
const channel = { closed: false };
const queue: Array<{ message: SDKMessage; consumed(): void }> = [];
const pump = (async function* () {
while (!channel.closed) {
const entry = queue.shift();
if (!entry) {
await new Promise<void>((resolve) => {
wake = resolve;
});
continue;
}
yield entry.message;
entry.consumed();
}
})();
const push = (message: unknown) =>
new Promise<void>((consumed) => {
queue.push({ message: message as SDKMessage, consumed });
wake?.();
});
const end = () => {
channel.closed = true;
wake?.();
};
let nativeInput!: AsyncIterator<SDKUserMessage>;
let connected!: () => void;
const connection = new Promise<void>((resolve) => {
connected = resolve;
});
// The SDK Query is injected through the production adapter's existing launch port.
const query = {
next: () => pump.next(),
return: async () => {
end();
return pump.return(undefined);
},
close: end,
interrupt: async () => {},
setPermissionMode: async () => {},
setModel: async () => {},
supportedModels: async () => [],
supportedCommands: async () => [],
[Symbol.asyncIterator]() {
return this;
},
} as unknown as Query;
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => "/test/claude/bin",
queryFactory: ({ prompt }) => {
if (typeof prompt === "string") throw new Error("Expected streaming input");
nativeInput = prompt[Symbol.asyncIterator]();
connected();
return query;
},
}).createSession({ provider: "claude", cwd: process.cwd() });
const init = {
type: "system",
subtype: "init",
session_id: "runtime-safety",
claude_code_version: "2.1.292",
permissionMode: "default",
};
const result = {
type: "result",
subtype: "success",
usage: { input_tokens: 1, output_tokens: 1 },
total_cost_usd: 0,
};
try {
await session.startTurn("start background work");
await connection;
const input = await nativeInput.next();
await push({ type: "command_lifecycle", command_uuid: input.value.uuid, state: "started" });
await push(init);
await push(result);
expect(await session.canStopRuntime!()).toBe(true);
await push({
type: "system",
subtype: "background_tasks_changed",
tasks: [{ task_id: "monitor", task_type: "local_bash", description: "quiet monitor" }],
});
expect(await session.canStopRuntime!()).toBe(false);
await push({ type: "system", subtype: "background_tasks_changed", tasks: [] });
expect(await session.canStopRuntime!()).toBe(false);
await push({
type: "system",
subtype: "task_notification",
task_id: "monitor",
status: "completed",
output: "ready",
summary: "ready",
});
// Completion can wake Claude autonomously after the foreground turn has ended.
await push(init);
expect(await session.canStopRuntime!()).toBe(false);
await push(result);
expect(await session.canStopRuntime!()).toBe(true);
} finally {
await session.close();
}
});
@@ -1,10 +1,11 @@
import { once } from "node:events";
import type { Query } from "@anthropic-ai/claude-agent-sdk";
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentLaunchContext } from "../../agent-sdk-types.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryInput } from "./query.js";
import { claudeQuery, type ClaudeQueryInput } from "./query.js";
function createQueryMock(events: unknown[]): Query {
let index = 0;
@@ -29,6 +30,55 @@ function createQueryMock(events: unknown[]): Query {
}
describe("Claude SDK env", () => {
test.each(
["node", "bun", process.execPath].flatMap((command) =>
[undefined, "Paseo", "custom-client"].map((entrypoint) => ({ command, entrypoint })),
),
)(
"preserves explicit launch identity $entrypoint in $command while clearing inherited session env",
async ({ command, entrypoint }) => {
let output = "";
let exited = Promise.resolve<unknown[]>([]);
const query = claudeQuery(
{ prompt: "", options: {} },
{
runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } },
launchEnv: entrypoint === undefined ? {} : { CLAUDE_CODE_ENTRYPOINT: entrypoint },
queryFactory: ({ options }) => {
if (!options.spawnClaudeCodeProcess) {
throw new Error("Missing Claude process launcher");
}
const child = options.spawnClaudeCodeProcess({
command,
args: [
"-e",
"process.stdout.write(JSON.stringify({ entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT, parentSession: process.env.CLAUDECODE ?? null }))",
],
cwd: process.cwd(),
env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: "sdk-ts", CLAUDECODE: "1" },
signal: new AbortController().signal,
});
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
exited = once(child, "close");
return createQueryMock([]);
},
},
);
try {
expect(await exited).toEqual([0, null]);
expect(JSON.parse(output)).toEqual({
...(entrypoint === undefined ? {} : { entrypoint }),
parentSession: null,
});
} finally {
query.close();
}
},
);
test("forwards launch-context env through Claude process env", async () => {
let capturedEnv: Record<string, string | undefined> | undefined;
let capturedPerTaskStopAffordance: boolean | undefined;
@@ -1,11 +1,20 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { measureMainThreadWork } from "../../../../test-utils/main-thread-work.js";
import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { open } from "node:fs/promises";
import { performance } from "node:perf_hooks";
import * as os from "node:os";
import * as path from "node:path";
import pino from "pino";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js";
import type { ClaudeRewindSdk } from "./rewind.js";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { withClaudeReplayHistory } from "./transcript-history.js";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import type { ClaudeQueryFactory } from "./query.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
/**
* A resumed Claude agent whose transcript is missing or unreadable opens with an empty timeline.
@@ -28,19 +37,47 @@ describe("ClaudeAgentSession persisted history load", () => {
let configDir: string;
let records: LogRecord[];
async function resume(): Promise<void> {
async function resume(
queryFactory?: ClaudeQueryFactory,
rewindSdk?: ClaudeRewindSdk,
): Promise<AgentSession> {
const client = new ClaudeAgentClient({
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
queryFactory: vi.fn(() => {
throw new Error("history load must not start a query");
}),
queryFactory:
queryFactory ??
(() => {
throw new Error("history load must not start a query");
}),
resolveBinary: async () => process.execPath,
resolveVersion: async () => "2.1.220",
rewindSdk,
});
const session = await client.resumeSession(
{ provider: "claude", sessionId: SESSION_ID },
{ cwd },
);
await session.close();
return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd });
}
function replyFactory(text: string, messageId = "live-reply"): ClaudeQueryFactory {
return (() => {
const messages = [
{ type: "assistant", uuid: messageId, message: { content: text } },
{ type: "result", subtype: "success", total_cost_usd: 0 },
];
return {
async next() {
const value = messages.shift();
return value ? { done: false, value } : { done: true, value: undefined };
},
close() {},
async return() {
return { done: true, value: undefined };
},
async supportedCommands() {
return [];
},
[Symbol.asyncIterator]() {
return this;
},
};
}) as ClaudeQueryFactory;
}
function transcriptPath(): string {
@@ -57,12 +94,498 @@ describe("ClaudeAgentSession persisted history load", () => {
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
rmSync(tempRoot, { recursive: true, force: true });
});
test("serves unrelated health requests while resuming 32 MiB of ordered history", async () => {
const startedAt = performance.now();
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
const file = await open(transcriptPath(), "w");
const text = "x".repeat(16 * 1024);
let bytes = 0;
let count = 0;
try {
while (bytes < 32 * 1024 * 1024) {
const line =
JSON.stringify({ type: "assistant", message: { content: `${count}:${text}` } }) + "\n";
bytes += Buffer.byteLength(line);
await file.write(line);
count++;
}
} finally {
await file.close();
}
const daemon = await createTestPaseoDaemon();
let loading = true;
let answeredDuringLoad = false;
const health = fetch(`http://127.0.0.1:${daemon.port}/api/health`).then(async (response) => {
expect(response.status).toBe(200);
await response.text();
answeredDuringLoad = loading;
return undefined;
});
let session: AgentSession | undefined;
try {
const {
result: { resumed: loadedSession, historyCount },
blockingMs,
} = await measureMainThreadWork("Claude history main-thread work", async () => {
const resumed = await resume(replyFactory("live after large resume"));
session = resumed;
let index = 0;
for await (const event of resumed.streamHistory()) {
expect(event).toEqual({
type: "timeline",
provider: "claude",
timestamp: undefined,
item: { type: "assistant_message", text: `${index}:${text}` },
});
index++;
}
loading = false;
await health;
return { resumed, historyCount: index };
});
console.info(
JSON.stringify({
bytes,
count,
blockingMs,
answeredDuringLoad,
elapsedMs: performance.now() - startedAt,
}),
);
expect(historyCount).toBe(count);
expect(blockingMs).toBeLessThan(50);
expect(answeredDuringLoad).toBe(true);
const live = await collectSessionTurnEvents(loadedSession, "follow up");
expect(
live
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([
{ type: "assistant_message", messageId: "live-reply", text: "live after large resume" },
]);
} finally {
await session?.close();
await daemon.close();
}
}, 60_000);
test("rebinds ordered history before returning from rewind and accepts a live turn", async () => {
const entries = [
{ type: "user", uuid: "u1", message: { content: "first" } },
{ type: "assistant", uuid: "a1", message: { content: "first reply" } },
{ type: "user", uuid: "u2", message: { content: "second" } },
{ type: "assistant", uuid: "a2", message: { content: "second reply" } },
];
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n"));
const forkId = "rebound-history";
const forkFile = path.join(path.dirname(transcriptPath()), `${forkId}.jsonl`);
writeFileSync(
forkFile,
entries
.slice(0, 2)
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
const forks: unknown[] = [];
const late = {
type: "assistant",
uuid: "retired-reply",
message: { content: "retired query" },
};
let pendingRead: ((value: unknown) => void) | undefined;
let releaseRetirement!: () => void;
const retirementGate = new Promise<void>((resolve) => {
releaseRetirement = resolve;
});
let retirementStarted!: () => void;
const retiring = new Promise<void>((resolve) => {
retirementStarted = resolve;
});
let firstQuery = true;
let retired = false;
const queryFactory = (() => {
if (!firstQuery) return replyFactory("live after rebind")({} as never);
firstQuery = false;
const messages = [
{ type: "assistant", uuid: "before-rebind", message: { content: "before rebind" } },
{ type: "result", subtype: "success", total_cost_usd: 0 },
];
return {
async next() {
const value = messages.shift();
if (value) return { done: false, value };
if (retired) return { done: true, value: undefined };
return new Promise((resolve) => {
pendingRead = resolve;
});
},
close() {
retired = true;
pendingRead?.({ done: false, value: late });
},
async return() {
retirementStarted();
await retirementGate;
return { done: true, value: undefined };
},
async supportedCommands() {
return [];
},
[Symbol.asyncIterator]() {
return this;
},
};
}) as ClaudeQueryFactory;
const session = await resume(queryFactory, {
async forkSession(sessionId, options) {
forks.push({ sessionId, ...options });
return { sessionId: forkId };
},
});
try {
const original = await Array.fromAsync(session.streamHistory());
expect(original).toHaveLength(4);
await collectSessionTurnEvents(session, "before rewind");
const emitted: AgentStreamEvent[] = [];
const subscription = session.subscribe((event) => emitted.push(event));
let rewindDone = false;
const rewind = (async () => {
await session.revertConversation!({ messageId: "u2" });
rewindDone = true;
})();
await retiring;
let historyDone = false;
const reading = (async () => {
const history = await Array.fromAsync(session.streamHistory());
historyDone = true;
return history;
})();
const readingAgain = Array.fromAsync(session.streamHistory());
await Promise.resolve();
expect(rewindDone).toBe(false);
expect(historyDone).toBe(false);
releaseRetirement();
await rewind;
subscription();
expect(retired).toBe(true);
expect(
emitted.filter(
(event) => event.type === "timeline" && event.item.type === "assistant_message",
),
).toEqual([
{
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: `Claude switched to a new session: ${SESSION_ID} -> ${forkId}`,
},
},
]);
expect(forks).toEqual([
{ sessionId: SESSION_ID, upToMessageId: "a1", transcriptPath: transcriptPath() },
]);
expect(session.id).toBe(forkId);
const [history, secondRead] = await Promise.all([reading, readingAgain]);
expect(history).toEqual(original.slice(0, 2));
expect(secondRead).toEqual([]);
const live = await collectSessionTurnEvents(session, "continue the fork");
expect(
live
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([
{ type: "assistant_message", messageId: "live-reply", text: "live after rebind" },
]);
} finally {
releaseRetirement();
await session.close();
}
});
test("reports a failed rewind without poisoning readiness when query retirement throws", async () => {
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(
transcriptPath(),
[
{ type: "user", uuid: "u1", message: { content: "first" } },
{ type: "assistant", uuid: "a1", message: { content: "reply" } },
{ type: "user", uuid: "u2", message: { content: "second" } },
]
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
let firstQuery = true;
let finishRead: ((value: unknown) => void) | undefined;
const queryFactory = ((input) => {
const query = replyFactory(
firstQuery ? "before retirement" : "still available",
firstQuery ? "before-retirement" : "live-reply",
)(input);
if (!firstQuery) return query;
firstQuery = false;
let reads = 0;
return {
...query,
async next() {
if (++reads <= 2) return query.next();
return new Promise((resolve) => {
finishRead = resolve;
});
},
close() {
finishRead?.({ done: true, value: undefined });
throw new Error("retirement failed");
},
[Symbol.asyncIterator]() {
return this;
},
};
}) as ClaudeQueryFactory;
const session = await resume(queryFactory, {
async forkSession() {
return { sessionId: "failed-rebind" };
},
});
try {
await Array.fromAsync(session.streamHistory());
await collectSessionTurnEvents(session, "before rewind");
await expect(session.revertConversation!({ messageId: "u2" })).rejects.toThrow(
"retirement failed",
);
expect(records).toContainEqual(
expect.objectContaining({
level: pino.levels.values.warn,
msg: "Failed to rebind Claude session",
sessionId: "failed-rebind",
err: expect.objectContaining({ message: "retirement failed" }),
}),
);
expect(session.id).toBe(SESSION_ID);
const live = await collectSessionTurnEvents(session, "after failed rebind");
expect(
live
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([{ type: "assistant_message", messageId: "live-reply", text: "still available" }]);
} finally {
await session.close();
}
});
test.each(["file_path", "files"])(
"replays %s without probing targets and preserves live existence checks",
async (field) => {
const existingPath = path.join(cwd, "existing.txt");
const missingPath = path.join(cwd, "missing.txt");
writeFileSync(existingPath, "existing");
const targets = [existingPath, missingPath];
const inputs = targets.map((target) => ({
[field]: field === "files" ? [target] : target,
}));
const toolBlocks = inputs.map((input, index) => ({
type: "tool_use",
id: `change-${index}`,
name: "FileChange",
input,
}));
const resultBlocks = toolBlocks.map((block) => ({
type: "tool_result",
tool_use_id: block.id,
content: "done",
}));
const transcript = [
{ type: "user", message: { content: "Change files" } },
{ type: "assistant", message: { content: toolBlocks } },
{ type: "user", message: { content: resultBlocks } },
{ type: "assistant", message: { content: "Finished" } },
];
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(transcriptPath(), transcript.map((entry) => JSON.stringify(entry)).join("\n"));
// Observe the real filesystem calls; neither existing nor missing targets are mocked.
const exists = vi.spyOn(fs, "existsSync");
const liveMessages = [
{ type: "assistant", message: { content: toolBlocks } },
{ type: "user", message: { content: resultBlocks } },
{ type: "result", subtype: "success", total_cost_usd: 0 },
];
const queryFactory = vi.fn();
queryFactory.mockReturnValue({
async next() {
const value = liveMessages.shift();
return value ? { done: false, value } : { done: true, value: undefined };
},
close() {},
async return() {
return { done: true, value: undefined };
},
async supportedCommands() {
return [];
},
[Symbol.asyncIterator]() {
return this;
},
});
const session = await resume(queryFactory);
try {
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) history.push(event);
expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual(
[],
);
const running = inputs.map((input, index) => ({
type: "tool_call",
callId: `change-${index}`,
name: "FileChange",
status: "running",
error: null,
detail: { type: "unknown", input, output: null },
}));
const completed = inputs.map((input, index) => ({
type: "tool_call",
callId: `change-${index}`,
name: "FileChange",
status: "completed",
error: null,
detail: {
type: "unknown",
input,
output: {
output: "done",
files: [{ path: index === 0 ? "existing.txt" : "missing.txt", kind: "update" }],
},
},
}));
expect(history).toEqual(
[
{ type: "user_message", text: "Change files" },
...running,
...completed,
{ type: "assistant_message", text: "Finished" },
].map((item) => ({ type: "timeline", provider: "claude", timestamp: undefined, item })),
);
exists.mockClear();
const live = await collectSessionTurnEvents(session, "Change files again");
expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual([
[existingPath],
[missingPath],
]);
const liveCompleted = live.flatMap((event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "completed"
? [event.item]
: [],
);
const missing = completed[1];
expect(liveCompleted).toEqual([
completed[0],
{
...missing,
detail: {
...missing.detail,
output: { output: "done", files: [{ path: "missing.txt", kind: "add" }] },
},
},
]);
} finally {
await session.close();
}
},
);
test.each(["parent", "subagent"])(
"replays %s Write and Edit calls without probing their targets",
async (scope) => {
const filePath = path.join(cwd, "unavailable", "file.txt");
const content = ["Write", "Edit"].map((name) => ({
type: "tool_use",
id: name,
name,
input: { file_path: filePath },
}));
const entry = { type: "assistant", message: { content } };
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
if (scope === "subagent") {
const directory = path.join(path.dirname(transcriptPath()), SESSION_ID, "subagents");
mkdirSync(directory, { recursive: true });
writeFileSync(
path.join(directory, "agent-child.meta.json"),
JSON.stringify({ toolUseId: "task" }),
);
writeFileSync(
path.join(directory, "agent-child.jsonl"),
JSON.stringify({
...entry,
isSidechain: true,
agentId: "child",
}),
);
writeFileSync(
transcriptPath(),
JSON.stringify({
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "task",
name: "Task",
input: { description: "Change files" },
},
],
},
}),
);
} else {
writeFileSync(transcriptPath(), JSON.stringify(entry));
}
const exists = vi.spyOn(fs, "existsSync");
const session = await resume();
try {
const items = [];
for await (const event of session.streamHistory()) {
if (scope === "parent" && event.type === "timeline") items.push(event.item);
if (event.type === "provider_subagent" && event.event.type === "timeline") {
items.push(event.event.item);
}
}
expect(exists.mock.calls.filter(([target]) => target === filePath)).toEqual([]);
expect(items).toEqual([
{
type: "tool_call",
callId: "Write",
name: "Write",
status: "running",
error: null,
detail: { type: "write", filePath },
},
{
type: "tool_call",
callId: "Edit",
name: "Edit",
status: "running",
error: null,
detail: { type: "edit", filePath },
},
]);
} finally {
await session.close();
}
},
);
test("logs the resolved transcript path when the transcript does not exist", async () => {
await resume();
await (await resume()).close();
expect(records).toContainEqual(
expect.objectContaining({
@@ -78,7 +601,7 @@ describe("ClaudeAgentSession persisted history load", () => {
// A directory at the transcript path exists but cannot be read as a file.
mkdirSync(transcriptPath(), { recursive: true });
await resume();
await (await resume()).close();
expect(records).toContainEqual(
expect.objectContaining({
@@ -91,6 +614,55 @@ describe("ClaudeAgentSession persisted history load", () => {
);
});
test("loads readable history when the temporary directory is unavailable", async () => {
const file = transcriptPath();
const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } };
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify(entry));
const unavailable = path.join(tempRoot, "unavailable-tmp");
for (const variable of ["TMPDIR", "TEMP", "TMP"]) vi.stubEnv(variable, unavailable);
await withClaudeReplayHistory(file, async (history) => {
expect(await Array.fromAsync(history.parentEntries)).toEqual([entry]);
});
});
test.each(["parent", "child", "workflow"])(
"preserves indexed %s history when its transcript disappears between passes",
async (target) => {
const parent = transcriptPath();
const sessionDirectory = parent.slice(0, -".jsonl".length);
const child = path.join(sessionDirectory, "subagents", "agent-child.jsonl");
const workflow = path.join(
sessionDirectory,
"subagents",
"workflows",
"wf_test",
"agent-child.jsonl",
);
const parentEntry = {
type: "assistant",
message: { content: [{ type: "text", text: "parent" }] },
};
const childEntry = { ...parentEntry, isSidechain: true, agentId: "child" };
mkdirSync(path.dirname(workflow), { recursive: true });
writeFileSync(parent, JSON.stringify(parentEntry));
writeFileSync(child, JSON.stringify(childEntry));
writeFileSync(workflow, JSON.stringify(childEntry));
await withClaudeReplayHistory(parent, async (history) => {
rmSync({ parent, child, workflow }[target]);
// Ownership facts and visible payloads are separate passes over the same source.
for (let pass = 0; pass < 2; pass++) {
expect(await Array.fromAsync(history.parentEntries)).toEqual([parentEntry]);
expect(history.subagents).toHaveLength(1);
expect(await Array.fromAsync(history.subagents[0]!.entries)).toEqual([childEntry]);
expect(await Array.fromAsync(history.workflowEntriesByRunId.get("wf_test")!)).toEqual([
childEntry,
]);
}
});
},
);
// chmod does not remove read access on Windows, or for root.
test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"logs a warning with the error when Claude's project folders cannot be listed",
@@ -100,7 +672,7 @@ describe("ClaudeAgentSession persisted history load", () => {
chmodSync(path.join(configDir, "projects"), 0o000);
try {
await resume();
await (await resume()).close();
} finally {
chmodSync(path.join(configDir, "projects"), 0o755);
}
@@ -13,8 +13,8 @@ const MATERIALIZED_PNG_PATH_PATTERN =
/paseo-attachments(?:-[^\\/]+)?[\\/](?:[^\\/]+[\\/])?[0-9a-f]{64}\.png$/;
interface ClaudeImageTestSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
convertHistoryEntry(entry: unknown): AgentTimelineItem[];
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
convertHistoryEntry(entry: unknown): Promise<AgentTimelineItem[]>;
}
async function createSession(): Promise<ClaudeImageTestSession> {
@@ -157,7 +157,7 @@ describe("Claude tool_result image rendering", () => {
test("emits the image as assistant markdown and keeps base64 out of the live tool output", async () => {
const session = await createSession();
const events = session.translateMessageToEvents(imageToolResultUserMessage());
const events = await session.translateMessageToEvents(imageToolResultUserMessage());
const timelineItems = events
.filter((event) => event.type === "timeline")
@@ -181,7 +181,7 @@ describe("Claude tool_result image rendering", () => {
test("replays the image as assistant markdown through history conversion", async () => {
const session = await createSession();
const items = session.convertHistoryEntry(imageToolResultHistoryEntry());
const items = await session.convertHistoryEntry(imageToolResultHistoryEntry());
const [imageMessage, ...extraImages] = imageMessages(items);
expect(extraImages).toEqual([]);
@@ -202,7 +202,7 @@ describe("Claude tool_result image rendering", () => {
test("keeps base64 out of an errored tool_result that carries an image", async () => {
const session = await createSession();
const events = session.translateMessageToEvents(erroredImageToolResultUserMessage());
const events = await session.translateMessageToEvents(erroredImageToolResultUserMessage());
const timelineItems = events
.filter((event) => event.type === "timeline")
@@ -226,7 +226,7 @@ describe("Claude tool_result image rendering", () => {
test("emits one image message per image block in a multi-image tool_result", async () => {
const session = await createSession();
const events = session.translateMessageToEvents(multiImageToolResultUserMessage());
const events = await session.translateMessageToEvents(multiImageToolResultUserMessage());
const timelineItems = events
.filter((event) => event.type === "timeline")
@@ -0,0 +1,53 @@
import { test, expect } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import pino from "pino";
import { ClaudeAgentClient } from "./agent.js";
test("lists a session containing a large image record and retains the final prompt", async () => {
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-import-image-"));
try {
const project = path.join(configDir, "projects", "image-project");
await fs.mkdir(project, { recursive: true });
const first = {
type: "user",
sessionId: "image-session",
cwd: "/image-project",
message: { content: "Describe the image" },
};
const image = {
type: "user",
sessionId: "image-session",
cwd: "/image-project",
message: {
content: [
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "a".repeat(5 * 1024 * 1024) },
},
],
},
};
const last = { type: "user", message: { content: "What changed?" } };
await fs.writeFile(
path.join(project, "image-session.jsonl"),
[first, image, last].map((record) => JSON.stringify(record)).join("\n"),
);
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
});
const sessions = await client.listImportableSessions({ limit: 1 });
expect(sessions).toEqual([
expect.objectContaining({
providerHandleId: "image-session",
title: "Describe the image",
firstPromptPreview: "Describe the image",
lastPromptPreview: "What changed?",
}),
]);
} finally {
await fs.rm(configDir, { recursive: true, force: true });
}
});
@@ -0,0 +1,237 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "../../agent-sdk-types.js";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { projectTimelineRows } from "../../timeline-projection.js";
describe("Claude session request journal replay", () => {
let root: string;
let cwd: string;
let configDir: string;
beforeEach(() => {
root = mkdtempSync(path.join(os.tmpdir(), "claude-request-journal-"));
cwd = path.join(root, "repo");
configDir = path.join(root, "claude");
mkdirSync(cwd);
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
async function replay(records: unknown[]): Promise<AgentTimelineItem[]> {
const historyDir = claudeProjectDirSync(cwd, { configDir });
mkdirSync(historyDir, { recursive: true });
writeFileSync(
path.join(historyDir, "journal-session.jsonl"),
records.map((record) => JSON.stringify(record)).join("\n"),
);
const client = new ClaudeAgentClient({ logger: createTestLogger() });
const session = await client.resumeSession(
{ provider: "claude", sessionId: "journal-session" },
{ cwd },
{ env: { CLAUDE_CONFIG_DIR: configDir } },
);
try {
const items: AgentTimelineItem[] = [];
for await (const event of session.streamHistory()) {
if (event.type === "timeline") items.push(event.item);
}
return items;
} finally {
await session.close();
}
}
test.each(["api-request-shape", "api-request", "api-request-blob", "future-journal-record"])(
"ignores %s content while preserving the conversation and tool calls",
async (type) => {
const items = await replay([
{ type: "user", message: { role: "user", content: "Read package.json" } },
{
type: "assistant",
message: {
role: "assistant",
content: [
{
type: "tool_use",
id: "read-1",
name: "Read",
input: { file_path: "package.json" },
},
],
},
},
...["user", "system", "assistant"].map((role) => ({
type,
message: {
role,
content: [
{
type: "text",
text: "SessionStart hook output\n# MCP Server Instructions\n<total_tokens>1000 tokens left</total_tokens>",
},
{
type: "tool_addition",
tool: {
name: "deferred_tool",
description: "Deferred tool",
input_schema: { type: "object", properties: {} },
},
},
{
type: "tool_use",
id: "journal-only",
name: "Read",
input: { file_path: "old.json" },
},
],
},
})),
{
type: "assistant",
message: { role: "assistant", content: [{ type: "text", text: "paseo" }] },
},
]);
expect(items).toEqual([
expect.objectContaining({ type: "user_message", text: "Read package.json" }),
expect.objectContaining({ type: "tool_call", callId: "read-1", name: "Read" }),
expect.objectContaining({ type: "assistant_message", text: "paseo" }),
]);
},
);
test("preserves system compaction and background task notifications", async () => {
const items = await replay([
{
type: "system",
subtype: "compact_boundary",
compactMetadata: { trigger: "manual", preTokens: 1000 },
},
{
type: "system",
subtype: "task_notification",
uuid: "note-1",
task_id: "task-1",
status: "completed",
summary: "Background work finished",
},
]);
expect(items).toEqual([
expect.objectContaining({
type: "compaction",
status: "completed",
trigger: "manual",
preTokens: 1000,
}),
expect.objectContaining({
type: "tool_call",
name: "task_notification",
status: "completed",
}),
]);
});
test.each(["remove", "user"])(
"replays a background completion once across queue enqueue and %s records",
async (delivery) => {
const content =
"<task-notification>\n<task-id>bg-1</task-id>\n<status>completed</status>\n<summary>Background command completed</summary>\n</task-notification>";
const items = await replay([
{ type: "queue-operation", operation: "enqueue", content },
delivery === "remove"
? { type: "queue-operation", operation: "remove", content }
: {
type: "user",
uuid: "notification-delivery",
origin: { kind: "task-notification" },
message: { role: "user", content },
},
]);
const projected = projectTimelineRows({
rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })),
mode: "projected",
});
expect(projected.map((row) => row.item)).toEqual([
expect.objectContaining({
type: "tool_call",
name: "task_notification",
status: "completed",
metadata: expect.objectContaining({ taskId: "bg-1" }),
}),
]);
},
);
test("restores each Monitor event and completion of one task once", async () => {
const notifications = ["1", "2", "3", "4"].map(
(event) =>
`<task-notification>\n<task-id>monitor-1</task-id>\n<summary>Monitor event</summary>\n<event>${event}</event>\n</task-notification>`,
);
notifications.push(
"<task-notification>\n<task-id>monitor-1</task-id>\n<status>completed</status>\n<summary>Monitor stream ended</summary>\n</task-notification>",
);
const items = await replay(
notifications.flatMap((content, index) => [
{ type: "queue-operation", operation: "enqueue", content },
{ type: "user", uuid: `delivery-${index}`, message: { role: "user", content } },
]),
);
const projected = projectTimelineRows({
rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })),
mode: "projected",
});
expect(projected.map(({ item }) => item.type === "tool_call" && item.detail)).toEqual(
notifications.map((text, index) =>
expect.objectContaining({
label: index === 4 ? "Monitor stream ended" : "Monitor event",
text,
}),
),
);
});
test.each(["user", "remove", "system"])(
"preserves identical notifications from separate occurrences with %s deliveries",
async (delivery) => {
const content =
"<task-notification>\n<task-id>monitor-repeat</task-id>\n<status>completed</status>\n<summary>Monitor event</summary>\n</task-notification>";
const queued = [1, 2].map(() => ({ type: "queue-operation", operation: "enqueue", content }));
const delivered = [1, 2].map((index) => {
if (delivery === "user") {
return { type: "user", uuid: `repeat-${index}`, message: { role: "user", content } };
}
if (delivery === "remove") {
return { type: "queue-operation", operation: "remove", content };
}
return {
type: "system",
subtype: "task_notification",
uuid: `repeat-${index}`,
task_id: "monitor-repeat",
status: "completed",
summary: "Monitor event",
};
});
const items = await replay([
...queued,
...delivered,
{ type: "queue-operation", operation: "enqueue", content },
]);
const projected = projectTimelineRows({
rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })),
mode: "projected",
});
expect(projected.map(({ item }) => item.type === "tool_call" && item.detail?.label)).toEqual([
"Monitor event",
"Monitor event",
"Monitor event",
]);
},
);
});
@@ -1,6 +1,9 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { generateReplayCorpus, generateWorkflowReplayCorpus } from "./replay-memory.fixture.js";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
@@ -419,6 +422,237 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
});
});
describe("a background subagent", () => {
// Shapes recorded from Claude Code 2.1.x: the launch returns at once with an async result,
// and a child stopped by an interrupt ends on the interrupt placeholder, not an end_turn.
function backgroundLaunch(): string[] {
return [
parentEntry([
{
type: "tool_use",
id: TOOL_USE_ID,
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "probe sleeper",
run_in_background: true,
},
},
]),
JSON.stringify({
type: "user",
sessionId: "replay-session",
timestamp: "2026-07-26T06:27:48.000Z",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: TOOL_USE_ID,
content: [
{
type: "text",
text: `Async agent launched successfully.\nagentId: ${AGENT_ID}`,
},
],
},
],
},
toolUseResult: { isAsync: true, status: "async_launched", agentId: AGENT_ID },
}),
];
}
function readStatus(event: { status?: string }): string | undefined {
return event.status;
}
function interruptedChild(): string[] {
return [
JSON.stringify({
type: "assistant",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:27:50.000Z",
message: {
role: "assistant",
content: [{ type: "tool_use", id: "toolu_child_bash", name: "Bash", input: {} }],
stop_reason: "tool_use",
},
}),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:28:05.000Z",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_child_bash",
content: "The user doesn't want to proceed with this tool use.",
is_error: true,
},
],
},
}),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:28:05.001Z",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user for tool use]" }],
},
}),
];
}
test.each([
["the meta sidecar", JSON.stringify({ toolUseId: TOOL_USE_ID })],
["the legacy scrape", null],
])("is canceled when an interrupt stopped it, linked through %s", async (_link, meta) => {
writeSession({ parentLines: backgroundLaunch(), meta, sidechainLines: interruptedChild() });
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
id: TOOL_USE_ID,
status: "canceled",
});
});
test("stays running when the child was given a new prompt after the interrupt", async () => {
writeSession({
parentLines: backgroundLaunch(),
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [
...interruptedChild(),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:29:00.000Z",
message: { role: "user", content: [{ type: "text", text: "Carry on with the task." }] },
}),
],
});
const statuses = upserts(await replayDescriptors()).map(readStatus);
expect(statuses).not.toContain("canceled");
expect(statuses).toContain("running");
});
test.each(["interrupted", "running"])(
"ignores an API request copy of a %s child's launch result",
async (state) => {
const launch = backgroundLaunch();
const result = JSON.parse(launch[1]!);
writeSession({
parentLines: [
...launch,
JSON.stringify({
type: "api-request-blob",
sessionId: "replay-session",
hash: "request-copy",
message: result.message,
}),
],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines:
state === "interrupted"
? interruptedChild()
: [sidechainEntry({ stopReason: "tool_use" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
id: TOOL_USE_ID,
status: state === "interrupted" ? "canceled" : "running",
});
},
);
test("stays running while the child has not finished", async () => {
writeSession({
parentLines: backgroundLaunch(),
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "tool_use" })],
});
const statuses = upserts(await replayDescriptors()).map(readStatus);
expect(statuses).not.toContain("completed");
expect(statuses).toContain("running");
});
function taskNotification(status: string, form = "user"): string {
const text = `<task-notification>\n<task-id>${AGENT_ID}</task-id>\n<tool-use-id>${TOOL_USE_ID}</tool-use-id>\n<status>${status}</status>\n<summary>Background task ended</summary>\n</task-notification>`;
const records = {
user: { message: { role: "user", content: [{ type: "text", text }] } },
system: {
subtype: "task_notification",
task_id: AGENT_ID,
tool_use_id: TOOL_USE_ID,
status,
},
"queue-operation": { operation: "enqueue", content: text },
};
return JSON.stringify({
type: form,
timestamp: "2026-07-26T06:29:00.000Z",
...records[form as keyof typeof records],
});
}
for (const form of ["user", "system", "queue-operation"]) {
test.each([
["completed", "completed"],
["failed", "failed"],
["killed", "canceled"],
["stopped", "canceled"],
])(
`restores a %s task notification from ${form} when the child stop_reason is null`,
async (status, expected) => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification(status, form)],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: null })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
id: TOOL_USE_ID,
status: expected,
});
},
);
}
test("prefers a stopped notification over the child's end_turn", async () => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification("stopped")],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "canceled" });
});
test("does not invent an outcome for an unknown notification status", async () => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification("unknown")],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: null })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "running" });
});
test("completes from the child's own end_turn", async () => {
writeSession({
parentLines: backgroundLaunch(),
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "completed" });
});
});
test("does not replay a skill-spawned subagent the parent never named", async () => {
// The reported bug. A /code-review subagent has no Task tool_use, so its sidecar carries only
// agentType and its id appears nowhere in the parent — it used to replay as running forever,
@@ -432,6 +666,267 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
expect(upserts(await replayDescriptors())).toEqual([]);
});
test("replays several legacy children interleaved in the parent transcript", async () => {
const lines: string[] = [];
for (let index = 0; index < 8; index++) {
const agentId = `legacy-${index}`;
const toolUseId = `task-${index}`;
lines.push(parentEntry([{ type: "tool_use", id: toolUseId, name: "Task", input: {} }]));
lines.push(
JSON.stringify({
type: "user",
message: {
content: [
{ type: "tool_result", tool_use_id: toolUseId, content: `agentId: ${agentId}\ndone` },
],
},
}),
);
lines.push(sidechainEntry({ agentId, stopReason: "end_turn" }));
}
writeParentSession(lines);
const events = await replayDescriptors();
const histories = events
.map((event) => event.event)
.filter((event) => event.type === "timeline");
expect(histories.map((event) => event.id).sort()).toEqual(
Array.from({ length: 8 }, (_, index) => `task-${index}`),
);
expect(histories.map((event) => event.item)).toEqual(
Array.from({ length: 8 }, () => ({ type: "assistant_message", text: "summary of the docs" })),
);
});
test("replays more child events than V8 permits in one spread call", async () => {
const childCount = 16;
const entriesPerChild = 10_000;
const children = Array.from({ length: childCount }, (_, index) => ({
agentId: `large-child-${index}`,
toolUseId: `large-task-${index}`,
}));
const parentLines = children.flatMap(({ agentId, toolUseId }) => [
parentEntry([{ type: "tool_use", id: toolUseId, name: "Task", input: {} }]),
JSON.stringify({
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: toolUseId,
content: `agentId: ${agentId}\ndone`,
},
],
},
}),
]);
const subagentDir = writeParentSession(parentLines);
for (const { agentId, toolUseId } of children) {
writeSubagent({
subagentDir,
agentId,
meta: JSON.stringify({ toolUseId }),
sidechainLines: Array.from({ length: entriesPerChild }, () => sidechainEntry({ agentId })),
});
}
const events = await replayDescriptors();
const timelineCount = events.filter((event) => event.event.type === "timeline").length;
expect(timelineCount).toBe(childCount * entriesPerChild);
expect(upserts(events).filter((event) => event.status === "completed")).toHaveLength(
childCount,
);
}, 120_000);
test("limits retained child shell output to the timeline's existing content limit", async () => {
const output = "x".repeat(128 * 1024);
writeSession({
parentLines: [taskToolUse(), taskToolResult()],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [
JSON.stringify({
type: "assistant",
isSidechain: true,
agentId: AGENT_ID,
message: {
content: [
{
type: "tool_use",
id: "bash-large",
name: "Bash",
input: { command: "echo large" },
},
],
},
}),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
message: {
content: [{ type: "tool_result", tool_use_id: "bash-large", content: output }],
},
}),
],
});
const events = await replayDescriptors();
const completed = events
.map((event) => event.event)
.filter(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "completed",
);
expect(completed).toEqual([
expect.objectContaining({
type: "timeline",
id: TOOL_USE_ID,
item: expect.objectContaining({
detail: expect.objectContaining({ type: "shell", output: output.slice(0, 64 * 1024) }),
}),
}),
]);
});
test.each([
{
label: "a parent transcript without sidechains",
mode: "children",
// Cross V8's whole-string limit only in the opt-in reproduction, keeping routine CI small.
generate: (root: string) =>
generateReplayCorpus(root, {
parentMiB: process.env.PASEO_TEST_LARGE_CLAUDE_REPLAY === "1" ? 550 : 2,
childrenMiB: 0,
children: 0,
}),
expected: { parentItems: 1, completed: 0, histories: 0 },
},
{
label: "4 MiB parent and 20 sidechains",
mode: "children",
generate: (root: string) =>
generateReplayCorpus(root, { parentMiB: 4, childrenMiB: 4, children: 20 }),
expected: { parentItems: 41, completed: 20, histories: 20 },
},
{
label: "five 2 MiB workflow sidechains",
mode: "workflow",
generate: (root: string) => generateWorkflowReplayCorpus(root, 2),
expected: { parentItems: 3, completed: 1, histories: 5 },
},
])(
"opens $label with all history under a 256 MB heap",
({ mode, generate, expected }) => {
const root = mkdtempSync(path.join(os.tmpdir(), "claude-replay-"));
try {
generate(root);
const worker = spawnSync(
process.execPath,
[
"--max-old-space-size=256",
"--import",
"tsx",
fileURLToPath(new URL("./replay-memory.fixture.ts", import.meta.url)),
root,
mode,
],
{ encoding: "utf8", env: { ...process.env, NODE_NO_WARNINGS: "1" }, timeout: 120_000 },
);
expect({ status: worker.status, signal: worker.signal, stderr: worker.stderr }).toEqual({
status: 0,
signal: null,
stderr: "",
});
const result = JSON.parse(worker.stdout);
expect(result).toMatchObject(expected);
console.log("Claude replay memory:", result);
} finally {
rmSync(root, { recursive: true, force: true });
}
},
150_000,
);
test("replays UTF-8 across read chunks, corrupt rows, CRLF and an unterminated final row", async () => {
const text = "é🙂".repeat(30_000);
const historyDir = claudeProjectDirSync(cwd, { configDir });
mkdirSync(historyDir, { recursive: true });
writeFileSync(
path.join(historyDir, "replay-session.jsonl"),
parentEntry([{ type: "text", text }]) +
"\r\nnot json\r\n" +
parentEntry([{ type: "text", text: "final row" }]),
);
const events = await replayEvents();
expect(events.filter((event) => event.type === "timeline").map((event) => event.item)).toEqual([
{ type: "assistant_message", text },
{ type: "assistant_message", text: "final row" },
]);
});
test("converts workflow tools in timestamp order before correlating their results", async () => {
writeWorkflowSession("completed");
const directory = path.join(
claudeProjectDirSync(cwd, { configDir }),
"replay-session",
"subagents",
"workflows",
WORKFLOW_RUN_ID,
);
writeSubagent({
subagentDir: directory,
agentId: "a-result",
sidechainLines: [
JSON.stringify({
type: "user",
timestamp: "2026-07-26T06:28:01.000Z",
message: {
content: [{ type: "tool_result", tool_use_id: "workflow-bash", content: "finished" }],
},
}),
],
});
writeSubagent({
subagentDir: directory,
agentId: "z-call",
sidechainLines: [
JSON.stringify({
type: "assistant",
timestamp: "2026-07-26T06:28:00.000Z",
message: {
content: [
{
type: "tool_use",
id: "workflow-bash",
name: "Bash",
input: { command: "echo finished" },
},
],
},
}),
],
});
const events = await replayDescriptors();
const calls = events
.map((event) => event.event)
.filter((event) => event.type === "timeline" && event.item.type === "tool_call");
expect(calls).toEqual([
expect.objectContaining({
id: WORKFLOW_TOOL_USE_ID,
item: expect.objectContaining({ callId: "workflow-bash", name: "Bash", status: "running" }),
}),
expect.objectContaining({
id: WORKFLOW_TOOL_USE_ID,
item: expect.objectContaining({
callId: "workflow-bash",
name: "Bash",
status: "completed",
detail: expect.objectContaining({ type: "shell", output: "finished" }),
}),
}),
]);
});
test("does not accumulate internal workflow agents as replay-only running rows", async () => {
const subagentRoot = writeParentSession([
parentEntry([
@@ -570,7 +1065,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
.filter((item) => item.type === "tool_call" && item.name === "task_notification"),
).toEqual([
expect.objectContaining({
callId: "task_notification_workflow-notification",
callId: expect.stringMatching(/^task_notification_[a-f0-9]+_1$/),
status: "completed",
}),
]);
@@ -1,6 +1,9 @@
import { execFile } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { promisify } from "node:util";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { PermissionResult, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
@@ -15,9 +18,11 @@ import {
resolveClaudeCodeVersion,
toClaudeSdkMcpConfig,
} from "./agent.js";
import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
import type {
ImportableProviderSession,
AgentPromptInput,
AgentSession,
AgentTimelineItem,
@@ -27,7 +32,7 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
close(): Promise<void>;
}
@@ -48,7 +53,7 @@ afterEach(() => {
});
describe("convertClaudeHistoryEntry", () => {
test("maps user tool results to timeline items", () => {
test("maps user tool results to timeline items", async () => {
const toolUseId = "toolu_test";
const entry = {
type: "user",
@@ -74,14 +79,14 @@ describe("convertClaudeHistoryEntry", () => {
];
const mapBlocks = vi.fn().mockReturnValue(stubTimeline);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual(stubTimeline);
expect(mapBlocks).toHaveBeenCalledTimes(1);
expect(Array.isArray(mapBlocks.mock.calls[0][0])).toBe(true);
});
test("replays persisted Claude tool results as completed tool calls", () => {
test("replays persisted Claude tool results as completed tool calls", async () => {
const entry = {
type: "user",
message: {
@@ -119,12 +124,12 @@ describe("convertClaudeHistoryEntry", () => {
const mapPersistedToolResultBlocks = (): AgentTimelineItem[] => completedToolCall;
expect(convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual(
expect(await convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual(
completedToolCall,
);
});
test("returns user messages when no tool blocks exist", () => {
test("returns user messages when no tool blocks exist", async () => {
const entry = {
type: "user",
message: {
@@ -133,7 +138,7 @@ describe("convertClaudeHistoryEntry", () => {
},
};
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
{
type: "user_message",
text: "Run npm test",
@@ -141,7 +146,7 @@ describe("convertClaudeHistoryEntry", () => {
]);
});
test("converts compact boundary metadata variants", () => {
test("converts compact boundary metadata variants", async () => {
const fixtures = [
{
entry: {
@@ -170,7 +175,7 @@ describe("convertClaudeHistoryEntry", () => {
] as const;
for (const fixture of fixtures) {
expect(convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([
expect(await convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([
{
type: "compaction",
status: "completed",
@@ -181,7 +186,7 @@ describe("convertClaudeHistoryEntry", () => {
}
});
test("skips synthetic user entries", () => {
test("skips synthetic user entries", async () => {
const entry = {
type: "user",
isSynthetic: true,
@@ -192,13 +197,13 @@ describe("convertClaudeHistoryEntry", () => {
};
const mapBlocks = vi.fn().mockReturnValue([]);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual([]);
expect(mapBlocks).not.toHaveBeenCalled();
});
test("skips meta user entries from Claude skill loading", () => {
test("skips meta user entries from Claude skill loading", async () => {
const entry = {
type: "user",
isMeta: true,
@@ -214,13 +219,13 @@ describe("convertClaudeHistoryEntry", () => {
};
const mapBlocks = vi.fn().mockReturnValue([]);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual([]);
expect(mapBlocks).not.toHaveBeenCalled();
});
test("skips interrupt placeholder transcript noise", () => {
test("skips interrupt placeholder transcript noise", async () => {
const interruptEntry = {
type: "user",
message: {
@@ -241,11 +246,11 @@ describe("convertClaudeHistoryEntry", () => {
.fn()
.mockReturnValue([{ type: "assistant_message", text: "No response requested." }]);
expect(convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
});
test("skips <local-command-stdout> messages (model switch, /context, etc.)", () => {
test("skips <local-command-stdout> messages (model switch, /context, etc.)", async () => {
// Real entries from Claude Code JSONL history files
const modelSwitch = {
type: "user",
@@ -300,24 +305,24 @@ describe("convertClaudeHistoryEntry", () => {
const mapBlocks = vi.fn().mockReturnValue([]);
expect(convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]);
// Real user messages must NOT be filtered
const realMessage = {
type: "user",
message: { role: "user", content: "fix the bug in auth.ts" },
};
expect(convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
expect(await convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
{ type: "user_message", text: "fix the bug in auth.ts" },
]);
});
test("maps task notifications to synthetic tool calls", () => {
test("maps task notifications to synthetic tool calls", async () => {
const entry = {
type: "system",
subtype: "task_notification",
@@ -328,7 +333,7 @@ describe("convertClaudeHistoryEntry", () => {
output_file: "/tmp/bg-fail-1.txt",
};
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
{
type: "tool_call",
callId: "task_notification_task-note-system-1",
@@ -352,7 +357,7 @@ describe("convertClaudeHistoryEntry", () => {
]);
});
test("maps queue-operation task notifications to synthetic tool calls", () => {
test("maps queue-operation task notifications to synthetic tool calls", async () => {
const entry = {
type: "queue-operation",
operation: "enqueue",
@@ -367,7 +372,7 @@ describe("convertClaudeHistoryEntry", () => {
].join("\n"),
};
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
{
type: "tool_call",
callId: "task_notification_task-note-queue-1",
@@ -391,7 +396,7 @@ describe("convertClaudeHistoryEntry", () => {
]);
});
test("passes assistant content blocks through to the mapper", () => {
test("passes assistant content blocks through to the mapper", async () => {
const entry = {
type: "assistant",
message: {
@@ -409,7 +414,7 @@ describe("convertClaudeHistoryEntry", () => {
];
const mapBlocks = vi.fn().mockReturnValue(mappedTimeline);
expect(convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline);
expect(await convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline);
expect(mapBlocks).toHaveBeenCalledWith(entry.message.content);
});
});
@@ -882,6 +887,68 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("keeps an extraArgs settings file when Paseo sets Claude flag settings", async () => {
const settingsDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-settings-"));
const settingsPath = path.join(settingsDir, "settings.json");
await fs.writeFile(
settingsPath,
JSON.stringify({ env: { ENABLE_TOOL_SEARCH: "false" }, permissions: { deny: ["Monitor"] } }),
);
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-5-5",
providerOptions: {
extraArgs: { chrome: null, settings: settingsPath },
},
});
await session.startTurn("hello");
const options = queryFactory.mock.calls[0]?.[0].options;
expect(options?.extraArgs).toEqual({ chrome: null });
expect(options?.settings).toEqual({
env: { ENABLE_TOOL_SEARCH: "false" },
permissions: { deny: ["Monitor"] },
fastMode: false,
});
await session.close();
await fs.rm(settingsDir, { recursive: true, force: true });
});
test("keeps inline extraArgs settings JSON for internal agents", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
internal: true,
providerOptions: {
extraArgs: { settings: '{"env":{"ENABLE_TOOL_SEARCH":"false"}}' },
},
});
await session.startTurn("hello");
const options = queryFactory.mock.calls[0]?.[0].options;
expect(options?.extraArgs).toEqual({});
expect(options?.settings).toEqual({
env: { ENABLE_TOOL_SEARCH: "false" },
disableAllHooks: true,
});
await session.close();
});
test("lists fast mode only for supported Opus models", async () => {
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
@@ -1203,7 +1270,7 @@ describe("ClaudeAgentSession features", () => {
input: Record<string, unknown>,
options: Record<string, unknown>,
): Promise<PermissionResult>;
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
};
try {
@@ -1243,7 +1310,7 @@ describe("ClaudeAgentSession features", () => {
),
).resolves.toMatchObject({ behavior: "deny", interrupt: undefined });
internal.translateMessageToEvents({
await internal.translateMessageToEvents({
type: "command_lifecycle",
command_uuid: steerUuid,
state: "started",
@@ -1740,6 +1807,404 @@ describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
});
describe("ClaudeAgentClient.listImportableSessions", () => {
async function descriptorFixture(count: number) {
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-errors-"));
const projectDir = path.join(configDir, "projects", "errors");
await fs.mkdir(projectDir, { recursive: true });
const files: string[] = [];
const descriptors: ImportableProviderSession[] = [];
const logs: unknown[] = [];
for (let index = 0; index < count; index += 1) {
const file = path.join(projectDir, `${index}.jsonl`);
const timestamp = new Date(Date.UTC(2026, 0, 1) + (count - index) * 1000);
await fs.writeFile(
file,
JSON.stringify({
type: "user",
sessionId: `session-${index}`,
cwd: "/errors",
message: { content: `Prompt ${index}` },
}),
);
await fs.utimes(file, timestamp, timestamp);
files.push(file);
descriptors.push({
providerHandleId: `session-${index}`,
cwd: "/errors",
title: `Prompt ${index}`,
firstPromptPreview: `Prompt ${index}`,
lastPromptPreview: `Prompt ${index}`,
lastActivityAt: timestamp,
});
}
const options = {
logger: pino({ level: "warn" }, { write: (line: string) => logs.push(JSON.parse(line)) }),
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
};
return {
files,
descriptors,
logs,
options,
cleanup: () => fs.rm(configDir, { recursive: true, force: true }),
};
}
const cannotEnforceFilePermissions = process.platform === "win32" || process.getuid?.() === 0;
const discoveryPermissionCases = [
{ label: "project enumeration", mode: 0, deniedSuffix: "", existingSuffix: "" },
{
label: "candidate stat",
mode: 0o400,
deniedSuffix: "hidden.jsonl",
existingSuffix: "0.jsonl",
},
];
test.skipIf(cannotEnforceFilePermissions).each(discoveryPermissionCases)(
"logs denied $label while discovering a healthy project",
async ({ mode, deniedSuffix }) => {
const fixture = await descriptorFixture(1);
const projectsRoot = path.dirname(path.dirname(fixture.files[0]!));
const deniedProject = path.join(projectsRoot, "denied-project");
await fs.mkdir(deniedProject);
try {
await fs.writeFile(path.join(deniedProject, "hidden.jsonl"), '{"type":"user"}');
await fs.chmod(deniedProject, mode);
const client = new ClaudeAgentClient(fixture.options);
await expect(client.listImportableSessions({ limit: 2 })).resolves.toEqual(
fixture.descriptors,
);
expect(fixture.logs).toEqual([
expect.objectContaining({
path: path.join(deniedProject, deniedSuffix),
err: expect.objectContaining({ code: "EACCES" }),
msg: "Skipping Claude session discovery path with denied access",
}),
]);
} finally {
await fs.chmod(deniedProject, 0o700);
await fixture.cleanup();
}
},
);
test.skipIf(cannotEnforceFilePermissions).each(discoveryPermissionCases)(
"surfaces a provider error when $label denies discovery in every project",
async ({ mode, deniedSuffix, existingSuffix }) => {
const fixture = await descriptorFixture(1);
const existingProject = path.dirname(fixture.files[0]!);
const deniedProject = path.join(path.dirname(existingProject), "denied-project");
await fs.mkdir(deniedProject);
try {
await fs.writeFile(path.join(deniedProject, "hidden.jsonl"), '{"type":"user"}');
await fs.chmod(existingProject, mode);
await fs.chmod(deniedProject, mode);
const client = new ClaudeAgentClient(fixture.options);
await expect(client.listImportableSessions({ limit: 2 })).rejects.toMatchObject({
name: "AggregateError",
message: expect.stringContaining("no transcripts could be discovered"),
errors: expect.arrayContaining([
expect.objectContaining({
code: "EACCES",
path: path.join(existingProject, existingSuffix),
}),
expect.objectContaining({
code: "EACCES",
path: path.join(deniedProject, deniedSuffix),
}),
]),
});
expect(fixture.logs).toHaveLength(2);
expect(fixture.logs).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: path.join(existingProject, existingSuffix),
err: expect.objectContaining({ code: "EACCES" }),
}),
expect.objectContaining({
path: path.join(deniedProject, deniedSuffix),
err: expect.objectContaining({ code: "EACCES" }),
}),
]),
);
} finally {
await fs.chmod(existingProject, 0o700);
await fs.chmod(deniedProject, 0o700);
await fixture.cleanup();
}
},
);
test.skipIf(cannotEnforceFilePermissions)(
"isolates an actual access-denied transcript while returning a healthy session",
async () => {
const fixture = await descriptorFixture(2);
try {
await fs.chmod(fixture.files[0]!, 0);
const client = new ClaudeAgentClient(fixture.options);
await expect(client.listImportableSessions({ limit: 2 })).resolves.toEqual([
fixture.descriptors[1],
]);
expect(fixture.logs).toEqual([
expect.objectContaining({
path: fixture.files[0],
err: expect.objectContaining({ code: "EACCES" }),
msg: "Skipping Claude transcript with unreadable import metadata",
}),
]);
} finally {
await fixture.cleanup();
}
},
);
test.skipIf(cannotEnforceFilePermissions)(
"surfaces a provider error when every actual candidate is access denied",
async () => {
const fixture = await descriptorFixture(2);
try {
await Promise.all(fixture.files.map((file) => fs.chmod(file, 0)));
const client = new ClaudeAgentClient(fixture.options);
await expect(client.listImportableSessions({ limit: 2 })).rejects.toMatchObject({
name: "AggregateError",
errors: fixture.files.map((file) =>
expect.objectContaining({ code: "EACCES", path: file }),
),
message: expect.stringContaining(
"all 2 candidate transcripts have unreadable import metadata",
),
});
expect(fixture.logs).toHaveLength(2);
} finally {
await fixture.cleanup();
}
},
);
test("returns an empty listing for readable transcripts without import metadata", async () => {
const fixture = await descriptorFixture(2);
try {
await Promise.all(fixture.files.map((file) => fs.writeFile(file, '{"type":"assistant"}')));
const client = new ClaudeAgentClient(fixture.options);
await expect(client.listImportableSessions({ limit: 2 })).resolves.toEqual([]);
expect(fixture.logs).toEqual([]);
} finally {
await fixture.cleanup();
}
});
test("refreshes descriptors after append, rewrite, replacement, and deletion", async () => {
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-cache-"));
try {
const projectDir = path.join(configDir, "projects", "changing-transcript");
await fs.mkdir(projectDir, { recursive: true });
const sessionFile = path.join(projectDir, "changing.jsonl");
const initial = `${JSON.stringify({
type: "user",
sessionId: "changing-session",
cwd: "/changing-project",
message: { content: "First prompt" },
})}\n`;
function title(customTitle: string): string {
return `${JSON.stringify({ type: "custom-title", customTitle })}\n`;
}
await fs.writeFile(sessionFile, initial + title("First title"));
const timestamp = new Date("2026-06-01T12:00:00.000Z");
await fs.utimes(sessionFile, timestamp, timestamp);
const client = new ClaudeAgentClient({
logger: createTestLogger(),
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
});
const first = await client.listImportableSessions({ limit: 1 });
expect(first.map((session) => session.title)).toEqual(["First title"]);
// Callers must not be able to mutate a cached descriptor, including its Date.
first[0]!.title = "Changed by caller";
first[0]!.lastActivityAt.setTime(0);
const repeated = await Promise.all([
client.listImportableSessions({ limit: 1 }),
client.listImportableSessions({ limit: 1 }),
]);
expect(repeated.map((sessions) => [sessions[0]!.title, sessions[0]!.lastActivityAt])).toEqual(
[
["First title", timestamp],
["First title", timestamp],
],
);
repeated[0]![0]!.title = "Changed by overlapping caller";
repeated[0]![0]!.lastActivityAt.setTime(0);
expect([repeated[1]![0]!.title, repeated[1]![0]!.lastActivityAt]).toEqual([
"First title",
timestamp,
]);
await fs.appendFile(sessionFile, title("After append"));
expect(
(await client.listImportableSessions({ limit: 1 })).map((session) => session.title),
).toEqual(["After append"]);
// Same-length rewrites with a restored mtime still invalidate the descriptor.
await fs.writeFile(sessionFile, initial + title("Other title"));
await fs.utimes(sessionFile, timestamp, timestamp);
expect(
(await client.listImportableSessions({ limit: 1 })).map((session) => session.title),
).toEqual(["Other title"]);
const replacement = path.join(projectDir, "replacement.tmp");
await fs.writeFile(replacement, initial + title("Final title"));
await fs.utimes(replacement, timestamp, timestamp);
await fs.rename(replacement, sessionFile);
expect(
(await client.listImportableSessions({ limit: 1 })).map((session) => session.title),
).toEqual(["Final title"]);
await fs.unlink(sessionFile);
await expect(client.listImportableSessions({ limit: 1 })).resolves.toEqual([]);
} finally {
await fs.rm(configDir, { recursive: true, force: true });
}
});
test.each([
{ providers: 1, requestsPerProvider: 4 },
{ providers: 5, requestsPerProvider: 3 },
])(
"discovers large histories with $providers providers and $requestsPerProvider requests each",
async ({ providers, requestsPerProvider }) => {
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-heap-"));
try {
const projectDir = path.join(configDir, "projects", "large-transcript");
await fs.mkdir(projectDir, { recursive: true });
const sessionFile = path.join(projectDir, "large.jsonl");
const transcript = await fs.open(sessionFile, "w");
try {
await transcript.writeFile(
`${JSON.stringify({
type: "user",
sessionId: "large-session",
cwd: "/large-project",
message: { content: "First prompt" },
})}\r\n`,
);
const block = `${JSON.stringify({ type: "assistant", text: "x".repeat(1024) })}\n`.repeat(
1024,
);
for (let index = 0; index < 128; index += 1) {
await transcript.writeFile(block);
}
// A malformed record must not hide the final unterminated record.
await transcript.writeFile(
`incomplete json\n${JSON.stringify({
type: "user",
message: { content: "Last prompt 🐢" },
})}`,
);
} finally {
await transcript.close();
}
const { stdout } = await promisify(execFile)(
process.execPath,
[
"--max-old-space-size=96",
"--import",
import.meta.resolve("tsx"),
"--input-type=module",
"--eval",
`
import { deepStrictEqual } from "node:assert";
import { ClaudeAgentClient } from ${JSON.stringify(new URL("./agent.ts", import.meta.url).href)};
import pino from ${JSON.stringify(import.meta.resolve("pino"))};
import { mkdir, symlink } from "node:fs/promises";
import path from "node:path";
const clients = [];
for (let index = 0; index < ${providers}; index++) {
const providerConfigDir = path.join(${JSON.stringify(configDir)}, "alias-" + index);
await mkdir(providerConfigDir);
await symlink(path.join(${JSON.stringify(configDir)}, "projects"), path.join(providerConfigDir, "projects"), "junction");
clients.push(new ClaudeAgentClient({
logger: pino({ level: "silent" }),
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } },
}));
}
const listings = await Promise.all(clients.flatMap(client =>
Array.from({ length: ${requestsPerProvider} }, () => client.listImportableSessions({ limit: 1 }))
));
const sessions = listings[0];
for (const listing of listings) deepStrictEqual(listing, sessions);
console.log(JSON.stringify(sessions));
`,
],
{ timeout: 20_000 },
);
expect(JSON.parse(stdout)).toEqual([
{
providerHandleId: "large-session",
cwd: "/large-project",
title: "First prompt",
firstPromptPreview: "First prompt",
lastPromptPreview: "Last prompt 🐢",
lastActivityAt: (await fs.stat(sessionFile)).mtime.toISOString(),
},
]);
} finally {
await fs.rm(configDir, { recursive: true, force: true });
}
},
);
test("keeps metadata and UTF-8 across read chunk boundaries", async () => {
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-limit-"));
try {
const projectDir = path.join(configDir, "projects", "boundary");
await fs.mkdir(projectDir, { recursive: true });
const prefix = '{"padding":"';
// Split the four-byte turtle across a 64 KiB input chunk boundary.
const padding = "x".repeat(64 * 1024 - Buffer.byteLength(prefix) - 1);
const suffix =
'","type":"user","sessionId":"boundary-session","cwd":"/boundary","message":{"content":"Hello 🐢"}}';
const remaining = 4 * 1024 * 1024 - Buffer.byteLength(prefix + padding + "🐢" + suffix);
await fs.writeFile(
path.join(projectDir, "boundary.jsonl"),
prefix + padding + "🐢" + "x".repeat(remaining) + suffix,
);
const client = new ClaudeAgentClient({
logger: createTestLogger(),
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
});
expect(
(await client.listImportableSessions({ limit: 1 })).map((session) => ({
providerHandleId: session.providerHandleId,
cwd: session.cwd,
title: session.title,
firstPromptPreview: session.firstPromptPreview,
lastPromptPreview: session.lastPromptPreview,
})),
).toEqual([
{
providerHandleId: "boundary-session",
cwd: "/boundary",
title: "Hello 🐢",
firstPromptPreview: "Hello 🐢",
lastPromptPreview: "Hello 🐢",
},
]);
} finally {
await fs.rm(configDir, { recursive: true, force: true });
}
});
test("rejects a cancelled listing before doing filesystem work", async () => {
const controller = new AbortController();
controller.abort(new Error("Picker closed"));
const client = new ClaudeAgentClient({ logger: createTestLogger() });
await expect(client.listImportableSessions({ signal: controller.signal })).rejects.toThrow(
"Picker closed",
);
});
test("uses the latest native custom title and leaves fixture mtimes unchanged", async () => {
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-"));
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
@@ -1992,7 +2457,7 @@ describe("ClaudeAgentSession context window usage", () => {
test("emits canonical task snapshots from Claude TaskCreate results", async () => {
const session = await createSessionForTest();
try {
session.translateMessageToEvents({
await session.translateMessageToEvents({
type: "assistant",
message: {
content: [
@@ -2006,7 +2471,7 @@ describe("ClaudeAgentSession context window usage", () => {
},
} as unknown as SDKMessage);
const events = session.translateMessageToEvents({
const events = await session.translateMessageToEvents({
type: "user",
message: {
content: [{ type: "tool_result", tool_use_id: "task-create-1", content: "created" }],
@@ -3208,9 +3673,9 @@ describe("ClaudeAgentSession context window usage", () => {
]);
try {
const compactEvents = (session as unknown as TestClaudeSession).translateMessageToEvents(
createCompactBoundary(),
);
const compactEvents = await (
session as unknown as TestClaudeSession
).translateMessageToEvents(createCompactBoundary());
expect(compactEvents).toContainEqual(
expect.objectContaining({
type: "usage_updated",
@@ -3396,7 +3861,7 @@ describe("ClaudeAgentSession context window usage", () => {
test("result.result is surfaced as an assistant message when no model output was produced", async () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
const events = await session.translateMessageToEvents({
type: "result",
subtype: "success",
result: "Unknown command: /foo-doesnt-exist",
@@ -3431,7 +3896,7 @@ describe("ClaudeAgentSession context window usage", () => {
test("result.result is not duplicated when the model produced output during the turn", async () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
const events = await session.translateMessageToEvents({
type: "result",
subtype: "success",
result: "Here is the answer.",
@@ -3695,3 +4160,184 @@ describe("Claude question permission notifications", () => {
expect(request.description).toBeUndefined();
});
});
describe("Claude import descriptor scheduling", () => {
function controlledReads() {
const started: string[] = [];
const reads = new Map<
string,
{ resolve: (value: string) => void; reject: (reason: unknown) => void }
>();
const signals = new Map<string, AbortSignal>();
const sharedScheduler = createImportDescriptorScheduler(
(candidate: { cacheKey: string }, signal) => {
started.push(candidate.cacheKey);
signals.set(candidate.cacheKey, signal);
return new Promise<string>((resolve, reject) => {
reads.set(candidate.cacheKey, { resolve, reject });
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
});
},
);
function scheduler(candidates: string[], signal: AbortSignal): Promise<string[]> {
return sharedScheduler(
candidates.map((cacheKey) => ({ cacheKey })),
signal,
);
}
return { scheduler, started, reads, signals };
}
test("shares a cold read across overlapping listings and leaves capacity for unrelated work", async () => {
const { scheduler, started, reads } = controlledReads();
const signal = new AbortController().signal;
const overlapping = Array.from({ length: 4 }, () => scheduler(["shared"], signal));
await vi.waitFor(() => expect(started).toEqual(["shared"]));
const unrelated = scheduler(["unrelated"], signal);
await vi.waitFor(() => expect(started).toEqual(["shared", "unrelated"]));
reads.get("unrelated")!.resolve("unrelated descriptor");
await expect(unrelated).resolves.toEqual(["unrelated descriptor"]);
reads.get("shared")!.resolve("shared descriptor");
await expect(Promise.all(overlapping)).resolves.toEqual(
Array.from({ length: 4 }, () => ["shared descriptor"]),
);
// A settled entry must not become an accidental second cache.
const later = scheduler(["shared"], signal);
await vi.waitFor(() => expect(started).toEqual(["shared", "unrelated", "shared"]));
reads.get("shared")!.resolve("updated descriptor");
await expect(later).resolves.toEqual(["updated descriptor"]);
});
test("cancelling the initiating listing keeps its other subscriber alive", async () => {
const { scheduler, started, reads } = controlledReads();
const initiator = new AbortController();
const first = scheduler(["shared"], initiator.signal);
const firstRejected = expect(first).rejects.toThrow("First picker closed");
const second = scheduler(["shared"], new AbortController().signal);
await vi.waitFor(() => expect(started).toEqual(["shared"]));
initiator.abort(new Error("First picker closed"));
await firstRejected;
reads.get("shared")!.resolve("shared descriptor");
await expect(second).resolves.toEqual(["shared descriptor"]);
expect(started).toEqual(["shared"]);
});
test("the final subscriber cancellation aborts the read and allows a fresh subscription", async () => {
const { scheduler, started, signals } = controlledReads();
const firstController = new AbortController();
const secondController = new AbortController();
const first = scheduler(["shared"], firstController.signal);
const second = scheduler(["shared"], secondController.signal);
const firstRejected = expect(first).rejects.toThrow("First picker closed");
const secondRejected = expect(second).rejects.toThrow("Final picker closed");
await vi.waitFor(() => expect(started).toEqual(["shared"]));
firstController.abort(new Error("First picker closed"));
await firstRejected;
expect(signals.get("shared")!.aborted).toEqual(false);
secondController.abort(new Error("Final picker closed"));
await secondRejected;
expect(signals.get("shared")!.aborted).toEqual(true);
const retryController = new AbortController();
const retry = scheduler(["shared"], retryController.signal);
const retryRejected = expect(retry).rejects.toThrow("Retry closed");
await vi.waitFor(() => expect(started).toEqual(["shared", "shared"]));
retryController.abort(new Error("Retry closed"));
await retryRejected;
expect(signals.get("shared")!.aborted).toEqual(true);
});
test("runs four reads per listing and gives a later small listing a bounded turn", async () => {
const { scheduler, started, reads, signals } = controlledReads();
const controller = new AbortController();
const large = scheduler(
Array.from({ length: 500 }, (_, index) => `large-${index}`),
controller.signal,
);
const largeRejected = expect(large).rejects.toThrow("Fairness check complete");
await vi.waitFor(() => expect(started).toEqual(["large-0", "large-1", "large-2", "large-3"]));
const small = scheduler(["small"], new AbortController().signal);
reads.get("large-0")!.resolve("first descriptor");
await vi.waitFor(() =>
expect(started).toEqual(["large-0", "large-1", "large-2", "large-3", "small"]),
);
reads.get("small")!.resolve("small descriptor");
await expect(small).resolves.toEqual(["small descriptor"]);
controller.abort(new Error("Fairness check complete"));
await largeRejected;
expect(signals.get("large-1")!.aborted).toEqual(true);
expect(signals.get("large-2")!.aborted).toEqual(true);
expect(signals.get("large-3")!.aborted).toEqual(true);
expect(started.length).toBeLessThanOrEqual(6);
});
test("refills four workers while retaining candidate order after out-of-order completions", async () => {
const { scheduler, started, reads } = controlledReads();
const listing = scheduler(["0", "1", "2", "3", "4", "5"], new AbortController().signal);
await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3"]));
reads.get("3")!.resolve("descriptor-3");
await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3", "4"]));
reads.get("2")!.resolve("descriptor-2");
await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3", "4", "5"]));
reads.get("5")!.resolve("descriptor-5");
reads.get("4")!.resolve("descriptor-4");
reads.get("1")!.resolve("descriptor-1");
reads.get("0")!.resolve("descriptor-0");
await expect(listing).resolves.toEqual([
"descriptor-0",
"descriptor-1",
"descriptor-2",
"descriptor-3",
"descriptor-4",
"descriptor-5",
]);
});
test("an unexpected read failure cancels sibling workers without starting remaining candidates", async () => {
const { scheduler, started, reads, signals } = controlledReads();
const listing = scheduler(["0", "1", "2", "3", "4", "5"], new AbortController().signal);
const rejected = expect(listing).rejects.toThrow("Unexpected read failure");
await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3"]));
reads.get("0")!.reject(new TypeError("Unexpected read failure"));
await rejected;
expect(signals.get("1")!.aborted).toEqual(true);
expect(signals.get("2")!.aborted).toEqual(true);
expect(signals.get("3")!.aborted).toEqual(true);
expect(started).toEqual(["0", "1", "2", "3"]);
});
test("bounds global reads and skips cancelled queued candidates", async () => {
const { scheduler, started, reads } = controlledReads();
const controllers = Array.from({ length: 5 }, () => new AbortController());
const listings = controllers.map((controller, index) =>
scheduler([`${index}-first`], controller.signal),
);
// Attach handlers before aborting any promises.
function errorMessage(error: Error): string {
return error.message;
}
const settled = listings.map((listing) => listing.catch(errorMessage));
await vi.waitFor(() => expect(started).toEqual(["0-first", "1-first", "2-first", "3-first"]));
controllers[4]!.abort(new Error("Queued picker closed"));
await expect(settled[4]).resolves.toEqual("Queued picker closed");
controllers[0]!.abort(new Error("Active picker closed"));
await expect(settled[0]).resolves.toEqual("Active picker closed");
reads.get("1-first")!.reject(new Error("Stop 1"));
reads.get("2-first")!.reject(new Error("Stop 2"));
reads.get("3-first")!.reject(new Error("Stop 3"));
await expect(Promise.all(settled)).resolves.toEqual([
"Active picker closed",
"Stop 1",
"Stop 2",
"Stop 3",
"Queued picker closed",
]);
expect(started).toEqual(["0-first", "1-first", "2-first", "3-first"]);
// A cancelled queue entry must not consume a later listing's read slot.
const later = scheduler(["later"], new AbortController().signal);
await vi.waitFor(() =>
expect(started).toEqual(["0-first", "1-first", "2-first", "3-first", "later"]),
);
reads.get("later")!.resolve("later descriptor");
await expect(later).resolves.toEqual(["later descriptor"]);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
import pLimit from "p-limit";
interface ImportDescriptorCandidate {
cacheKey: string;
}
interface PendingDescriptor<Descriptor> {
controller: AbortController;
subscribers: number;
settled: boolean;
promise: Promise<Descriptor>;
}
export function createImportDescriptorScheduler<
Candidate extends ImportDescriptorCandidate,
Descriptor,
>(
read: (candidate: Candidate, signal: AbortSignal) => Promise<Descriptor>,
): (candidates: Candidate[], signal: AbortSignal) => Promise<Descriptor[]> {
const limit = pLimit(4);
const reads = new Map<string, PendingDescriptor<Descriptor>>();
function acquire(candidate: Candidate): PendingDescriptor<Descriptor> {
let pending = reads.get(candidate.cacheKey);
if (!pending) {
const controller = new AbortController();
const entry: PendingDescriptor<Descriptor> = {
controller,
subscribers: 0,
settled: false,
promise: limit(() => {
controller.signal.throwIfAborted();
return read(candidate, controller.signal);
}).finally(() => {
entry.settled = true;
if (reads.get(candidate.cacheKey) === entry) reads.delete(candidate.cacheKey);
}),
};
reads.set(candidate.cacheKey, entry);
pending = entry;
}
pending.subscribers += 1;
return pending;
}
function release(candidate: Candidate, pending: PendingDescriptor<Descriptor>): void {
pending.subscribers -= 1;
// The initiating listing has no special ownership of a shared read.
if (pending.subscribers === 0 && !pending.settled) {
if (reads.get(candidate.cacheKey) === pending) reads.delete(candidate.cacheKey);
pending.controller.abort();
}
}
return async (candidates, signal) => {
signal.throwIfAborted();
const controller = new AbortController();
signal = AbortSignal.any([signal, controller.signal]);
let onAbort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
});
const results: Descriptor[] = [];
let nextIndex = 0;
async function worker(): Promise<void> {
while (nextIndex < candidates.length) {
signal.throwIfAborted();
const index = nextIndex++;
const candidate = candidates[index]!;
// Each worker queues only its next read. Later listings enter the
// shared FIFO after at most four outstanding reads from this listing.
const pending = acquire(candidate);
try {
results[index] = await Promise.race([pending.promise, aborted]);
} finally {
release(candidate, pending);
}
}
}
try {
const workers = Array.from({ length: Math.min(4, candidates.length) }, () => worker());
await Promise.all(workers);
return results;
} catch (error) {
controller.abort(error);
throw error;
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
};
}
@@ -0,0 +1,6 @@
const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/;
/** The text Claude Code records in place of a turn that an interrupt stopped. */
export function isClaudeInterruptPlaceholderText(value: unknown): boolean {
return typeof value === "string" && INTERRUPT_PLACEHOLDER_PATTERN.test(value.trim());
}
@@ -1,5 +1,5 @@
import { existsSync, readdirSync, realpathSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { realpathSync } from "node:fs";
import { realpath, access, readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -35,21 +35,21 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti
// one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the
// cwd under another name (a Windows binary under WSL). Returns the expected path when no
// transcript exists anywhere.
export function claudeTranscriptPathSync(input: {
export async function claudeTranscriptPath(input: {
cwd: string;
sessionId: string;
configDir?: string;
}): string {
}): Promise<string> {
const options = { configDir: input.configDir };
const fileName = `${input.sessionId}.jsonl`;
const expected = join(claudeProjectDirSync(input.cwd, options), fileName);
if (existsSync(expected)) {
const expected = join(await claudeProjectDir(input.cwd, options), fileName);
if (await exists(expected)) {
return expected;
}
const projectsRoot = join(resolveConfigDir(options), "projects");
let projectDirs: string[];
try {
projectDirs = readdirSync(projectsRoot);
projectDirs = await readdir(projectsRoot);
} catch (error) {
if (isMissingDirectoryError(error)) {
return expected;
@@ -58,13 +58,23 @@ export function claudeTranscriptPathSync(input: {
}
for (const projectDir of projectDirs) {
const candidate = join(projectsRoot, projectDir, fileName);
if (existsSync(candidate)) {
if (await exists(candidate)) {
return candidate;
}
}
return expected;
}
async function exists(file: string): Promise<boolean> {
try {
await access(file);
return true;
} catch (error) {
if (isMissingDirectoryError(error)) return false;
throw error;
}
}
function isMissingDirectoryError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | null)?.code;
return code === "ENOENT" || code === "ENOTDIR";
@@ -2,11 +2,10 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:chi
import { query, type Options, type Query, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk";
import {
createProviderEnv,
createProviderEnvSpec,
type ProviderRuntimeSettings,
} from "../../provider-launch-config.js";
import { buildSelfNodeCommand } from "../../../paseo-env.js";
import { buildSelfNodeCommand, createExternalProcessEnv } from "../../../paseo-env.js";
import { spawnProcess } from "../../../../utils/spawn.js";
// Keep the raw SDK query import in this module only. Claude process launch behavior
@@ -74,11 +73,10 @@ function applyRuntimeSettingsToClaudeOptions(
runtimeSettings,
overlays: [launchEnv],
});
const providerEnv = createProviderEnv({
baseEnv: spawnOptions.env,
runtimeSettings,
overlays: [launchEnv],
});
// Session-open hooks may explicitly supply a client identity. Keep inherited
// SDK/parent identities cleared while honoring this launch-only contribution.
providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = launchEnv?.CLAUDE_CODE_ENTRYPOINT;
const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay);
const selfNodeCommand = isDefaultRuntime
? buildSelfNodeCommand(resolved.args, providerEnv)
: null;
@@ -0,0 +1,202 @@
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import pino from "pino";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
/** Full-scale reproduction is opt-in: run this file with ROOT children --generate. */
export function generateReplayCorpus(
root: string,
size = { parentMiB: 395, childrenMiB: 362, children: 269 },
): { cwd: string; configDir: string } {
const cwd = path.join(root, "repo");
const configDir = path.join(root, "claude");
mkdirSync(cwd, { recursive: true });
const project = claudeProjectDirSync(cwd, { configDir });
const subagents = path.join(project, "large-session", "subagents");
mkdirSync(subagents, { recursive: true });
const parent = path.join(project, "large-session.jsonl");
const row = (value: unknown) => JSON.stringify(value) + "\n";
// Large transcripts include records and context summaries that never become timeline items.
// Generate those incrementally: the fixture generator itself must not hold the corpus.
const padding = row({ type: "progress", data: "x".repeat(64 * 1024) });
writeFileSync(parent, "");
for (let i = 0; i < Math.ceil((size.parentMiB * 1024 * 1024) / padding.length); i++) {
appendFileSync(parent, padding);
}
appendFileSync(parent, row({ type: "user", message: { content: "Open the large session" } }));
for (let i = 0; i < size.children; i++) {
const agentId = `child-${i}`;
const id = `task-${i}`;
appendFileSync(
parent,
row({
type: "assistant",
message: {
content: [{ type: "tool_use", id, name: "Task", input: { description: agentId } }],
},
}),
);
appendFileSync(
parent,
row({
type: "user",
message: { content: [{ type: "tool_result", tool_use_id: id, content: "done" }] },
}),
);
const file = path.join(subagents, `agent-${agentId}.jsonl`);
writeFileSync(file, "");
const summary = row({
type: "user",
isCompactSummary: true,
isSidechain: true,
agentId,
message: { content: "x".repeat(64 * 1024) },
});
for (
let j = 0;
j < Math.ceil((size.childrenMiB * 1024 * 1024) / size.children / summary.length);
j++
)
appendFileSync(file, summary);
appendFileSync(
file,
row({
type: "assistant",
isSidechain: true,
agentId,
timestamp: "2026-07-26T06:28:00.000Z",
message: {
content: [{ type: "text", text: `History of ${agentId}` }],
stop_reason: "end_turn",
},
}),
);
writeFileSync(
path.join(subagents, `agent-${agentId}.meta.json`),
JSON.stringify({ toolUseId: id }),
);
}
return { cwd, configDir };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const root = process.argv[2];
if (!root) throw new Error("Usage: replay-memory.fixture.ts ROOT children|workflow [--generate]");
if (process.argv.includes("--generate")) {
if (process.argv[3] === "workflow") generateWorkflowReplayCorpus(root);
else generateReplayCorpus(root);
}
process.env.CLAUDE_CONFIG_DIR = path.join(root, "claude");
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),
queryFactory: () => {
throw new Error("Replay must not query Claude");
},
resolveVersion: async () => "2.1.220",
});
const session = await client.resumeSession(
{ provider: "claude", sessionId: "large-session" },
{ cwd: path.join(root, "repo") },
);
let parentItems = 0;
const completed = new Set<string>();
const histories = new Set<string>();
for await (const event of session.streamHistory()) {
if (event.type === "timeline") parentItems++;
if (
event.type === "provider_subagent" &&
event.event.type === "upsert" &&
event.event.status === "completed"
)
completed.add(event.event.id);
if (
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.item.type === "assistant_message"
)
histories.add(event.event.item.text);
}
await session.close();
console.log(
JSON.stringify({
parentItems,
completed: completed.size,
histories: histories.size,
maxRSSKiB: process.resourceUsage().maxRSS,
}),
);
}
export function generateWorkflowReplayCorpus(
root: string,
childMiB = 320,
): { cwd: string; configDir: string } {
const cwd = path.join(root, "repo");
const configDir = path.join(root, "claude");
mkdirSync(cwd, { recursive: true });
const project = claudeProjectDirSync(cwd, { configDir });
const runId = "wf_large";
const sessionDirectory = path.join(project, "large-session");
const childrenDirectory = path.join(sessionDirectory, "subagents", "workflows", runId);
mkdirSync(childrenDirectory, { recursive: true });
mkdirSync(path.join(sessionDirectory, "workflows"), { recursive: true });
writeFileSync(
path.join(project, "large-session.jsonl"),
[
{ type: "user", message: { content: "Open the large session" } },
{
type: "assistant",
message: {
content: [{ type: "tool_use", id: "workflow-task", name: "Workflow", input: {} }],
},
},
{
type: "user",
message: {
content: [
{
type: "tool_result",
tool_use_id: "workflow-task",
content: `Workflow launched in background.\nRun ID: ${runId}`,
},
],
},
},
]
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
writeFileSync(
path.join(sessionDirectory, "workflows", `${runId}.json`),
JSON.stringify({ runId, status: "completed", summary: "Large workflow" }),
);
for (let i = 0; i < 5; i++) {
const file = path.join(childrenDirectory, `agent-child-${i}.jsonl`);
writeFileSync(file, "");
const prompt =
JSON.stringify({
type: "user",
isSidechain: true,
agentId: `child-${i}`,
message: { content: "x".repeat(64 * 1024) },
}) + "\n";
for (let j = 0; j < Math.ceil((childMiB * 1024 * 1024) / prompt.length); j++)
appendFileSync(file, prompt);
appendFileSync(
file,
JSON.stringify({
type: "assistant",
isSidechain: true,
agentId: `child-${i}`,
timestamp: `2026-07-26T06:28:0${4 - i}.000Z`,
message: {
content: [{ type: "text", text: `History of child-${i}` }],
stop_reason: "end_turn",
},
}) + "\n",
);
}
return { cwd, configDir };
}
@@ -76,7 +76,7 @@ export async function revertClaudeConversation(input: {
transcriptPath: string | null;
messageId: string;
resolveMessageId?: (messageId: string) => string | Promise<string>;
setSessionId: (sessionId: string) => void;
setSessionId: (sessionId: string) => void | Promise<void>;
}): Promise<void> {
if (!input.sessionId || !input.transcriptPath) {
throw new Error("Claude session is not ready for rewind");
@@ -86,7 +86,7 @@ export async function revertClaudeConversation(input: {
upToMessageId: messageId,
transcriptPath: input.transcriptPath,
});
input.setSessionId(fork.sessionId);
await input.setSessionId(fork.sessionId);
}
export async function revertClaudeFiles(input: {
@@ -108,7 +108,7 @@ export async function revertClaudeConversationAndFiles(input: {
transcriptPath: string | null;
messageId: string;
resolveMessageId?: (messageId: string) => string | Promise<string>;
setSessionId: (sessionId: string) => void;
setSessionId: (sessionId: string) => void | Promise<void>;
}): Promise<void> {
await revertClaudeFiles({
query: input.query,
@@ -0,0 +1,42 @@
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { parseClaudeCodeVersion } from "./model-manifest.js";
/** Process-local background ownership. Completion delivery can follow an empty roster. */
export class ClaudeRuntimeSafety {
private supported = false;
private liveTasks = new Set<string>();
private readonly awaitingNotifications = new Set<string>();
reset(): void {
this.supported = false;
this.liveTasks.clear();
this.awaitingNotifications.clear();
}
observe(message: SDKMessage): void {
if (message.type !== "system") return;
if (message.subtype === "init") {
// 2.1.292 is a known roster-capable release. Older binaries can demonstrate
// support by sending a roster; a fresh process starts with an empty roster.
const version = parseClaudeCodeVersion(message.claude_code_version ?? "");
if (
version &&
(version[0] > 2 ||
(version[0] === 2 && (version[1] > 1 || (version[1] === 1 && version[2] >= 292))))
)
this.supported = true;
}
if (message.subtype === "background_tasks_changed") {
this.supported = true;
this.liveTasks = new Set(message.tasks.map((task) => task.task_id));
for (const id of this.liveTasks) this.awaitingNotifications.add(id);
}
if (message.subtype === "task_notification") {
this.awaitingNotifications.delete(message.task_id);
}
}
canStopRuntime(): boolean {
return this.supported && this.liveTasks.size === 0 && this.awaitingNotifications.size === 0;
}
}
@@ -0,0 +1,46 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
import { ClaudeAgentClient } from "./agent.js";
test("the real SDK passes the user's settings file contents alongside Paseo flags", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-settings-argv-"));
const capturePath = path.join(cwd, "argv.json");
const binaryPath = path.join(cwd, "capture.mjs");
const settingsPath = path.join(cwd, "settings.json");
const userSettings = {
env: { ENABLE_TOOL_SEARCH: "false" },
permissions: { deny: ["Monitor"] },
};
try {
// A CLI fixture records the real SDK's arguments without making a model request.
await fs.writeFile(
binaryPath,
`import fs from "node:fs"; fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify(process.argv.slice(2)));`,
);
await fs.writeFile(settingsPath, JSON.stringify(userSettings));
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),
resolveBinary: async () => binaryPath,
});
const session = await client.createSession({
provider: "claude",
cwd,
model: "claude-opus-5-5",
providerOptions: { extraArgs: { settings: settingsPath } },
});
try {
await session.startTurn("hello");
} finally {
await session.close();
}
const argv: string[] = JSON.parse(await fs.readFile(capturePath, "utf8"));
const settingsIndex = argv.indexOf("--settings");
expect(settingsIndex).toBeGreaterThanOrEqual(0);
expect(JSON.parse(argv[settingsIndex + 1]!)).toEqual({ ...userSettings, fastMode: false });
} finally {
await fs.rm(cwd, { recursive: true, force: true });
}
});
@@ -1,7 +1,8 @@
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { mapTaskStatus } from "./task-status.js";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AgentMetadata } from "../../../agent-sdk-types.js";
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { resolveObservedClaudeModelId } from "../models.js";
import type { SubagentObservation } from "./observation.js";
import {
@@ -120,28 +121,6 @@ function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
/**
* The descriptor has no paused or pending state, and neither is terminal, so both read as
* running. `killed` is a cancellation, not a failure — the child was stopped, it did not error.
*/
function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined {
switch (status) {
case "pending":
case "running":
case "paused":
return "running";
case "completed":
return "completed";
case "failed":
return "failed";
case "killed":
case "stopped":
return "canceled";
default:
return undefined;
}
}
export interface ClaudeTaskProtocolSourceInput {
/**
* The parent's Task tool input, by tool_use id.
@@ -1,3 +1,9 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
} from "../../provider-image-output.js";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { describe, expect, it } from "vitest";
@@ -30,7 +36,7 @@ function parentWithTaskCall(failed = false): ClaudeReplayParentFacts {
],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[TOOL_USE_ID, { failed }]]),
outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: failed ? "failed" : "completed" }]]),
};
}
@@ -45,7 +51,7 @@ function applyToStore(observations: SubagentObservation[]): ProviderSubagentDesc
}
describe("parseClaudeSubagentMeta", () => {
it("reads the real sidecar shape", () => {
it("reads the real sidecar shape", async () => {
expect(
parseClaudeSubagentMeta(
'{"agentType":"general-purpose","description":"Reply banana","toolUseId":"toolu_013i","spawnDepth":1}',
@@ -68,7 +74,7 @@ describe("parseClaudeSubagentMeta", () => {
expect(parseClaudeSubagentMeta(contents)).toBeNull();
});
it("keeps the good fields when one field has the wrong type", () => {
it("keeps the good fields when one field has the wrong type", async () => {
expect(parseClaudeSubagentMeta('{"agentType":"Explore","toolUseId":42}')).toEqual({
agentType: "Explore",
description: undefined,
@@ -79,12 +85,14 @@ describe("parseClaudeSubagentMeta", () => {
});
describe("observeReplaySubagents", () => {
it("links a subagent to its Task call through meta.toolUseId", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
it("links a subagent to its Task call through meta.toolUseId", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations[0]).toMatchObject({
kind: "declared",
@@ -95,31 +103,33 @@ describe("observeReplaySubagents", () => {
});
});
it("replays descendants beneath the sidechain that declared their Task", () => {
it("replays descendants beneath the sidechain that declared their Task", async () => {
const nestedToolUseId = "toolu_012rzYnFZA";
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 },
entries: [],
parentFacts: {
toolCalls: new Map([
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 },
entries: [],
parentFacts: {
toolCalls: new Map([
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[nestedToolUseId, { status: "completed" }]]),
},
},
},
{
agentId: "a6acb4b898",
meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 },
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
{
agentId: "a6acb4b898",
meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 },
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations).toContainEqual(
expect.objectContaining({
@@ -130,89 +140,143 @@ describe("observeReplaySubagents", () => {
);
});
it("keeps a subagent recorded before spawnDepth existed", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
it("keeps a subagent recorded before spawnDepth existed", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID });
});
it("drops a meta-linked subagent when the parent never declared its Task", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" },
entries: [],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations;
it("drops a meta-linked subagent when the parent never declared its Task", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" },
entries: [],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations;
expect(observations).toEqual([]);
});
it("falls back to the scraped link when there is no meta file", () => {
it("falls back to the scraped link when there is no meta file", async () => {
const parent: ClaudeReplayParentFacts = {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]),
outcomesByToolCallId: new Map(),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]),
outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: "completed" }]]),
};
const [declared, ...rest] = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent,
convertEntry: () => [],
}).observations;
const [declared, ...rest] = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent,
convertEntry: () => [],
})
).observations;
expect(declared).toMatchObject({ id: TOOL_USE_ID, toolCallId: TOOL_USE_ID, title: "Explore" });
expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" });
});
it("drops an unlinkable subagent", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: emptyParent(),
convertEntry: () => [],
}).observations;
it("drops an unlinkable subagent", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: emptyParent(),
convertEntry: () => [],
})
).observations;
expect(observations).toEqual([]);
});
it("drops a terminal subagent the parent has no Task call for", () => {
it("drops a terminal subagent the parent has no Task call for", async () => {
// A legacy grandchild: its toolUseId names a Task call made inside a sibling's transcript, so
// no tool_result for it can ever reach the parent. Left running, it never stops.
const descriptor = applyToStore(
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations,
(
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toBeNull();
});
it("drops a terminal skill-spawned subagent that has no link at all", () => {
it("drops a terminal skill-spawned subagent that has no link at all", async () => {
// A /code-review run: no Task tool_use exists to name it, so its sidecar carries only
// agentType and its id appears nowhere in the parent transcript.
const descriptor = applyToStore(
observeReplaySubagents({
(
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { agentType: "general-purpose" },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toBeNull();
});
it("reports a scraped link's failure as failed", async () => {
const descriptor = applyToStore(
(
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]),
outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: "failed" }]]),
},
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
});
it("drops a non-terminal linkless subagent", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
@@ -221,111 +285,81 @@ describe("observeReplaySubagents", () => {
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
message: { stop_reason: null },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations,
);
expect(descriptor).toBeNull();
});
it("reports a scraped link's failure as failed", () => {
const descriptor = applyToStore(
observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]),
outcomesByToolCallId: new Map(),
},
convertEntry: () => [],
}).observations,
);
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
});
it("drops a non-terminal linkless subagent", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { agentType: "general-purpose" },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: null },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations;
})
).observations;
expect(observations).toEqual([]);
});
it("finishes a terminal Task child when the parent has not recorded its outcome", () => {
it("finishes a terminal Task child when the parent has not recorded its outcome", async () => {
const descriptor = applyToStore(
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
}).observations,
(
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toMatchObject({ status: "completed" });
});
it("leaves a subagent with no recorded outcome running", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
}).observations;
it("leaves a subagent with no recorded outcome running", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
})
).observations;
expect(observations.some((o) => o.kind === "status")).toBe(false);
});
it("reports a failed outcome as failed", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(true),
convertEntry: () => [],
}).observations;
it("reports a failed outcome as failed", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(true),
convertEntry: () => [],
})
).observations;
expect(observations.at(-1)).toMatchObject({ kind: "status", status: "failed" });
});
it("carries entry timestamps onto timeline observations", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [{ type: "reasoning", text: "thinking" }],
}).observations;
it("carries entry timestamps onto timeline observations", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [{ type: "reasoning", text: "thinking" }],
})
).observations;
expect(observations.find((observation) => observation.kind === "timeline")).toMatchObject({
kind: "timeline",
@@ -336,18 +370,20 @@ describe("observeReplaySubagents", () => {
});
describe("replay runtime", () => {
it("reads the model and effort off the subagent's own transcript", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
it("reads the model and effort off the subagent's own transcript", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations.find((o) => o.kind === "subtitle")).toEqual({
kind: "subtitle",
@@ -356,49 +392,55 @@ describe("replay runtime", () => {
});
});
it("takes the last observed values when the model changes mid-transcript", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } },
{ type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } },
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
it("takes the last observed values when the model changes mid-transcript", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } },
{ type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } },
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
subtitle: "general-purpose · Sonnet 5 · Low",
});
});
it("reports nothing when the transcript never names a model or effort", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
it("reports nothing when the transcript never names a model or effort", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations.some((o) => o.kind === "subtitle")).toBe(false);
});
it("passes through a model the manifest does not know", () => {
it("passes through a model the manifest does not know", async () => {
// Claude Code is an Anthropic-compatible client; subagents legitimately report other models.
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", message: { model: "glm-5.1" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", message: { model: "glm-5.1" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
subtitle: "general-purpose · glm-5.1",
});
@@ -406,20 +448,24 @@ describe("replay runtime", () => {
});
describe("replay usage", () => {
function subtitleOf(entries: ClaudeReplayEntry[]): SubagentObservation | undefined {
return observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations.find((observation) => observation.kind === "subtitle");
async function subtitleOf(
entries: ClaudeReplayEntry[],
): Promise<SubagentObservation | undefined> {
return (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations.find((observation) => observation.kind === "subtitle");
}
it("derives the counters Claude Code itself reports, from a real transcript's shape", () => {
it("derives the counters Claude Code itself reports, from a real transcript's shape", async () => {
// Shape and numbers taken from an on-disk subagent transcript. Claude Code finalizes a
// subagent from the LAST assistant entry's usage, so the earlier, smaller turn must not win
// and must not be added in.
expect(
subtitleOf([
await subtitleOf([
{
type: "assistant",
timestamp: "2026-07-23T23:18:43.023Z",
@@ -462,9 +508,9 @@ describe("replay usage", () => {
});
});
it("formats tokens for a child that only answered", () => {
it("formats tokens for a child that only answered", async () => {
expect(
subtitleOf([
await subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -479,12 +525,12 @@ describe("replay usage", () => {
).toMatchObject({ subtitle: "general-purpose · 16.4k tokens" });
});
it("emits nothing for a transcript with no entries", () => {
expect(subtitleOf([])).toBeUndefined();
it("emits nothing for a transcript with no entries", async () => {
expect(await subtitleOf([])).toBeUndefined();
});
it("keeps duration out of the compact subtitle", () => {
const observation = subtitleOf([
it("keeps duration out of the compact subtitle", async () => {
const observation = await subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -494,8 +540,8 @@ describe("replay usage", () => {
expect(observation).toMatchObject({ subtitle: "general-purpose · 10 tokens" });
});
it("omits totalTokens when no entry carries a usage block", () => {
const observation = subtitleOf([
it("omits totalTokens when no entry carries a usage block", async () => {
const observation = await subtitleOf([
{ type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", message: { content: [] } },
{
type: "assistant",
@@ -506,9 +552,9 @@ describe("replay usage", () => {
expect(observation).toBeUndefined();
});
it("keeps the last usage-bearing entry when the final assistant entry has none", () => {
it("keeps the last usage-bearing entry when the final assistant entry has none", async () => {
expect(
subtitleOf([
await subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -519,34 +565,36 @@ describe("replay usage", () => {
).toMatchObject({ subtitle: "general-purpose · 1k tokens" });
});
it("only reports counters the transcript actually shows", () => {
it("only reports counters the transcript actually shows", async () => {
// A user-only transcript says nothing about cost: no assistant turn, so no tool count either.
const observation = subtitleOf([
const observation = await subtitleOf([
{ type: "user", timestamp: "2026-07-27T16:42:14.698Z", message: { content: "prompt" } },
{ type: "user", timestamp: "2026-07-27T16:42:20.000Z", message: { content: "more" } },
]);
expect(observation).toBeUndefined();
});
it("lands the complete provider-owned subtitle on the descriptor", () => {
it("lands the complete provider-owned subtitle on the descriptor", async () => {
const store = new ProviderSubagentStore();
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
message: { content: [], usage: { input_tokens: 7 } },
},
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
message: { content: [], usage: { input_tokens: 7 } },
},
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
for (const event of foldSubagentObservations(observations)) {
store.apply("parent", "claude", event);
}
@@ -559,7 +607,7 @@ describe("live and replay agree", () => {
// The property the design exists to guarantee: one session, two sources, one descriptor.
// This is inexpressible against the inference implementation, where the paths share no
// vocabulary and each derives identity and status its own way.
it("produces identical descriptor state from the wire and from disk", () => {
it("produces identical descriptor state from the wire and from disk", async () => {
const live = new ClaudeTaskProtocolSource();
const liveObservations = [
...live.observe({
@@ -579,21 +627,23 @@ describe("live and replay agree", () => {
} as unknown as SDKMessage),
];
const replayObservations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: {
toolUseId: TOOL_USE_ID,
agentType: "general-purpose",
description: "Summarize hover and unistyles docs",
const replayObservations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: {
toolUseId: TOOL_USE_ID,
agentType: "general-purpose",
description: "Summarize hover and unistyles docs",
},
entries: [],
},
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
const fromWire = applyToStore(liveObservations);
const fromDisk = applyToStore(replayObservations);
@@ -616,3 +666,34 @@ describe("live and replay agree", () => {
});
});
});
it("awaits image materialization before publishing replay observations", async () => {
const bytes = Buffer.from("async replay image fixture");
const convertEntry = async () => {
const item = await renderProviderImageOutputAsAssistantMarkdown(
{ data: bytes.toString("base64"), mimeType: "image/png" },
{ materialize: materializeProviderImage },
);
return item ? [item] : [];
};
const { observations } = await observeReplaySubagents({
parent: parentWithTaskCall(),
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", message: { content: [] } }],
},
],
convertEntry,
});
const image = observations.find(
(observation) =>
observation.kind === "timeline" && observation.item.type === "assistant_message",
);
if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message")
throw new Error("Expected replay image");
const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1];
expect(source).toBeTruthy();
expect(await readFile(fileURLToPath(source!))).toEqual(bytes);
});
@@ -3,6 +3,7 @@ import { z } from "zod";
import type { AgentTimelineItem } from "../../../agent-sdk-types.js";
import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js";
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { isClaudeInterruptPlaceholderText } from "../interrupt-placeholder.js";
import { resolveObservedClaudeModelId } from "../models.js";
import type { SubagentObservation } from "./observation.js";
import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js";
@@ -65,17 +66,12 @@ export interface ClaudeReplayEntry {
* Effort is replay-only: the SDK's live assistant message does not carry it, and it can be
* silently downgraded for the selected model, so any live value would be a guess.
*/
function readRuntime(entries: readonly ClaudeReplayEntry[]): { model?: string; effort?: string } {
const runtime: { model?: string; effort?: string } = {};
for (const entry of entries) {
if (entry.type !== "assistant") continue;
const effort = typeof entry.effort === "string" ? entry.effort.trim() : "";
if (effort) runtime.effort = effort;
const rawModel = (entry.message as { model?: unknown } | undefined)?.model;
const model = resolveObservedClaudeModelId(typeof rawModel === "string" ? rawModel : undefined);
if (model) runtime.model = model;
}
return runtime;
function readRuntime(entry: ClaudeReplayEntry): { model?: string; effort?: string } {
if (entry.type !== "assistant") return {};
const effort = typeof entry.effort === "string" ? entry.effort.trim() : "";
const rawModel = entry.message?.model;
const model = resolveObservedClaudeModelId(typeof rawModel === "string" ? rawModel : undefined);
return { ...(effort ? { effort } : {}), ...(model ? { model } : {}) };
}
/**
@@ -126,22 +122,16 @@ function readTotalTokens(raw: unknown): number | undefined {
*
* The last assistant entry's summed usage matches Claude Code's live `total_tokens` definition.
*/
function readUsage(entries: readonly ClaudeReplayEntry[]): ClaudeSubagentUsage | null {
let totalTokens: number | undefined;
for (const entry of entries) {
if (entry.type !== "assistant") continue;
const observed = readTotalTokens(entry.message?.usage);
if (observed !== undefined) totalTokens = observed;
}
function readUsage(entry: ClaudeReplayEntry): ClaudeSubagentUsage | null {
if (entry.type !== "assistant") return null;
const totalTokens = readTotalTokens(entry.message?.usage);
return totalTokens === undefined ? null : { totalTokens };
}
export interface ClaudeReplaySubagentInput {
agentId: string;
meta: ClaudeSubagentMeta | null;
entries: ClaudeReplayEntry[];
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
/** Task declarations made by this subagent, used to attach its direct children. */
parentFacts?: ClaudeReplayParentFacts;
}
@@ -150,9 +140,12 @@ export interface ClaudeReplayParentFacts {
/** Task `tool_use` id -> identity declared in the parent's tool input. */
toolCalls: ReadonlyMap<string, { title?: string; description?: string }>;
/** agentId -> tool call, recovered by scraping tool-result text. Legacy fallback only. */
linksByAgentId: ReadonlyMap<string, { toolCallId: string; failed: boolean }>;
/** Task `tool_use` id -> outcome. Usable once meta.json supplies the link directly. */
outcomesByToolCallId: ReadonlyMap<string, { failed: boolean }>;
linksByAgentId: ReadonlyMap<string, { toolCallId: string }>;
/**
* Task `tool_use` id -> outcome, whichever way the child was linked. A background launch has
* none: its immediate result only says the child started.
*/
outcomesByToolCallId: ReadonlyMap<string, { status: ProviderSubagentStatus }>;
}
interface ParentLink {
@@ -188,39 +181,14 @@ function resolveParentLink(
parent: ClaudeReplayParentFacts,
): ParentLink | null {
const metaToolUseId = subagent.meta?.toolUseId?.trim();
if (metaToolUseId && parent.toolCalls.has(metaToolUseId)) {
const outcome = parent.outcomesByToolCallId.get(metaToolUseId);
let status: ProviderSubagentStatus | null = null;
if (outcome) status = outcome.failed ? "failed" : "completed";
return {
id: metaToolUseId,
toolCallId: metaToolUseId,
status,
};
}
const toolCallId =
metaToolUseId && parent.toolCalls.has(metaToolUseId)
? metaToolUseId
: parent.linksByAgentId.get(subagent.agentId)?.toolCallId;
if (!toolCallId || !parent.toolCalls.has(toolCallId)) return null;
const scraped = parent.linksByAgentId.get(subagent.agentId);
if (scraped && parent.toolCalls.has(scraped.toolCallId)) {
return {
id: scraped.toolCallId,
toolCallId: scraped.toolCallId,
status: scraped.failed ? "failed" : "completed",
};
}
return null;
}
/** A final `end_turn` is the child's own completion signal when the parent result is absent. */
function readChildTerminalStatus(
entries: readonly ClaudeReplayEntry[],
): ProviderSubagentStatus | null {
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index];
if (entry?.type !== "assistant") continue;
return entry.message?.stop_reason === "end_turn" ? "completed" : null;
}
return null;
const status = parent.outcomesByToolCallId.get(toolCallId)?.status ?? null;
return { id: toolCallId, toolCallId, status };
}
/**
@@ -231,11 +199,11 @@ function declareSubagent(
subagent: ClaudeReplaySubagentInput,
link: ParentLink,
toolCall: { title?: string; description?: string } | undefined,
parentSubagentId?: string,
parentSubagentId: string | undefined,
firstTimestamp: string | null,
): SubagentObservation {
const title = toolCall?.title ?? subagent.meta?.agentType;
const description = toolCall?.description ?? subagent.meta?.description;
const firstTimestamp = normalizeProviderReplayTimestamp(subagent.entries[0]?.timestamp);
return {
kind: "declared",
id: link.id,
@@ -248,12 +216,12 @@ function declareSubagent(
}
function observeSubtitle(
subagent: ClaudeReplaySubagentInput,
link: ParentLink,
title: string | undefined,
runtime: { model?: string; effort?: string },
usage: ClaudeSubagentUsage | null,
timestamp: string | null,
): SubagentObservation | null {
const runtime = readRuntime(subagent.entries);
const usage = readUsage(subagent.entries);
const hasDetails =
runtime.model !== undefined ||
runtime.effort !== undefined ||
@@ -265,7 +233,6 @@ function observeSubtitle(
...(usage ? { usage } : {}),
});
if (!subtitle) return null;
const timestamp = normalizeProviderReplayTimestamp(subagent.entries.at(-1)?.timestamp);
return {
kind: "subtitle",
id: link.id,
@@ -274,39 +241,52 @@ function observeSubtitle(
};
}
function observeSubagent(
async function observeSubagent(
subagent: ClaudeReplaySubagentInput,
parent: ClaudeReplayParentFacts,
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[],
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
toolOwners: Map<string, string>,
parentSubagentId?: string,
): SubagentObservation[] {
): Promise<SubagentObservation[]> {
const link = resolveParentLink(subagent, parent);
if (!link) return [];
const toolCall = parent.toolCalls.get(link.toolCallId);
const timeline: SubagentObservation[] = [];
const runtime: { model?: string; effort?: string } = {};
let usage: ClaudeSubagentUsage | null = null;
let firstTimestamp: string | null = null;
let lastTimestamp: string | null = null;
let seenEntry = false;
let childTerminalStatus: ProviderSubagentStatus | null = null;
const observations: SubagentObservation[] = [
declareSubagent(subagent, link, toolCall, parentSubagentId),
];
const subtitle = observeSubtitle(subagent, link, toolCall?.title ?? subagent.meta?.agentType);
if (subtitle) observations.push(subtitle);
for (const entry of subagent.entries) {
// Consume each source once for presentation and timeline; keep no raw transcript records.
for await (const entry of subagent.entries) {
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
for (const item of convertEntry(entry)) {
observations.push({
kind: "timeline",
id: link.id,
item,
...(timestamp ? { timestamp } : {}),
});
if (!seenEntry) firstTimestamp = timestamp;
seenEntry = true;
lastTimestamp = timestamp;
Object.assign(runtime, readRuntime(entry));
usage = readUsage(entry) ?? usage;
childTerminalStatus = readChildTerminalStatus(entry, childTerminalStatus);
recordReplayToolOwners(toolOwners, entry, link.id);
for (const item of await convertEntry(entry)) {
timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) });
}
}
// Prefer the parent's result because it carries failure. If that result is missing, the child's
// final end_turn still proves completion. Any other shape remains running.
const terminalStatus = link.status ?? readChildTerminalStatus(subagent.entries);
const observations = [
declareSubagent(subagent, link, toolCall, parentSubagentId, firstTimestamp),
];
const subtitle = observeSubtitle(
link,
toolCall?.title ?? subagent.meta?.agentType,
runtime,
usage,
lastTimestamp,
);
if (subtitle) observations.push(subtitle);
for (const observation of timeline) observations.push(observation);
const terminalStatus = link.status ?? childTerminalStatus;
if (terminalStatus) {
const lastTimestamp = normalizeProviderReplayTimestamp(subagent.entries.at(-1)?.timestamp);
observations.push({
kind: "status",
id: link.id,
@@ -314,30 +294,50 @@ function observeSubagent(
...(lastTimestamp ? { timestamp: lastTimestamp } : {}),
});
}
return observations;
}
/** Advance the child's fallback outcome without retaining transcript entries. */
function readChildTerminalStatus(
entry: ClaudeReplayEntry,
previous: ProviderSubagentStatus | null,
): ProviderSubagentStatus | null {
if (entry.type === "assistant") {
return entry.message?.stop_reason === "end_turn" ? "completed" : null;
}
if (entry.type !== "user") return previous;
const texts = readUserTexts(entry);
if (texts.length === 0) return previous;
return texts.some(isClaudeInterruptPlaceholderText) ? "canceled" : null;
}
/** Tool results carry no user text and do not change the child's terminal status. */
function readUserTexts(entry: ClaudeReplayEntry): string[] {
const content = entry.message?.content;
if (typeof content === "string") return [content];
if (!Array.isArray(content)) return [];
return content.flatMap((block: { type?: unknown; text?: unknown } | null) =>
block?.type === "text" && typeof block.text === "string" ? [block.text] : [],
);
}
function recordReplayToolOwners(
owners: Map<string, string>,
entries: readonly ClaudeReplayEntry[],
entry: ClaudeReplayEntry,
subagentId: string,
): void {
for (const entry of entries) {
if (entry.type !== "assistant" || !Array.isArray(entry.message?.content)) continue;
for (const block of entry.message.content) {
if (block?.type === "tool_use" && typeof block.id === "string") {
owners.set(block.id, subagentId);
}
}
if (entry.type !== "assistant" || !Array.isArray(entry.message?.content)) return;
for (const block of entry.message.content) {
if (block?.type === "tool_use" && typeof block.id === "string")
owners.set(block.id, subagentId);
}
}
export function observeReplaySubagents(input: {
export async function observeReplaySubagents(input: {
subagents: readonly ClaudeReplaySubagentInput[];
parent: ClaudeReplayParentFacts;
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> } {
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> {
const observations: SubagentObservation[] = [];
const toolOwners = new Map<string, string>();
const unresolved = [...input.subagents].sort(
@@ -359,8 +359,15 @@ export function observeReplaySubagents(input: {
const { ownerId, parent, link } = resolved;
// Only proven descendants may own notifications; ambient sidecars cannot claim them.
recordReplayToolOwners(toolOwners, subagent.entries, link.id);
observations.push(...observeSubagent(subagent, parent, input.convertEntry, ownerId));
for (const observation of await observeSubagent(
subagent,
parent,
input.convertEntry,
toolOwners,
ownerId,
)) {
observations.push(observation);
}
if (subagent.parentFacts) resolvedParents.set(link.id, subagent.parentFacts);
unresolved.splice(index, 1);
madeProgress = true;
@@ -0,0 +1,23 @@
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
/**
* The descriptor has no paused or pending state, and neither is terminal, so both read as
* running. `killed` is a cancellation, not a failure — the child was stopped, it did not error.
*/
export function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined {
switch (status) {
case "pending":
case "running":
case "paused":
return "running";
case "completed":
return "completed";
case "failed":
return "failed";
case "killed":
case "stopped":
return "canceled";
default:
return undefined;
}
}
@@ -1,3 +1,9 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
} from "../../provider-image-output.js";
import { describe, expect, it } from "vitest";
import { ProviderSubagentStore } from "../../../provider-subagents/store.js";
@@ -35,7 +41,7 @@ function parentEntries(
}
describe("Claude workflow replay", () => {
it("parses the persisted run summary observed from Claude Code 2.1.220", () => {
it("parses the persisted run summary observed from Claude Code 2.1.220", async () => {
expect(
parseClaudeWorkflowRun(
JSON.stringify({
@@ -62,8 +68,8 @@ describe("Claude workflow replay", () => {
});
});
it("rebuilds the same generic completed descriptor through the parent Workflow call", () => {
const observations = observeReplayWorkflows({
it("rebuilds the same generic completed descriptor through the parent Workflow call", async () => {
const observations = await observeReplayWorkflows({
workflows: [
{
runId: RUN_ID,
@@ -91,8 +97,8 @@ describe("Claude workflow replay", () => {
});
});
it("rebuilds a workflow when Claude persists its result as structured text blocks", () => {
const observations = observeReplayWorkflows({
it("rebuilds a workflow when Claude persists its result as structured text blocks", async () => {
const observations = await observeReplayWorkflows({
workflows: [{ runId: RUN_ID, summary: "Structured result workflow", status: "completed" }],
parentEntries: parentEntries([
{
@@ -114,7 +120,7 @@ describe("Claude workflow replay", () => {
});
});
it("replays the workflow result without exposing its script or child prompt", () => {
it("replays the workflow result without exposing its script or child prompt", async () => {
const workflow = parseClaudeWorkflowRun(
JSON.stringify({
runId: RUN_ID,
@@ -128,7 +134,7 @@ describe("Claude workflow replay", () => {
expect(workflow).not.toBeNull();
if (!workflow) throw new Error("expected workflow");
const observations = observeReplayWorkflows({
const observations = await observeReplayWorkflows({
workflows: [workflow],
parentEntries: parentEntries(),
});
@@ -145,9 +151,9 @@ describe("Claude workflow replay", () => {
expect(JSON.stringify(observations)).not.toContain("SECRET CHILD PROMPT");
});
it("does not duplicate a result already present in the child transcript", () => {
it("does not duplicate a result already present in the child transcript", async () => {
const report = "What Paseo is\nA local-first coding-agent environment.";
const observations = observeReplayWorkflows({
const observations = await observeReplayWorkflows({
workflows: [
{ runId: RUN_ID, summary: "Inspect Paseo", status: "completed", result: { report } },
],
@@ -166,8 +172,8 @@ describe("Claude workflow replay", () => {
).toHaveLength(1);
});
it("fails a nonterminal persisted run because its former runtime cannot update it", () => {
const observations = observeReplayWorkflows({
it("fails a nonterminal persisted run because its former runtime cannot update it", async () => {
const observations = await observeReplayWorkflows({
workflows: [{ runId: RUN_ID, summary: "Interrupted workflow", status: "running" }],
parentEntries: parentEntries(),
});
@@ -179,9 +185,9 @@ describe("Claude workflow replay", () => {
});
});
it("drops a run that no parent Workflow tool call declared", () => {
it("drops a run that no parent Workflow tool call declared", async () => {
expect(
observeReplayWorkflows({
await observeReplayWorkflows({
workflows: [{ runId: RUN_ID, status: "completed" }],
parentEntries: [],
}),
@@ -195,3 +201,29 @@ describe("Claude workflow replay", () => {
},
);
});
it("awaits image materialization before publishing replay observations", async () => {
const bytes = Buffer.from("async replay image fixture");
const convertEntry = async () => {
const item = await renderProviderImageOutputAsAssistantMarkdown(
{ data: bytes.toString("base64"), mimeType: "image/png" },
{ materialize: materializeProviderImage },
);
return item ? [item] : [];
};
const observations = await observeReplayWorkflows({
workflows: [{ runId: RUN_ID, status: "completed" }],
parentEntries: parentEntries(),
entriesByRunId: new Map([[RUN_ID, [{ type: "assistant", message: { content: [] } }]]]),
convertEntry,
});
const image = observations.find(
(observation) =>
observation.kind === "timeline" && observation.item.type === "assistant_message",
);
if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message")
throw new Error("Expected replay image");
const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1];
expect(source).toBeTruthy();
expect(await readFile(fileURLToPath(source!))).toEqual(bytes);
});
@@ -45,13 +45,17 @@ export function parseClaudeWorkflowRun(contents: string): ClaudeWorkflowRun | nu
* provider runtime was recreated, so replay terminalizes it as failed instead of resurrecting a
* row that can never receive another update.
*/
export function observeReplayWorkflows(input: {
export async function observeReplayWorkflows(input: {
workflows: readonly ClaudeWorkflowRun[];
parentEntries: readonly ClaudeWorkflowParentEntry[];
entriesByRunId?: ReadonlyMap<string, readonly ClaudeReplayEntry[]>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): SubagentObservation[] {
const toolCallIdByRunId = readWorkflowLinks(input.parentEntries);
parentEntries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>;
/** Repeatable entries in chronological order, supplied by the transcript reader. */
entriesByRunId?: ReadonlyMap<
string,
Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>
>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
}): Promise<SubagentObservation[]> {
const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries);
const observations: SubagentObservation[] = [];
for (const workflow of input.workflows) {
@@ -78,15 +82,15 @@ export function observeReplayWorkflows(input: {
});
}
observations.push(
...observeWorkflowTimeline({
id,
result: workflow.result,
entries: input.entriesByRunId?.get(workflow.runId) ?? [],
convertEntry: input.convertEntry,
finishedAt,
}),
);
for (const observation of await observeWorkflowTimeline({
id,
result: workflow.result,
entries: input.entriesByRunId?.get(workflow.runId) ?? [],
convertEntry: input.convertEntry,
finishedAt,
})) {
observations.push(observation);
}
const subtitle = buildClaudeSubagentSubtitle({
title: "Workflow",
@@ -114,20 +118,19 @@ export function observeReplayWorkflows(input: {
return observations;
}
function observeWorkflowTimeline(input: {
async function observeWorkflowTimeline(input: {
id: string;
result: unknown;
entries: readonly ClaudeReplayEntry[];
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
finishedAt?: string;
}): SubagentObservation[] {
}): Promise<SubagentObservation[]> {
const observations: SubagentObservation[] = [];
const replayedAssistantText = new Set<string>();
const entries = [...input.entries].sort(compareReplayTimestamps);
for (const entry of entries) {
for await (const entry of input.entries) {
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
const items = input.convertEntry?.(entry) ?? [];
const items = (await input.convertEntry?.(entry)) ?? [];
for (const item of items) {
if (item.type === "assistant_message") replayedAssistantText.add(item.text.trim());
observations.push({
@@ -150,20 +153,13 @@ function observeWorkflowTimeline(input: {
return observations;
}
function compareReplayTimestamps(a: ClaudeReplayEntry, b: ClaudeReplayEntry): number {
const aTimestamp = normalizeProviderReplayTimestamp(a.timestamp);
const bTimestamp = normalizeProviderReplayTimestamp(b.timestamp);
if (!aTimestamp && !bTimestamp) return 0;
if (!aTimestamp) return 1;
if (!bTimestamp) return -1;
return Date.parse(aTimestamp) - Date.parse(bTimestamp);
}
function readWorkflowLinks(entries: readonly ClaudeWorkflowParentEntry[]): Map<string, string> {
async function readWorkflowLinks(
entries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>,
): Promise<Map<string, string>> {
const workflowToolCallIds = new Set<string>();
const toolCallIdByRunId = new Map<string, string>();
for (const entry of entries) {
for await (const entry of entries) {
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -174,7 +170,7 @@ function readWorkflowLinks(entries: readonly ClaudeWorkflowParentEntry[]): Map<s
}
}
for (const entry of entries) {
for await (const entry of entries) {
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -97,4 +97,17 @@ describe("task-notification-tool-call", () => {
expect(item).toBeNull();
});
it("keeps notifications without task IDs distinct by their message IDs", () => {
const items = ["message-1", "message-2"].map((messageId) =>
mapTaskNotificationUserContentToToolCall({
content: "<task-notification><status>completed</status></task-notification>",
messageId,
}),
);
expect(items.map((item) => item?.callId)).toEqual([
"task_notification_message-1",
"task_notification_message-2",
]);
});
});
@@ -12,6 +12,7 @@ const OptionalNonEmptyTrimmedStringSchema = z.preprocess(
);
const TaskNotificationEnvelopeSchema = z.object({
notificationId: z.string().optional(),
messageId: z.string().nullable(),
taskId: z.string().nullable(),
toolUseId: z.string().nullable(),
@@ -35,7 +36,9 @@ const TaskNotificationUserContentSchema = z.union([
const TaskNotificationHistoryRecordSchema = z
.object({
notificationId: z.string().optional(),
type: z.string().optional(),
operation: z.string().optional(),
subtype: z.string().optional(),
uuid: z.string().optional(),
message_id: z.string().optional(),
@@ -52,6 +55,7 @@ const TaskNotificationHistoryRecordSchema = z
const MapTaskNotificationUserContentToToolCallInputSchema = z.object({
content: z.unknown(),
messageId: z.string().optional().nullable(),
notificationId: z.string().optional(),
});
export type TaskNotificationEnvelope = z.infer<typeof TaskNotificationEnvelopeSchema>;
@@ -155,6 +159,7 @@ function parseTaskNotificationFromUserContent(
}
return TaskNotificationEnvelopeSchema.parse({
notificationId: parsedInput.data.notificationId,
messageId: toNonEmptyString(parsedInput.data.messageId),
taskId: readTaskNotificationTagValue({ text: rawText, tagName: "task-id" }),
toolUseId:
@@ -186,6 +191,7 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
const rawText = toNonEmptyString(systemRecord.content);
return TaskNotificationEnvelopeSchema.parse({
notificationId: systemRecord.notificationId,
messageId: toNonEmptyString(systemRecord.uuid) ?? toNonEmptyString(systemRecord.message_id),
taskId:
toNonEmptyString(systemRecord.task_id) ??
@@ -212,25 +218,78 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
});
}
/** Normalize notification identity for one transcript traversal. Queue bookkeeping and delivery
* are separate records of the same occurrence; each source counts repeated identical bodies
* independently so the second event is not merged with the first. */
export async function* assignTaskNotificationReplayIds<T>(
entries: AsyncIterable<T>,
): AsyncGenerator<T> {
const occurrences = new Map<string, Map<string, number>>();
for await (const entry of entries) {
const record = TaskNotificationHistoryRecordSchema.safeParse(entry);
if (!record.success) {
yield entry;
continue;
}
const data = record.data;
const envelope =
data.type === "user"
? parseTaskNotificationFromUserContent({ content: data.message?.content })
: parseTaskNotificationFromSystemRecord(data);
if (!envelope?.taskId || !data.type) {
yield entry;
continue;
}
const signature = taskNotificationReplaySignature(envelope);
const source = data.type === "queue-operation" ? `queue-${data.operation}` : data.type;
const counts = occurrences.get(signature) ?? new Map<string, number>();
const occurrence = (counts.get(source) ?? 0) + 1;
counts.set(source, occurrence);
occurrences.set(signature, counts);
yield { ...entry, notificationId: `task_notification_${signature}_${occurrence}` };
}
}
function taskNotificationReplaySignature(envelope: TaskNotificationEnvelope): string {
// Known fields are canonical across XML user/queue records and structured system records.
// Keep the remaining body too: Monitor events share a summary but carry different <event>s.
const remainder = (envelope.rawText ?? "")
.replace(/<\/?task-notification>/gi, "")
.replace(
/<(task-id|tool-use-id|tool_use_id|status|summary|output-file|output_file)>[\s\S]*?<\/\1>/gi,
"",
)
.trim();
return createHash("sha1")
.update(
JSON.stringify([
envelope.taskId,
envelope.toolUseId,
envelope.status,
envelope.summary,
envelope.outputFile,
remainder,
]),
)
.digest("hex");
}
function normalizeTaskNotificationCallIdSegment(segment: string): string | null {
const normalized = segment.trim().replace(/[^a-zA-Z0-9._:-]+/g, "_");
return normalized.length > 0 ? normalized : null;
}
function buildTaskNotificationCallId(envelope: TaskNotificationEnvelope): string {
if (envelope.notificationId) return envelope.notificationId;
const messageSegment = envelope.messageId
? normalizeTaskNotificationCallIdSegment(envelope.messageId)
: null;
if (messageSegment) {
return `task_notification_${messageSegment}`;
}
if (messageSegment) return `task_notification_${messageSegment}`;
const taskSegment = envelope.taskId
? normalizeTaskNotificationCallIdSegment(envelope.taskId)
: null;
if (taskSegment) {
return `task_notification_${taskSegment}`;
}
if (taskSegment) return `task_notification_${taskSegment}`;
const seed =
[envelope.status, envelope.summary, envelope.outputFile, envelope.rawText]
@@ -348,19 +407,21 @@ export function mapTaskNotificationSystemRecordToToolCall(
}
export function readTaskNotificationToolUseIdFromHistoryRecord(record: unknown): string | null {
return readTaskNotificationFromHistoryRecord(record)?.toolUseId ?? null;
}
export function readTaskNotificationFromHistoryRecord(
record: unknown,
): TaskNotificationEnvelope | null {
const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record);
if (!parsedRecord.success) {
return null;
}
if (!parsedRecord.success) return null;
if (parsedRecord.data.type === "user" && parsedRecord.data.message) {
return (
parseTaskNotificationFromUserContent({
content: parsedRecord.data.message.content,
messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id,
})?.toolUseId ?? null
);
return parseTaskNotificationFromUserContent({
content: parsedRecord.data.message.content,
messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id,
});
}
return parseTaskNotificationFromSystemRecord(record)?.toolUseId ?? null;
return parseTaskNotificationFromSystemRecord(record);
}
export function coerceTaskNotificationHistoryRecordToSystemMessage(
@@ -0,0 +1,283 @@
import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js";
import fs, { type FileHandle } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import path from "node:path";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import {
parseClaudeSubagentMeta,
type ClaudeReplayEntry,
type ClaudeSubagentMeta,
} from "./subagents/replay-source.js";
import {
parseClaudeWorkflowRun,
type ClaudeWorkflowRun,
} from "./subagents/workflow-replay-source.js";
interface TranscriptEntry extends ClaudeReplayEntry {
isSidechain?: unknown;
}
interface RecordLocation {
file: string;
offset: number;
length: number;
timestamp: string | null;
}
/** Chunk in bytes so UTF-8 and multi-chunk records are decoded once, at a line boundary. */
async function* locatedRecords(
file: string,
fd: FileHandle,
): AsyncGenerator<{ entry: TranscriptEntry; location: RecordLocation }> {
const stat = await fd.stat();
// Opening a directory succeeds on some platforms; it must never look like empty history.
if (stat.isDirectory()) {
throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${file}'`), {
code: "EISDIR",
syscall: "read",
path: file,
});
}
let remaining = stat.size;
let offset = 0;
let pieces: Buffer[] = [];
let length = 0;
while (remaining > 0) {
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining));
const { bytesRead: count } = await fd.read(buffer, 0, buffer.length, null);
if (count === 0) break;
remaining -= count;
let start = 0;
let end: number;
while ((end = buffer.indexOf(10, start)) !== -1 && end < count) {
pieces.push(buffer.subarray(start, end));
length += end - start;
const entry = parseRecord(Buffer.concat(pieces, length).toString("utf8"));
const location = {
file,
offset,
length,
timestamp: normalizeProviderReplayTimestamp(entry?.timestamp),
};
offset += length + 1;
pieces = [];
length = 0;
if (entry) yield { entry, location };
start = end + 1;
}
// Bound parsing work as well as IO; microtasks alone do not service timers or sockets.
await setImmediate();
if (start < count) {
pieces.push(buffer.subarray(start, count));
length += count - start;
}
}
const entry = parseRecord(Buffer.concat(pieces, length).toString("utf8"));
if (entry)
yield {
entry,
location: {
file,
offset,
length,
timestamp: normalizeProviderReplayTimestamp(entry.timestamp),
},
};
}
/** Indexed sources parse only their own records using descriptors owned by this replay. */
function indexedRecords(
locations: readonly RecordLocation[],
descriptors: ReadonlyMap<string, FileHandle>,
): AsyncIterable<TranscriptEntry> {
return {
async *[Symbol.asyncIterator]() {
yield* assignTaskNotificationReplayIds(readEntries());
},
};
async function* readEntries(): AsyncGenerator<TranscriptEntry> {
for (const location of locations) {
const fd = descriptors.get(location.file)!;
const buffer = Buffer.allocUnsafe(location.length);
let read = 0;
while (read < buffer.length) {
const { bytesRead: count } = await fd.read(
buffer,
read,
buffer.length - read,
location.offset + read,
);
if (count === 0)
throw new Error(`Claude transcript was truncated during replay: ${location.file}`);
read += count;
}
const entry = parseRecord(buffer.toString("utf8"));
if (entry) yield entry;
}
}
}
function isToolResult(entry: TranscriptEntry): boolean {
return (
Array.isArray(entry.message?.content) &&
entry.message.content.some((block) => block?.type === "tool_result")
);
}
function parseRecord(line: string): TranscriptEntry | null {
try {
const value: unknown = JSON.parse(line);
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as TranscriptEntry)
: null;
} catch {
return null;
}
}
async function readOptionalFile(file: string): Promise<string | null> {
try {
return await fs.readFile(file, "utf8");
} catch {
return null;
}
}
/** Discover sources without retaining transcript contents. Unrelated sidechains stay excluded
* by the replay ownership resolver; workflow children retain their separate run ownership. */
export interface ClaudeReplayHistory {
parentEntries: AsyncIterable<TranscriptEntry>;
subagents: {
agentId: string;
meta: ClaudeSubagentMeta | null;
entries: AsyncIterable<TranscriptEntry>;
}[];
workflows: ClaudeWorkflowRun[];
workflowEntriesByRunId: Map<string, AsyncIterable<TranscriptEntry>>;
}
/** Own descriptors through every pass so unlink or rename cannot invalidate discovered history.
* Sources must be consumed before the callback returns. No transcript copies are needed. */
export async function withClaudeReplayHistory<T>(
historyPath: string,
consume: (history: ClaudeReplayHistory) => Promise<T>,
): Promise<T> {
const descriptors = new Map<string, FileHandle>();
try {
return await consume(await indexHistory(historyPath, descriptors));
} finally {
await closeReplaySources(descriptors.values());
}
}
async function closeReplaySources(descriptors: Iterable<FileHandle>): Promise<void> {
const results = await Promise.allSettled([...descriptors].map((fd) => fd.close()));
const failure = results.find((result) => result.status === "rejected");
if (failure) throw failure.reason;
}
async function indexHistory(
historyPath: string,
descriptors: Map<string, FileHandle>,
): Promise<ClaudeReplayHistory> {
const sessionDirectory = path.join(
path.dirname(historyPath),
path.basename(historyPath, ".jsonl"),
);
const sidechainDirectory = path.join(sessionDirectory, "subagents");
const sidechainFiles = [historyPath];
const workflowFiles = new Map<string, string[]>();
const metaByAgentId = new Map<string, ClaudeSubagentMeta>();
const directories = [sidechainDirectory];
while (directories.length) {
const directory = directories.pop()!;
for (const entry of await optionalDirectory(directory)) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) {
directories.push(file);
continue;
}
if (!entry.isFile()) continue;
if (entry.name.endsWith(".jsonl")) {
const parts = path.relative(sidechainDirectory, file).split(path.sep);
if (parts[0] === "workflows" && parts.length >= 3) {
const runId = parts[1]!;
const files = workflowFiles.get(runId) ?? [];
files.push(file);
workflowFiles.set(runId, files);
} else sidechainFiles.push(file);
continue;
}
const agentId = /^agent-(.+)\.meta\.json$/.exec(entry.name)?.[1];
if (!agentId) continue;
const contents = await readOptionalFile(file);
const meta = contents === null ? null : parseClaudeSubagentMeta(contents);
if (meta) metaByAgentId.set(agentId, meta);
}
}
const parentLocations: RecordLocation[] = [];
const locationsByAgentId = new Map<string, RecordLocation[]>();
async function scan(
file: string,
visit: (entry: TranscriptEntry, location: RecordLocation) => void,
): Promise<void> {
const fd = await fs.open(file, "r");
descriptors.set(file, fd);
for await (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
}
for (const file of sidechainFiles) {
await scan(file, (entry, location) => {
if (file === historyPath && entry.isSidechain !== true) parentLocations.push(location);
if (entry.isSidechain !== true || typeof entry.agentId !== "string") return;
const locations = locationsByAgentId.get(entry.agentId) ?? [];
locations.push(location);
locationsByAgentId.set(entry.agentId, locations);
});
}
const workflowEntriesByRunId = new Map<string, AsyncIterable<TranscriptEntry>>();
for (const [runId, files] of workflowFiles) {
const locations: RecordLocation[] = [];
for (const file of files)
await scan(file, (entry, location) => {
if (entry.type !== "user" || isToolResult(entry)) locations.push(location);
});
locations.sort((a, b) => {
if (!a.timestamp && !b.timestamp) return 0;
if (!a.timestamp) return 1;
if (!b.timestamp) return -1;
return Date.parse(a.timestamp) - Date.parse(b.timestamp);
});
workflowEntriesByRunId.set(runId, indexedRecords(locations, descriptors));
}
return {
parentEntries: indexedRecords(parentLocations, descriptors),
subagents: [...locationsByAgentId].map(([agentId, locations]) => ({
agentId,
meta: metaByAgentId.get(agentId) ?? null,
entries: indexedRecords(locations, descriptors),
})),
workflows: await readWorkflows(sessionDirectory),
workflowEntriesByRunId,
};
}
async function readWorkflows(sessionDirectory: string): Promise<ClaudeWorkflowRun[]> {
const workflows: ClaudeWorkflowRun[] = [];
const workflowDirectory = path.join(sessionDirectory, "workflows");
for (const entry of await optionalDirectory(workflowDirectory)) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
const contents = await readOptionalFile(path.join(workflowDirectory, entry.name));
const workflow = contents === null ? null : parseClaudeWorkflowRun(contents);
if (workflow) workflows.push(workflow);
}
return workflows;
}
async function optionalDirectory(directory: string) {
try {
return await fs.readdir(directory, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
}
File diff suppressed because it is too large Load Diff
@@ -92,12 +92,18 @@ import {
type CodexAppServerTraceContext,
} from "./codex/app-server-transport.js";
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
import {
type CodexHistoryRow,
CodexThreadHistoryTooLargeError,
readCodexThreadHistory,
} from "./codex/thread-history.js";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
type ProviderImageOutput,
} from "./provider-image-output.js";
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
import { limitAgentTimelineItemContent } from "../agent-timeline-content.js";
import {
formatProviderDiagnostic,
formatProviderDiagnosticError,
@@ -496,6 +502,11 @@ interface PersistedTimelineEntry {
providerTurnId?: string;
}
interface CodexThreadHistory {
timeline: PersistedTimelineEntry[];
providerSubagentEvents: AgentStreamEvent[];
}
interface PersistedSubAgentRoute {
childThreadId: string;
toolCall: ToolCallTimelineItem;
@@ -1799,22 +1810,13 @@ function readCodexHistoryTimestamp(item: unknown): string | null {
);
}
function readCodexTurnHistoryTimestamp(
turn: unknown,
/** Falls back to the row's start time for a user message and its end time otherwise. */
function readCodexHistoryRowTimestamp(
row: CodexHistoryRow,
timelineItem: AgentTimelineItem,
): string | null {
const record = toObjectRecord(turn);
if (!record) {
return null;
}
const startedAt =
normalizeProviderReplayTimestamp(record.startedAt) ??
normalizeProviderReplayTimestamp(record.started_at);
const completedAt =
normalizeProviderReplayTimestamp(record.completedAt) ??
normalizeProviderReplayTimestamp(record.completed_at);
const startedAt = normalizeProviderReplayTimestamp(row.startedAt);
const completedAt = normalizeProviderReplayTimestamp(row.completedAt);
if (timelineItem.type === "user_message") {
return startedAt ?? completedAt;
}
@@ -1939,12 +1941,12 @@ function codexImageOutputFromResult(result: unknown): ProviderImageOutput | null
};
}
function mapCodexThreadImageItem(
async function mapCodexThreadImageItem(
normalizedType: string,
normalizedItem: Record<string, unknown>,
): AgentTimelineItem | null {
): Promise<AgentTimelineItem | null> {
if (normalizedType === "imageView") {
return renderProviderImageOutputAsAssistantMarkdown({
return await renderProviderImageOutputAsAssistantMarkdown({
path: firstStringField(normalizedItem, ["path"]),
});
}
@@ -1960,7 +1962,7 @@ function mapCodexThreadImageItem(
const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]);
const result = codexImageOutputFromResult(normalizedItem.result);
return renderProviderImageOutputAsAssistantMarkdown(
return await renderProviderImageOutputAsAssistantMarkdown(
{
path: savedPath ?? result?.path ?? null,
url: result?.url ?? null,
@@ -1982,10 +1984,10 @@ function mapCodexAgentMessage(item: Record<string, unknown>): AgentTimelineItem
};
}
export function threadItemToTimeline(
export async function threadItemToTimeline(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): AgentTimelineItem | null {
): Promise<AgentTimelineItem | null> {
const itemRecord = toObjectRecord(item);
if (!itemRecord) return null;
const includeUserMessage = options?.includeUserMessage ?? true;
@@ -1999,7 +2001,7 @@ export function threadItemToTimeline(
: itemRecord;
if (normalizedType === "imageView" || normalizedType === "imageGeneration") {
return mapCodexThreadImageItem(normalizedType, normalizedItem);
return await mapCodexThreadImageItem(normalizedType, normalizedItem);
}
if (normalizedType && CODEX_TOOL_THREAD_ITEM_TYPES.has(normalizedType)) {
return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
@@ -2024,7 +2026,7 @@ export function threadItemToTimeline(
}
}
function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] {
async function mcpToolResultImagesToTimeline(item: unknown): Promise<AgentTimelineItem[]> {
const itemRecord = toObjectRecord(item);
if (!itemRecord) {
return [];
@@ -2037,104 +2039,40 @@ function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] {
}
const { images } = splitCodexMcpToolResultImages(itemRecord.result);
return images
.map((image) =>
renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
}),
)
.filter((timelineItem): timelineItem is AgentTimelineItem => timelineItem !== null);
const items: AgentTimelineItem[] = [];
for (const image of images) {
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
});
if (imageItem) items.push(imageItem);
}
return items;
}
function threadItemToTimelineEntries(
async function threadItemToTimelineEntries(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): AgentTimelineItem[] {
const timelineItem = threadItemToTimeline(item, options);
): Promise<AgentTimelineItem[]> {
const timelineItem = await threadItemToTimeline(item, options);
if (!timelineItem) {
return [];
}
return [timelineItem, ...mcpToolResultImagesToTimeline(item)];
}
const CodexThreadReadResponseSchema = z
.object({
thread: z
.object({
turns: z
.array(
z
.object({
items: z.array(z.unknown()).default([]),
})
.passthrough(),
)
.default([]),
})
.passthrough()
.default({ turns: [] }),
})
.passthrough();
type CodexThreadReadResponse = z.infer<typeof CodexThreadReadResponseSchema>;
type CodexThreadReadRequest = (threadId: string) => Promise<unknown>;
async function requestCodexThreadHistory(
requestThread: CodexThreadReadRequest,
threadId: string,
): Promise<CodexThreadReadResponse> {
const response = await requestThread(threadId);
return CodexThreadReadResponseSchema.parse(response);
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
}
async function loadCodexThreadHistoryTimeline(params: {
client: CodexAppServerClientLike;
threadId: string;
cwd: string | null;
requestThread: CodexThreadReadRequest;
}): Promise<CodexThreadHistoryProjection> {
const response = await requestCodexThreadHistory(params.requestThread, params.threadId);
const timeline: PersistedTimelineEntry[] = [];
const subAgentTimelineIndexByThreadId = new Map<string, number>();
for (const turn of response.thread.turns) {
for (const item of turn.items) {
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
if (historicalSubAgentActivity) {
const existingIndex = subAgentTimelineIndexByThreadId.get(
historicalSubAgentActivity.agentThreadId,
);
if (existingIndex !== undefined) {
const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd });
updateHistoricalSubAgentActivity(
timeline,
existingIndex,
historicalSubAgentActivity.kind,
activityTimelineItem?.type === "tool_call" &&
activityTimelineItem.detail.type === "sub_agent"
? activityTimelineItem.detail.subAgentType
: undefined,
);
continue;
}
}
for (const timelineItem of threadItemToTimelineEntries(item, { cwd: params.cwd })) {
const timestamp =
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
const settledTimelineItem =
historicalSubAgentActivity && timelineItem.type === "tool_call"
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
: timelineItem;
timeline.push({
item: settledTimelineItem,
timestamp: timestamp ?? undefined,
...(timelineItem.type === "user_message" && typeof turn.id === "string"
? { providerTurnId: turn.id }
: {}),
});
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
}
}
}
try {
await projectCodexThreadHistory(params, { timeline, subAgentTimelineIndexByThreadId });
} catch (error) {
if (!(error instanceof CodexThreadHistoryTooLargeError)) throw error;
// The session stays usable; the row says why the history stops here.
timeline.push({ item: { type: "notification", level: "warning", message: error.message } });
}
const subAgentRoutes = Array.from(subAgentTimelineIndexByThreadId.entries()).flatMap(
([childThreadId, timelineIndex]): PersistedSubAgentRoute[] => {
@@ -2147,11 +2085,55 @@ async function loadCodexThreadHistoryTimeline(params: {
return { timeline, subAgentRoutes };
}
function readCodexThread(client: CodexAppServerClientLike, threadId: string): Promise<unknown> {
return client.request("thread/read", {
threadId,
includeTurns: true,
});
async function projectCodexThreadHistory(
params: { client: CodexAppServerClientLike; threadId: string; cwd: string | null },
projection: {
timeline: PersistedTimelineEntry[];
subAgentTimelineIndexByThreadId: Map<string, number>;
},
): Promise<void> {
const { timeline, subAgentTimelineIndexByThreadId } = projection;
for await (const row of readCodexThreadHistory(params.client, params.threadId)) {
const { item } = row;
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
if (historicalSubAgentActivity) {
const existingIndex = subAgentTimelineIndexByThreadId.get(
historicalSubAgentActivity.agentThreadId,
);
if (existingIndex !== undefined) {
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
updateHistoricalSubAgentActivity(
timeline,
existingIndex,
historicalSubAgentActivity.kind,
activityTimelineItem?.type === "tool_call" &&
activityTimelineItem.detail.type === "sub_agent"
? activityTimelineItem.detail.subAgentType
: undefined,
);
continue;
}
}
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
const timestamp =
readCodexHistoryTimestamp(item) ?? readCodexHistoryRowTimestamp(row, timelineItem);
const settledTimelineItem =
historicalSubAgentActivity && timelineItem.type === "tool_call"
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
: timelineItem;
timeline.push({
// Cap each item as it is projected so a large history is never held uncapped.
item: limitAgentTimelineItemContent(settledTimelineItem),
timestamp: timestamp ?? undefined,
...(timelineItem.type === "user_message" && row.turnId !== undefined
? { providerTurnId: row.turnId }
: {}),
});
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
}
}
}
}
export async function forkCodexThread(
@@ -3290,10 +3272,12 @@ export async function codexAppServerTurnInputFromPrompt(
}
if (block.type === "image") {
try {
const filePath = materializeProviderImage({
data: block.data,
mimeType: block.mimeType,
}).path;
const filePath = (
await materializeProviderImage({
data: block.data,
mimeType: block.mimeType,
})
).path;
output.push({ type: "localImage", path: filePath });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -3453,6 +3437,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private client: CodexAppServerClient | null = null;
private threadRollbackAvailable = true;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private startupEvents: AgentStreamEvent[] | null = null;
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private activeClientMessageId: string | null = null;
@@ -3460,10 +3445,12 @@ export class CodexAppServerAgentSession implements AgentSession {
private serviceTier: string | null = null;
private speedModels: CodexModel[] = [];
private planModeEnabled = false;
private historyPending = false;
private persistedHistory: PersistedTimelineEntry[] = [];
private loadingPersistedHistory = false;
private persistedProviderSubagentEvents: AgentStreamEvent[] = [];
// History read by rewind or an archived resume, held until streamHistory consumes it.
private stagedHistory: CodexThreadHistory | null = null;
// Collects provider subagent events emitted while a thread history read rebuilds routes.
private historySubagentEvents: AgentStreamEvent[] | null = null;
// A resumed thread's earlier turns and sub-agents are known only after reading its history.
private historyIndexed = true;
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private mcpElicitationPermissionIds = new Map<number, string>();
private pendingPermissionHandlers = new Map<string, CodexPendingPermissionHandler>();
@@ -3570,7 +3557,9 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.resumeHandle?.sessionId) {
this.currentThreadId = this.resumeHandle.sessionId;
this.historyPending = true;
this.historyIndexed = false;
// Resuming can start a goal before the caller receives the session.
if (this.initialResumePurpose === "interactive") this.startupEvents = [];
}
}
@@ -3616,7 +3605,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private async establishConnection(): Promise<void> {
if (this.initialResumePurpose === "history") {
await this.readArchivedHistory();
this.stagedHistory = await this.readArchivedHistory();
this.connectionState = "history-ready";
return;
}
@@ -3649,7 +3638,6 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.currentThreadId) {
await this.ensureThreadLoaded();
await this.loadPersistedHistory(this.client);
await this.applyDefaultModelAndThinking();
}
@@ -3964,39 +3952,40 @@ export class CodexAppServerAgentSession implements AgentSession {
);
}
private async readArchivedHistory(): Promise<void> {
private async readArchivedHistory(): Promise<CodexThreadHistory> {
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);
return await this.readThreadHistory(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({
threadId,
// Reads the thread and its sub-agent threads, rebuilding the turn index and
// sub-agent routes. The caller owns the returned history; nothing retains it.
private async readThreadHistory(
client: CodexAppServerClientLike | null,
): Promise<CodexThreadHistory> {
if (!client || !this.currentThreadId) {
return { timeline: [], providerSubagentEvents: [] };
}
const { timeline, subAgentRoutes } = await loadCodexThreadHistoryTimeline({
client,
threadId: this.currentThreadId,
cwd: this.config.cwd ?? null,
requestThread: (threadIdToRead) => {
return readCodexThread(client, threadIdToRead);
},
});
const { timeline, subAgentRoutes } = history;
this.subAgentCallsByCallId.clear();
this.subAgentCallIdByChildThreadId.clear();
this.pendingSubAgentNotificationsByThreadId.clear();
this.persistedProviderSubagentEvents = [];
this.loadingPersistedHistory = true;
const providerSubagentEvents: AgentStreamEvent[] = [];
this.historySubagentEvents = providerSubagentEvents;
try {
await this.loadPersistedSubAgentHistories(client, subAgentRoutes);
} finally {
this.loadingPersistedHistory = false;
this.historySubagentEvents = null;
}
this.resetCodexUserMessageTurns();
for (const entry of timeline) {
@@ -4007,8 +3996,15 @@ export class CodexAppServerAgentSession implements AgentSession {
this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId);
}
}
this.persistedHistory = timeline;
this.historyPending = timeline.length > 0;
this.historyIndexed = true;
return { timeline, providerSubagentEvents };
}
// Turns and rewind route by the thread's earlier turns and sub-agents. A resumed
// session whose projection Paseo already holds reads them here and drops the rest.
private async ensureHistoryIndexed(): Promise<void> {
if (this.historyIndexed) return;
await this.readThreadHistory(this.client);
}
private async loadPersistedSubAgentHistories(
@@ -4035,9 +4031,9 @@ export class CodexAppServerAgentSession implements AgentSession {
});
try {
const childHistory = await loadCodexThreadHistoryTimeline({
client,
threadId: next.route.childThreadId,
cwd: this.config.cwd ?? null,
requestThread: (childThreadId) => readCodexThread(client, childThreadId),
});
for (const entry of childHistory.timeline) {
this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp);
@@ -4060,7 +4056,8 @@ export class CodexAppServerAgentSession implements AgentSession {
private async ensureThreadLoaded(): Promise<void> {
if (!this.client || !this.currentThreadId) return;
const params: Record<string, unknown> = { threadId: this.currentThreadId };
// Paseo reads history only when it replays it; the resume response needs no turns.
const params: Record<string, unknown> = { threadId: this.currentThreadId, excludeTurns: true };
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
@@ -4367,6 +4364,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.dismissPendingPlanApprovals("Dismissed by a new prompt");
try {
await this.pendingNotifications;
await this.connect();
if (!this.client) {
throw new Error("Codex client not initialized");
@@ -4379,6 +4377,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.currentThreadId) {
await this.ensureThreadLoaded();
await this.ensureHistoryIndexed();
} else {
await this.ensureThread();
}
@@ -4433,9 +4432,18 @@ export class CodexAppServerAgentSession implements AgentSession {
): Promise<SteerResult> {
const client = this.client;
const threadId = this.currentThreadId;
const nativeTurnId = this.currentTurnId;
let nativeTurnId = this.currentTurnId;
const foregroundTurnId = this.activeForegroundTurnId;
if (!client || !threadId || !nativeTurnId || foregroundTurnId !== options.expectedTurnId) {
if (!client || !threadId || foregroundTurnId !== options.expectedTurnId) {
return { status: "unavailable" };
}
const pendingIdentification = this.pendingForegroundTurnIdentification;
// The start acknowledgement precedes the notification that identifies the native
// turn. Keep this steer attached to the accepted foreground turn while it arrives.
if (!nativeTurnId && pendingIdentification?.foregroundTurnId === foregroundTurnId) {
nativeTurnId = await pendingIdentification.promise;
}
if (!nativeTurnId) {
return { status: "unavailable" };
}
if (await this.resolveSlashCommandInvocation(prompt)) return { status: "unavailable" };
@@ -4543,27 +4551,21 @@ export class CodexAppServerAgentSession implements AgentSession {
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
const startupEvents = this.startupEvents;
this.startupEvents = null;
for (const event of startupEvents ?? []) this.notifySubscribers(event);
return () => {
this.subscribers.delete(callback);
};
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
if (
(!this.historyPending || this.persistedHistory.length === 0) &&
this.persistedProviderSubagentEvents.length === 0
) {
return;
}
const history = this.persistedHistory;
const providerSubagents = this.persistedProviderSubagentEvents;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
for (const event of providerSubagents) {
const history = this.stagedHistory ?? (await this.readThreadHistory(this.client));
this.stagedHistory = null;
for (const event of history.providerSubagentEvents) {
yield event;
}
for (const entry of history) {
for (const entry of history.timeline) {
yield {
type: "timeline",
provider: CODEX_PROVIDER,
@@ -4946,6 +4948,7 @@ export class CodexAppServerAgentSession implements AgentSession {
} else {
await this.ensureThread();
}
await this.ensureHistoryIndexed();
await revertCodexConversation({
client: this.client,
@@ -4960,10 +4963,8 @@ export class CodexAppServerAgentSession implements AgentSession {
setThreadId: async (threadId) => {
this.currentThreadId = threadId;
this.cachedRuntimeInfo = null;
this.persistedHistory = [];
this.historyPending = false;
await this.loadPersistedHistory(this.client);
this.reconcileAsyncQuestionsAfterRewind();
this.stagedHistory = await this.readThreadHistory(this.client);
this.reconcileAsyncQuestionsAfterRewind(this.stagedHistory.timeline);
},
});
}
@@ -5023,11 +5024,40 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
async canStopRuntime(): Promise<boolean> {
if (
!this.client ||
this.client.hasPendingRequests() ||
!this.currentThreadId ||
this.activeForegroundTurnId ||
this.currentTurnId ||
this.pendingForegroundStart ||
this.pendingPermissionHandlers.size ||
this.getPendingPermissions().length ||
this.pendingSubAgentNotificationsByThreadId.size
)
return false;
// Older app servers reject this request. A failed or malformed probe must retain
// the process: command completion alone does not mean its terminal has exited.
const loaded = z
.object({ data: z.array(z.string()) })
.parse(await this.client.request("thread/loaded/list", {}, 10_000));
const threadIds = new Set([this.currentThreadId, ...loaded.data]);
for (const threadId of threadIds) {
const response = z
.object({ data: z.array(z.unknown()), nextCursor: z.string().nullable() })
.parse(await this.client.request("thread/backgroundTerminals/list", { threadId }, 10_000));
if (response.data.length > 0 || response.nextCursor !== null) return false;
}
return !this.client.hasPendingRequests();
}
async close(): Promise<void> {
this.closed = true;
this.clearPendingPermissions();
this.pendingSubAgentNotificationsByThreadId.clear();
this.subscribers.clear();
this.startupEvents = null;
this.activeForegroundTurnId = null;
this.activeClientMessageId = null;
this.pendingForegroundTurnIdentification?.resolve(null);
@@ -5172,6 +5202,8 @@ export class CodexAppServerAgentSession implements AgentSession {
await this.connect();
if (this.currentThreadId) {
await this.ensureThreadLoaded();
// A goal runs turns of its own.
await this.ensureHistoryIndexed();
} else {
await this.ensureThread();
}
@@ -5365,8 +5397,8 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private emitEvent(event: AgentStreamEvent): void {
if (this.loadingPersistedHistory && event.type === "provider_subagent") {
this.persistedProviderSubagentEvents.push(event);
if (this.historySubagentEvents && event.type === "provider_subagent") {
this.historySubagentEvents.push(event);
return;
}
this.notifySubscribers(event);
@@ -5375,6 +5407,10 @@ export class CodexAppServerAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event;
if (this.startupEvents) {
this.startupEvents.push(tagged);
return;
}
this.logger.trace(
{
agentId: this.agentId,
@@ -5398,7 +5434,21 @@ export class CodexAppServerAgentSession implements AgentSession {
return `codex-turn-${this.nextTurnOrdinal++}`;
}
private handleNotification(method: string, params: unknown): void {
private pendingNotifications: Promise<void> = Promise.resolve();
private handleNotification(method: string, params: unknown): Promise<void> {
// An image must exist before its event, and completion must follow that event.
// Transport callbacks cannot await: the session owns their ordered drain.
const next = this.pendingNotifications.then(() => {
return this.closed ? undefined : this.processNotification(method, params);
});
this.pendingNotifications = next.catch((error: unknown) => {
this.handleUnexpectedTermination(error instanceof Error ? error : new Error(String(error)));
});
return this.pendingNotifications;
}
private async processNotification(method: string, params: unknown): Promise<void> {
const notificationParams = toObjectRecord(params);
if (method === "serverRequest/resolved" && typeof notificationParams?.requestId === "number") {
const requestId = this.mcpElicitationPermissionIds.get(notificationParams.requestId);
@@ -5428,13 +5478,16 @@ export class CodexAppServerAgentSession implements AgentSession {
return;
}
if (route.kind === "sub_agent") {
this.dispatchSubAgentNotification(parsed, route.callId);
await this.dispatchSubAgentNotification(parsed, route.callId);
return;
}
this.dispatchParsedNotification(parsed);
await this.dispatchParsedNotification(parsed);
}
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
private async dispatchSubAgentNotification(
parsed: ParsedCodexNotification,
callId: string,
): Promise<void> {
switch (parsed.kind) {
case "thread_started":
this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
@@ -5445,7 +5498,7 @@ export class CodexAppServerAgentSession implements AgentSession {
case "reasoning_delta":
case "item_started":
case "item_completed":
this.dispatchParsedNotification(parsed);
await this.dispatchParsedNotification(parsed);
return;
case "exec_command_output_delta":
case "file_change_output_delta":
@@ -5471,7 +5524,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private dispatchParsedNotification(parsed: ParsedCodexNotification): void {
private async dispatchParsedNotification(parsed: ParsedCodexNotification): Promise<void> {
if (isCodexDeltaNotification(parsed)) {
this.handleCodexDeltaNotification(parsed);
return;
@@ -5516,10 +5569,10 @@ export class CodexAppServerAgentSession implements AgentSession {
this.handlePatchApplyCompletedNotification(parsed);
return;
case "item_completed":
this.handleItemCompletedNotification(parsed);
await this.handleItemCompletedNotification(parsed);
return;
case "item_started":
this.handleItemStartedNotification(parsed);
await this.handleItemStartedNotification(parsed);
return;
case "invalid_payload":
this.warnInvalidNotificationPayload(parsed.method, parsed.params);
@@ -5561,7 +5614,7 @@ export class CodexAppServerAgentSession implements AgentSession {
pending.push(parsed);
}
private replayPendingSubAgentNotifications(threadIds: readonly string[]): void {
private async replayPendingSubAgentNotifications(threadIds: readonly string[]): Promise<void> {
for (const threadId of threadIds) {
const pending = this.pendingSubAgentNotificationsByThreadId.get(threadId);
if (!pending) {
@@ -5573,7 +5626,7 @@ export class CodexAppServerAgentSession implements AgentSession {
continue;
}
for (const parsed of pending) {
this.dispatchSubAgentNotification(parsed, callId);
await this.dispatchSubAgentNotification(parsed, callId);
}
}
}
@@ -6523,9 +6576,9 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private reconcileAsyncQuestionsAfterRewind(): void {
private reconcileAsyncQuestionsAfterRewind(timeline: readonly PersistedTimelineEntry[]): void {
const retainedIds = new Set(
this.persistedHistory.flatMap(({ item }) =>
timeline.flatMap(({ item }) =>
item.type === "tool_call" && item.name === "request_user_input_async" ? [item.callId] : [],
),
);
@@ -6539,9 +6592,13 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private handleItemCompletedNotification(
private ownsNotificationTurn(turnId: string | null): boolean {
return !this.closed && this.activeForegroundTurnId === turnId;
}
private async handleItemCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
): void {
): Promise<void> {
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
// and canonical `item/*`. Render ordinary items only from the canonical
// channel, but accept a legacy-only child announcement so it can establish
@@ -6551,20 +6608,23 @@ export class CodexAppServerAgentSession implements AgentSession {
}
this.receiveAsyncQuestion(parsed.threadId, parsed.item);
if (this.isUserMessageItem(parsed.item)) {
this.handleUserMessageItem(parsed);
await this.handleUserMessageItem(parsed);
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (this.handleCompletedSpecialItem(parsed, childSubAgentCallId)) {
return;
}
const timelineItem = threadItemToTimeline(parsed.item, {
const foregroundTurnId = this.activeForegroundTurnId;
const timelineItem = await threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
if (!timelineItem) {
return;
}
const imageItems = await mcpToolResultImagesToTimeline(parsed.item);
if (!this.ownsNotificationTurn(foregroundTurnId)) return;
const registeredChildThreadIds =
timelineItem.type === "tool_call"
? this.registerSubAgentToolCall({
@@ -6574,12 +6634,11 @@ export class CodexAppServerAgentSession implements AgentSession {
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
})
: [];
const imageItems = mcpToolResultImagesToTimeline(parsed.item);
if (childSubAgentCallId) {
this.emitCompletedProviderSubagentItem(parsed, timelineItem);
this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem);
this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
const normalizedItemType = normalizeCodexThreadItemType(
@@ -6587,7 +6646,7 @@ export class CodexAppServerAgentSession implements AgentSession {
);
const itemId = parsed.item.id;
if (this.shouldSkipCompletedThreadItem(timelineItem, normalizedItemType, itemId)) {
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
if (this.consumeStreamedTextCompletion(timelineItem, itemId)) {
@@ -6598,7 +6657,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emittedItemCompletedIds.add(itemId);
this.emittedItemStartedIds.delete(itemId);
}
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
@@ -6627,7 +6686,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
}
private consumeStreamedTextCompletion(
@@ -6699,14 +6758,14 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private handleItemStartedNotification(
private async handleItemStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
): void {
): Promise<void> {
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return;
}
if (this.isUserMessageItem(parsed.item)) {
this.handleUserMessageItem(parsed);
await this.handleUserMessageItem(parsed);
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
@@ -6728,7 +6787,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.handleRegisteredSubAgentActivity(parsed.item)) {
return;
}
const timelineItem = threadItemToTimeline(parsed.item, {
const timelineItem = await threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
@@ -6747,7 +6806,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
}
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
const normalizedItemType = normalizeCodexThreadItemType(
@@ -6771,14 +6830,14 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
}
private handleUserMessageItem(
private async handleUserMessageItem(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): void {
): Promise<void> {
const itemId = parsed.item.id;
const timelineItem = threadItemToTimeline(parsed.item, {
const timelineItem = await threadItemToTimeline(parsed.item, {
includeUserMessage: true,
cwd: this.config.cwd ?? null,
});
@@ -0,0 +1,53 @@
import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
// Regression for #3905. Real Codex, synthetic on-disk history, no model turns.
// Opt in because the oversized cases need approximately 2 GiB of memory and over
// 1 GiB of temporary storage.
const enabled = process.env.PASEO_CODEX_HISTORY_STRING_LIMIT_REPRO === "1";
const script = fileURLToPath(
new URL("../../../../scripts/reproduce-codex-history-string-limit.mts", import.meta.url),
);
test.runIf(enabled).each([
{ megabytes: 2, historyMode: "legacy", expected: "History loaded: 3 timeline rows" },
{ megabytes: 520, historyMode: "legacy", expected: "codex migrate-rollouts --apply --thread" },
{ megabytes: 520, historyMode: "paginated", expected: "History loaded: 521 timeline rows" },
])(
"opening $megabytes MiB of $historyMode Codex history does not crash its hosting process",
async ({ megabytes, historyMode, expected }) => {
const root = await mkdtemp(path.join(process.cwd(), ".codex-history-test-"));
try {
const result = await new Promise<{ code: number; stdout: string; stderr: string }>(
(resolve) => {
const child = execFile(
process.execPath,
["--import", "tsx", script, String(megabytes), historyMode],
{
cwd: root,
detached: true,
timeout: 120_000,
maxBuffer: 1024 * 1024,
},
(error, stdout, stderr) => {
try {
if (child.pid) process.kill(-child.pid, "SIGKILL");
} catch (killError) {
if ((killError as NodeJS.ErrnoException).code !== "ESRCH") throw killError;
}
resolve({ code: error ? Number(error.code ?? 1) : 0, stdout, stderr });
},
);
},
);
expect(result.code, result.stderr).toBe(0);
expect(result.stdout).toContain(expected);
} finally {
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
}
},
180_000,
);
@@ -183,3 +183,56 @@ describe("Codex app-server transport", () => {
codex.child.stdin.end();
});
});
describe("Codex stdout byte admission", () => {
test("an oversized response fails only its own request", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const terminations: Error[] = [];
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
const oversized = client.request("thread/items/list", {}).catch((error: Error) => error);
const other = client.request("model/list", {});
const chunk = Buffer.alloc(64 * 1024, "x");
child.stdout.write('{"id":1,"result":"');
for (let index = 0; index < 2049; index++) child.stdout.write(chunk);
child.stdout.write('"}\n{"id":2,"result":{"data":[]}}\n');
expect(await oversized).toMatchObject({
name: "CodexAppServerResponseTooLargeError",
method: "thread/items/list",
limitBytes: 128 * 1024 * 1024,
});
await expect(other).resolves.toEqual({ data: [] });
const next = client.request("model/list", {});
child.stdout.write('{"id":3,"result":{"data":["next"]}}\n');
await expect(next).resolves.toEqual({ data: ["next"] });
expect(terminations).toEqual([]);
await client.dispose();
child.stdout.end();
child.stderr.end();
});
test("an oversized notification fails pending requests and the session", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const terminations: Error[] = [];
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
const request = client.request("thread/resume", {}, 1000).catch((error: Error) => error);
const chunk = Buffer.alloc(64 * 1024, "x");
child.stdout.write('{"method":"item/completed","params":"');
for (let index = 0; index < 2048; index++) child.stdout.write(chunk);
const error = await request;
expect(error).toMatchObject({
name: "ProviderStreamLimitError",
code: "PROVIDER_STREAM_LINE_TOO_LARGE",
limitBytes: 128 * 1024 * 1024,
});
expect(terminations).toEqual([error]);
expect(client.hasPendingRequests()).toBe(false);
child.stdout.write('"}\n{"id":1,"result":"late"}\n');
await expect(client.request("model/list")).rejects.toThrow("closed");
await client.dispose();
child.stdout.end();
child.stderr.end();
});
});
@@ -1,10 +1,15 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { StringDecoder } from "node:string_decoder";
import type { Logger } from "pino";
import { z } from "zod";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import { ProviderLineReader, ProviderStreamLimitError } from "../jsonl-frame-decoder.js";
// A measured long-thread resume was 19.75 MB; preserve the existing 32 MiB fixture
// with 4x margin for longer threads. This is provider stdout, not the client protocol.
export const CODEX_PROVIDER_LINE_BYTES = 128 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
@@ -28,11 +33,26 @@ interface JsonRpcNotification {
}
interface PendingRequest {
method: string;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
/** A response past the provider line cap. Only its request fails; the session stays up. */
export class CodexAppServerResponseTooLargeError extends Error {
constructor(
readonly method: string,
readonly limitBytes: number,
) {
super(`Codex app-server response to ${method} exceeds ${limitBytes} bytes`);
this.name = "CodexAppServerResponseTooLargeError";
}
}
// Codex serializes a response with `id` first, so its head names the request.
const OVERSIZED_RESPONSE_HEAD = /^\{"id":(\d+),"result":/;
export class CodexAppServerRpcError extends Error {
constructor(
message: string,
@@ -167,41 +187,9 @@ function readProviderTurnId(params: unknown): string | undefined {
return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined;
}
/**
* Splits stdout into lines on "\n" only. `node:readline` additionally treats U+2028 and U+2029
* as line terminators, but Codex emits those characters raw inside JSON strings, so readline
* would cut a single JSON-RPC message into pieces that fail to parse.
*/
function createNewlineLineReader(
input: NodeJS.ReadableStream,
onLine: (line: string) => void,
): { close(): void } {
const decoder = new StringDecoder("utf8");
// Text after the last "\n", kept as parts and joined once its line ends, so a large line
// arriving in many chunks is scanned and copied once instead of once per chunk.
let unterminated: string[] = [];
const onData = (chunk: Buffer | string): void => {
const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
let lineStart = 0;
let index = text.indexOf("\n");
while (index !== -1) {
unterminated.push(text.slice(lineStart, index));
const line = unterminated.join("");
unterminated = [];
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
lineStart = index + 1;
index = text.indexOf("\n", lineStart);
}
if (lineStart < text.length) {
unterminated.push(text.slice(lineStart));
}
};
input.on("data", onData);
return { close: () => void input.off("data", onData) };
}
export class CodexAppServerClient {
private readonly stdoutLineReader: { close(): void };
private activeProviderRequests = 0;
private readonly pending = new Map<number, PendingRequest>();
private readonly requestHandlers = new Map<string, RequestHandler>();
private notificationHandler: NotificationHandler | null = null;
@@ -215,11 +203,24 @@ export class CodexAppServerClient {
private readonly logger: Logger,
private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}),
) {
this.stdoutLineReader = createNewlineLineReader(child.stdout, (line) => {
void this.handleLine(line).catch((error) => {
this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line");
});
});
// Split only on LF: U+2028/U+2029 are valid raw characters inside Codex JSON.
const reader = new ProviderLineReader(
CODEX_PROVIDER_LINE_BYTES,
(line) => {
void this.handleLine(line).catch((error) => {
this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line");
});
},
(error) => this.handleStreamLimit(error),
);
const onData = (chunk: Buffer | string) => reader.write(chunk);
child.stdout.on("data", onData);
this.stdoutLineReader = {
close: () => {
reader.close();
child.stdout.off("data", onData);
},
};
child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
@@ -255,6 +256,10 @@ export class CodexAppServerClient {
this.requestHandlers.set(method, handler);
}
hasPendingRequests(): boolean {
return this.pending.size > 0 || this.activeProviderRequests > 0;
}
request(method: string, params?: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
if (this.disposed) {
return Promise.reject(new Error("Codex app-server client is closed"));
@@ -268,7 +273,7 @@ export class CodexAppServerClient {
this.pending.delete(id);
reject(new Error(`Codex app-server request timed out for ${method}`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
this.pending.set(id, { method, resolve, reject, timer });
});
}
@@ -313,6 +318,28 @@ export class CodexAppServerClient {
}
}
private handleStreamLimit(failure: ProviderStreamLimitError): void {
const responseId = OVERSIZED_RESPONSE_HEAD.exec(failure.lineHead)?.[1];
const pending = responseId === undefined ? undefined : this.pending.get(Number(responseId));
if (pending) {
clearTimeout(pending.timer);
this.pending.delete(Number(responseId));
this.logger.warn(
{ method: pending.method, limitBytes: failure.limitBytes },
"Codex app-server response exceeded the line cap",
);
pending.reject(new CodexAppServerResponseTooLargeError(pending.method, failure.limitBytes));
return;
}
// A lost notification or server request cannot be replayed. Fail the session,
// including live turns with no pending RPC, rather than dropping a stream unit.
this.stdoutLineReader.close();
this.handleUnexpectedTermination(failure);
void this.dispose().catch((err) => {
this.logger.warn({ err }, "Failed to terminate oversized Codex stdout producer");
});
}
private handleUnexpectedTermination(error: Error): void {
if (this.disposed) {
return;
@@ -392,6 +419,7 @@ export class CodexAppServerClient {
this.traceRawEvent(request);
const handler = this.requestHandlers.get(request.method);
try {
this.activeProviderRequests++;
const result = handler ? await handler(request.params, request.id) : {};
this.writeJsonRpcResponse({ id: request.id, result });
} catch (error) {
@@ -399,6 +427,8 @@ export class CodexAppServerClient {
id: request.id,
error: { message: error instanceof Error ? error.message : String(error) },
});
} finally {
this.activeProviderRequests--;
}
return;
}
@@ -108,7 +108,7 @@ describe("Codex Rewind", () => {
cwd: "/workspace/project",
model: "gpt-5.4-mini",
serviceTier: null,
excludeTurns: false,
excludeTurns: true,
persistExtendedHistory: true,
},
]);
@@ -190,7 +190,7 @@ describe("Codex Rewind", () => {
cwd: "/workspace/project",
model: "gpt-5.4-mini",
serviceTier: null,
excludeTurns: false,
excludeTurns: true,
persistExtendedHistory: true,
},
]);
@@ -8,6 +8,7 @@ import {
parseCodexThreadForkResponse,
parseCodexThreadRollbackResponse,
} from "./app-server-transport.js";
import { readCodexThreadHistoryMode } from "./thread-history.js";
export interface CodexRewindClient {
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
@@ -20,26 +21,6 @@ export interface CodexUserMessageTurnIndex {
count(): number;
}
type CodexThreadHistoryMode = "legacy" | "paginated";
async function readCodexThreadHistoryMode(
client: CodexRewindClient,
threadId: string,
): Promise<CodexThreadHistoryMode> {
const response = await client.request("thread/read", { threadId, includeTurns: false });
if (typeof response !== "object" || response === null || !("thread" in response)) {
throw new Error("Codex thread/read did not return thread metadata");
}
const thread = response.thread;
if (typeof thread !== "object" || thread === null || !("historyMode" in thread)) {
return "legacy";
}
if (thread.historyMode === "legacy" || thread.historyMode === "paginated") {
return thread.historyMode;
}
throw new Error(`Codex thread/read returned unknown history mode ${String(thread.historyMode)}`);
}
async function forkCodexThread(
client: CodexRewindClient,
params: CodexThreadForkParams,
@@ -95,7 +76,8 @@ export async function revertCodexConversation(input: {
model: input.model ?? null,
serviceTier: input.serviceTier ?? null,
...(input.config ? { config: input.config } : {}),
excludeTurns: false,
// Paseo re-reads the rewound thread through paged history, not the fork reply.
excludeTurns: true,
persistExtendedHistory: true,
};
@@ -3,6 +3,10 @@ import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js";
import { CODEX_PROVIDER_LINE_BYTES } from "../app-server-transport.js";
/** A handler result that the fake writes as a response line past the provider line cap. */
export const OVERSIZED_RESPONSE = Symbol("oversized Codex response");
type JsonObject = Record<string, unknown>;
type FakeCodexAppServerHandler = (params: unknown) => unknown;
@@ -229,8 +233,18 @@ export function createFakeCodexAppServer(
return;
}
Promise.resolve(handler(message.params))
let response: unknown;
try {
response = handler(message.params);
} catch (error) {
response = Promise.reject(error);
}
Promise.resolve(response)
.then((result) => {
if (result === OVERSIZED_RESPONSE) {
writeOversizedResponse(message.id as number);
return undefined;
}
const rpcError = toJsonObject(result).__jsonRpcError;
if (rpcError) {
child.stdout.write(`${JSON.stringify({ id: message.id, error: rpcError })}\n`);
@@ -313,6 +327,16 @@ export function createFakeCodexAppServer(
});
}
// Codex writes `id` first, then a result past the provider line cap.
function writeOversizedResponse(id: number): void {
child.stdout.write(`{"id":${id},"result":{"data":[{"item":{"text":"`);
const chunk = Buffer.alloc(1024 * 1024, "x");
for (let written = 0; written <= CODEX_PROVIDER_LINE_BYTES; written += chunk.byteLength) {
child.stdout.write(chunk);
}
child.stdout.write('"}}]}}\n');
}
function writeNotification(method: string, params: JsonObject): void {
child.stdout.write(`${JSON.stringify({ method, params })}\n`);
}
@@ -696,3 +720,81 @@ export function waitForProviderSubagent(
): Promise<ProviderSubagentEvent> {
return waitForNextEvent(session, "provider_subagent", (event) => event.event.id === id);
}
export interface FakeCodexHistoryTurn {
id: string;
startedAt?: number;
completedAt?: number;
items: JsonObject[];
}
export interface FakeCodexHistoryThread {
/** Omitted on Codex builds older than 0.143, which only have legacy history. */
historyMode?: "legacy" | "paginated";
turns: FakeCodexHistoryTurn[];
}
export interface FakeCodexHistoryOptions {
/** Any response carrying one of these items is written past the line cap. */
oversizedItemIds?: string[];
}
/**
* Codex's persisted thread history: `thread/read` for metadata and legacy full
* reads, `thread/items/list` for paginated threads. Paginated threads reject
* the full read as Codex 0.145 and 0.146 do.
*/
export function fakeCodexThreadHistory(
threads: Record<string, FakeCodexHistoryThread>,
options: FakeCodexHistoryOptions = {},
): Record<string, FakeCodexAppServerHandler> {
const oversizedItemIds = new Set(options.oversizedItemIds ?? []);
const isOversized = (items: JsonObject[]) =>
items.some((item) => oversizedItemIds.has(String(item.id)));
function readThread(params: unknown): { id: string; thread: FakeCodexHistoryThread } {
const id = String(toJsonObject(params).threadId);
const thread = threads[id];
if (!thread) throw new Error(`thread not found: ${id}`);
return { id, thread };
}
return {
"thread/read": (params) => {
const { id, thread } = readThread(params);
if (!toJsonObject(params).includeTurns) {
return {
thread: { id, ...(thread.historyMode ? { historyMode: thread.historyMode } : {}) },
};
}
if (thread.historyMode === "paginated") {
throw new Error("paginated threads do not support thread/read(includeTurns=true)");
}
if (isOversized(thread.turns.flatMap((turn) => turn.items))) return OVERSIZED_RESPONSE;
return { thread: { id, turns: thread.turns } };
},
"thread/items/list": (params) => {
const { thread } = readThread(params);
if (thread.historyMode !== "paginated") {
throw new Error("thread/items/list is not supported for legacy threads");
}
const request = toJsonObject(params);
// Codex records each item's own times; the fake gives every item its turn's.
const rows = thread.turns.flatMap((turn) =>
turn.items.map((item) => ({
turnId: turn.id,
item,
startedAtMs: turn.startedAt === undefined ? null : turn.startedAt * 1000,
completedAtMs: turn.completedAt === undefined ? null : turn.completedAt * 1000,
})),
);
const limit = Math.min(typeof request.limit === "number" ? request.limit : 25, 100);
const start =
typeof request.cursor === "string" ? Number(request.cursor.slice("cursor-".length)) : 0;
const end = Math.min(rows.length, start + limit);
const data = rows.slice(start, end);
if (isOversized(data.map((row) => row.item))) return OVERSIZED_RESPONSE;
return { data, nextCursor: end < rows.length ? `cursor-${end}` : null };
},
};
}
@@ -0,0 +1,220 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
import { CodexAppServerAgentClient } from "../codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
type FakeCodexHistoryOptions,
type FakeCodexHistoryThread,
fakeCodexThreadHistory,
} from "./test-utils/fake-app-server.js";
function userItem(id: string, text: string) {
return { type: "userMessage", id, content: [{ type: "text", text }] };
}
function agentItem(id: string, text: string) {
return { type: "agentMessage", id, text };
}
async function openCodexThread(
thread: FakeCodexHistoryThread,
options?: FakeCodexHistoryOptions,
): Promise<{ appServer: FakeCodexAppServer; session: AgentSession }> {
const appServer = createFakeCodexAppServer(
fakeCodexThreadHistory({ "saved-thread": thread }, options),
);
const provider = new CodexAppServerAgentClient(createTestLogger());
const internals = provider as unknown as {
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
};
internals.goalsEnabledPromise = Promise.resolve(false);
internals.autoReviewEnabledPromise = Promise.resolve(false);
internals.spawnAppServer = async () => appServer.child;
const session = await provider.resumeSession({ sessionId: "saved-thread" });
return { appServer, session };
}
async function readHistory(session: AgentSession): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) events.push(event);
return events;
}
function timelineItems(events: AgentStreamEvent[]) {
return events.flatMap((event) => (event.type === "timeline" ? [event.item] : []));
}
function historyRequests(appServer: FakeCodexAppServer) {
return appServer
.requests()
.filter(
(request) =>
request.method === "thread/read" ||
request.method === "thread/turns/list" ||
request.method === "thread/items/list",
)
.map((request) => ({ method: request.method, params: request.params }));
}
describe("Codex thread history", () => {
test("loads a paginated thread through pages", async () => {
const answers = Array.from({ length: 150 }, (_, index) =>
agentItem(`agent-${index}`, `Answer ${index}`),
);
const { appServer, session } = await openCodexThread({
historyMode: "paginated",
turns: [
{
id: "turn-1",
startedAt: 1_778_832_941,
completedAt: 1_778_833_000,
items: [userItem("user-1", "First"), ...answers.slice(0, 120)],
},
{
id: "turn-2",
startedAt: 1_778_833_094,
completedAt: 1_778_833_100,
items: [userItem("user-2", "Second"), ...answers.slice(120)],
},
],
});
try {
const events = await readHistory(session);
expect(timelineItems(events)).toEqual([
{ type: "user_message", text: "First", messageId: "user-1" },
...answers.slice(0, 120).map((answer) => ({
type: "assistant_message",
text: answer.text,
messageId: answer.id,
})),
{ type: "user_message", text: "Second", messageId: "user-2" },
...answers.slice(120).map((answer) => ({
type: "assistant_message",
text: answer.text,
messageId: answer.id,
})),
]);
const timestamps = events.flatMap((event) =>
event.type === "timeline" ? [event.timestamp] : [],
);
expect(timestamps[0]).toBe("2026-05-15T08:15:41.000Z");
expect(timestamps[1]).toBe("2026-05-15T08:16:40.000Z");
expect(timestamps[121]).toBe("2026-05-15T08:18:14.000Z");
const requests = historyRequests(appServer);
expect(requests).not.toContainEqual(
expect.objectContaining({ params: expect.objectContaining({ includeTurns: true }) }),
);
const itemPages = requests.filter((request) => request.method === "thread/items/list");
expect(itemPages.length).toBeGreaterThan(1);
for (const page of itemPages) {
expect(page.params).toMatchObject({ threadId: "saved-thread", sortDirection: "asc" });
expect((page.params as { limit: number }).limit).toBeLessThanOrEqual(100);
}
} finally {
await session.close();
}
appServer.assertNoErrors();
});
test("loads a paginated thread whose full read Codex 0.145 rejects", async () => {
const { appServer, session } = await openCodexThread({
historyMode: "paginated",
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }],
});
try {
expect(timelineItems(await readHistory(session))).toEqual([
{ type: "user_message", text: "Hello", messageId: "user-1" },
{ type: "assistant_message", text: "Hi", messageId: "agent-1" },
]);
} finally {
await session.close();
}
appServer.assertNoErrors();
});
test("a page too big to send shows a warning row and stops loading", async () => {
const answers = Array.from({ length: 120 }, (_, index) =>
agentItem(`agent-${index}`, `Answer ${index}`),
);
const { appServer, session } = await openCodexThread(
{
historyMode: "paginated",
turns: [
{ id: "turn-1", items: [userItem("user-1", "One"), ...answers] },
{ id: "turn-2", items: [userItem("user-2", "Two"), agentItem("huge", "never sent")] },
],
},
{ oversizedItemIds: ["huge"] },
);
const loaded = [
{ type: "user_message", text: "One", messageId: "user-1" },
...answers.slice(0, 99).map((answer) => ({
type: "assistant_message",
text: answer.text,
messageId: answer.id,
})),
{
type: "notification",
level: "warning",
message: "The rest of this Codex thread's history is too large to load.",
},
];
try {
expect(timelineItems(await readHistory(session))).toEqual(loaded);
// The oversized reply failed only its own request; the session still answers.
expect(timelineItems(await readHistory(session))).toEqual(loaded);
} finally {
await session.close();
}
appServer.assertNoErrors();
});
test("loads a legacy thread that fits in one read", async () => {
const { appServer, session } = await openCodexThread({
historyMode: "legacy",
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }],
});
try {
expect(timelineItems(await readHistory(session))).toEqual([
{ type: "user_message", text: "Hello", messageId: "user-1" },
{ type: "assistant_message", text: "Hi", messageId: "agent-1" },
]);
expect(historyRequests(appServer).map((request) => request.method)).not.toContain(
"thread/items/list",
);
} finally {
await session.close();
}
appServer.assertNoErrors();
});
test("a legacy thread too big for one read shows the migration command", async () => {
const { appServer, session } = await openCodexThread(
{
historyMode: "legacy",
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("huge", "x")] }],
},
{ oversizedItemIds: ["huge"] },
);
const migrationRow = {
type: "notification",
level: "warning",
message: expect.stringContaining("codex migrate-rollouts --apply --thread saved-thread"),
};
try {
expect(timelineItems(await readHistory(session))).toEqual([migrationRow]);
// The session stays up for the user to prompt until they archive it to migrate.
expect(timelineItems(await readHistory(session))).toEqual([migrationRow]);
} finally {
await session.close();
}
appServer.assertNoErrors();
});
});
@@ -0,0 +1,168 @@
import { z } from "zod";
import { CodexAppServerResponseTooLargeError } from "./app-server-transport.js";
// Codex clamps history pages to 100 rows.
const PAGE_SIZE = 100;
export interface CodexThreadHistoryClient {
request(method: string, params?: unknown): Promise<unknown>;
}
export type CodexThreadHistoryMode = "legacy" | "paginated";
export interface CodexHistoryRow {
item: unknown;
turnId: string | undefined;
/** The item's times on a paginated thread; its turn's on a legacy thread. */
startedAt: unknown;
completedAt: unknown;
}
/** A history reply over the line cap; the rows before it were already read. */
export class CodexThreadHistoryTooLargeError extends Error {
constructor(message: string) {
super(message);
this.name = "CodexThreadHistoryTooLargeError";
}
}
const CodexThreadMetadataSchema = z
.object({
thread: z.object({ historyMode: z.enum(["legacy", "paginated"]).optional() }).passthrough(),
})
.passthrough();
const CodexLegacyThreadReadSchema = z
.object({
thread: z
.object({
turns: z
.array(
z
.object({
id: z.string().optional(),
startedAt: z.unknown().optional(),
completedAt: z.unknown().optional(),
items: z.array(z.unknown()).default([]),
})
.passthrough(),
)
.default([]),
})
.passthrough()
.default({ turns: [] }),
})
.passthrough();
const CodexItemsPageSchema = z
.object({
data: z.array(
z
.object({
turnId: z.string(),
item: z.unknown(),
startedAtMs: z.number().nullable().optional(),
completedAtMs: z.number().nullable().optional(),
})
.passthrough(),
),
nextCursor: z.string().nullable().optional(),
})
.passthrough();
/** Codex builds before 0.143 report no history mode; their threads are legacy. */
export async function readCodexThreadHistoryMode(
client: CodexThreadHistoryClient,
threadId: string,
): Promise<CodexThreadHistoryMode> {
const response = CodexThreadMetadataSchema.parse(
await client.request("thread/read", { threadId, includeTurns: false }),
);
return response.thread.historyMode ?? "legacy";
}
/**
* Reads a thread's persisted items in order. Paginated threads are read in
* pages; a legacy thread arrives in one `thread/read` reply, as Codex offers
* no pages for it.
*/
export async function* readCodexThreadHistory(
client: CodexThreadHistoryClient,
threadId: string,
): AsyncGenerator<CodexHistoryRow> {
if ((await readCodexThreadHistoryMode(client, threadId)) === "paginated") {
yield* readPaginatedHistory(client, threadId);
} else {
yield* readLegacyHistory(client, threadId);
}
}
async function* readLegacyHistory(
client: CodexThreadHistoryClient,
threadId: string,
): AsyncGenerator<CodexHistoryRow> {
const response = await requestHistory(
client,
"thread/read",
{ threadId, includeTurns: true },
`This Codex thread's history is too large to load in one read. ` +
`To move it to paginated history, archive this agent, ` +
`run \`codex migrate-rollouts --apply --thread ${threadId}\` (Codex 0.148 or newer), ` +
`then unarchive the agent.`,
);
for (const turn of CodexLegacyThreadReadSchema.parse(response).thread.turns) {
for (const item of turn.items) {
yield { item, turnId: turn.id, startedAt: turn.startedAt, completedAt: turn.completedAt };
}
}
}
async function* readPaginatedHistory(
client: CodexThreadHistoryClient,
threadId: string,
): AsyncGenerator<CodexHistoryRow> {
const seenCursors = new Set<string>();
let cursor: string | undefined;
for (;;) {
const page = CodexItemsPageSchema.parse(
await requestHistory(
client,
"thread/items/list",
{ threadId, limit: PAGE_SIZE, sortDirection: "asc", cursor },
"The rest of this Codex thread's history is too large to load.",
),
);
for (const row of page.data) {
yield {
item: row.item,
turnId: row.turnId,
startedAt: row.startedAtMs,
completedAt: row.completedAtMs,
};
}
if (page.nextCursor == null) return;
// Codex's own client guards against a cursor that does not advance.
if (seenCursors.has(page.nextCursor)) {
throw new Error(`Codex repeated history cursor for thread ${threadId}`);
}
seenCursors.add(page.nextCursor);
cursor = page.nextCursor;
}
}
async function requestHistory(
client: CodexThreadHistoryClient,
method: string,
params: unknown,
tooLargeMessage: string,
): Promise<unknown> {
try {
return await client.request(method, params);
} catch (error) {
if (error instanceof CodexAppServerResponseTooLargeError) {
throw new CodexThreadHistoryTooLargeError(tooLargeMessage);
}
throw error;
}
}
@@ -1,3 +1,4 @@
import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js";
import { describe, expect, it } from "vitest";
import { JsonlFrameDecoder, type JsonlFrameProblem } from "./jsonl-frame-decoder.js";
@@ -36,6 +37,16 @@ describe("JsonlFrameDecoder", () => {
expect(problems).toEqual([]);
});
it("preserves UTF-8 characters split across raw stdout buffers", () => {
const { decoder, frames, problems } = decoderHarness();
const bytes = Buffer.from('{"type":"notice","text":"é🙂"}\n');
const split = bytes.indexOf(Buffer.from("🙂")) + 1;
decoder.write(bytes.subarray(0, split));
decoder.write(bytes.subarray(split));
expect(frames).toEqual([{ type: "notice", text: "é🙂" }]);
expect(problems).toEqual([]);
});
it("reassembles a large ordered v2 frame", () => {
const { decoder, frames, problems } = decoderHarness();
const frame = { type: "models", value: "x".repeat(1024 * 1024) };
@@ -55,3 +66,46 @@ describe("JsonlFrameDecoder", () => {
expect(problems).toEqual(["out-of-order-chunk"]);
});
});
describe("provider line admission", () => {
it("keeps the loop responsive while accumulating a fragmented provider response", async () => {
const { decoder, frames, problems } = decoderHarness();
const part = "x".repeat(512);
const { blockingMs } = await measureMainThreadWork("fragmented JSONL main-thread work", () => {
decoder.write('{"type":"notice","text":"');
// 1 MiB exposes the former rescan of every accumulated prefix.
for (let index = 0; index < 2048; index++) decoder.write(part);
decoder.write('"}\n');
});
expect(frames).toEqual([{ type: "notice", text: part.repeat(2048) }]);
expect(problems).toEqual([]);
expect(blockingMs).toBeLessThan(50);
});
it("rejects an unterminated over-cap UTF-8 line and stops accepting frames", () => {
const frames: Record<string, unknown>[] = [];
const failures: Array<{ problem: string; detail: unknown }> = [];
const decoder = new JsonlFrameDecoder({
frame: (frame) => frames.push(frame),
problem: (problem, detail) => failures.push({ problem, detail }),
});
// 64 MiB of UTF-8, only 32 Mi UTF-16 code units. No newline or JSON parse needed.
const chunk = "é".repeat(32 * 1024);
for (let index = 0; index < 1024; index++) decoder.write(chunk);
expect(failures).toEqual([]);
decoder.write("é");
expect(failures).toEqual([
{
problem: "line-too-large",
detail: expect.objectContaining({
name: "ProviderStreamLimitError",
code: "PROVIDER_STREAM_LINE_TOO_LARGE",
limitBytes: 64 * 1024 * 1024,
}),
},
]);
decoder.write('\n{"type":"ready"}\n');
expect(frames).toEqual([]);
expect(failures).toHaveLength(1);
});
});
@@ -1,3 +1,92 @@
// Provider-process stdout only; these are not app/daemon wire limits.
export const JSONL_PROVIDER_LINE_BYTES = 64 * 1024 * 1024;
// Enough of an oversized line to read which response it was, e.g. `{"id":12,"result":`.
const OVERSIZED_LINE_HEAD_BYTES = 64;
export class ProviderStreamLimitError extends Error {
readonly code = "PROVIDER_STREAM_LINE_TOO_LARGE";
constructor(
readonly limitBytes: number,
/** The first bytes of the oversized line; the rest is discarded unread. */
readonly lineHead = "",
) {
super(
`Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`,
);
this.name = "ProviderStreamLimitError";
}
}
function readLineHead(parts: Buffer[]): string {
const head: Buffer[] = [];
let bytes = 0;
for (const part of parts) {
if (bytes >= OVERSIZED_LINE_HEAD_BYTES) break;
head.push(part.subarray(0, OVERSIZED_LINE_HEAD_BYTES - bytes));
bytes += head[head.length - 1].byteLength;
}
return Buffer.concat(head).toString("utf8");
}
/**
* Admits raw UTF-8 bytes before retaining, decoding or joining a complete line.
* An oversized line is reported with its head and skipped up to its newline;
* the owner closes the reader if the stream cannot continue without it.
*/
export class ProviderLineReader {
private parts: Buffer[] = [];
private bytes = 0;
private skippingOversizedLine = false;
private stopped = false;
constructor(
private readonly limitBytes: number,
private readonly onLine: (line: string) => void,
private readonly onLimit: (error: ProviderStreamLimitError) => void,
) {}
write(chunk: Buffer | string): void {
if (this.stopped) return;
const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
let start = 0;
while (start < buffer.byteLength && !this.stopped) {
const newline = buffer.indexOf(10, start);
const end = newline === -1 ? buffer.byteLength : newline;
if (this.skippingOversizedLine) {
if (newline === -1) return;
this.skippingOversizedLine = false;
start = newline + 1;
continue;
}
const length = end - start;
if (length > this.limitBytes - this.bytes) {
const head = readLineHead([...this.parts, buffer.subarray(start, end)]);
this.parts = [];
this.bytes = 0;
this.skippingOversizedLine = true;
this.onLimit(new ProviderStreamLimitError(this.limitBytes, head));
continue;
}
if (length) this.parts.push(buffer.subarray(start, end));
this.bytes += length;
if (newline === -1) return;
const line = Buffer.concat(this.parts, this.bytes).toString("utf8");
this.parts = [];
this.bytes = 0;
this.onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
start = newline + 1;
}
}
close(): void {
this.stopped = true;
this.parts = [];
this.bytes = 0;
}
}
const V2_FRAME_BYTES = 1024 * 1024;
const V2_REASSEMBLED_BYTES = 64 * 1024 * 1024;
const V2_CHUNK_BYTES = 256 * 1024;
@@ -5,6 +94,7 @@ const V2_BASE64_CHARS = Math.ceil(V2_CHUNK_BYTES / 3) * 4;
const V2_MAX_CHUNKS = Math.ceil(V2_REASSEMBLED_BYTES / V2_CHUNK_BYTES);
export type JsonlFrameProblem =
| "line-too-large"
| "invalid-json"
| "invalid-chunk"
| "invalid-base64"
@@ -78,7 +168,7 @@ function chunkBytes(data: string): Buffer | null {
}
export class JsonlFrameDecoder {
private lineBuffer = "";
private readonly lines: ProviderLineReader;
private assembly: ChunkAssembly | null = null;
constructor(
@@ -86,17 +176,21 @@ export class JsonlFrameDecoder {
frame(message: Record<string, unknown>): void;
problem(problem: JsonlFrameProblem, detail?: unknown): void;
},
) {}
) {
this.lines = new ProviderLineReader(
JSONL_PROVIDER_LINE_BYTES,
(line) => {
if (line.trim()) this.consumeLine(line);
},
(error) => {
this.lines.close();
this.drop("line-too-large", error);
},
);
}
write(text: string): void {
this.lineBuffer += text;
let newline = this.lineBuffer.indexOf("\n");
while (newline !== -1) {
const line = this.lineBuffer.slice(0, newline).replace(/\r$/, "");
this.lineBuffer = this.lineBuffer.slice(newline + 1);
if (line.trim()) this.consumeLine(line);
newline = this.lineBuffer.indexOf("\n");
}
write(chunk: Buffer | string): void {
this.lines.write(chunk);
}
private consumeLine(line: string): void {
@@ -471,3 +471,25 @@ describe("JsonlRpcProcess", () => {
}
});
});
describe("JSONL stdout byte admission", () => {
test("fails pending requests and publishes the typed failure to live-turn consumers", async () => {
const child = createInMemoryChildProcess();
const process = startProcess({ child });
const exit = new Promise<JsonlRpcExit>((resolve) => process.onExit(resolve));
const request = process.request({ type: "hang" }, 1000).catch((error: Error) => error);
const chunk = Buffer.alloc(64 * 1024, "x");
child.stdout.write('{"type":"notice","text":"');
for (let index = 0; index < 1024; index++) child.stdout.write(chunk);
const error = await request;
expect(error).toMatchObject({
name: "ProviderStreamLimitError",
code: "PROVIDER_STREAM_LINE_TOO_LARGE",
limitBytes: 64 * 1024 * 1024,
});
expect((await exit).error).toBe(error);
await process.close();
child.stdout.end();
child.stderr.end();
});
});
@@ -3,7 +3,7 @@ import type { Logger } from "pino";
import { spawnProcess } from "../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
import { JsonlFrameDecoder } from "./jsonl-frame-decoder.js";
import { JsonlFrameDecoder, ProviderStreamLimitError } from "./jsonl-frame-decoder.js";
export { supportsJsonlRpcProtocolV2 } from "./jsonl-frame-decoder.js";
/** Default wall-clock timeout for control-plane / short RPC calls. */
@@ -81,6 +81,7 @@ export class JsonlRpcProcess {
private stderrBuffer = "";
private nextRequestId = 1;
private disposed = false;
private streamFailure: ProviderStreamLimitError | null = null;
private exited = false;
private closing: Promise<void> | null = null;
private readonly frameDecoder: JsonlFrameDecoder;
@@ -90,6 +91,14 @@ export class JsonlRpcProcess {
this.frameDecoder = new JsonlFrameDecoder({
frame: (message) => this.dispatchFrame(message),
problem: (problem, detail) => {
if (problem === "line-too-large" && detail instanceof ProviderStreamLimitError) {
// Stop the producer: silently skipping a response or turn event loses state.
this.streamFailure = detail;
void this.close(detail).catch((err) => {
this.options.logger.warn({ err }, `Failed to close ${this.diagnosticName}`);
});
return;
}
this.options.logger.warn(
{ problem, detail },
`Ignoring invalid ${this.diagnosticName} frame`,
@@ -98,7 +107,7 @@ export class JsonlRpcProcess {
});
this.child = (options.spawn ?? spawnJsonlRpcProcess)(options.launch);
this.child.stdout.on("data", (chunk) => {
this.handleStdoutChunk(chunk.toString());
this.handleStdoutChunk(chunk);
});
this.child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
@@ -114,9 +123,11 @@ export class JsonlRpcProcess {
});
this.child.on("exit", (code, signal) => {
this.exited = true;
const error = new Error(
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
const error =
this.streamFailure ??
new Error(
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
const exit = { code, signal, error };
for (const subscriber of this.exitSubscribers) {
subscriber(exit);
@@ -254,7 +265,7 @@ export class JsonlRpcProcess {
}
}
private handleStdoutChunk(chunk: string): void {
private handleStdoutChunk(chunk: Buffer | string): void {
this.frameDecoder.write(chunk);
}
@@ -8,6 +8,7 @@ import { AgentManager } from "../agent-manager.js";
import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
import {
MOCK_LOAD_TEST_DEFAULT_MODEL_ID,
MOCK_LOAD_TEST_HANDLED_COMMAND,
MockLoadTestAgentClient,
} from "./mock-load-test-agent.js";
@@ -49,6 +50,18 @@ describe("MockLoadTestAgentClient", () => {
});
});
test("lists the command it handles without starting a turn", async () => {
const client = new MockLoadTestAgentClient();
const session = await client.createSession({ provider: "mock", cwd: process.cwd() });
const commands = (await session.listCommands?.()) ?? [];
expect(commands.map((command) => `/${command.name} ${command.argumentHint}`)).toEqual([
MOCK_LOAD_TEST_HANDLED_COMMAND,
]);
expect(session.tryHandleOutOfBand?.(MOCK_LOAD_TEST_HANDLED_COMMAND)).not.toBeNull();
});
test("rejects the configured number of prompts before starting a retry", async () => {
const client = new MockLoadTestAgentClient();
const session = await client.createSession({
@@ -18,6 +18,7 @@ import type {
AgentRuntimeInfo,
AgentSession,
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
FetchCatalogOptions,
@@ -35,7 +36,13 @@ import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest
export const MOCK_LOAD_TEST_PROVIDER_ID = "mock";
export const MOCK_LOAD_TEST_DEFAULT_MODEL_ID = "five-minute-stream";
export const MOCK_LOAD_TEST_HANDLED_COMMAND = "/mock handled-command";
const MOCK_LOAD_TEST_COMMAND: AgentSlashCommand = {
name: "mock",
description: "Reply without starting a turn",
argumentHint: "handled-command",
kind: "command",
};
export const MOCK_LOAD_TEST_HANDLED_COMMAND = `/${MOCK_LOAD_TEST_COMMAND.name} ${MOCK_LOAD_TEST_COMMAND.argumentHint}`;
const MOCK_LOAD_TEST_MODE_ID = "load-test";
const MOCK_LOAD_TEST_DURATION_MS = 5 * 60 * 1000;
const MOCK_LOAD_TEST_INTERVAL_MS = 40;
@@ -223,6 +230,7 @@ interface LargeAgentStreamPayloadRequest {
}
interface AgentStreamStressRequest {
tools: boolean;
count: number;
coalesced: boolean;
}
@@ -288,6 +296,35 @@ function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRe
return null;
}
if (/with\s+links/i.test(text)) {
return {
questions: [
{
question:
"Sign in at https://example.com/login, then follow [the setup guide](https://example.com/setup). Done?",
header: "signIn",
options: [
{
label: "Done",
description: "Logs are at https://example.com/logs if sign-in fails.",
},
{ label: "Not yet, see [status](https://example.com/status)" },
],
multiSelect: false,
},
{
question: "Which checks should run?",
header: "checks",
options: [
{ label: "Lint", description: "Rules: https://example.com/lint" },
{ label: "Typecheck" },
],
multiSelect: true,
},
],
};
}
if (/free[-\s]?write|freeform|text[-\s]?only/i.test(text)) {
return {
questions: [
@@ -425,7 +462,7 @@ function parseLargeAgentStreamPayloadPrompt(
function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStressRequest | null {
const text = promptToText(prompt);
const match = /emit\s+(\d+)\s+(coalesced\s+)?agent stream updates/i.exec(text);
const match = /emit\s+(\d+)\s+(coalesced\s+|tool\s+)?agent stream updates/i.exec(text);
if (!match) {
return null;
}
@@ -435,7 +472,8 @@ function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStre
}
return {
count: Math.min(count, 5_000),
coalesced: Boolean(match[2]),
coalesced: match[2]?.trim() === "coalesced",
tools: match[2]?.trim() === "tool",
};
}
@@ -954,6 +992,10 @@ export class MockLoadTestAgentSession implements AgentSession {
return { turnId };
}
async listCommands(): Promise<AgentSlashCommand[]> {
return [MOCK_LOAD_TEST_COMMAND];
}
tryHandleOutOfBand(
prompt: AgentPromptInput,
): { run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void> } | null {
@@ -1412,21 +1454,37 @@ export class MockLoadTestAgentSession implements AgentSession {
this.emitTurnStarted(turn);
for (let index = 0; index < stress.count; index += 1) {
this.emitTimeline(
turn.turnId,
stress.coalesced
? {
type: "assistant_message",
text: `stress-update-${index}`,
messageId: turn.assistantMessageId,
}
: {
type: "todo",
items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }],
},
);
let item: AgentTimelineItem;
if (stress.tools) {
item = {
type: "tool_call",
callId: `${turn.turnId}:stress:${index}`,
name: "Bash",
status: "completed",
error: null,
detail: { type: "shell", command: `echo stress-${index}`, output: `stress-${index}` },
};
} else if (stress.coalesced) {
item = {
type: "assistant_message",
text: `stress-update-${index}`,
messageId: turn.assistantMessageId,
};
} else {
item = {
type: "todo",
items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }],
};
}
this.emitTimeline(turn.turnId, item);
}
if (stress.tools)
this.emitTimeline(turn.turnId, {
type: "assistant_message",
text: `Completed ${stress.count} commands`,
messageId: turn.assistantMessageId,
});
this.activeTurn = null;
const usage = {
inputTokens: 1,
@@ -10,7 +10,7 @@ import type { OmpAgentMessage } from "./rpc-types.js";
import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js";
import type { OmpUsagePollScheduler } from "./usage-poller.js";
import { resolveOmpProviderOptions } from "./provider-config.js";
import { OmpRuntimeEventSchema } from "./rpc-types.js";
import { OmpRuntimeEventSchema, OmpSessionStateSchema } from "./rpc-types.js";
import { OmpHarness } from "./test-utils/omp-harness.js";
import { OmpAgentClient } from "./agent.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
@@ -1133,6 +1133,17 @@ describe("OMP agent client and session", () => {
messageId: "assistant-history",
},
]);
await expect(
omp.runPrompt("continue after resume", "resumed follow-up complete"),
).resolves.toMatchObject({
finalText: "resumed follow-up complete",
});
expect(omp.timeline()).toContainEqual(
expect.objectContaining({
type: "assistant_message",
text: "resumed follow-up complete",
}),
);
expect(omp.usageSession()).toMatchObject({ provider: "omp", sessionKey: expect.any(String) });
await omp.close();
expect(omp.usageSession()).toBeNull();
@@ -1332,13 +1343,14 @@ describe("OMP agent client and session", () => {
expect(omp.runtimeLaunches()).toHaveLength(1);
});
test("closes a replacement process if the session closes during relaunch", async () => {
test("rejects a pending recovery start and closes its processes when the session closes", async () => {
const omp = new OmpHarness();
await omp.start();
omp.processExit("OMP RPC process exited with code 1 and signal null");
const turn = omp.startTurnDetached("continue");
const rejected = expect(turn).rejects.toThrow("closed");
await omp.close();
await turn;
await rejected;
await waitForImmediate();
expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true);
});
@@ -1544,3 +1556,176 @@ describe("OMP agent client and session", () => {
]);
});
});
test("omp runtime safety protects queued replies and runtime-owned activity", async () => {
const runtime = new FakeOmp();
const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({
provider: "omp",
cwd: process.cwd(),
});
onTestFinished(() => session.close());
const native = runtime.latestSession();
native.state.isSettled = true;
native.state.hasPendingAsyncWork = false;
expect(await session.canStopRuntime!()).toBe(true);
native.state.queuedMessageCount = 1;
expect(await session.canStopRuntime!()).toBe(false);
native.state.queuedMessageCount = 0;
native.state.isStreaming = true;
expect(await session.canStopRuntime!()).toBe(false);
native.state.isStreaming = false;
native.state.isCompacting = true;
expect(await session.canStopRuntime!()).toBe(false);
native.state.isCompacting = false;
expect(await session.canStopRuntime!()).toBe(true);
native.getStateError = new Error("Unable to prove idle");
await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle");
});
test("OMP runtime safety retains async work after the foreground turn finishes", async () => {
const runtime = new FakeOmp();
const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({
provider: "omp",
cwd: process.cwd(),
});
onTestFinished(() => session.close());
const native = runtime.latestSession();
native.state.hasPendingAsyncWork = true;
native.state.isSettled = false;
const prompted = native.nextPrompt();
const run = session.run("start a background job");
await prompted;
native.beginTurn();
native.acceptPrompt("start a background job", "async-user");
native.streamAssistantText("Job started in background");
native.finishTurn();
await run;
expect(native.state.isStreaming).toBe(false);
expect(native.state.queuedMessageCount).toBe(0);
expect(await session.canStopRuntime!()).toBe(false);
// The delivery is drained, but an admitted/scheduled continuation still prevents settlement.
native.state.hasPendingAsyncWork = false;
expect(await session.canStopRuntime!()).toBe(false);
native.state.isSettled = true;
expect(await session.canStopRuntime!()).toBe(true);
});
test.each([
{ hasPendingAsyncWork: true, isSettled: true, expected: false },
{ hasPendingAsyncWork: false, isSettled: false, expected: false },
{ hasPendingAsyncWork: false, isSettled: true, expected: true },
{ hasPendingAsyncWork: undefined, isSettled: true, expected: false },
{ hasPendingAsyncWork: false, isSettled: undefined, expected: false },
{ hasPendingAsyncWork: undefined, isSettled: undefined, expected: false },
])(
"OMP runtime safety requires explicit settlement: %j",
async ({ hasPendingAsyncWork, isSettled, expected }) => {
const runtime = new FakeOmp();
const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession(
{
provider: "omp",
cwd: process.cwd(),
},
);
onTestFinished(() => session.close());
const native = runtime.latestSession();
const response = { ...native.state };
if (hasPendingAsyncWork !== undefined) response.hasPendingAsyncWork = hasPendingAsyncWork;
if (isSettled !== undefined) response.isSettled = isSettled;
// Legacy get_state responses stay valid; missing safety fields never imply settlement.
native.state = OmpSessionStateSchema.parse(response);
expect(native.state.hasPendingAsyncWork).toBe(hasPendingAsyncWork);
expect(native.state.isSettled).toBe(isSettled);
expect(await session.canStopRuntime!()).toBe(expected);
},
);
test.each([false, true])(
"image prompt keeps OMP's vision capability contract (vision: %s)",
async (vision) => {
const runtime = new FakeOmp();
runtime.queueSessionSetup((session) => {
session.state = {
...session.state,
model: {
provider: "test",
id: "image-model",
input: vision ? ["text", "image"] : ["text"],
},
};
});
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
onTestFinished(() => session.close());
const bytes = Buffer.from("OMP attachment fixture");
const first = session.startTurn([
{ type: "text", text: "Describe this image." },
{ type: "image", data: bytes.toString("base64"), mimeType: "image/png" },
]);
await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active");
await first;
const [prompt] = runtime.latestSession().prompts;
expect(prompt.imageCount).toBe(vision ? 1 : 0);
if (vision) expect(prompt.message).toBe("Describe this image.");
else {
const imagePath = prompt.message.match(/\[Image available at: (.+)\]/)?.[1];
expect(imagePath).toBeTruthy();
const { readFile, rm } = await import("node:fs/promises");
expect(await readFile(imagePath!)).toEqual(bytes);
await rm(imagePath!);
}
},
);
test("interrupting OMP during image conversion cancels the admitted turn and permits a follow-up", async () => {
const runtime = new FakeOmp();
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
onTestFinished(() => session.close());
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
let admitted = false;
const first = session
.startTurn([
{
type: "image",
data: Buffer.alloc(16 * 1024 * 1024, 53).toString("base64"),
mimeType: "image/png",
},
])
.then((result) => {
admitted = true;
return result;
});
await waitForImmediate();
expect(admitted).toBe(false);
expect(runtime.latestSession().prompts).toHaveLength(0);
await session.interrupt();
const followup = await session.startTurn("follow-up after image interrupt");
const original = await first;
expect(events).toContainEqual(
expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }),
);
expect(followup.turnId).not.toBe(original.turnId);
expect(runtime.latestSession().prompts).toEqual([
{ message: "follow-up after image interrupt", imageCount: 0 },
]);
});
test("closing OMP during image conversion rejects its pending start", async () => {
const runtime = new FakeOmp();
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
const first = session.startTurn([
{
type: "image",
data: Buffer.alloc(16 * 1024 * 1024, 54).toString("base64"),
mimeType: "image/png",
},
]);
const rejected = expect(first).rejects.toThrow("closed");
await waitForImmediate();
expect(runtime.latestSession().prompts).toHaveLength(0);
await session.close();
await rejected;
});

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