Merge remote-tracking branch 'origin/main' into diagnose/3589-contributor-6b87ee9f

This commit is contained in:
paseo-bot[bot]
2026-11-08 06:37:04 +00:00
1387 changed files with 87894 additions and 20485 deletions
@@ -247,7 +247,7 @@ second line'`,
]);
});
it("keeps nested sub-agent logs beside the sub-agent that produced them", () => {
it("lists each sub-agent as its launch marker", () => {
const timeline: AgentTimelineItem[] = [
{ type: "assistant_message", text: "Before first child." },
toolCallItem({
@@ -257,7 +257,7 @@ second line'`,
type: "sub_agent",
subAgentType: "Child one",
description: "First investigation",
log: "[Assistant] First child result.",
log: "",
},
}),
{ type: "assistant_message", text: "Between children." },
@@ -268,7 +268,7 @@ second line'`,
type: "sub_agent",
subAgentType: "Child two",
description: "Second investigation",
log: "[Assistant] Second child result.",
log: "",
},
}),
{ type: "assistant_message", text: "After second child." },
@@ -279,10 +279,8 @@ second line'`,
expect(result.split("\n")).toEqual([
"[Assistant] Before first child.",
"[Child one] First investigation",
"[Assistant] First child result.",
"[Assistant] Between children.",
"[Child two] Second investigation",
"[Assistant] Second child result.",
"[Assistant] After second child.",
]);
});
@@ -456,6 +454,34 @@ second line'`,
).toThrow("Fork from a later completed response");
});
it("forks from a checkpoint when a plugin card was updated after it", () => {
const card = (seq: number, turns: number) =>
row(seq, {
type: "plugin",
id: "memory-receipt",
pluginId: "memorise",
kind: "memory-receipt",
version: 1,
data: { turns },
});
const result = buildAgentForkContextAttachment({
boundaryMessageId: "assistant-1",
rows: [
row(1, { type: "user_message", text: "First question", messageId: "user-1" }),
card(2, 1),
row(3, { type: "assistant_message", text: "First answer.", messageId: "assistant-1" }),
row(4, { type: "user_message", text: "Second question", messageId: "user-2" }),
row(5, { type: "assistant_message", text: "Second answer.", messageId: "assistant-2" }),
card(6, 2),
],
});
expect(result.attachment.text).toContain("[User] First question");
expect(result.attachment.text).toContain("[Assistant] First answer.");
expect(result.attachment.text).not.toContain("Second question");
expect(result.attachment.text).not.toContain("memory-receipt");
});
it("selects a synthetic assistant error by its timeline cursor", () => {
const result = buildAgentForkContextAttachment({
cursorBoundary: {
@@ -13,6 +13,12 @@ const MAX_TOOL_SUMMARY_CHARS = 200;
const MAX_FORK_CONTEXT_CHARS = 100_000;
const MAX_FORK_METADATA_CHARS = 1_000;
const FORK_HISTORY_OMITTED = "[Earlier chat history omitted]\n";
// A fork copies only these kinds, so only they can make a checkpoint stale.
const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [
"user_message",
"assistant_message",
"tool_call",
];
interface ActivityCuratorOptions {
maxItems?: number;
@@ -164,9 +170,6 @@ function curateProjectedActivityEntries(
break;
}
entries.push(formatToolCallEntry(item, options));
if (item.detail.type === "sub_agent" && item.detail.log.trim()) {
entries.push(activityEntry(item.detail.log.trim()));
}
break;
}
case "todo":
@@ -272,7 +275,14 @@ function selectForkContextRows(input: {
);
}
const boundarySeq = projectedRows[boundaryIndex].seqEnd;
if (projectedRows.some((row) => row.seqStart <= boundarySeq && row.seqEnd > boundarySeq)) {
if (
projectedRows.some(
(row) =>
FORK_CONTEXT_KINDS.includes(row.item.type) &&
row.seqStart <= boundarySeq &&
row.seqEnd > boundarySeq,
)
) {
throw new Error(
"This checkpoint changed after it was created. Fork from a later completed response instead.",
);
@@ -352,7 +362,7 @@ export function buildAgentForkContextAttachment(input: {
const entries = curateProjectedActivityEntries(selected.items, {
maxItems: 0,
labelAssistantMessages: true,
includeKinds: ["user_message", "assistant_message", "tool_call"],
includeKinds: FORK_CONTEXT_KINDS,
includeExternalToolInput: false,
});
const metadata = { agentTitle: input.agentTitle, cwd: input.cwd };
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
@@ -17,6 +17,7 @@ import type {
AgentSessionConfig,
} from "./agent-sdk-types.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import { createDaemonTestContext } from "../test-utils/daemon-test-context.js";
test("loads archived records for history and active records with the interactive default", async () => {
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-purpose-"));
@@ -142,60 +143,247 @@ test("resuming a stored agent keeps its unread flag and its last-activity time",
}
});
test("loads an archived agent's history after its working directory is removed", async () => {
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-missing-cwd-"));
const worktree = path.join(root, "managed-worktree");
await mkdir(worktree, { recursive: true });
const logger = createTestLogger();
const storage = new AgentStorage(path.join(root, "agents"), logger);
const manager = new AgentManager({
clients: createTestAgentClients(),
registry: storage,
logger,
});
const agentId = "00000000-0000-4000-8000-000000000501";
try {
const agent = await manager.createAgent({ provider: "codex", cwd: worktree }, agentId, {
workspaceId: "workspace-worktree",
});
await startAgentRun(manager, agent.id, "what did you change", logger, {});
// Dispatching a run does not finish it: the provider appends the reply to its
// history afterwards, and the turn is finalized only once that append lands.
// Archive after the turn is finalized so the transcript this test reads back is
// already on disk when the worktree goes away.
const finished = await manager.waitForAgentEvent(agent.id);
expect(finished.status).toBe("idle");
await manager.archiveAgent(agent.id);
await manager.closeAgent(agent.id);
await manager.flush();
await storage.flush();
// Archiving the workspace removes the worktree it owned. The agent's history is
// persisted and reading it must not depend on that directory still being there.
await rm(worktree, { recursive: true, force: true });
const loaded = await ensureAgentLoaded(agentId, {
agentManager: manager,
agentStorage: storage,
test.each([false, true])(
"loads history after its working directory is removed (archived: %s)",
async (archived) => {
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-missing-cwd-"));
const worktree = path.join(root, "managed-worktree");
await mkdir(worktree, { recursive: true });
const logger = createTestLogger();
const storage = new AgentStorage(path.join(root, "agents"), logger);
const manager = new AgentManager({
clients: createTestAgentClients(),
registry: storage,
logger,
});
expect(loaded.id).toBe(agentId);
// The transcript is replayed from the provider's persisted history, so the reply the
// agent gave before the worktree went away is still readable.
const replies = manager
.getTimeline(agentId)
.filter((item) => item.type === "assistant_message");
expect(replies.length).toBeGreaterThan(0);
expect(replies.every((item) => item.type === "assistant_message" && item.text.length > 0)).toBe(
true,
const agentId = "00000000-0000-4000-8000-000000000501";
try {
const agent = await manager.createAgent({ provider: "codex", cwd: worktree }, agentId, {
workspaceId: "workspace-worktree",
});
await startAgentRun(manager, agent.id, "what did you change", logger, {});
// Dispatching a run does not finish it: the provider appends the reply to its
// history afterwards, and the turn is finalized only once that append lands.
// Archive after the turn is finalized so the transcript this test reads back is
// already on disk when the worktree goes away.
const finished = await manager.waitForAgentEvent(agent.id);
expect(finished.status).toBe("idle");
if (archived) await manager.archiveAgent(agent.id);
await manager.closeAgent(agent.id);
await manager.flush();
await storage.flush();
// Archiving the workspace removes the worktree it owned. The agent's history is
// persisted and reading it must not depend on that directory still being there.
await rm(worktree, { recursive: true, force: true });
const loaded = await ensureAgentLoaded(agentId, {
agentManager: manager,
agentStorage: storage,
logger,
purpose: "history",
});
expect(loaded.id).toBe(agentId);
// The transcript is replayed from the provider's persisted history, so the reply the
// agent gave before the worktree went away is still readable.
const replies = manager
.getTimeline(agentId)
.filter((item) => item.type === "assistant_message");
expect(replies.length).toBeGreaterThan(0);
expect(
replies.every((item) => item.type === "assistant_message" && item.text.length > 0),
).toBe(true);
} finally {
await manager.closeAgent(agentId).catch(() => undefined);
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
await rm(root, { recursive: true, force: true });
}
},
);
test("timeline requests read unarchived stored history with a deleted cwd", async () => {
const ctx = await createDaemonTestContext();
const cwd = await mkdtemp(path.join(tmpdir(), "timeline-deleted-cwd-"));
try {
const agent = await ctx.client.createAgent({ provider: "codex", cwd });
await ctx.client.sendMessage(agent.id, "remember this conversation");
await ctx.client.waitForFinish(agent.id, 15000);
const before = await ctx.client.fetchAgentTimeline(agent.id);
await ctx.daemon.daemon.agentManager.closeAgent(agent.id);
await ctx.daemon.daemon.agentManager.flush();
await rm(cwd, { recursive: true, force: true });
const after = await ctx.client.fetchAgentTimeline(agent.id);
expect(after.entries).toEqual(before.entries);
expect(after.entries.length).toBeGreaterThan(0);
await expect(ctx.client.sendMessage(agent.id, "continue")).rejects.toThrow(
"Working directory does not exist",
);
await expect(ctx.client.createAgent({ provider: "codex", cwd })).rejects.toThrow(
"Working directory does not exist",
);
await mkdir(cwd);
await ctx.client.sendMessage(agent.id, "continue after restoring directory");
expect((await ctx.client.waitForFinish(agent.id, 15000)).status).toBe("idle");
} finally {
await manager.closeAgent(agentId).catch(() => undefined);
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
await rm(root, { recursive: true, force: true });
await ctx.cleanup();
await rm(cwd, { recursive: true, force: true });
}
});
}, 30000);
test("timeline reads keep runnable unarchived agents interactive for the next prompt", async () => {
const clients = createTestAgentClients();
const base = clients.codex!;
const purposes: Array<AgentResumeSessionOptions | undefined> = [];
const client: AgentClient = {
provider: base.provider,
capabilities: base.capabilities,
createSession: (config, context) => base.createSession(config, context),
resumeSession: (handle, overrides, context, options) => {
purposes.push(options);
return base.resumeSession(handle, overrides, context, options);
},
fetchCatalog: (options) => base.fetchCatalog(options),
isAvailable: () => base.isAvailable(),
};
const ctx = await createDaemonTestContext({ agentClients: { codex: client } });
const cwd = await mkdtemp(path.join(tmpdir(), "timeline-interactive-"));
try {
const agent = await ctx.client.createAgent({ provider: "codex", cwd });
await ctx.daemon.daemon.agentManager.closeAgent(agent.id);
await ctx.client.fetchAgentTimeline(agent.id);
expect(purposes).toEqual([{ purpose: "interactive" }]);
await ctx.client.sendMessage(agent.id, "continue after opening history");
expect((await ctx.client.waitForFinish(agent.id, 15000)).status).toBe("idle");
const timeline = await ctx.client.fetchAgentTimeline(agent.id);
expect(timeline.entries.length).toBeGreaterThan(0);
await ctx.daemon.daemon.agentManager.closeAgent(agent.id);
await rm(cwd, { recursive: true, force: true });
await ctx.client.fetchAgentTimeline(agent.id);
await mkdir(cwd);
const manager = ctx.daemon.daemon.agentManager;
await Promise.all([
manager.tryRunOutOfBand(agent.id, "first prompt"),
manager.tryRunOutOfBand(agent.id, "second prompt"),
]);
expect(purposes).toEqual([
{ purpose: "interactive" },
{ purpose: "history" },
undefined, // Reload uses the provider's interactive default.
]);
} finally {
await ctx.cleanup();
await rm(cwd, { recursive: true, force: true });
}
}, 30000);
test.each(["mode", "thinking", "rewind"])(
"restored OMP history accepts %s before starting an interactive turn",
async (setting) => {
const { OmpAgentClient } = await import("./providers/omp/agent.js");
const { FakeOmp } = await import("./providers/omp/test-utils/fake-omp.js");
const { sendPreparedPromptToAgent } = await import("./agent-prompt.js");
const root = await mkdtemp(path.join(tmpdir(), "history-mode-"));
const cwd = path.join(root, "removed-worktree");
const sessionFile = path.join(root, "session.jsonl");
await writeFile(
sessionFile,
[
{ type: "session", id: "root", parentId: null },
{
type: "message",
id: "user-1",
parentId: "root",
message: { role: "user", content: "saved question" },
},
]
.map((entry) => JSON.stringify(entry))
.join("\n") + "\n",
);
const logger = createTestLogger();
const storage = new AgentStorage(path.join(root, "agents"), logger);
const runtime = new FakeOmp();
const manager = new AgentManager({
clients: {
omp: new OmpAgentClient({
logger,
runtime,
runtimeSettings: { command: { mode: "replace", argv: [process.execPath] } },
}),
},
registry: storage,
logger,
});
const agentId = "00000000-0000-4000-8000-000000000601";
const now = new Date().toISOString();
try {
await storage.upsert({
id: agentId,
provider: "omp",
cwd,
createdAt: now,
updatedAt: now,
labels: {},
lastStatus: "closed",
archivedAt: null,
persistence: {
provider: "omp",
sessionId: "history-mode",
nativeHandle: sessionFile,
metadata: { cwd },
},
});
await ensureAgentLoaded(agentId, {
agentManager: manager,
agentStorage: storage,
logger,
purpose: "history",
});
expect(
runtime.recordedLaunches.filter((launch) => launch.session === sessionFile),
).toHaveLength(0);
await mkdir(cwd);
if (setting === "rewind") {
await manager.rewind(agentId, "user-1", "conversation");
expect(runtime.latestSession().branchRequests).toEqual(["user-1"]);
}
if (setting === "thinking") {
await manager.setAgentThinkingOption(agentId, "high");
expect(manager.getAgent(agentId)?.config.thinkingOptionId).toBe("high");
expect(runtime.latestSession().setThinkingLevelRequests).toContain("high");
}
await sendPreparedPromptToAgent({
agentManager: manager,
agentStorage: storage,
agentId,
logger,
prompt: "continue",
sessionMode: "full",
});
expect(
runtime.recordedLaunches.filter((launch) => launch.session === sessionFile),
).toHaveLength(1);
expect(manager.getAgent(agentId)?.currentModeId).toBe("full");
expect(runtime.latestSession().prompts).toEqual([
expect.objectContaining({ message: "continue" }),
]);
runtime.latestSession().finishTurn();
await manager.waitForAgentEvent(agentId);
await manager.archiveAgent(agentId);
await manager.closeAgent(agentId);
await ensureAgentLoaded(agentId, { agentManager: manager, agentStorage: storage, logger });
const launchesBeforeModeChange = runtime.recordedLaunches.length;
await expect(manager.setAgentMode(agentId, "full")).rejects.toThrow("Agent is archived");
expect(runtime.recordedLaunches).toHaveLength(launchesBeforeModeChange);
} finally {
await manager.closeAgent(agentId).catch(() => undefined);
await manager.flush();
await storage.flush();
await rm(root, { recursive: true, force: true });
}
},
30000,
);
@@ -41,6 +41,7 @@ export interface EnsureAgentLoadedDeps {
agentStorage: AgentStorage;
validProviders?: Iterable<AgentProvider>;
broadcastTimeline?: boolean;
purpose?: "history" | "interactive";
logger: Logger;
}
@@ -118,7 +119,7 @@ export async function ensureAgentLoaded(
buildConfigOverrides(record),
agentId,
{ ...extractTimestamps(record), attention: extractAttention(record) },
record.archivedAt ? { purpose: "history" } : undefined,
deps.purpose ? { purpose: deps.purpose } : undefined,
);
deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence");
} else {
@@ -135,6 +136,7 @@ export async function ensureAgentLoaded(
labels: record.labels,
workspaceId: record.workspaceId,
owner: record.owner,
background: record.background,
env: record.launchEnv,
});
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager, type AgentManagerEvent } from "./agent-manager.js";
import { AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS } from "./agent-stream-coalescer.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
import { projectTimelineRows } from "./timeline-projection.js";
import type {
@@ -31,7 +32,6 @@ import type {
const COALESCE_WINDOW_MS = AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS;
const BEFORE_COALESCE_WINDOW_MS = Math.max(COALESCE_WINDOW_MS - 1, 0);
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: false,
@@ -400,10 +400,7 @@ describe("target coalesced behavior", () => {
try {
const { agentId, session } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output }));
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
await waitForSessionEventQueue();
@@ -425,10 +422,7 @@ describe("target coalesced behavior", () => {
try {
const { agentId } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output }));
await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output }));
@@ -449,10 +443,7 @@ describe("target coalesced behavior", () => {
try {
const { agentId, session } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output }));
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
await harness.manager.hydrateTimelineFromProvider(agentId, { force: true });
@@ -470,10 +461,7 @@ describe("target coalesced behavior", () => {
try {
const { agentId } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output }));
await harness.manager.emitLiveTimelineItem(
agentId,
@@ -495,10 +483,9 @@ describe("target coalesced behavior", () => {
try {
const { agentId, session } = await createManagedSession(harness);
const content = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "failed",
error: { content: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH) },
});
const expectedItem = limitAgentTimelineItemContent(
toolCall({ status: "failed", error: { content } }),
);
session.pushEvent(timelineEvent(toolCall({ status: "failed", error: { content } })));
await waitForSessionEventQueue();
File diff suppressed because it is too large Load Diff
+270 -50
View File
@@ -98,6 +98,7 @@ import {
type ProviderSubagentDescriptor,
type ProviderSubagentStoreEvent,
} from "./provider-subagents/store.js";
import { AgentBackgroundWorkStore, type AgentBackgroundWorkItem } from "./background-work/index.js";
import { withTimeout } from "../../utils/promise-timeout.js";
import { extractAttention } from "../persistence-hooks.js";
@@ -171,6 +172,29 @@ async function assertUsableWorkingDirectory(cwd: string): Promise<void> {
}
}
interface StopAgentBackgroundWorkOptions {
agentId: string;
itemId: string;
stopToken: string;
}
async function resolveResumePurpose(
record: StoredAgentRecord | null,
requested: AgentResumePurpose | undefined,
cwd: string,
): Promise<AgentResumePurpose> {
if (!record) return requested ?? "interactive";
if (record.archivedAt) return "history";
if (requested !== "history") return "interactive";
try {
await stat(cwd);
return "interactive";
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "history";
throw error;
}
}
interface PreparedSessionConfig {
storedConfig: AgentSessionConfig;
launchConfig: AgentSessionConfig;
@@ -238,6 +262,7 @@ export type AgentManagerEvent =
| { type: "agent_state"; agent: ManagedAgent }
| { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean }
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
| { type: "background_work"; agentId: string; items: AgentBackgroundWorkItem[] }
| { type: "timeline_replacement"; agentId: string; epoch: string }
| {
type: "agent_stream";
@@ -324,6 +349,8 @@ export interface CreateAgentOptions {
// undefined is an explicit decision: the agent never appears in the sidebar.
workspaceId: string | undefined;
owner?: AgentOwner;
/** Background agents stay out of discovery, tabs, tracks and attention. Fixed at creation. */
background?: boolean;
}
export interface AgentManagerOptions {
@@ -387,6 +414,18 @@ export type AttentionState =
attentionTimestamp: Date;
};
/** Private helpers and background agents report to their creator, never to the user. */
function raisesAttention(agent: Pick<ManagedAgent, "internal" | "background">): boolean {
return !agent.internal && !agent.background;
}
/** The attention a read agent gets back when marked unread; null while it is still working. */
function unreadAttentionReason(status: AgentLifecycleStatus): "finished" | "error" | null {
if (status === "idle" || status === "closed") return "finished";
if (status === "error") return "error";
return null;
}
function resolveInitialAttention(input: AttentionState | undefined): AttentionState {
if (input == null || !input.requiresAttention) {
return { requiresAttention: false };
@@ -452,6 +491,11 @@ interface ManagedAgentBase {
* Internal agents are hidden from listings and don't trigger notifications.
*/
internal?: boolean;
/**
* Background agents are durable but hidden from default discovery and never raise attention.
* Fixed at creation.
*/
background?: boolean;
/**
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
*/
@@ -620,6 +664,36 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri
return Object.assign(new Error(message), { name: "AbortError" });
}
// PASEO_WORKSPACE_ID lets an agent rename or archive its own workspace; several workspaces can
// share one cwd, so the agent can't work it out from PASEO_AGENT_CWD.
function agentIdentityEnv({
agentId,
cwd,
opening,
}: {
agentId: string;
cwd: string;
opening: { workspaceId?: string | null } | undefined;
}): Record<string, string> {
const workspaceId = opening?.workspaceId;
return {
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
...(workspaceId ? { PASEO_WORKSPACE_ID: workspaceId } : {}),
};
}
// Launch env and the registered agent must agree, or the next snapshot erases the stored workspace.
function resolveResumeWorkspaceId({
options,
record,
}: {
options: { workspaceId?: string } | undefined;
record: Pick<StoredAgentRecord, "workspaceId"> | null;
}): string | undefined {
return options?.workspaceId ?? record?.workspaceId;
}
function validateAgentId(agentId: string, source: string): string {
const result = AgentIdSchema.safeParse(agentId);
if (!result.success) {
@@ -758,6 +832,7 @@ export class AgentManager {
private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore: InMemoryAgentTimelineStore;
private readonly providerSubagents = new ProviderSubagentStore();
private readonly backgroundWork = new AgentBackgroundWorkStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
private readonly sessionEventTails = new Map<string, Promise<void>>();
private readonly steerEventBarriers = new Map<string, SteerEventBarrier>();
@@ -771,6 +846,7 @@ export class AgentManager {
private readonly backgroundTasks = new Set<Promise<void>>();
private readonly agentRegistrationTasks = new Set<Promise<void>>();
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
private readonly historySessions = new WeakSet<AgentSession>();
private readonly reloadedSessionCloses = new WeakMap<AgentSession, Promise<void>>();
private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>();
private readonly collectingRuntimes = new Set<string>();
@@ -1317,6 +1393,38 @@ export class AgentManager {
return this.providerSubagents.list(parentAgentId);
}
listAgentBackgroundWork(agentId: string): AgentBackgroundWorkItem[] {
this.requirePublicAgent(agentId);
return this.backgroundWork.list(agentId);
}
async stopAgentBackgroundWork({
agentId,
itemId,
stopToken,
}: StopAgentBackgroundWorkOptions): Promise<void> {
this.requirePublicAgent(agentId);
// Inventory events share the session queue; drain events already received before
// authorizing the token, including a reconnect's ownership revocation.
await this.sessionEventTails.get(agentId)?.catch(() => undefined);
const agent = this.requirePublicAgent(agentId);
if (!agent.session || !agent.session.stopBackgroundWork)
throw new Error("Background work cannot be stopped");
const item = this.backgroundWork.list(agentId).find((entry) => entry.id === itemId);
if (!item?.canStop || item.stopToken !== stopToken)
throw new Error("Background work is no longer available to stop");
await agent.session.stopBackgroundWork(itemId);
}
getAgentBackgroundWorkSummary(agentId: string): { count: number } | undefined {
return this.backgroundWork.summary(agentId);
}
private clearBackgroundWork(agentId: string): void {
const items = this.backgroundWork.clear(agentId);
if (items) this.dispatch({ type: "background_work", agentId, items });
}
listProviderSubagentActivity(): ProviderSubagentDescriptor[] {
const publicParentIds = new Set(
Array.from(this.agents.values())
@@ -1402,6 +1510,7 @@ export class AgentManager {
initialTitle: options.initialTitle,
workspaceId: options.workspaceId,
owner: options.owner,
background: options.background,
launchEnv: options.env ? { ...options.env } : undefined,
historyPrimed: true,
});
@@ -1434,6 +1543,7 @@ export class AgentManager {
labels?: Record<string, string>;
workspaceId?: string;
owner?: AgentOwner;
background?: boolean;
attention?: AttentionState;
},
resumeOptions?: AgentResumeSessionOptions,
@@ -1466,6 +1576,7 @@ export class AgentManager {
labels?: Record<string, string>;
workspaceId?: string;
owner?: AgentOwner;
background?: boolean;
attention?: AttentionState;
},
resumeOptions?: AgentResumeSessionOptions,
@@ -1486,12 +1597,11 @@ export class AgentManager {
// settled before the config is prepared, because a history load reads an archived
// agent whose working directory may be gone.
const record = this.registry ? await this.registry.get(resolvedAgentId) : null;
const currentResumeOptions = record
? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) }
: resumeOptions;
const purpose = currentResumeOptions?.purpose ?? "interactive";
const purpose = await resolveResumePurpose(record, resumeOptions?.purpose, mergedConfig.cwd);
const currentResumeOptions = { purpose } satisfies AgentResumeSessionOptions;
const launchEnv = record?.launchEnv;
const workspaceId = resolveResumeWorkspaceId({ options, record });
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
mergedConfig,
resolvedAgentId,
@@ -1514,7 +1624,7 @@ export class AgentManager {
{
reason: "resume",
purpose,
workspaceId: options?.workspaceId ?? null,
workspaceId,
title: record?.title ?? mergedConfig.title ?? null,
labels: record?.labels ?? options?.labels ?? {},
},
@@ -1526,9 +1636,11 @@ export class AgentManager {
launchContext,
currentResumeOptions,
);
if (purpose === "history") this.historySessions.add(session);
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, {
...options,
workspaceId,
launchEnv,
persistence: handle,
restoring: true,
@@ -1567,6 +1679,7 @@ export class AgentManager {
cwd: input.cwd,
},
resolvedAgentId,
{ resolveDefaultModel: false },
);
this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy);
const launchContext = await this.buildLaunchContext(
@@ -1593,8 +1706,13 @@ export class AgentManager {
await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext);
let handedToRegistration = false;
try {
const runtimeInfo = await imported.session.getRuntimeInfo();
const importedConfig = await this.normalizeConfig(
stripInternalPaseoMcpServer(imported.config),
stripInternalPaseoMcpServer({
...imported.config,
model: runtimeInfo.model ?? imported.config.model,
}),
{ resolveDefaultModel: false },
);
const timelineRows = buildImportedTimelineRows(imported.timeline);
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
@@ -1701,6 +1819,7 @@ export class AgentManager {
await this.closeReloadedSession(existing.session, agentId);
await this.drainSessionEvents(agentId);
this.cancelRunningProviderSubagents(agentId);
this.clearBackgroundWork(agentId);
closedExisting = this.prepareAgentForClosure(existing, "agent reloaded");
await this.persistSnapshot(closedExisting);
this.assertAcceptingAgentRegistrations();
@@ -1727,6 +1846,7 @@ export class AgentManager {
labels: existing.labels,
workspaceId: existing.workspaceId,
owner: existing.owner,
background: existing.background,
launchEnv: existing.launchEnv,
createdAt: existing.createdAt,
updatedAt: existing.updatedAt,
@@ -1743,6 +1863,7 @@ export class AgentManager {
} else if (this.agents.get(agentId) === existing) {
existing.lifecycle = "error";
existing.lastError = error instanceof Error ? error.message : String(error);
this.raiseErrorAttention(existing);
this.emitState(existing);
}
throw error;
@@ -1947,7 +2068,10 @@ export class AgentManager {
// 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();
await this.drainSessionEvents(agentId);
this.cancelRunningProviderSubagents(agentId);
// close() drops the session's subscribers before the provider could report an empty list.
this.clearBackgroundWork(agentId);
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
let persistError: unknown;
@@ -2048,7 +2172,7 @@ export class AgentManager {
return;
}
const parentAgentId = parent.id;
const records = await registry.list();
const records = await registry.listActive();
for (const record of records) {
if (record.archivedAt) {
continue;
@@ -2070,7 +2194,10 @@ export class AgentManager {
return;
}
if (shouldDetachFromArchivedParent(parent, currentChild)) {
await this.detachAgentUnlocked(currentChild.id);
const detached = await this.detachAgentUnlocked(currentChild.id);
if (!detached.live) {
this.dispatchStoredAgentState(detached.record);
}
} else if (this.agents.has(currentChild.id)) {
await this.archiveAgentUnlocked(currentChild.id);
} else {
@@ -2156,7 +2283,7 @@ export class AgentManager {
session: null,
capabilities: STORED_AGENT_CAPABILITIES,
config: buildStoredAgentConfig(record),
runtimeInfo: undefined,
runtimeInfo: record.runtimeInfo,
lifecycle: "closed",
createdAt: new Date(record.createdAt),
updatedAt,
@@ -2180,13 +2307,14 @@ export class AgentManager {
lastError: record.lastError ?? undefined,
attention,
internal: record.internal,
background: record.background,
labels: record.labels,
},
});
}
async setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
const agent = this.requireSessionAgent(agentId);
const agent = await this.ensureInteractiveSession(agentId);
const notice = (await agent.session.setMode(modeId)) ?? null;
await this.drainSessionEvents(agentId);
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
@@ -2202,18 +2330,20 @@ export class AgentManager {
}
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
const agent = this.requireSessionAgent(agentId);
const agent = await this.ensureInteractiveSession(agentId);
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
if (agent.session.setModel) {
await agent.session.setModel(normalizedModelId);
}
const selection = await agent.session.setModel?.(normalizedModelId);
await this.drainSessionEvents(agentId);
agent.config.model = normalizedModelId ?? undefined;
// A reset clears the preference even when the provider retains its active model.
let effectiveModelId = normalizedModelId;
if (normalizedModelId !== null && selection) effectiveModelId = selection.model;
agent.config.model = effectiveModelId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
agent.runtimeInfo = { ...agent.runtimeInfo, model: effectiveModelId };
}
this.refreshSessionPersistence(agent);
this.touchUpdatedAt(agent);
@@ -2224,7 +2354,7 @@ export class AgentManager {
agentId: string,
thinkingOptionId: string | null,
): Promise<AgentProviderNotice | null> {
const agent = this.requireSessionAgent(agentId);
const agent = await this.ensureInteractiveSession(agentId);
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
@@ -2255,7 +2385,7 @@ export class AgentManager {
}
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
const agent = this.requireAgent(agentId);
const agent = await this.ensureInteractiveSession(agentId);
if (!agent.session.setFeature) {
throw new Error("Agent session does not support setting features");
@@ -2398,16 +2528,16 @@ export class AgentManager {
async markAgentUnread(agentId: string): Promise<void> {
const liveAgent = this.agents.get(agentId);
if (liveAgent) {
const isFinished = liveAgent.lifecycle === "idle";
const attentionReason = unreadAttentionReason(liveAgent.lifecycle);
const hasPendingPermissions = liveAgent.pendingPermissions.size > 0;
const canMarkUnread =
isFinished && !liveAgent.attention.requiresAttention && !hasPendingPermissions;
attentionReason && !liveAgent.attention.requiresAttention && !hasPendingPermissions;
if (!canMarkUnread) {
throw new Error(`Agent is no longer finished and read: ${agentId}`);
throw new Error(`Agent is no longer settled and read: ${agentId}`);
}
liveAgent.attention = {
requiresAttention: true,
attentionReason: "finished",
attentionReason,
attentionTimestamp: new Date(),
};
await this.persistSnapshot(liveAgent);
@@ -2417,18 +2547,18 @@ export class AgentManager {
const registry = this.requireRegistry();
const record = await registry.get(agentId);
const hasFinishedStatus = record?.lastStatus === "idle" || record?.lastStatus === "closed";
const attentionReason = record?.lastStatus ? unreadAttentionReason(record.lastStatus) : null;
const canMarkUnread =
record && !record.internal && !record.archivedAt && !record.requiresAttention;
if (!canMarkUnread || !hasFinishedStatus) {
throw new Error(`Agent is no longer finished and read: ${agentId}`);
if (!canMarkUnread || !attentionReason) {
throw new Error(`Agent is no longer settled and read: ${agentId}`);
}
const updatedAt = this.nextStoredUpdatedAt(record);
const nextRecord: StoredAgentRecord = {
...record,
updatedAt,
requiresAttention: true,
attentionReason: "finished",
attentionReason,
attentionTimestamp: updatedAt,
};
await registry.upsert(nextRecord);
@@ -2497,23 +2627,28 @@ export class AgentManager {
if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId);
await this.syncNativeArchiveState(record.provider, record.persistence, "restore");
await registry.upsert({
const nextRecord: StoredAgentRecord = {
...record,
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
archivedAt: null,
updatedAt: new Date().toISOString(),
});
};
await registry.upsert(nextRecord);
if (this.getAgent(agentId)) {
this.notifyAgentState(agentId);
} else if (!nextRecord.internal) {
// Same publication archiveSnapshot makes, so a stored-only agent reappears for every client.
this.dispatchStoredAgentState(nextRecord);
}
return true;
}
async unarchiveSnapshotByHandle(handle: AgentPersistenceHandle): Promise<void> {
const registry = this.requireRegistry();
const records = await registry.list();
// Restoring an archived provider session is an explicit recovery operation.
const records = await registry.listAll();
const matched = records.find(
(record) =>
record.persistence?.provider === handle.provider &&
@@ -2636,7 +2771,7 @@ export class AgentManager {
logger: this.logger,
});
}
const agent = this.requireSessionAgent(agentId);
const agent = await this.ensureInteractiveSession(agentId);
// Providers parse slash commands from the raw text, so a command another agent sent
// must reach them without its sender envelope.
const agentMessage = typeof prompt === "string" ? parseAgentMessage(prompt) : null;
@@ -2765,6 +2900,34 @@ export class AgentManager {
}
}
private async ensureInteractiveSession(agentId: string): Promise<ActiveManagedAgent> {
const agent = this.requireSessionAgent(agentId);
if (this.historySessions.has(agent.session)) {
await this.trackAgentRegistrationOperation(
this.runLifecycleMutation(agentId, async () => {
const record = await this.registry?.get(agentId);
if (record?.archivedAt) throw new Error(`Agent is archived: ${agentId}`);
const current = this.requireSessionAgent(agentId);
if (this.historySessions.has(current.session)) {
// Recheck inside the lane: another prompt may already have upgraded it.
// Reload validates the directory before releasing readable history.
await this.reloadAgentSessionInternal(agentId);
}
}),
);
}
return this.requireSessionAgent(agentId);
}
private async *streamAfterHistoryLoad(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions,
): AsyncGenerator<AgentStreamEvent> {
await this.ensureInteractiveSession(agentId);
yield* this.streamAgent(agentId, prompt, options);
}
private async *streamAfterRuntimeCollection(
agentId: string,
prompt: AgentPromptInput,
@@ -2787,6 +2950,8 @@ export class AgentManager {
if (this.collectingRuntimes.has(agentId) || (!this.agents.has(agentId) && this.registry))
return this.streamAfterRuntimeCollection(agentId, prompt, options);
const existingAgent = this.requireSessionAgent(agentId);
if (this.historySessions.has(existingAgent.session))
return this.streamAfterHistoryLoad(agentId, prompt, options);
this.logger.trace(
{
agentId,
@@ -2840,6 +3005,7 @@ export class AgentManager {
this.runs.settleForegroundStart(pendingRun, { status: "started", turnId });
agent.activeForegroundTurnId = turnId;
this.openActiveTurn(agent, turnId, turnStartedAt);
agent.attention = { requiresAttention: false };
agent.lifecycle = "running";
this.touchUpdatedAt(agent);
// AgentManager owns the accepted-turn boundary. Publish liveness before the canonical
@@ -3029,7 +3195,13 @@ export class AgentManager {
expectedTurnId,
});
if (admission.status === "accepted") {
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId);
await this.recordAcceptedSteer(
agent,
prompt,
options?.clientMessageId,
expectedTurnId,
admission.providerMessageId,
);
}
return admission;
});
@@ -3062,7 +3234,13 @@ export class AgentManager {
expectedTurnId,
});
if (admission.status === "accepted") {
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId);
await this.recordAcceptedSteer(
agent,
prompt,
options?.clientMessageId,
expectedTurnId,
admission.providerMessageId,
);
}
return admission;
})
@@ -3167,6 +3345,7 @@ export class AgentManager {
prompt: AgentPromptInput,
clientMessageId: string | undefined,
expectedTurnId: string,
providerMessageId: string | undefined,
): Promise<void> {
if (!clientMessageId) {
return;
@@ -3174,6 +3353,7 @@ export class AgentManager {
this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", {
messageId: clientMessageId,
turnId: expectedTurnId,
...(providerMessageId ? { providerMessageId } : {}),
});
this.emitState(agent);
}
@@ -3458,7 +3638,12 @@ export class AgentManager {
}
async rewind(agentId: string, messageId: string, mode: RewindMode): Promise<void> {
const agent = this.requireSessionAgent(agentId);
let agent = this.requireSessionAgent(agentId);
if (this.historySessions.has(agent.session)) {
agent = await this.ensureInteractiveSession(agentId);
}
// An interactive session must acquire its rewind lock without yielding.
const submittedRow = this.timelineStore
.getRows(agentId)
.find(
@@ -3505,6 +3690,10 @@ export class AgentManager {
"agent.rewind.complete",
);
} catch (error) {
// Native rewind can commit a fork before history refresh fails. Recovery
// must resume that latest handle rather than the abandoned conversation.
this.refreshSessionPersistence(agent);
await this.persistSnapshot(agent);
this.logger.warn(
{ err: error, agentId, provider: agent.provider, messageId, mode },
"agent.rewind.failed",
@@ -3787,6 +3976,7 @@ export class AgentManager {
publishWhenReady?: boolean;
workspaceId?: string;
owner?: AgentOwner;
background?: boolean;
launchEnv?: Record<string, string>;
},
): Promise<ManagedAgent> {
@@ -3816,6 +4006,7 @@ export class AgentManager {
config,
now,
durableTimelineHasRows,
background: options?.background === true,
options,
});
@@ -3960,6 +4151,7 @@ export class AgentManager {
config: AgentSessionConfig;
now: Date;
durableTimelineHasRows: boolean;
background: boolean;
options:
| {
createdAt?: Date;
@@ -4014,6 +4206,7 @@ export class AgentManager {
lastError: options?.lastError,
attention: resolveInitialAttention(options?.attention),
internal: config.internal ?? false,
background: params.background,
labels: options?.labels ?? {},
} as ActiveManagedAgent;
}
@@ -4073,6 +4266,7 @@ export class AgentManager {
for (const event of this.providerSubagents.deleteParent(agentId)) {
this.dispatch({ type: "provider_subagent", event });
}
this.clearBackgroundWork(agentId);
}
private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void {
@@ -4182,6 +4376,14 @@ export class AgentManager {
this.dispatch({ type: "provider_subagent", event: update });
return;
}
if (event.type === "background_work") {
const items =
event.items === null
? this.backgroundWork.clear(agent.id)
: this.backgroundWork.apply(agent.id, event.items);
if (items) this.dispatch({ type: "background_work", agentId: agent.id, items });
return;
}
const turnId = getAgentStreamEventTurnId(event);
const matchingWaiters = this.runs.getMatchingWaiters(agent, turnId);
this.logger.trace(
@@ -4934,6 +5136,8 @@ export class AgentManager {
if (eventTurnId) {
this.openActiveTurn(agent, eventTurnId, new Date());
}
agent.lastError = undefined;
agent.attention = { requiresAttention: false };
agent.lifecycle = "running";
this.emitState(agent);
}
@@ -4945,7 +5149,7 @@ export class AgentManager {
const hadPendingPermissions = agent.pendingPermissions.size > 0;
agent.pendingPermissions.set(event.request.id, event.request);
this.refreshSessionPersistence(agent);
if (!hadPendingPermissions && !agent.internal) {
if (!hadPendingPermissions && raisesAttention(agent)) {
this.broadcastAgentAttention(agent, "permission");
}
this.emitState(agent);
@@ -5200,17 +5404,19 @@ export class AgentManager {
// Track the new status
this.previousStatuses.set(agent.id, currentStatus);
// Skip attention tracking for internal agents
if (agent.internal) {
if (!raisesAttention(agent)) {
return;
}
if (previousStatus !== "error" && currentStatus === "error") {
this.raiseErrorAttention(agent);
return;
}
// Skip if already requires attention
if (agent.attention.requiresAttention) {
return;
}
// Check if agent transitioned from running to idle (finished)
if (previousStatus === "running" && currentStatus === "idle") {
agent.attention = {
requiresAttention: true,
@@ -5218,19 +5424,25 @@ export class AgentManager {
attentionTimestamp: new Date(),
};
this.broadcastAgentAttention(agent, "finished");
return;
}
}
// Check if agent entered error state
if (previousStatus !== "error" && currentStatus === "error") {
agent.attention = {
requiresAttention: true,
attentionReason: "error",
attentionTimestamp: new Date(),
};
this.broadcastAgentAttention(agent, "error");
// The failed state is only shown while error attention is unread, so every new failure raises
// it, including one that keeps the lifecycle at "error". An error outranks an unread finish,
// which would otherwise make the failure look green.
private raiseErrorAttention(agent: ManagedAgent): void {
if (!raisesAttention(agent)) {
return;
}
if (agent.attention.requiresAttention && agent.attention.attentionReason !== "finished") {
return;
}
agent.attention = {
requiresAttention: true,
attentionReason: "error",
attentionTimestamp: new Date(),
};
this.broadcastAgentAttention(agent, "error");
}
private enqueueBackgroundPersist(agent: ManagedAgent): void {
@@ -5391,6 +5603,13 @@ export class AgentManager {
) {
continue;
}
if (
subscriber.agentId &&
event.type === "background_work" &&
subscriber.agentId !== event.agentId
) {
continue;
}
if (
subscriber.agentId &&
event.type === "agent_state" &&
@@ -5424,6 +5643,7 @@ export class AgentManager {
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 === "background_work") return this.agents.get(event.agentId)?.internal === true;
if (event.type !== "provider_subagent") return false;
const parentAgentId =
event.event.type === "upsert"
@@ -5511,11 +5731,12 @@ export class AgentManager {
private async prepareSessionConfig(
config: AgentSessionConfig,
agentId: string,
options: { env?: Record<string, string>; purpose?: AgentResumePurpose } = {},
options: NormalizeConfigOptions = {},
): Promise<PreparedSessionConfig> {
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), {
env: options.env,
purpose: options.purpose,
resolveDefaultModel: options.resolveDefaultModel,
});
const paseoToolPolicy = this.paseoToolsEnabled
? this.resolvePaseoToolPolicy(storedConfig.provider)
@@ -5594,8 +5815,7 @@ export class AgentManager {
mcpServerOwners,
env: {
...env,
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
...agentIdentityEnv({ agentId, cwd, opening }),
},
};
const useNativeTools =
@@ -4,6 +4,7 @@ import { AGENT_LIFECYCLE_STATUSES } from "./agent-manager.js";
import {
buildStoredAgentPayload,
toAgentPayload,
toAgentListItemPayload,
toRecentProviderSessionDescriptorPayload,
toStoredAgentRecord,
type ManagedAgent,
@@ -539,3 +540,11 @@ describe("toRecentProviderSessionDescriptorPayload", () => {
});
});
});
it("omits unknown background work and preserves known summary in list items", () => {
const snapshot = toAgentPayload(createManagedAgent());
expect(toAgentListItemPayload(snapshot)).not.toHaveProperty("backgroundWork");
expect(toAgentListItemPayload({ ...snapshot, backgroundWork: { count: 2 } })).toMatchObject({
backgroundWork: { count: 2 },
});
});
@@ -106,6 +106,7 @@ export function toStoredAgentRecord(
? agent.attention.attentionTimestamp.toISOString()
: null,
internal: options?.internal,
...(agent.background ? { background: true } : {}),
owner: agent.owner,
} satisfies StoredAgentRecord;
}
@@ -149,7 +150,7 @@ export function toAgentPayload(
title: options?.title ?? null,
labels: agent.labels,
archivedAt: options?.archivedAt ?? null,
...(agent.internal ? { internal: true } : {}),
...visibilityFlags(agent),
};
const usage = sanitizeUsage(agent.lastUsage);
@@ -206,6 +207,17 @@ function buildStoredPersistenceHandle(
return toAgentPersistenceHandle(validProviders, record.persistence);
}
/** The wire carries `internal` and `background` only when set. */
function visibilityFlags(agent: {
internal?: boolean;
background?: boolean;
}): Pick<AgentSnapshotPayload, "internal" | "background"> {
return {
...(agent.internal ? { internal: true } : {}),
...(agent.background ? { background: true } : {}),
};
}
export function buildStoredAgentPayload(
record: StoredAgentRecord,
validProviders: Iterable<AgentProvider>,
@@ -258,7 +270,7 @@ export function buildStoredAgentPayload(
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ?? null,
archivedAt: record.archivedAt ?? null,
...(record.internal ? { internal: true } : {}),
...visibilityFlags(record),
labels: normalizeLabels(record.labels),
...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}),
...(providerAvailable ? {} : { providerUnavailable: true }),
@@ -275,6 +287,7 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt
thinkingOptionId: agent.thinkingOptionId,
effectiveThinkingOptionId: agent.effectiveThinkingOptionId,
status: agent.status,
...(agent.backgroundWork ? { backgroundWork: agent.backgroundWork } : {}),
cwd: agent.cwd,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
@@ -208,7 +208,11 @@ export interface AgentSteerOptions extends AgentRunOptions {
clearPendingPermissions?: boolean;
}
export type SteerResult = { status: "accepted" } | { status: "unavailable" };
// providerMessageId names the steered message in the provider's own history, when the provider
// mints one. Rewinding to the message needs it.
export type SteerResult =
| { status: "accepted"; providerMessageId?: string }
| { status: "unavailable" };
export interface SteerActiveTurnOptions extends AgentSteerOptions {
expectedTurnId: string;
@@ -313,7 +317,10 @@ export type ToolCallDetail =
subAgentType?: string;
description?: string;
childSessionId?: string;
log: string;
// COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. Apps before v0.14.1
// require `log`; every producer sends it empty and carries the same tag. Optional here because
// protocol timeline items, where `log` is optional, flow into this type.
log?: string;
actions?: Array<{
index: number;
toolName: string;
@@ -461,6 +468,12 @@ export type AgentStreamEvent =
type: "provider_subagent";
provider: AgentProvider;
event: import("./provider-subagents/store.js").ProviderSubagentInputEvent;
}
| {
/** Full replacement of the agent's live background work (shells, monitors…). */
type: "background_work";
provider: AgentProvider;
items: import("./background-work/index.js").AgentBackgroundWorkInput[] | null;
};
export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined {
@@ -700,6 +713,7 @@ export interface AgentSession {
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void>;
/** Null until a new provider process could resume this session; see docs/providers.md. */
describePersistence(): AgentPersistenceHandle | null;
/**
* Resolve once every foreground turn that predates this call can no longer run or become active.
@@ -707,6 +721,8 @@ export interface AgentSession {
* still uncertain.
*/
interrupt(): Promise<void>;
/** Stop one currently owned background item and wait for provider confirmation. */
stopBackgroundWork?(id: string): 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. */
@@ -714,7 +730,9 @@ export interface AgentSession {
/** Release live runtime resources without archiving or deleting the durable native session. */
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
/** A returned model is the effective selection committed by this mutation.
* Returning void means the requested selection was accepted. No status read is required. */
setModel?(modelId: string | null): Promise<void | { model: string | null }>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>;
revertConversation?(input: { messageId: string }): Promise<void>;
@@ -3,6 +3,8 @@ import os from "node:os";
import path from "node:path";
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
import { promises as fs } from "node:fs";
import { Session as InspectorSession } from "node:inspector/promises";
import type { HeapProfiler } from "node:inspector";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentStorage } from "./agent-storage.js";
@@ -148,6 +150,101 @@ describe("AgentStorage", () => {
rmSync(tmpDir, { recursive: true, force: true });
});
test("workspace and active queries follow updates, removal, and reload", async () => {
const base = {
provider: "codex",
cwd: tmpDir,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
labels: {},
};
await storage.upsert({
...base,
id: "a",
workspaceId: "one",
lastUserMessageAt: base.createdAt,
});
await storage.upsert({ ...base, id: "b", workspaceId: "two", archivedAt: base.createdAt });
expect((await storage.listActive()).map((r) => r.id)).toEqual(["a"]);
await storage.upsert({
...base,
id: "a",
workspaceId: "two",
lastUserMessageAt: base.updatedAt,
});
expect(await storage.listByWorkspaces(["one"])).toEqual([]);
expect((await storage.listByWorkspaces(["two"])).map((r) => r.id)).toEqual(["a"]);
expect(
(await storage.listByWorkspaces(["two"], { includeArchived: true })).map((r) => r.id).sort(),
).toEqual(["a", "b"]);
expect(await storage.lastUserMessageAtByWorkspace(["one", "two"])).toEqual(
new Map([["two", base.createdAt]]),
);
await storage.flush();
const reloaded = new AgentStorage(storagePath, createTestLogger());
expect((await reloaded.listActive()).map((r) => r.id)).toEqual(["a"]);
expect(
await reloaded.lastUserMessageAtByWorkspace(["two"], { excludeAgentIds: ["a"] }),
).toEqual(new Map());
await reloaded.remove("a");
expect(await reloaded.listActive()).toEqual([]);
expect(await reloaded.lastUserMessageAtByWorkspace(["two"])).toEqual(new Map());
});
test("single-agent lookups keep allocations bounded with a large stored history", async () => {
await fs.mkdir(storagePath, { recursive: true });
const record = {
id: "history-0",
provider: "codex",
cwd: tmpDir,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
labels: {},
lastStatus: "idle",
};
for (let offset = 0; offset < 2048; offset += 64) {
await Promise.all(
Array.from({ length: 64 }, (_, index) => {
const id = `history-${offset + index}`;
return fs.writeFile(
path.join(storagePath, `${id}.json`),
JSON.stringify({ ...record, id }),
);
}),
);
}
await storage.initialize();
expect(await storage.listAll()).toHaveLength(2048);
const expected = await storage.get(record.id);
expect(expected).toMatchObject(record);
const inspector = new InspectorSession();
inspector.connect();
let allocatedBytes = 0;
let matches = 0;
try {
await inspector.post("HeapProfiler.startSampling", {
samplingInterval: 16384,
includeObjectsCollectedByMajorGC: true,
includeObjectsCollectedByMinorGC: true,
});
for (let index = 0; index < 1000; index++) {
if ((await storage.get(record.id)) === expected) matches++;
}
const { profile } = await inspector.post("HeapProfiler.stopSampling");
const sum = (node: HeapProfiler.SamplingHeapProfileNode): number =>
node.selfSize + node.children.reduce((bytes, child) => bytes + sum(child), 0);
allocatedBytes = sum(profile.head);
} finally {
inspector.disconnect();
}
expect(matches).toBe(1000);
// Copying this history on every lookup allocates >16 MiB. Allow generous
// sampling/runtime overhead without relying on the host's execution speed.
expect(allocatedBytes).toBeLessThan(4 * 1024 * 1024);
});
test("applySnapshot persists configs and snapshot metadata", async () => {
await storage.applySnapshot(
createManagedAgent({
@@ -172,7 +269,7 @@ describe("AgentStorage", () => {
}),
);
const records = await storage.list();
const records = await storage.listAll();
expect(records).toHaveLength(1);
const [record] = records;
expect(record.provider).toBe("claude");
@@ -189,8 +286,8 @@ describe("AgentStorage", () => {
expect(record.lastModeId).toBe("coding");
expect(record.lastStatus).toBe("idle");
const reloaded = new AgentStorage(storagePath, logger);
const [persisted] = await reloaded.list();
const reloaded = new AgentStorage(storagePath, createTestLogger());
const [persisted] = await reloaded.listAll();
expect(persisted.cwd).toBe("/tmp/project");
expect(persisted.config?.providerOptions).toEqual({ allowedTools: ["Read"] });
});
@@ -210,7 +307,7 @@ describe("AgentStorage", () => {
const record = await storage.get("agent-feature-values");
expect(record?.config?.featureValues).toEqual({ fast_mode: true });
const reloaded = new AgentStorage(storagePath, logger);
const reloaded = new AgentStorage(storagePath, createTestLogger());
const persisted = await reloaded.get("agent-feature-values");
expect(persisted?.config?.featureValues).toEqual({ fast_mode: true });
expect(buildSessionConfig(persisted!).featureValues).toEqual({ fast_mode: true });
@@ -223,7 +320,7 @@ describe("AgentStorage", () => {
}),
);
const reloaded = new AgentStorage(storagePath, logger);
const reloaded = new AgentStorage(storagePath, createTestLogger());
const persisted = await reloaded.get("agent-no-feature-values");
expect(persisted?.config?.featureValues).toBeUndefined();
expect(buildSessionConfig(persisted!).featureValues).toBeUndefined();
@@ -312,7 +409,7 @@ describe("AgentStorage", () => {
const current = await storage.get("agent-2");
expect(current?.title).toBe("Fix Login Bug");
const reloaded = new AgentStorage(storagePath, logger);
const reloaded = new AgentStorage(storagePath, createTestLogger());
const persisted = await reloaded.get("agent-2");
expect(persisted?.title).toBe("Fix Login Bug");
});
@@ -415,7 +512,7 @@ describe("AgentStorage", () => {
);
// Registry should return all agents - filtering is done at the manager level
const records = await storage.list();
const records = await storage.listAll();
expect(records).toHaveLength(2);
});
@@ -486,12 +583,12 @@ describe("AgentStorage", () => {
);
// Reload the registry from disk
const reloaded = new AgentStorage(storagePath, logger);
const reloaded = new AgentStorage(storagePath, createTestLogger());
const record = await reloaded.get("internal-agent");
expect(record?.internal).toBe(true);
// Registry returns all agents - filtering happens at manager level
const records = await reloaded.list();
const records = await reloaded.listAll();
expect(records).toHaveLength(1);
expect(records[0]?.internal).toBe(true);
});
@@ -543,8 +640,8 @@ describe("AgentStorage", () => {
);
// Force a reload so the registry has to discover from disk (and may choose either copy).
const reloaded = new AgentStorage(storagePath, logger);
const before = await reloaded.list();
const reloaded = new AgentStorage(storagePath, createTestLogger());
const before = await reloaded.listAll();
expect(before.map((r) => r.id)).toContain(agentId);
await reloaded.remove(agentId);
@@ -571,8 +668,8 @@ describe("AgentStorage", () => {
expect(await hasAnyRecordFile()).toBe(false);
const afterReload = new AgentStorage(storagePath, logger);
const after = await afterReload.list();
const afterReload = new AgentStorage(storagePath, createTestLogger());
const after = await afterReload.listAll();
expect(after.some((r) => r.id === agentId)).toBe(false);
});
});
+135 -17
View File
@@ -75,6 +75,7 @@ const STORED_AGENT_SCHEMA = z.object({
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
attentionTimestamp: z.string().nullable().optional(),
internal: z.boolean().optional(),
background: z.boolean().optional(),
archivedAt: z.string().nullable().optional(),
owner: AgentOwnerSchema.optional(),
});
@@ -104,9 +105,14 @@ export class AgentStorage {
private deleting: Set<string> = new Set();
private daemonAgentIdsByExecution: Map<string, string> = new Map();
private daemonExecutionKeysByAgentId: Map<string, string> = new Map();
private readonly idsByWorkspace = new Map<string, Set<string>>();
private readonly workspaceById = new Map<string, string>();
private readonly activeIds = new Set<string>();
private readonly promptTimeByAgent = new Map<string, string>();
private readonly promptTimes = new Map<string, string>();
private loaded = false;
private baseDir: string;
private loadPromise: Promise<StoredAgentRecord[]> | null = null;
private loadPromise: Promise<void> | null = null;
private logger: Logger;
constructor(baseDir: string, logger: Logger) {
@@ -118,11 +124,119 @@ export class AgentStorage {
await this.load();
}
async list(): Promise<StoredAgentRecord[]> {
async listAll(): Promise<StoredAgentRecord[]> {
await this.load();
return Array.from(this.cache.values());
}
async listActive(options?: { includeArchived?: boolean }): Promise<StoredAgentRecord[]> {
await this.load();
return Array.from(
options?.includeArchived ? this.cache.keys() : this.activeIds,
(id) => this.cache.get(id)!,
);
}
async listByWorkspaces(
workspaceIds: Iterable<string>,
options?: { includeArchived?: boolean },
): Promise<StoredAgentRecord[]> {
await this.load();
const records: StoredAgentRecord[] = [];
for (const workspaceId of new Set(workspaceIds)) {
for (const id of this.idsByWorkspace.get(workspaceId) ?? []) {
const record = this.cache.get(id)!;
if (options?.includeArchived || !record.archivedAt) records.push(record);
}
}
return records;
}
async lastUserMessageAtByWorkspace(
workspaceIds?: Iterable<string>,
options?: { excludeAgentIds?: Iterable<string> },
): Promise<ReadonlyMap<string, string>> {
await this.load();
const times = new Map<string, string>();
const excluded = new Set(options?.excludeAgentIds);
const affected = new Set(Array.from(excluded, (id) => this.workspaceById.get(id)));
for (const id of workspaceIds ?? this.promptTimes.keys()) {
let at = this.promptTimes.get(id);
if (affected.has(id)) {
at = undefined;
for (const agentId of this.idsByWorkspace.get(id) ?? []) {
const record = this.cache.get(agentId)!;
if (
!excluded.has(agentId) &&
!record.internal &&
!record.background &&
record.lastUserMessageAt &&
record.lastUserMessageAt > (at ?? "")
) {
at = record.lastUserMessageAt;
}
}
}
if (at) times.set(id, at);
}
return times;
}
private indexWorkspace(record: StoredAgentRecord): void {
const previous = this.workspaceById.get(record.id);
if (previous) this.idsByWorkspace.get(previous)?.delete(record.id);
this.workspaceById.delete(record.id);
this.activeIds.delete(record.id);
if (!record.archivedAt) this.activeIds.add(record.id);
if (record.workspaceId) {
const ids = this.idsByWorkspace.get(record.workspaceId) ?? new Set<string>();
ids.add(record.id);
this.idsByWorkspace.set(record.workspaceId, ids);
this.workspaceById.set(record.id, record.workspaceId);
}
this.indexPromptTime(record, previous);
}
private indexPromptTime(record: StoredAgentRecord, previous: string | undefined): void {
const previousTime = this.promptTimeByAgent.get(record.id);
const nextTime =
!record.internal && !record.background ? (record.lastUserMessageAt ?? undefined) : undefined;
if (nextTime) this.promptTimeByAgent.set(record.id, nextTime);
else this.promptTimeByAgent.delete(record.id);
if (previous === record.workspaceId && previousTime === nextTime) return;
if (
previous &&
previousTime &&
this.promptTimes.get(previous) === previousTime &&
(previous !== record.workspaceId || !nextTime || nextTime < previousTime)
) {
this.refreshPromptTime(previous);
}
if (
record.workspaceId &&
nextTime &&
nextTime > (this.promptTimes.get(record.workspaceId) ?? "")
) {
this.promptTimes.set(record.workspaceId, nextTime);
}
}
private refreshPromptTime(workspaceId: string): void {
this.promptTimes.delete(workspaceId);
let latest = "";
for (const id of this.idsByWorkspace.get(workspaceId) ?? []) {
const record = this.cache.get(id)!;
if (
!record.internal &&
!record.background &&
record.lastUserMessageAt &&
record.lastUserMessageAt > latest
)
latest = record.lastUserMessageAt;
}
if (latest) this.promptTimes.set(workspaceId, latest);
}
async get(agentId: string): Promise<StoredAgentRecord | null> {
await this.load();
return this.cache.get(agentId) ?? null;
@@ -142,8 +256,7 @@ export class AgentStorage {
}
async listByWorkspace(workspaceId: string): Promise<StoredAgentRecord[]> {
await this.load();
return Array.from(this.cache.values()).filter((record) => record.workspaceId === workspaceId);
return this.listByWorkspaces([workspaceId], { includeArchived: true });
}
async findByDaemonExecution(owner: DaemonAgentOwner): Promise<StoredAgentRecord | null> {
@@ -233,6 +346,14 @@ export class AgentStorage {
}),
);
const workspaceId = this.workspaceById.get(agentId);
this.workspaceById.delete(agentId);
this.promptTimeByAgent.delete(agentId);
this.activeIds.delete(agentId);
if (workspaceId) {
this.idsByWorkspace.get(workspaceId)?.delete(agentId);
this.refreshPromptTime(workspaceId);
}
this.cache.delete(agentId);
this.removeOwnerIndex(agentId);
this.pathById.delete(agentId);
@@ -261,6 +382,8 @@ export class AgentStorage {
if (existing && existing.archivedAt !== undefined) {
record.archivedAt = existing.archivedAt;
}
// Background is fixed at creation; a runtime opened without it cannot clear it.
if (existing?.background) record.background = true;
record.resumeOnStartup =
existing?.resumeOnStartup && agent.lifecycle !== "idle" && agent.lifecycle !== "error";
return record;
@@ -283,9 +406,9 @@ export class AgentStorage {
await Promise.allSettled(writes);
}
private async load(): Promise<StoredAgentRecord[]> {
private async load(): Promise<void> {
if (this.loaded) {
return Array.from(this.cache.values());
return;
}
if (!this.loadPromise) {
@@ -295,7 +418,7 @@ export class AgentStorage {
return this.loadPromise;
}
private async doLoad(): Promise<StoredAgentRecord[]> {
private async doLoad(): Promise<void> {
this.cache.clear();
this.pathById.clear();
this.pathsById.clear();
@@ -303,28 +426,25 @@ export class AgentStorage {
this.daemonExecutionKeysByAgentId.clear();
try {
const records = await this.scanDisk();
await this.scanDisk();
this.loaded = true;
return records;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
this.loaded = true;
return [];
return;
}
this.logger.error({ err: error }, "Failed to load agents");
this.loaded = true;
return [];
}
}
private async scanDisk(): Promise<StoredAgentRecord[]> {
const records: StoredAgentRecord[] = [];
private async scanDisk(): Promise<void> {
let entries: Dirent[] = [];
try {
entries = await fs.readdir(this.baseDir, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
return;
}
throw error;
}
@@ -361,14 +481,11 @@ export class AgentStorage {
for (const item of loaded) {
if (!item) continue;
const { record, filePath } = item;
records.push(record);
this.cache.set(record.id, record);
this.indexOwner(record);
this.pathById.set(record.id, filePath);
this.addIndexedPath(record.id, filePath);
}
return records;
}
private async readRecordFile(filePath: string): Promise<StoredAgentRecord | null> {
@@ -405,6 +522,7 @@ export class AgentStorage {
}
private indexOwner(record: StoredAgentRecord): void {
this.indexWorkspace(record);
this.removeOwnerIndex(record.id);
if (record.owner?.kind === "daemon") {
const key = daemonExecutionKey(record.owner);
@@ -1,12 +1,30 @@
import { AgentTimelineItemPayloadSchema } from "@getpaseo/protocol/messages";
import { describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
function serializedBytes(item: AgentTimelineItem): number {
return Buffer.byteLength(JSON.stringify(item), "utf8");
}
describe("agent timeline content", () => {
test("returns an item within budget unchanged", () => {
const item: AgentTimelineItem = {
type: "tool_call",
callId: "small",
name: "Bash",
status: "completed",
error: null,
detail: { type: "shell", command: "ls", output: "x".repeat(60 * 1024) },
};
expect(limitAgentTimelineItemContent(item)).toBe(item);
});
test.each([false, true])(
"preserves the exact UTF-16 prefix for oversized shell output (failed=%s)",
"cuts oversized shell output and keeps short fields whole (failed=%s)",
(failed) => {
const text = "x".repeat(64 * 1024 - 1) + "🙂";
const text = "x".repeat(1024 * 1024) + "🙂";
const item = limitAgentTimelineItemContent({
type: "tool_call",
callId: "large-shell",
@@ -15,45 +33,116 @@ describe("agent timeline content", () => {
detail: { type: "shell", command: "echo large", output: text },
error: failed ? { content: text } : null,
});
expect(item).toEqual({
type: "tool_call",
expect(item).toMatchObject({
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,
detail: { type: "shell", command: "echo large" },
});
if (item.type !== "tool_call" || item.detail.type !== "shell") throw new Error("shape");
expect(item.detail.output).toMatch(/^x+…\[truncated \d{3}\.\d KB\]$/);
expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024);
},
);
test("marks truncated plain-text tool content within the content budget", () => {
const oversizedInput = "x".repeat(64 * 1024 + 1);
test("replaces an oversized data URL in an unknown tool result with a marker", () => {
const url = `data:image/jpeg;base64,${"A".repeat(14 * 1024 * 1024)}`;
const item = limitAgentTimelineItemContent({
type: "tool_call",
callId: "terminal-session-4242",
name: "terminal",
callId: "screenshot",
name: "node_repl.js",
status: "completed",
error: null,
detail: {
type: "plain_text",
text: oversizedInput,
icon: "square_terminal",
type: "unknown",
input: { index: 1 },
output: { content: [{ type: "image", url }], note: "captured" },
},
});
expect(limitAgentTimelineItemContent(item)).toEqual(item);
expect(item).toEqual({
type: "tool_call",
callId: "terminal-session-4242",
name: "terminal",
callId: "screenshot",
name: "node_repl.js",
status: "completed",
error: null,
detail: {
type: "plain_text",
text: "x".repeat(64 * 1024 - "\n\n[Content truncated]".length) + "\n\n[Content truncated]",
icon: "square_terminal",
type: "unknown",
input: { index: 1 },
output: { content: [{ type: "image", url: "…[truncated 14.0 MB]" }], note: "captured" },
},
});
});
test("gives non-tool items a 256 KiB budget", () => {
const text = "a".repeat(10 * 1024 * 1024);
const item = limitAgentTimelineItemContent({ type: "assistant_message", text });
if (item.type !== "assistant_message") throw new Error("shape");
expect(item.text).toMatch(/^a+…\[truncated 9\.\d MB\]$/);
expect(item.text.length).toBeGreaterThan(200 * 1024);
expect(serializedBytes(item)).toBeLessThanOrEqual(256 * 1024);
});
test("bounds an item whose structure alone exceeds the budget", () => {
const item = limitAgentTimelineItemContent({
type: "tool_call",
callId: "many",
name: "query",
status: "completed",
error: null,
detail: { type: "unknown", input: null, output: Array.from({ length: 1_000_000 }, () => 1) },
});
if (item.type !== "tool_call" || item.detail.type !== "unknown") throw new Error("shape");
const output = item.detail.output as unknown[];
expect(output.length).toBeLessThan(1_000_000);
expect(output.every((value) => value === 1)).toBe(true);
expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024);
});
test("keeps short strings whole and limits idempotently when an item has many strings", () => {
const callId = "3f2b8c1e-9a4d-4e6f-8b2a-1c3d5e7f9a0b";
const item = limitAgentTimelineItemContent({
type: "tool_call",
callId,
name: "mcp__search__files",
status: "completed",
error: null,
detail: {
type: "unknown",
input: { query: "useWorkspaceSomethingVeryDescriptive" },
output: Array.from(
{ length: 10_000 },
(_, index) => `${String(index).padStart(5, "0")}${"p".repeat(28)}`,
),
},
});
if (item.type !== "tool_call" || item.detail.type !== "unknown") throw new Error("shape");
expect(item.callId).toBe(callId);
expect(item.detail.input).toEqual({ query: "useWorkspaceSomethingVeryDescriptive" });
const output = item.detail.output as string[];
expect(output.length).toBeLessThan(10_000);
expect(output.every((path) => path.length === 33)).toBe(true);
expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024);
expect(limitAgentTimelineItemContent(item)).toBe(item);
});
test("keeps a long typed array valid against the wire schema", () => {
const item = limitAgentTimelineItemContent({
type: "todo",
items: Array.from({ length: 20_000 }, (_, index) => ({
text: `Task ${index}`,
completed: false,
})),
});
if (item.type !== "todo") throw new Error("shape");
expect(item.items.length).toBeLessThan(20_000);
expect(AgentTimelineItemPayloadSchema.safeParse(item).success).toBe(true);
expect(serializedBytes(item)).toBeLessThanOrEqual(256 * 1024);
});
test("is stable on an already-limited item", () => {
const item = limitAgentTimelineItemContent({
type: "reasoning",
text: "r".repeat(1024 * 1024),
});
expect(limitAgentTimelineItemContent(item)).toBe(item);
});
});
@@ -1,9 +1,19 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
export const PLUGIN_TIMELINE_DATA_MAX_BYTES = 64 * 1024;
// One timeline fetch sends its entries in one WebSocket message, so every item must stay small.
// Budgets count UTF-16 units plus a fixed cost per node; neither is exact JSON bytes. They bound
// long strings and long arrays, not wide objects (every entry is kept) or multi-byte text.
const TOOL_CALL_BUDGET = 64 * 1024;
const ITEM_BUDGET = 256 * 1024;
const NODE_COST = 8;
const MARKER_COST = 32;
// Ids, names, paths and queries stay whole, so an item keeps its identity across passes.
const SHORT_STRING = 1024;
const MAX_DEPTH = 64;
export function assertPluginTimelineDataSize(data: JsonValue): void {
const serializedBytes = Buffer.byteLength(JSON.stringify(data), "utf8");
if (serializedBytes > PLUGIN_TIMELINE_DATA_MAX_BYTES) {
@@ -17,66 +27,119 @@ 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" ||
item.detail.type !== "shell" ||
item.status !== "failed" ||
typeof item.error !== "object" ||
item.error === null ||
!("content" in item.error) ||
typeof item.error.content !== "string" ||
item.error.content.length <= TOOL_CALL_CONTENT_MAX_LENGTH
) {
return item;
}
return {
...item,
error: {
...item.error,
content: retainTextPrefix(item.error.content, TOOL_CALL_CONTENT_MAX_LENGTH),
},
};
function formatSize(units: number): string {
if (units < 1024) return `${units} B`;
if (units < 1024 * 1024) return `${(units / 1024).toFixed(1)} KB`;
return `${(units / (1024 * 1024)).toFixed(1)} MB`;
}
function limitPlainText(item: AgentTimelineItem): AgentTimelineItem {
if (
item.type !== "tool_call" ||
item.detail.type !== "plain_text" ||
typeof item.detail.text !== "string" ||
item.detail.text.length <= TOOL_CALL_CONTENT_MAX_LENGTH
) {
return item;
}
const notice = "\n\n[Content truncated]";
return {
...item,
detail: {
...item.detail,
text:
retainTextPrefix(item.detail.text, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice,
},
};
function truncatedMarker(units: number): string {
return `…[truncated ${formatSize(units)}]`;
}
interface ItemSize {
nodeCost: number;
stringLengths: number[];
tooDeep: boolean;
}
function measure(value: unknown, depth: number, size: ItemSize): void {
size.nodeCost += NODE_COST;
if (depth > MAX_DEPTH) {
size.tooDeep = true;
} else if (typeof value === "string") {
size.stringLengths.push(value.length);
} else if (Array.isArray(value)) {
for (const element of value) measure(element, depth + 1, size);
} else if (typeof value === "object" && value !== null) {
for (const [key, child] of Object.entries(value)) {
size.nodeCost += key.length;
measure(child, depth + 1, size);
}
}
}
/** The largest per-string length that keeps all strings, once cut, within `available`. */
function findStringCap(lengths: readonly number[], available: number): number {
let low = 0;
let high = 0;
for (const length of lengths) high = Math.max(high, length);
while (low < high) {
const cap = Math.ceil((low + high) / 2);
let total = 0;
for (const length of lengths) total += Math.min(length, cap);
if (total <= available) low = cap;
else high = cap - 1;
}
return low;
}
interface CutState {
stringCap: number;
remaining: number;
}
function cutString(text: string, cap: number): string {
if (text.length <= cap) return text;
// A partial data URL is useless to every reader, so drop it whole.
if (text.startsWith("data:")) return truncatedMarker(text.length);
return retainTextPrefix(text, cap) + truncatedMarker(text.length - cap);
}
function cut(value: unknown, depth: number, state: CutState): unknown {
state.remaining -= NODE_COST;
if (depth > MAX_DEPTH) return "…[truncated]";
if (typeof value === "string") {
const text = cutString(value, state.stringCap);
state.remaining -= text.length;
return text;
}
if (Array.isArray(value)) {
let changed = false;
const output: unknown[] = [];
for (const element of value) {
const remainingBefore = state.remaining;
const next = cut(element, depth + 1, state);
if (state.remaining < 0) {
// Drop this and later elements. A marker element would break typed arrays.
state.remaining = remainingBefore;
changed = true;
break;
}
changed ||= next !== element;
output.push(next);
}
return changed ? output : value;
}
if (typeof value === "object" && value !== null) {
let changed = false;
const output: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
state.remaining -= key.length;
const next = cut(child, depth + 1, state);
changed ||= next !== child;
output[key] = next;
}
return changed ? output : value;
}
return value;
}
/** Returns the item itself when it fits its budget; otherwise a copy with its largest strings cut. */
export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTimelineItem {
item = limitFailedShellError(item);
item = limitPlainText(item);
if (
item.type !== "tool_call" ||
item.detail.type !== "shell" ||
typeof item.detail.output !== "string"
) {
return item;
}
if (item.detail.output.length <= TOOL_CALL_CONTENT_MAX_LENGTH) {
return item;
}
return {
...item,
detail: {
...item.detail,
output: retainTextPrefix(item.detail.output, TOOL_CALL_CONTENT_MAX_LENGTH),
},
};
const budget = item.type === "tool_call" ? TOOL_CALL_BUDGET : ITEM_BUDGET;
const size: ItemSize = { nodeCost: 0, stringLengths: [], tooDeep: false };
measure(item, 0, size);
let stringTotal = 0;
for (const length of size.stringLengths) stringTotal += length;
if (!size.tooDeep && size.nodeCost + stringTotal <= budget) return item;
// Strings get what structure leaves, but at least half the budget, less a margin for JSON
// escaping. Arrays drop the elements that do not fit, which bounds items whose short strings
// or structure alone are too big.
const markerCost =
MARKER_COST * size.stringLengths.filter((length) => length > SHORT_STRING).length;
const available = (budget * 15) / 16 - Math.min(size.nodeCost, budget / 2) - markerCost;
const stringCap = Math.max(SHORT_STRING, findStringCap(size.stringLengths, available));
return cut(item, 0, { stringCap, remaining: budget }) as AgentTimelineItem;
}
@@ -181,6 +181,20 @@ describe("projected timeline retention", () => {
},
]);
});
it("serves a merged assistant message within the item budget", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent", { epoch: "e" });
const chunk = "a".repeat(200 * 1024);
for (let index = 0; index < 10; index++) {
store.append("agent", { type: "assistant_message", messageId: "m", text: chunk });
}
const [row] = store.fetch("agent", { limit: 1 }).rows;
expect(row).toMatchObject({ seqStart: 1, seqEnd: 10 });
expect(row.item).toMatchObject({ type: "assistant_message", messageId: "m" });
expect(Buffer.byteLength(JSON.stringify(row.item))).toBeLessThanOrEqual(256 * 1024);
expect(store.getLastAssistantMessage("agent")).toHaveLength(10 * chunk.length);
});
});
describe("projected sequence ownership", () => {
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type {
AgentTimelineFetchOptions,
@@ -120,7 +121,12 @@ export class InMemoryAgentTimelineStore {
hasNewer: page.hasNewer,
startSeq: page.startSeq,
endSeq: page.endSeq,
rows: page.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)),
// Stored rows are already within budget; this bounds assistant and reasoning merges.
rows: page.entries.map((entry) =>
Object.assign({ seq: entry.seqEnd }, entry, {
item: limitAgentTimelineItemContent(entry.item),
}),
),
};
}
@@ -0,0 +1,133 @@
import { describe, expect, test } from "vitest";
import { AgentBackgroundWorkStore } from "./index.js";
const T0 = "2026-10-06T10:00:00.000Z";
const T1 = "2026-10-06T10:05:00.000Z";
describe("AgentBackgroundWorkStore", () => {
test("stamps new items and keeps their start time across replacements", () => {
const store = new AgentBackgroundWorkStore();
expect(
store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: "sleep 20" }], T0),
).toEqual([{ id: "bash-1", kind: "shell", description: "sleep 20", startedAt: T0 }]);
expect(
store.apply(
"agent-a",
[
{ id: "bash-1", kind: "shell", description: "sleep 20" },
{ id: "task-1", kind: "other", description: null },
],
T1,
),
).toEqual([
{ id: "bash-1", kind: "shell", description: "sleep 20", startedAt: T0 },
{ id: "task-1", kind: "other", description: null, startedAt: T1 },
]);
});
test("returns null when the replacement changes nothing", () => {
const store = new AgentBackgroundWorkStore();
store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: "sleep 20" }], T0);
expect(
store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: "sleep 20" }], T1),
).toBeNull();
expect(store.apply("agent-b", [], T1)).toEqual([]);
expect(store.apply("agent-b", [], T1)).toBeNull();
});
test("distinguishes an unknown inventory from an observed empty inventory", () => {
const store = new AgentBackgroundWorkStore();
expect(store.summary("agent-a")).toBeUndefined();
store.apply("agent-a", []);
expect(store.summary("agent-a")).toEqual({ count: 0 });
store.clear("agent-a");
expect(store.summary("agent-a")).toBeUndefined();
});
test("keeps the first occurrence of a duplicated id", () => {
const store = new AgentBackgroundWorkStore();
expect(
store.apply(
"agent-a",
[
{ id: "bash-1", kind: "shell", description: "first" },
{ id: "bash-1", kind: "shell", description: "second" },
],
T0,
),
).toEqual([{ id: "bash-1", kind: "shell", description: "first", startedAt: T0 }]);
});
test("scopes lists per agent and clears one agent", () => {
const store = new AgentBackgroundWorkStore();
store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: null }], T0);
store.apply("agent-b", [{ id: "bash-2", kind: "shell", description: null }], T0);
expect(store.clear("agent-a")).toEqual([]);
expect(store.clear("agent-a")).toBeNull();
expect(store.list("agent-a")).toEqual([]);
expect(store.list("agent-b")).toHaveLength(1);
});
test("keeps only the wire fields of each input", () => {
const store = new AgentBackgroundWorkStore();
const input = { id: "bash-1", kind: "shell", description: null, pid: 4242 };
expect(store.apply("agent-a", [input], T0)).toEqual([
{ id: "bash-1", kind: "shell", description: null, startedAt: T0 },
]);
});
test("list returns a copy", () => {
const store = new AgentBackgroundWorkStore();
store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: null }], T0);
store.list("agent-a").pop();
expect(store.list("agent-a")).toHaveLength(1);
});
});
test("stop tokens follow a live item and change when an id is reused", () => {
const store = new AgentBackgroundWorkStore();
const input = { id: "1", kind: "shell", description: null, canStop: true };
const first = store.apply("agent", [input], T0)![0];
expect(first.stopToken).toBeTruthy();
expect(store.apply("agent", [input], T1)).toBeNull();
store.apply("agent", []);
const second = store.apply("agent", [input], T0)![0];
expect(second.stopToken).not.toBe(first.stopToken);
});
test("revokes a stop token while inventory ownership is being re-established", () => {
const store = new AgentBackgroundWorkStore();
const item = { id: "1", kind: "shell", description: null, canStop: true };
const first = store.apply("agent", [item])![0];
const stale = store.apply("agent", [{ ...item, canStop: false }])![0];
expect(stale.stopToken).toBeUndefined();
expect(store.apply("agent", [item])![0].stopToken).not.toBe(first.stopToken);
});
test("publishes command changes without replacing a live item's identity", () => {
const store = new AgentBackgroundWorkStore();
const item = {
id: "1",
kind: "monitor",
description: "Watch build",
command: "tail -f build.log",
canStop: true,
};
const first = store.apply("agent", [item], T0)![0];
expect(first.command).toBe("tail -f build.log");
const changed = store.apply("agent", [{ ...item, command: "tail -f other.log" }], T1)![0];
expect(changed).toMatchObject({
command: "tail -f other.log",
startedAt: T0,
stopToken: first.stopToken,
});
});
@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
export interface AgentBackgroundWorkInput {
id: string;
/** Provider-neutral kind; consumers treat unknown kinds as other work. */
kind: string;
description: string | null;
command?: string;
canStop?: boolean;
}
export interface AgentBackgroundWorkItem extends AgentBackgroundWorkInput {
startedAt: string;
stopToken?: string;
}
/**
* Live background work per agent. Providers report the whole list on every change, so `apply`
* replaces it. `startedAt` is stamped the first time an id is seen and kept while it stays listed.
* Nothing is persisted; providers determine when their live inventory is no longer available.
*/
export class AgentBackgroundWorkStore {
private readonly items = new Map<string, AgentBackgroundWorkItem[]>();
apply(
agentId: string,
inputs: readonly AgentBackgroundWorkInput[],
timestamp: string = new Date().toISOString(),
): AgentBackgroundWorkItem[] | null {
const known = this.items.has(agentId);
const previous = this.items.get(agentId) ?? [];
const previousById = new Map(previous.map((item) => [item.id, item]));
const seen = new Set<string>();
const next: AgentBackgroundWorkItem[] = [];
for (const input of inputs) {
if (seen.has(input.id)) continue;
seen.add(input.id);
next.push({
id: input.id,
kind: input.kind,
description: input.description,
...(input.command ? { command: input.command } : {}),
...(input.canStop
? { canStop: true, stopToken: previousById.get(input.id)?.stopToken ?? randomUUID() }
: {}),
startedAt: previousById.get(input.id)?.startedAt ?? timestamp,
});
}
if (known && sameItems(previous, next)) return null;
this.items.set(agentId, next);
return [...next];
}
list(agentId: string): AgentBackgroundWorkItem[] {
return [...(this.items.get(agentId) ?? [])];
}
summary(agentId: string): { count: number } | undefined {
const items = this.items.get(agentId);
return items ? { count: items.length } : undefined;
}
clear(agentId: string): AgentBackgroundWorkItem[] | null {
if (!this.items.delete(agentId)) return null;
return [];
}
}
function sameItems(
left: readonly AgentBackgroundWorkItem[],
right: readonly AgentBackgroundWorkItem[],
): boolean {
return (
left.length === right.length &&
left.every((item, index) => {
const other = right[index];
return (
item.id === other.id &&
item.kind === other.kind &&
item.description === other.description &&
item.command === other.command &&
item.canStop === other.canStop &&
item.stopToken === other.stopToken &&
item.startedAt === other.startedAt
);
})
);
}
@@ -78,7 +78,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: OPENCODE_MODES,
}),
).toThrow(
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan",
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode in settings.modeId. Available modes for 'opencode': build, plan",
);
});
@@ -92,7 +92,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: CODEX_MODES,
}),
).toThrow(
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
);
});
@@ -180,7 +180,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
targetUnattendedMode: "full-access",
}),
).toThrow(
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
);
});
@@ -195,7 +195,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
targetUnattendedMode: undefined,
}),
).toThrow(
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode. Available modes for 'zai-custom': unknown",
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode in settings.modeId. Available modes for 'zai-custom': unknown",
);
});
@@ -77,7 +77,7 @@ export function resolveAndValidateCreateAgentMode(
}
throw new Error(
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode in settings.modeId. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
);
}
@@ -21,7 +21,17 @@ 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 { resolveCreateAgentIntent } from "./intent.js";
import {
inheritAgentBackground,
inheritWorkspaceBackground,
resolveCreateAgentIntent,
} from "./intent.js";
/** How a workspace this creation makes inherits its creator's visibility. */
interface McpWorkspaceIntent {
background?: boolean;
callerWorkspaceId?: string;
}
export interface CreateAgentCommandDependencies {
finishSubscriptions?: FinishSubscriptions;
@@ -40,7 +50,7 @@ export interface CreateAgentCommandDependencies {
export type EnsureWorkspaceForCreate = (
cwd: string,
firstAgentContext?: FirstAgentContext,
context?: { callerWorkspaceId?: string },
context?: { background?: boolean; callerWorkspaceId?: string },
) => Promise<string>;
export interface CreateAgentFromSessionInput {
@@ -49,6 +59,7 @@ export interface CreateAgentFromSessionInput {
agentId?: string;
config: AgentSessionConfig;
workspaceId: string;
background: boolean;
initialPrompt?: string;
source?: AgentPromptSource;
clientMessageId?: string;
@@ -77,6 +88,8 @@ export interface CreateAgentFromMcpInput {
promptFailure?: CreateAgentPromptFailureMode;
notifyOnFinish: boolean;
internal?: boolean;
/** Omitted with a caller, the caller agent's own background applies. */
background?: boolean;
detached?: boolean;
owner?: AgentOwner;
env?: Record<string, string>;
@@ -255,6 +268,7 @@ async function resolveSessionCreateAgent(
// provider session either. Same as the daemon's own ephemeral helpers.
...(sessionConfig.internal ? { persistSession: false } : {}),
workspaceId: requireResolvedWorkspaceId(workspaceId),
background: input.background,
},
prompt: hasPromptContent ? prompt : undefined,
source: input.source,
@@ -277,12 +291,16 @@ async function resolveMcpCreateAgent(
? requireParentAgent(dependencies.agentManager, input.callerAgentId)
: null;
const cwd = resolveMcpInitialCwd(input, parentAgent);
const workspaceIntent = {
background: inheritWorkspaceBackground(input.background, parentAgent),
callerWorkspaceId: parentAgent?.workspaceId,
};
const { resolvedCwd, createdWorkspaceId, createdWorktree } = await resolveMcpCwd({
dependencies,
cwd,
worktree: input.worktree,
initialPrompt: input.initialPrompt ?? "",
callerWorkspaceId: parentAgent?.workspaceId,
workspaceIntent,
});
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
const intent = await resolveCreateAgentIntent({
@@ -300,7 +318,7 @@ async function resolveMcpCreateAgent(
dependencies,
resolvedCwd,
input.initialPrompt ?? "",
parentAgent?.workspaceId,
workspaceIntent,
),
),
cwd: resolvedCwd,
@@ -329,6 +347,7 @@ async function resolveMcpCreateAgent(
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
workspaceId: intent.workspaceId,
owner: input.owner,
background: inheritAgentBackground(input.background, parentAgent),
env: input.env,
},
prompt: trimmedPrompt ? trimmedPrompt : undefined,
@@ -410,16 +429,12 @@ async function ensureWorkspaceForMcpCreate(
dependencies: CreateAgentCommandDependencies,
cwd: string,
initialPrompt: string,
callerWorkspaceId?: string,
workspaceIntent: McpWorkspaceIntent,
): Promise<string | undefined> {
if (!dependencies.ensureWorkspaceForCreate) {
return undefined;
}
return dependencies.ensureWorkspaceForCreate(
cwd,
{ prompt: initialPrompt },
{ callerWorkspaceId },
);
return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt }, workspaceIntent);
}
async function sendInitialPrompt(
@@ -512,7 +527,7 @@ async function resolveMcpCwd(params: {
dependencies: CreateAgentCommandDependencies;
cwd: string;
initialPrompt: string;
callerWorkspaceId?: string;
workspaceIntent: McpWorkspaceIntent;
worktree: CreateAgentFromMcpInput["worktree"];
}): Promise<{
resolvedCwd: string;
@@ -542,7 +557,7 @@ async function resolveMcpCwd(params: {
const createdWorktree = await createMcpWorktree({
input: {
cwd: params.cwd,
callerWorkspaceId: params.callerWorkspaceId,
...params.workspaceIntent,
worktreeSlug: worktree.worktreeName,
branchName: worktree.branchName,
refName: worktree.refName,
@@ -6,6 +6,30 @@ export interface CreateAgentCaller {
workspaceId?: string;
}
/** The agent on whose behalf something is created, as far as background inheritance goes. */
export interface BackgroundCaller {
background?: boolean;
}
/** Omitted, an agent inherits its caller agent's own background; without a caller it is visible. */
export function inheritAgentBackground(
requested: boolean | undefined,
caller: BackgroundCaller | null | undefined,
): boolean {
return requested ?? caller?.background === true;
}
/**
* Omitted, a new workspace inherits its caller's effective background. A background caller
* agent decides it here; otherwise `undefined` lets provisioning apply the caller workspace's value.
*/
export function inheritWorkspaceBackground(
requested: boolean | undefined,
caller: BackgroundCaller | null | undefined,
): boolean | undefined {
return requested ?? (caller?.background === true ? true : undefined);
}
export interface CreateAgentPlacement {
workspaceId: string;
cwd: string;
@@ -0,0 +1,139 @@
import { resolveWorkspaceRootAgent } from "../workspace-directory.js";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { buildStoredAgentPayload } from "./agent-projections.js";
import { AgentStorage } from "./agent-storage.js";
import { AgentDirectory, type BackgroundVisibility } from "./directory.js";
import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
const NOW = "2026-03-01T12:00:00.000Z";
let home: string;
let storage: AgentStorage;
let directory: AgentDirectory;
function workspace(workspaceId: string, background: boolean): PersistedWorkspaceRecord {
return {
workspaceId,
projectId: "project",
cwd: home,
kind: "local_checkout",
displayName: workspaceId,
background,
createdAt: NOW,
updatedAt: NOW,
archivedAt: null,
};
}
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), "agent-directory-"));
storage = new AgentStorage(home, createTestLogger());
const base = {
provider: "codex",
cwd: home,
createdAt: NOW,
updatedAt: NOW,
labels: {},
lastStatus: "idle" as const,
};
await storage.upsert({ ...base, id: "visible", workspaceId: "open" });
await storage.upsert({ ...base, id: "background", workspaceId: "open", background: true });
await storage.upsert({ ...base, id: "in-background-workspace", workspaceId: "hidden" });
await storage.upsert({ ...base, id: "internal", workspaceId: "open", internal: true });
const workspaces = [workspace("open", false), workspace("hidden", true)];
directory = new AgentDirectory({
storage,
manager: { listAgents: () => [] },
workspaces: {
get: async (id) => workspaces.find((entry) => entry.workspaceId === id) ?? null,
},
projectLive: () => {
throw new Error("No live agents in this suite");
},
projectStored: (record) => buildStoredAgentPayload(record, ["codex"]),
isProviderVisible: () => true,
isStoredProviderAvailable: () => true,
});
});
afterEach(async () => {
await rm(home, { recursive: true, force: true });
});
async function listedIds(includeBackground: BackgroundVisibility): Promise<string[]> {
return (await directory.list({ includeBackground })).map((agent) => agent.id).toSorted();
}
test("default lists hide agents that are background or live in a background workspace", async () => {
expect(await listedIds(false)).toEqual(["visible"]);
});
test("inclusive lists show every background agent", async () => {
expect(await listedIds(true)).toEqual(["background", "in-background-workspace", "visible"]);
});
test("workspace-only visibility hides agent-level background agents from older clients", async () => {
expect(await listedIds("workspaces")).toEqual(["in-background-workspace", "visible"]);
});
test("includes applies the same visibility to a single agent", async () => {
const background = { workspaceId: "open", background: true };
const inBackgroundWorkspace = { workspaceId: "hidden" };
expect(await directory.includes(background)).toBe(false);
expect(await directory.includes(background, "workspaces")).toBe(false);
expect(await directory.includes(background, true)).toBe(true);
expect(await directory.includes(inBackgroundWorkspace)).toBe(false);
expect(await directory.includes(inBackgroundWorkspace, "workspaces")).toBe(true);
});
test("prompt recency ignores background agents", async () => {
const base = {
provider: "codex",
cwd: home,
workspaceId: "open",
createdAt: NOW,
updatedAt: NOW,
labels: {},
lastStatus: "idle" as const,
};
await storage.upsert({ ...base, id: "visible", lastUserMessageAt: "2026-03-02T00:00:00.000Z" });
await storage.upsert({
...base,
id: "background",
background: true,
lastUserMessageAt: "2026-03-03T00:00:00.000Z",
});
expect(await directory.workspaceLastUserMessageTimes()).toEqual(
new Map([["open", "2026-03-02T00:00:00.000Z"]]),
);
});
test("scoped status queries retain cross-workspace parent context", async () => {
const base = { provider: "codex", cwd: home, createdAt: NOW, updatedAt: NOW };
await storage.upsert({ ...base, id: "parent", workspaceId: "open" });
await storage.upsert({
...base,
id: "child",
workspaceId: "hidden",
labels: { "paseo.parent-agent-id": "parent" },
lastStatus: "running",
});
const agents = await directory.list({
workspaceIds: ["hidden"],
includeBackground: true,
includeAncestors: true,
});
const child = agents.find((agent) => agent.id === "child")!;
expect(
resolveWorkspaceRootAgent(child, new Map(agents.map((agent) => [agent.id, agent])))?.id,
).toBe("child");
expect(child.status).toBe("running");
expect(
(await directory.list({ workspaceIds: ["hidden"], includeBackground: true })).map((a) => a.id),
).not.toContain("parent");
});
+116 -37
View File
@@ -1,15 +1,37 @@
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
import type { AgentSnapshotPayload } from "../messages.js";
import type { WorkspaceRegistry } from "../workspace-registry.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
/** Public agent discovery owns workspace visibility for live and durable records. */
/**
* Which background agents a discovery read admits. An agent is effectively background when
* it or its workspace is background.
* - `false`: none, the default for lists.
* - `true`: all of them.
* - `"workspaces"`: only agents in background workspaces.
* COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor
* >= v0.15.0. Inclusive reads from sources without `background_agents` use it: older apps
* replicate inclusively and auto-open a tab for every root agent.
*/
export type BackgroundVisibility = boolean | "workspaces";
interface DiscoverableAgent {
workspaceId?: string;
internal?: boolean;
background?: boolean;
}
/** Public agent discovery owns background visibility for live and durable records. */
export class AgentDirectory {
constructor(
private readonly deps: {
manager: Pick<AgentManager, "listAgents">;
storage: Pick<AgentStorage, "list">;
workspaces: Pick<WorkspaceRegistry, "get" | "list">;
storage: Pick<
AgentStorage,
"get" | "listActive" | "listByWorkspaces" | "lastUserMessageAtByWorkspace"
>;
workspaces: Pick<WorkspaceRegistry, "get">;
projectLive(agent: ManagedAgent): Promise<AgentSnapshotPayload>;
projectStored(record: StoredAgentRecord): AgentSnapshotPayload;
isProviderVisible(provider: string): boolean;
@@ -18,70 +40,127 @@ export class AgentDirectory {
) {}
/** Human prompt recency includes archived/unavailable agents without projecting their UI payloads. */
async workspaceLastUserMessageTimes(): Promise<Map<string, string>> {
async workspaceLastUserMessageTimes(
workspaceIds?: Iterable<string>,
): Promise<Map<string, string>> {
const requested = workspaceIds ? new Set(workspaceIds) : undefined;
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);
}
const times = new Map(
await this.deps.storage.lastUserMessageAtByWorkspace(requested, {
excludeAgentIds: live.map((agent) => agent.id),
}),
);
for (const agent of live) {
if (!agent.internal) record(agent.workspaceId, agent.lastUserMessageAt?.toISOString());
if (agent.internal || agent.background || !agent.workspaceId || !agent.lastUserMessageAt)
continue;
if (requested && !requested.has(agent.workspaceId)) continue;
const at = agent.lastUserMessageAt.toISOString();
if (at > (times.get(agent.workspaceId) ?? "")) times.set(agent.workspaceId, at);
}
return times;
}
async includes(
agent: Pick<AgentSnapshotPayload, "workspaceId" | "internal">,
includeBackground = false,
agent: DiscoverableAgent,
visibility: BackgroundVisibility = false,
): Promise<boolean> {
if (agent.internal) return false;
if (includeBackground || !agent.workspaceId) return true;
return !(await this.deps.workspaces.get(agent.workspaceId))?.background;
const workspace = agent.workspaceId ? await this.deps.workspaces.get(agent.workspaceId) : null;
return admits(agent, workspace?.background === true, visibility);
}
private async appendAncestors(
live: ManagedAgent[],
selectedLive: ManagedAgent[],
records: StoredAgentRecord[],
): Promise<void> {
const selectedIds = new Set([...selectedLive, ...records].map((agent) => agent.id));
const liveById = new Map(live.map((agent) => [agent.id, agent]));
const pending = [...selectedLive, ...records];
for (const agent of pending) {
const parentId = getParentAgentIdFromLabels(agent.labels);
if (!parentId || selectedIds.has(parentId)) continue;
selectedIds.add(parentId);
const liveParent = liveById.get(parentId);
if (liveParent) {
pending.push(liveParent);
selectedLive.push(liveParent);
} else {
const storedParent = await this.deps.storage.get(parentId);
if (storedParent) {
pending.push(storedParent);
records.push(storedParent);
}
}
}
}
async list(filter?: {
workspaceIds?: Iterable<string>;
/** Include parent chains by ID for workspace root classification. */
includeAncestors?: boolean;
labels?: Record<string, string>;
includeArchived?: boolean;
includeBackground?: boolean;
includeBackground?: BackgroundVisibility;
includeUnavailablePersisted?: boolean;
}): Promise<AgentSnapshotPayload[]> {
const requested = filter?.workspaceIds ? new Set(filter.workspaceIds) : null;
const live = this.deps.manager.listAgents();
const liveIds = new Set(live.map((agent) => agent.id));
const labels = Object.entries(filter?.labels ?? {});
const backgroundIds = new Set(
filter?.includeBackground
? []
: (await this.deps.workspaces.list())
.filter((workspace) => workspace.background)
.map((workspace) => workspace.workspaceId),
const visibility = filter?.includeBackground ?? false;
const records = requested
? await this.deps.storage.listByWorkspaces(requested, filter)
: await this.deps.storage.listActive(filter);
const selectedLive = requested
? live.filter((agent) => requested.has(agent.workspaceId ?? ""))
: live;
if (filter?.includeAncestors && requested) {
await this.appendAncestors(live, selectedLive, records);
}
const workspaceIds = new Set(
[...selectedLive, ...records].flatMap((agent) =>
agent.workspaceId ? [agent.workspaceId] : [],
),
);
const matches = (agent: {
archivedAt?: string | null;
workspaceId?: string;
labels?: Record<string, string>;
}) =>
const backgroundWorkspaceIds = new Set<string>();
if (visibility !== true) {
for (const id of workspaceIds) {
if ((await this.deps.workspaces.get(id))?.background) backgroundWorkspaceIds.add(id);
}
}
const matches = (
agent: DiscoverableAgent & { archivedAt?: string | null; labels?: Record<string, string> },
) =>
(filter?.includeArchived || !agent.archivedAt) &&
(filter?.includeBackground || !agent.workspaceId || !backgroundIds.has(agent.workspaceId)) &&
admits(
agent,
agent.workspaceId !== undefined && backgroundWorkspaceIds.has(agent.workspaceId),
visibility,
) &&
labels.every(([key, value]) => agent.labels?.[key] === value);
const stored = (await this.deps.storage.list())
.filter((record) => !record.internal && !liveIds.has(record.id) && matches(record))
const stored = records
.filter((record) => !liveIds.has(record.id) && matches(record))
.filter(
(record) =>
filter?.includeUnavailablePersisted || this.deps.isStoredProviderAvailable(record),
)
.map((record) => this.deps.projectStored(record));
const active = await Promise.all(
live.filter(matches).map((agent) => this.deps.projectLive(agent)),
selectedLive.filter(matches).map((agent) => this.deps.projectLive(agent)),
);
return [...active, ...stored]
.filter(matches)
.filter((agent) => this.deps.isProviderVisible(agent.provider));
}
}
function admits(
agent: DiscoverableAgent,
inBackgroundWorkspace: boolean,
visibility: BackgroundVisibility,
): boolean {
if (agent.internal) return false;
if (agent.background) return visibility === true;
return !inBackgroundWorkspace || visibility !== false;
}
@@ -0,0 +1,149 @@
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, expect, test } from "vitest";
import { createHostResolver, type HostOperations } from "./index.js";
type Handler = ((event?: unknown) => void) | null;
/** A connected host from before background agents: background workspaces, no background agents. */
class OlderHostSocket {
readyState = 1;
readonly requests: Array<Record<string, unknown>> = [];
onopen: Handler = null;
onmessage: Handler = null;
onclose: Handler = null;
onerror: Handler = null;
private readonly createSent = Promise.withResolvers<Record<string, unknown>>();
constructor() {
queueMicrotask(() => this.onopen?.());
}
createRequest(): Promise<Record<string, unknown>> {
return this.createSent.promise;
}
send(data: string): void {
const frame = JSON.parse(data);
if (frame.type === "hello") {
this.reply({
type: "status",
payload: {
status: "server_info",
serverId: "srv_older",
hostname: null,
version: null,
features: {
providerUsageList: true,
providersSnapshotCwd: true,
ownedSubscriptions: true,
backgroundWorkspaces: true,
},
},
});
return;
}
if (frame.type !== "session") return;
const message = frame.message;
this.requests.push(message);
if (message.type === "fetch_workspaces_request") {
this.reply({
type: "fetch_workspaces_response",
payload: {
requestId: message.requestId,
entries: [workspace],
pageInfo: emptyPage,
},
});
}
if (message.type === "create_agent_request") this.createSent.resolve(message);
}
close(): void {
this.readyState = 3;
this.onclose?.({ code: 1000, reason: "closed" });
}
private reply(message: object): void {
this.onmessage?.({ data: JSON.stringify({ type: "session", message }) });
}
}
const emptyPage = { nextCursor: null, prevCursor: null, hasMore: false };
const workspace = {
id: "workspace_older",
projectId: "project_older",
projectDisplayName: "Older",
projectRootPath: "/repo/older",
workspaceDirectory: "/repo/older",
projectKind: "git",
workspaceKind: "directory",
name: "older",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: "2026-05-16T00:00:00.000Z",
scripts: [],
gitRuntime: null,
githubRuntime: null,
};
const clients: DaemonClient[] = [];
afterEach(async () => {
await Promise.all(clients.splice(0).map((client) => client.close()));
});
async function olderHost() {
const socket = new OlderHostSocket();
const client = new DaemonClient({
url: "ws://older.test",
clientId: "older-host-test",
webSocketFactory: () => socket,
reconnect: { enabled: false },
});
clients.push(client);
await client.connect();
return { client, socket };
}
function resolverFor(client: DaemonClient, callerBackground: boolean) {
return createHostResolver({
serverId: "origin",
callerAgentId: "caller",
caller: () => ({ background: callerBackground }),
local: {} as HostOperations,
hosts: () => ({
directory: async () => ({ self: { serverId: "origin", label: "Origin" }, hosts: [] }),
use: (_serverId, action) => action(client),
}),
});
}
const createArgs = {
workspaceId: workspace.id,
provider: "codex/gpt-5.4",
title: "Helper",
initialPrompt: "Summarize",
};
test("a visible caller still creates agents on a host without background agents", async () => {
const { client, socket } = await olderHost();
const creating = resolverFor(client, false).use("srv_older", (host) =>
host.createAgent(createArgs, {} as never),
);
creating.catch(() => {});
const request = await socket.createRequest();
expect(request).toMatchObject({ workspaceId: workspace.id });
expect(request).not.toHaveProperty("background");
});
test("a background caller's remote child asks the host to update instead of turning visible", async () => {
const { client, socket } = await olderHost();
await expect(
resolverFor(client, true).use("srv_older", (host) => host.createAgent(createArgs, {} as never)),
).rejects.toThrow("Update the host to use background agents.");
expect(socket.requests.map((request) => request.type)).not.toContain("create_agent_request");
});
@@ -16,6 +16,11 @@ 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 {
inheritAgentBackground,
inheritWorkspaceBackground,
type BackgroundCaller,
} from "../create-agent/intent.js";
import { expandUserPath } from "../../path-utils.js";
import type { PaseoToolExecutionContext, PaseoToolResult } from "../tools/types.js";
@@ -177,6 +182,7 @@ const remoteCreate = z
workspaceId: z.string().min(1),
labels: z.record(z.string(), z.string()).optional(),
notifyOnFinish: z.boolean().optional(),
background: z.boolean().optional(),
idempotencyKey: z.string().optional(),
settings: z
.object({
@@ -197,6 +203,8 @@ export function createHostResolver(input: {
local: HostOperations;
hosts: () => ToolHosts;
callerAgentId?: string;
/** The destination can't see the caller, so its background defaults resolve before routing. */
caller: () => BackgroundCaller | null;
lockedCwd?: string;
}) {
return {
@@ -214,9 +222,7 @@ export function createHostResolver(input: {
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,
);
const workspace = await client.findWorkspace(parsed.workspaceId);
if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`);
const labels = {
...parsed.labels,
@@ -235,6 +241,10 @@ export function createHostResolver(input: {
featureValues: parsed.settings?.features,
},
workspaceId: parsed.workspaceId,
// Older hosts reject any background with a workspaceId, so a visible create omits it.
...(inheritAgentBackground(parsed.background, input.caller())
? { background: true }
: {}),
labels,
initialPrompt: parsed.initialPrompt,
idempotencyKey: parsed.idempotencyKey,
@@ -359,7 +369,10 @@ export function createHostResolver(input: {
worktreeSlug: args.worktreeSlug as string | undefined,
},
title: args.title as string | undefined,
background: args.background as boolean | undefined,
background: inheritWorkspaceBackground(
args.background as boolean | undefined,
input.caller(),
),
});
if (response.error || !response.workspace)
throw new Error(response.error ?? "Workspace creation failed");
@@ -399,14 +412,16 @@ function remoteObservation(client: DaemonClient): HostObservation {
// 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;
if (current.status === "timeout") {
// Queued delivery can leave a closed/error snapshot visible before the new turn.
// A timed-out wait has not established an outcome for that queued work.
if (event?.type === "permission_resolved") handler(snapshot, event);
return;
}
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);
};
@@ -221,7 +221,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
listImportableSessions,
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">;
const agentStorage = {
list: async () => [
listAll: async () => [
{
provider: "codex",
persistence: {
@@ -231,7 +231,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
},
} as StoredAgentRecord,
],
} satisfies Pick<AgentStorage, "list">;
} satisfies Pick<AgentStorage, "listAll">;
const result = await listImportableProviderSessions({
request: makeRequest({
@@ -303,7 +303,7 @@ test("listImportableProviderSessions looks past already-imported rows to fill th
listImportableSessions,
},
agentStorage: {
list: async () => [
listAll: async () => [
{
provider: "claude",
persistence: { provider: "claude", sessionId: "already-imported" },
@@ -360,7 +360,7 @@ test("listImportableProviderSessions requests a bounded deep scan for search res
const result = await listImportableProviderSessions({
request: makeRequest({ query: "INVOICE", limit: 10 }),
agentManager: { listAgents: () => [], listImportableSessions },
agentStorage: { list: async () => [] },
agentStorage: { listAll: async () => [] },
providerSnapshotManager: { getProviderLabel: (provider) => provider },
});
@@ -397,7 +397,7 @@ test("listImportableProviderSessions includes a provider session after its Paseo
listImportableSessions: async () => makeImportableSessionsResult([archivedSession]),
},
agentStorage: {
list: async () => [
listAll: async () => [
{
provider: "claude",
archivedAt: "2026-04-30T12:01:00.000Z",
@@ -441,7 +441,7 @@ test("listImportableProviderSessions includes an archived provider session still
listImportableSessions: async () => makeImportableSessionsResult([archivedSession]),
},
agentStorage: {
list: async () => [
listAll: async () => [
{
id: agentId,
provider: "claude",
@@ -489,8 +489,8 @@ test("listImportableProviderSessions filters out metadata generation sessions",
listImportableSessions: async () => makeImportableSessionsResult(sessions),
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
listAll: async () => [],
} satisfies Pick<AgentStorage, "listAll">,
providerSnapshotManager: { getProviderLabel: () => "Codex" },
});
@@ -525,8 +525,8 @@ test("listImportableProviderSessions keeps realpath-equivalent cwd matches", asy
]),
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
listAll: async () => [],
} satisfies Pick<AgentStorage, "listAll">,
providerSnapshotManager: { getProviderLabel: () => "Pi" },
});
@@ -542,8 +542,8 @@ test("listImportableProviderSessions rejects invalid since values", async () =>
listImportableSessions: async () => makeImportableSessionsResult([]),
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
listAll: async () => [],
} satisfies Pick<AgentStorage, "listAll">,
providerSnapshotManager: { getProviderLabel: () => "" },
}),
).rejects.toMatchObject(
@@ -64,7 +64,7 @@ export class ImportSessionsRequestError extends Error {
export interface ListImportableProviderSessionsInput {
request: FetchRecentProviderSessionsRequestMessage;
agentManager: Pick<AgentManager, "listAgents" | "listImportableSessions">;
agentStorage: Pick<AgentStorage, "list">;
agentStorage: Pick<AgentStorage, "listAll">;
providerSnapshotManager: Pick<ProviderSnapshotManager, "getProviderLabel">;
}
@@ -342,12 +342,13 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n
async function collectImportedProviderSessions(
agentManager: Pick<AgentManager, "listAgents">,
agentStorage: Pick<AgentStorage, "list">,
agentStorage: Pick<AgentStorage, "listAll">,
providerFilter: Set<string> | undefined,
): Promise<{ handles: Set<string>; count: number }> {
const handles = new Set<string>();
const sessions = new Set<string>();
const records = await agentStorage.list();
// Explicit provider-history import deduplicates against archived as well as active sessions.
const records = await agentStorage.listAll();
const storedRecordsById = new Map(records.map((record) => [record.id, record]));
const collect = (
@@ -380,7 +381,7 @@ function toProviderSessionHandleKey(provider: string, providerHandleId: string):
return `${provider}\0${providerHandleId}`;
}
function isMetadataGenerationSession(input: { firstPromptPreview: string | null }): boolean {
export function isMetadataGenerationSession(input: { firstPromptPreview: string | null }): boolean {
return (
input.firstPromptPreview?.trimStart().startsWith(METADATA_GENERATION_PROMPT_PREFIX) ?? false
);
@@ -404,6 +404,7 @@ describe("Suite A: Core Fixes", () => {
expect(parseAgentMessage(callerPrompts[0]).source).toEqual({
kind: "agent-notification",
agentId: childId,
serverId: daemon.daemon.getServerId(),
title: "Notification child",
event: "finished",
});
@@ -517,6 +518,7 @@ describe("Suite A: Core Fixes", () => {
expect(notification.source).toEqual({
kind: "agent-notification",
agentId: childId,
serverId: daemon.daemon.getServerId(),
title: "Watched child",
event: "errored",
});
@@ -579,7 +581,11 @@ describe("Suite A: Core Fixes", () => {
type: "tool_call",
agentMessage: {
event: outcome,
sender: { id: childId, title: "Follow-up child" },
sender: {
id: childId,
serverId: daemonHandle.daemon.getServerId(),
title: "Follow-up child",
},
text: expect.stringContaining(`(Follow-up child) ${outcome}.`),
},
}),
@@ -1165,7 +1171,7 @@ describe("Suite E: Workspace Tools", () => {
.callTool({ name: "archive_workspace", args: { workspaceId } })
.catch(() => undefined);
}
});
}, 30_000);
test("agent-scoped workspace creation inherits visibility and permits an explicit false override", async () => {
const parentWorkspace = await callToolStructured(topLevelClient, "create_workspace", {
@@ -1191,12 +1197,43 @@ describe("Suite E: Workspace Tools", () => {
});
ownedWorkspaces.push(str(inherited.workspaceId));
expect(inherited.background).toBe(true);
expect(inherited.parentWorkspaceId).toBe(parentWorkspaceId);
const visible = await callToolStructured(scoped, "create_workspace", {
isolation: "local",
background: false,
});
ownedWorkspaces.push(str(visible.workspaceId));
expect(visible.background).toBe(false);
expect(visible.parentWorkspaceId).toBe(parentWorkspaceId);
const worktree = await callToolStructured(scoped, "create_workspace", {
isolation: "worktree",
branchName: "parentage-child",
baseBranch: "main",
background: false,
});
ownedWorkspaces.push(str(worktree.workspaceId));
expect(worktree.parentWorkspaceId).toBe(parentWorkspaceId);
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemonHandle.port}/ws` });
try {
await client.connect();
const created = await client.createWorkspace({
source: { kind: "directory", path: worktreeRepoCwd },
callerAgentId: str(parent.agentId),
background: false,
});
const workspaceId = str(created.workspace?.id);
ownedWorkspaces.push(workspaceId);
expect(created.workspace?.parentWorkspaceId).toBe(parentWorkspaceId);
const listed = await client.fetchWorkspaces();
expect(listed.entries).toContainEqual(
expect.objectContaining({
id: workspaceId,
parentWorkspaceId: parentWorkspaceId,
}),
);
} finally {
await client.close();
}
} finally {
await scoped?.close();
for (const workspaceId of ownedWorkspaces.toReversed())
@@ -1204,5 +1241,138 @@ describe("Suite E: Workspace Tools", () => {
.callTool({ name: "archive_workspace", args: { workspaceId } })
.catch(() => undefined);
}
}, 30_000);
test("a background agent's children and workspaces are background unless it overrides", async () => {
const visibleAgentId = await createTopLevelAgent();
const workspaceId = daemonHandle.daemon.agentManager.getAgent(visibleAgentId)?.workspaceId;
const caller = await callToolStructured(topLevelClient, "create_agent", {
workspaceId,
title: "Background caller",
provider: "claude/claude-test-model",
initialPrompt: "Say done.",
background: true,
});
const callerId = str(caller.agentId);
const background = (agentId: string) =>
daemonHandle.daemon.agentManager.getAgent(agentId)?.background;
expect(background(callerId)).toBe(true);
const scoped = await createMcpClient(
`http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${callerId}`,
);
const ownedWorkspaces: string[] = [];
try {
const inheritedChild = await callToolStructured(scoped, "create_agent", {
relationship: { kind: "subagent" },
workspace: { kind: "current" },
title: "Inherited child",
provider: "claude/claude-test-model",
initialPrompt: "Say done.",
notifyOnFinish: false,
});
expect(background(str(inheritedChild.agentId))).toBe(true);
const visibleChild = await callToolStructured(scoped, "create_agent", {
relationship: { kind: "subagent" },
workspace: { kind: "current" },
title: "Visible child",
provider: "claude/claude-test-model",
initialPrompt: "Say done.",
notifyOnFinish: false,
background: false,
});
expect(background(str(visibleChild.agentId))).toBe(false);
const inheritedWorkspace = await callToolStructured(scoped, "create_workspace", {
isolation: "local",
});
ownedWorkspaces.push(str(inheritedWorkspace.workspaceId));
expect(inheritedWorkspace.background).toBe(true);
} finally {
await scoped.close();
await archiveAgentIfPresent(callerId);
await archiveAgentIfPresent(visibleAgentId);
for (const id of ownedWorkspaces)
await topLevelClient
.callTool({ name: "archive_workspace", args: { workspaceId: id } })
.catch(() => undefined);
}
});
test("a background subagent stays out of default lists, notifies its parent and archives with it", async () => {
const callerPrompts: string[] = [];
const daemon = await createTestPaseoDaemon({
agentClients: createTestAgentClients({
onStartTurn(prompt, config) {
if (config.title === "Background parent" && typeof prompt === "string")
callerPrompts.push(prompt);
},
}),
});
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: "Background parent",
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: "subagent" },
workspace: { kind: "current" },
provider: "codex/gpt-5.4",
title: "Background child",
initialPrompt: "say done and stop",
notifyOnFinish: true,
background: true,
settings: { modeId: "full-access" },
});
const childId = str(created.agentId);
expect((await client.fetchAgent(childId))?.agent).toMatchObject({
background: true,
workspaceId: caller.workspaceId,
labels: { [PARENT_AGENT_ID_LABEL]: caller.id },
});
expect((await client.fetchAgents()).entries.map((entry) => entry.agent.id)).not.toContain(
childId,
);
expect(
(await client.fetchAgents({ filter: { includeBackground: true } })).entries.map(
(entry) => entry.agent.id,
),
).toContain(childId);
await waitFor({
timeoutMs: 10000,
label: "parent finish notification",
check: () => (callerPrompts.length > 0 ? true : null),
});
expect(parseAgentMessage(callerPrompts[0]).source).toMatchObject({
kind: "agent-notification",
agentId: childId,
event: "finished",
});
expect((await client.fetchAgent(childId))?.agent.requiresAttention).toBe(false);
await waitFor({
timeoutMs: 10000,
label: "parent idle",
check: () =>
daemon.daemon.agentManager.getAgent(caller.id)?.lifecycle === "idle" ? true : null,
});
await client.archiveAgent(caller.id);
await waitFor({
timeoutMs: 10000,
label: "child archived with parent",
check: async () => ((await client.fetchAgent(childId))?.agent.archivedAt ? true : null),
});
} finally {
await mcp?.close();
await client.close();
await daemon.close();
}
}, 30_000);
});
@@ -292,7 +292,9 @@ function buildAgentStorageSpies() {
setTitle: vi.fn().mockResolvedValue(undefined),
upsert: vi.fn().mockResolvedValue(undefined),
applySnapshot: vi.fn(),
list: vi.fn().mockResolvedValue([]),
listActive: vi.fn().mockResolvedValue([]),
listByWorkspaces: vi.fn().mockResolvedValue([]),
lastUserMessageAtByWorkspace: async () => new Map<string, string>(),
remove: vi.fn(),
};
}
@@ -639,7 +641,16 @@ class BoundaryAgentManagerFake {
}
class BoundaryAgentStorageFake {
public async list(): Promise<StoredAgentRecord[]> {
public async listActive(): Promise<StoredAgentRecord[]> {
return [];
}
async listByWorkspaces() {
return [];
}
async lastUserMessageAtByWorkspace() {
return new Map<string, string>();
}
async listAll() {
return [];
}
}
@@ -772,7 +783,8 @@ function createPaseoWorktreeForMcpTest(options: {
initialize: async () => {},
existsOnDisk: async () => true,
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
list: async () => Array.from(workspaces.values()),
listAll: async () => Array.from(workspaces.values()),
listActive: async () => Array.from(workspaces.values()).filter((w) => !w.archivedAt),
update: async (workspaceId, updater) => {
const workspace = workspaces.get(workspaceId);
if (!workspace) return null;
@@ -894,7 +906,11 @@ async function createAgentMcpServer(
const server = await createAgentMcpServerWithDependencies({
agentMessages: () => messages,
finishSubscriptions: () => subscriptions,
workspaceRegistry: { get: async () => null, list: async () => [], upsert: async () => {} },
workspaceRegistry: {
get: async () => null,
listActive: async () => [],
upsert: async () => {},
},
...options,
});
serverSubscriptionRecords.set(server, records);
@@ -1493,7 +1509,6 @@ describe("terminal MCP tools", () => {
id: "term-1",
name: "daemon",
cwd: process.cwd(),
getState: vi.fn().mockReturnValue({ scrollback: [], grid: [[]] }),
}),
captureTerminal,
});
@@ -1648,11 +1663,11 @@ describe("create_agent MCP tool", () => {
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ cwd: existingCwd }),
undefined,
{ workspaceId: "workspace-created" },
{ workspaceId: "workspace-created", background: false },
);
});
it("rejects public internal helpers and the old top-level execution background option", async () => {
it("rejects public internal helpers", async () => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-public-helper-removal-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const agentManager = new AgentManager({
@@ -1670,9 +1685,6 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
const input = { title: "Helper", provider: "codex/gpt-5.4", initialPrompt: "Summarize" };
await expect(tool.handler({ ...input, internal: true })).rejects.toThrow(/Unrecognized key/);
await expect(tool.handler({ ...input, background: true })).rejects.toThrow(
/Unrecognized key/,
);
} finally {
await removeAgentStateDir(agentManager, storage, workdir);
}
@@ -1794,7 +1806,7 @@ describe("create_agent MCP tool", () => {
cwd: existingCwd,
}),
undefined,
{ workspaceId: "wks_existing" },
{ workspaceId: "wks_existing", background: false },
);
});
@@ -1838,7 +1850,7 @@ describe("create_agent MCP tool", () => {
featureValues: { fast_mode: true },
}),
undefined,
{ workspaceId: "workspace-created" },
{ workspaceId: "workspace-created", background: false },
);
});
@@ -2034,7 +2046,7 @@ describe("create_agent MCP tool", () => {
title: "Fix auth bug",
}),
undefined,
{ workspaceId: "workspace-created" },
{ workspaceId: "workspace-created", background: false },
);
});
@@ -2069,7 +2081,7 @@ describe("create_agent MCP tool", () => {
title: "Fix auth",
}),
undefined,
{ workspaceId: "workspace-created" },
{ workspaceId: "workspace-created", background: false },
);
});
@@ -2113,6 +2125,7 @@ describe("create_agent MCP tool", () => {
{
labels: { source: "mcp" },
workspaceId: "workspace-created",
background: false,
},
);
});
@@ -2215,7 +2228,7 @@ describe("create_agent MCP tool", () => {
title: "Directory agent",
}),
undefined,
{ workspaceId: "workspace-directory-auto-title" },
{ workspaceId: "workspace-directory-auto-title", background: false },
);
expect(workspaceRecords.get("workspace-directory-auto-title")).toMatchObject({
title: "Directory Workspace Title",
@@ -2700,7 +2713,7 @@ describe("create_agent MCP tool", () => {
providerSnapshotManager: createOpenCodeManager().manager,
workspaceRegistry: {
get: vi.fn(async () => workspace),
list: vi.fn(async () => [workspace]),
listActive: vi.fn(async () => [workspace]),
upsert: vi.fn(async () => undefined),
},
logger,
@@ -2733,7 +2746,7 @@ describe("create_agent MCP tool", () => {
providerSnapshotManager: createOpenCodeManager().manager,
workspaceRegistry: {
get: vi.fn(async () => backgroundWorkspace),
list: vi.fn(async () => [backgroundWorkspace]),
listActive: vi.fn(async () => [backgroundWorkspace]),
upsert: vi.fn(async () => undefined),
},
createDirectoryWorkspace: async (cwd, _title, _projectId, context) => {
@@ -2829,12 +2842,13 @@ describe("create_agent MCP tool", () => {
source: "voice",
},
workspaceId: "wks_voice",
background: false,
},
);
await rm(baseDir, { recursive: true, force: true });
});
it("rejects background from caller agents and defaults notify-on-finish on", async () => {
it("defaults notify-on-finish on for caller agents", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
@@ -2854,16 +2868,6 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: false,
}),
).rejects.toThrow(/Unrecognized key/);
const parsed = await tool.inputSchema.safeParseAsync({
...subagentCurrentWorkspace(),
title: "Child",
@@ -3020,6 +3024,7 @@ describe("create_agent MCP tool", () => {
source: "handoff",
},
workspaceId: "wks_parent",
background: false,
},
);
});
@@ -3081,6 +3086,7 @@ describe("create_agent MCP tool", () => {
[PARENT_AGENT_ID_LABEL]: "parent-agent",
},
workspaceId: "wks_parent",
background: false,
},
);
});
@@ -3219,6 +3225,7 @@ describe("create_agent MCP tool", () => {
expect(agentIdArg).toBeUndefined();
expect(optionsArg).toEqual({
workspaceId: "workspace-created",
background: false,
});
});
@@ -3296,7 +3303,7 @@ describe("create_agent MCP tool", () => {
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "dynamic" }),
undefined,
{ workspaceId: "workspace-created" },
{ workspaceId: "workspace-created", background: false },
);
});
@@ -3335,7 +3342,7 @@ describe("create_agent MCP tool", () => {
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
undefined,
{ workspaceId: "workspace-created" },
{ workspaceId: "workspace-created", background: false },
);
});
@@ -6335,7 +6342,7 @@ describe("agent snapshot MCP serialization", () => {
createManagedAgent({ id: "in-child-cwd", cwd: "/tmp/workspace/packages/server" }),
createManagedAgent({ id: "other-cwd", cwd: "/tmp/other" }),
]);
spies.agentStorage.list.mockResolvedValue([
spies.agentStorage.listActive.mockResolvedValue([
createStoredRecord({
id: "stored-in-cwd",
cwd: "/tmp/workspace",
@@ -6394,7 +6401,7 @@ describe("agent snapshot MCP serialization", () => {
updatedAt: new Date(old),
}),
]);
spies.agentStorage.list.mockResolvedValue([
spies.agentStorage.listActive.mockResolvedValue([
createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }),
createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }),
createStoredRecord({
@@ -6435,7 +6442,7 @@ describe("agent snapshot MCP serialization", () => {
archivedAt: new Date(now - index * 60 * 1000).toISOString(),
}),
);
spies.agentStorage.list.mockResolvedValue([
spies.agentStorage.listActive.mockResolvedValue([
...recentArchivedRecords,
createStoredRecord({
id: "old-archived",
@@ -6466,7 +6473,7 @@ describe("agent snapshot MCP serialization", () => {
it("returns compact list items for stored archived agents", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const now = new Date().toISOString();
spies.agentStorage.list.mockResolvedValue([
spies.agentStorage.listActive.mockResolvedValue([
createStoredRecord({
id: "stored-archived-compact",
cwd: REPO_CWD,
@@ -6585,7 +6592,7 @@ describe("agent snapshot MCP serialization", () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const now = new Date().toISOString();
spies.agentManager.listAgents.mockReturnValue([createManagedAgent()]);
spies.agentStorage.list.mockResolvedValue([
spies.agentStorage.listActive.mockResolvedValue([
createStoredRecord({
id: "stored-non-archived",
updatedAt: now,
@@ -53,6 +53,7 @@ const CAPABILITIES = [
interface ProviderHarnessOptions {
capabilities?: ProviderConnection["capabilities"];
completeTurn?: boolean;
closeAcknowledgementOnly?: boolean;
openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void;
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
}
@@ -208,6 +209,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
}
if (input.type === "session.close") {
emit({ type: "session.closed", sessionId: input.sessionId });
if (options.closeAcknowledgementOnly) return;
}
if ("requestId" in input) {
emit({ type: "request.completed", requestId: input.requestId });
@@ -246,6 +248,572 @@ function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]
return events.filter((event) => event.type === type);
}
function createRewindProviderHarness(
options: {
reuseAnswerId?: boolean;
forkOnRevert?: boolean;
handleInput?: ProviderHarnessOptions["handleInput"];
} = {},
) {
const nativeMessages: string[] = [];
let originalMessages: string[] | null = null;
const harness = createProviderHarness({
closeAcknowledgementOnly: true,
capabilities: [
...CAPABILITIES,
"session.revert.conversation",
"session.revert.files",
"session.revert.both",
],
async handleInput(input, emit) {
if (await options.handleInput?.(input, emit)) return true;
if (input.type === "session.open" && input.history === "replay") {
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [
...CAPABILITIES,
"session.revert.conversation",
"session.revert.files",
"session.revert.both",
],
restoration: "core",
persistence: input.persistence ?? { version: 1, data: { token: "root" } },
cwd: input.config.cwd,
});
const messages =
options.forkOnRevert &&
(input.persistence?.data as { token?: string })?.token === "root" &&
originalMessages
? originalMessages
: nativeMessages;
for (const [index, text] of messages.entries()) {
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: { type: "user_message", id: `user-${text}`, text, revertToken: text },
});
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "assistant_message",
id: options.reuseAnswerId ? `answer-${index + 1}` : `answer-${text}`,
text: `Reply ${text}`,
},
});
}
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
}
if (input.type === "session.prompt") {
if (input.prompt.input.type !== "message") throw new Error("Expected a message");
const text = input.prompt.input.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("");
nativeMessages.push(text);
const turnId = `turn-${text}`;
emit({
type: "session.prompt_result",
sessionId: input.sessionId,
clientMessageId: input.prompt.clientMessageId,
result: { type: "turn", turnId },
});
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" });
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "user_message",
id: `user-${text}`,
text,
clientMessageId: input.prompt.clientMessageId,
revertToken: text,
},
});
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "assistant_message",
id: options.reuseAnswerId ? `answer-${nativeMessages.length}` : `answer-${text}`,
text: `Reply ${text}`,
},
});
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" });
return true;
}
if (input.type === "session.revert") {
if (options.forkOnRevert && input.scope !== "files") {
originalMessages = [...nativeMessages];
emit({
type: "session.persistence",
sessionId: input.sessionId,
persistence: { version: 1, data: { token: "fork" } },
});
}
if (input.scope !== "files")
nativeMessages.splice(nativeMessages.indexOf(String(input.token)));
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
return false;
},
});
return { ...harness, nativeMessages };
}
async function withRewindAgent(
run: (context: {
manager: AgentManager;
agentId: string;
nativeMessages: string[];
messages: () => string[];
}) => Promise<void>,
options: Parameters<typeof createRewindProviderHarness>[0] = {},
) {
const harness = createRewindProviderHarness(options);
const logger = createTestLogger();
const registry = new PluginAgentClientRegistry(logger);
registry.replace([harness.registration]);
const manager = new AgentManager({
logger,
clients: registry.clients(),
providerDefinitions: registry.definitions(),
});
const agent = await manager.createAgent(
{ provider: harness.registration.id, cwd: tmpdir() },
undefined,
{ workspaceId: undefined },
);
const messages = () =>
manager
.getTimeline(agent.id)
.flatMap((item) =>
item.type === "user_message" || item.type === "assistant_message" ? [item.text] : [],
);
try {
await manager.runAgent(agent.id, "A", { clientMessageId: "client-A" });
await manager.runAgent(agent.id, "B", { clientMessageId: "client-B" });
await manager.runAgent(agent.id, "C", { clientMessageId: "client-C" });
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
await run({ manager, agentId: agent.id, nativeMessages: harness.nativeMessages, messages });
} finally {
await manager.closeAgent(agent.id);
await registry.shutdown();
}
}
test("conversation rewind discards withdrawn plugin turns before and after a replacement prompt", async () => {
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
await manager.rewind(agentId, "user-B", "conversation");
const afterRewind = messages();
expect(nativeMessages).toEqual(["A"]);
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
const afterReplacement = messages();
expect(nativeMessages).toEqual(["A", "D"]);
expect({ afterRewind, afterReplacement }).toEqual({
afterRewind: ["A", "Reply A"],
afterReplacement: ["A", "Reply A", "D", "Reply D"],
});
});
});
test("file-only plugin rewind preserves the conversation before and after a replacement prompt", async () => {
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
await manager.rewind(agentId, "user-B", "files");
expect(nativeMessages).toEqual(["A", "B", "C"]);
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C", "D", "Reply D"]);
});
});
test("plugin rewind restores translation state when a replacement reuses a discarded item id", async () => {
const harness = createRewindProviderHarness({ reuseAnswerId: 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: tmpdir() });
const live: AgentStreamEvent[] = [];
try {
await session.run("A");
await session.run("B");
session.subscribe((event) => live.push(event));
await session.revertConversation!({ messageId: "user-B" });
live.length = 0;
await session.run("B revised");
const assistant = live.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
);
expect(harness.nativeMessages).toEqual(["A", "B revised"]);
expect(assistant).toEqual(["Reply B revised"]);
} finally {
await session.close();
await registry.shutdown();
}
});
async function withPluginRewindSession(
options: Parameters<typeof createRewindProviderHarness>[0],
run: (
session: Awaited<ReturnType<AgentClient["createSession"]>>,
harness: ReturnType<typeof createRewindProviderHarness>,
) => Promise<void>,
) {
const harness = createRewindProviderHarness(options);
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: tmpdir() });
try {
await session.run("A");
await session.run("B");
await session.run("C");
await run(session, harness);
} finally {
await session.close();
await registry.shutdown();
}
}
async function pluginMessages(session: Awaited<ReturnType<AgentClient["createSession"]>>) {
const events = await Array.fromAsync(session.streamHistory());
return events.flatMap((event) =>
event.type === "timeline" &&
(event.item.type === "user_message" || event.item.type === "assistant_message")
? [event.item.text]
: [],
);
}
test("combined plugin rewind replays the retained native branch", async () => {
await withPluginRewindSession({}, async (session) => {
await session.revertBoth!({ messageId: "user-B" });
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
await session.revertConversation!({ messageId: "user-A" });
expect(await pluginMessages(session)).toEqual([]);
});
});
test("plugin rewind resumes the latest persistence rather than the original handle", async () => {
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type === "session.revert")
emit({
type: "session.persistence",
sessionId: input.sessionId,
persistence: { version: 1, data: { token: "fork" } },
});
return false;
},
},
async (session, harness) => {
await session.revertConversation!({ messageId: "user-B" });
expect(harness.inputs.findLast((input) => input.type === "session.open")).toMatchObject({
persistence: { version: 1, data: { token: "fork" } },
history: "replay",
});
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
},
);
});
test("failed native plugin rewind preserves history and does not reopen", async () => {
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.revert") return false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "native rewind failed" },
});
return true;
},
},
async (session, harness) => {
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"native rewind failed",
);
expect(await pluginMessages(session)).toEqual([
"A",
"Reply A",
"B",
"Reply B",
"C",
"Reply C",
]);
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
},
);
});
test("failed plugin rewind replay blocks prompts until retry without repeating native rewind", async () => {
let failReplay = true;
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
return false;
failReplay = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "replay failed" },
});
return true;
},
},
async (session, harness) => {
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"replay failed",
);
await expect(session.run("D")).rejects.toThrow("Retry the rewind or reload");
await session.revertConversation!({ messageId: "user-B" });
await session.run("D");
expect(await pluginMessages(session)).toEqual(["A", "Reply A", "D", "Reply D"]);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
},
);
});
test("retrying a failed plugin rewind replay replaces the manager timeline before prompting", async () => {
let failReplay = true;
await withRewindAgent(
async ({ manager, agentId, messages }) => {
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
"replay failed",
);
await expect(manager.runAgent(agentId, "D")).rejects.toThrow("Retry the rewind or reload");
await manager.rewind(agentId, "user-B", "conversation");
expect(messages()).toEqual(["A", "Reply A"]);
await manager.runAgent(agentId, "D");
expect(messages()).toEqual(["A", "Reply A", "D", "Reply D"]);
},
{
handleInput: async (input, emit) => {
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
return false;
failReplay = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "replay failed" },
});
return true;
},
},
);
});
test("reload after failed plugin rewind replay resumes the committed fork", async () => {
let failReplay = true;
await withRewindAgent(
async ({ manager, agentId, messages }) => {
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
"replay failed",
);
await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true });
await manager.hydrateTimelineFromProvider(agentId, { force: true });
expect(messages()).toEqual(["A", "Reply A"]);
},
{
forkOnRevert: true,
handleInput: async (input, emit) => {
if (input.type === "session.close") {
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
return false;
failReplay = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "replay failed" },
});
return true;
},
},
);
});
test("closing during plugin rewind replay closes each native binding once", async () => {
let opened!: () => void;
let release!: () => void;
const replayStarted = new Promise<void>((resolve) => {
opened = resolve;
});
const replayAllowed = new Promise<void>((resolve) => {
release = resolve;
});
await withPluginRewindSession(
{
handleInput: async (input) => {
if (input.type === "session.open" && input.history === "replay") {
opened();
await replayAllowed;
}
return false;
},
},
async (session, harness) => {
const rewind = session.revertConversation!({ messageId: "user-B" });
await replayStarted;
const closing = session.close();
release();
await Promise.all([rewind, closing]);
const closes = harness.inputs.flatMap((input) =>
input.type === "session.close" ? [input.sessionId] : [],
);
expect(closes).toHaveLength(2);
expect(new Set(closes).size).toBe(2);
},
);
});
test("plugin rewind retries a failed close before reopening the native writer", async () => {
let failClose = true;
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.close" || !failClose) return false;
failClose = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "close failed" },
});
return true;
},
},
async (session, harness) => {
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"close failed",
);
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
},
);
});
test("plugin rewind retries permission settlement before retiring its session", async () => {
let failPermission = true;
await withPluginRewindSession(
{
handleInput: async (input) => {
if (input.type !== "session.permission" || !failPermission) return false;
failPermission = false;
throw new Error("permission delivery failed");
},
},
async (session, harness) => {
const opened = harness.inputs.find((input) => input.type === "session.open")!;
if (opened.type !== "session.open") throw new Error("Missing root");
harness.emit({
type: "session.permission",
sessionId: opened.sessionId,
request: { id: "pending", name: "tool", kind: "tool" },
});
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"permission delivery failed",
);
expect(session.getPendingPermissions()).toHaveLength(1);
expect(harness.inputs.filter((input) => input.type === "session.close")).toHaveLength(0);
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
expect(session.getPendingPermissions()).toEqual([]);
expect(harness.inputs.filter((input) => input.type === "session.permission")).toHaveLength(2);
},
);
});
test("plugin rewind replaces child listeners and settles old pending permissions", async () => {
await withPluginRewindSession({}, async (session, harness) => {
const root = harness.inputs.find((input) => input.type === "session.open")!;
if (root.type !== "session.open") throw new Error("Missing root");
harness.emit({
type: "session.permission",
sessionId: root.sessionId,
request: { id: "old-permission", name: "tool", kind: "tool", title: "Run tool" },
});
expect(session.getPendingPermissions()).toHaveLength(1);
await session.revertConversation!({ messageId: "user-B" });
expect(session.getPendingPermissions()).toEqual([]);
expect(harness.inputs).toContainEqual(
expect.objectContaining({
type: "session.permission",
permissionId: "old-permission",
response: { behavior: "deny", message: "Session replaced after rewind" },
}),
);
harness.emit({
type: "timeline.item",
sessionId: "child-1",
item: { type: "assistant_message", id: "child-message", text: "Discarded child update" },
});
const history = await Array.fromAsync(session.streamHistory());
expect(history.filter((event) => event.type === "provider_subagent")).toEqual([]);
const reopened = harness.inputs.findLast((input) => input.type === "session.open")!;
if (reopened.type !== "session.open") throw new Error("Missing reopened root");
harness.emit({
type: "session.opened",
sessionId: "child-1",
parentSessionId: reopened.sessionId,
capabilities: [],
restoration: "parent",
cwd: tmpdir(),
});
harness.emit({
type: "timeline.item",
sessionId: "child-1",
item: { type: "assistant_message", id: "child-message", text: "New child reply" },
});
const next = await Array.fromAsync(session.streamHistory());
expect(next).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: expect.objectContaining({
type: "timeline",
item: expect.objectContaining({ text: "New child reply" }),
}),
}),
);
});
});
test("nonpersisted plugin sessions reject conversation rewind before native mutation", async () => {
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.open") return false;
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [...CAPABILITIES, "session.revert.conversation", "session.revert.files"],
restoration: "core",
cwd: input.config.cwd,
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
},
},
async (session, harness) => {
expect(session.capabilities.supportsRewindConversation).toBe(false);
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"requires session persistence",
);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toEqual([]);
await session.revertFiles!({ messageId: "user-B" });
expect(harness.nativeMessages).toEqual(["A", "B", "C"]);
},
);
});
function openNestedChildren(rootSessionId: string, emit: (event: ProviderEvent) => void) {
for (const [sessionId, parentSessionId] of [
["a", rootSessionId],
@@ -603,6 +603,7 @@ class ProviderRuntimeSession {
>();
private readonly activeTurnIds = new Set<string>();
private terminal = false;
private closePromise: Promise<void> | null = null;
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
persistence: ProviderPersistence | null = null;
@@ -724,6 +725,14 @@ class ProviderRuntimeSession {
this.runtime.removeSession(this.id, this.providerSessionId);
return;
}
this.closePromise ??= this.closeNativeSession().catch((error) => {
this.closePromise = null;
throw error;
});
await this.closePromise;
}
private async closeNativeSession(): Promise<void> {
try {
await this.runtime.complete({
type: "session.close",
@@ -732,9 +741,9 @@ class ProviderRuntimeSession {
});
} catch (error) {
if (!this.runtime.isClosed) throw error;
} finally {
this.runtime.removeSession(this.id, this.providerSessionId);
}
// A failed close still needs its routing for a subsequent session.closed ack.
this.runtime.removeSession(this.id, this.providerSessionId);
}
beginOpen(requestId: string): Promise<void> {
@@ -1092,11 +1101,40 @@ class PluginAgentClient implements AgentClient {
persistence: input.persistence,
history: input.history,
});
const session = new PluginAgentSession(this.provider, bridge, () => {
this.rootsBySession.delete(bridge.id);
});
this.rootsBySession.set(bridge.id, session);
this.attachPendingChildren();
const forget = () => {
for (const [id, owner] of this.rootsBySession) {
if (owner === session) this.rootsBySession.delete(id);
}
};
const bind = (next: ProviderRuntimeSession) => {
this.rootsBySession.set(next.id, session);
this.attachPendingChildren();
};
const config = mapSessionConfig(input.config, input.launchContext, input.persist);
const session = new PluginAgentSession(
this.provider,
bridge,
forget,
async (persistence, state) => {
forget();
return await this.runtime.openSession({
sessionId: randomUUID(),
config: {
...config,
model: state.model,
mode: state.mode,
thinkingOption: state.thinkingOption,
settings: Object.fromEntries(
state.settings.map((setting) => [setting.id, setting.value]),
),
},
persistence,
history: "replay",
});
},
bind,
);
bind(bridge);
return session;
}
@@ -1157,16 +1195,32 @@ class PluginAgentSession implements AgentSession {
private readonly revertTokens = new Map<string, ProviderTimelineItem["revertToken"]>();
private readonly timelineSnapshots = new Map<string, TimelineTextSnapshot>();
private readonly subagentIdsBySession = new Map<string, string | null>();
private readonly childUnsubscribes = new Map<string, () => void>();
private readonly children = new Map<
string,
{ session: ProviderRuntimeSession; unsubscribe: () => void }
>();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
private unsubscribe: (() => void) | null = null;
private currentTurnId: string | null = null;
private closed = false;
private replay: {
persistence: ProviderPersistence;
config: ProviderConfigState;
messageId: string;
scope: "conversation" | "both";
closed: boolean;
} | null = null;
private replaying: Promise<void> | null = null;
constructor(
readonly provider: string,
private readonly bridge: ProviderRuntimeSession,
private bridge: ProviderRuntimeSession,
private readonly onClose: () => void,
private readonly reopen: (
persistence: ProviderPersistence,
state: ProviderConfigState,
) => Promise<ProviderRuntimeSession>,
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
) {
this.subagentIdsBySession.set(bridge.id, null);
for (const event of bridge.history) this.accept(event, false);
@@ -1183,7 +1237,13 @@ class PluginAgentSession implements AgentSession {
}
get capabilities(): AgentCapabilityFlags {
return agentCapabilities(this.bridge.negotiatedCapabilities);
const capabilities = agentCapabilities(this.bridge.negotiatedCapabilities);
const resumable = this.canReplayHistory;
return {
...capabilities,
supportsRewindConversation: capabilities.supportsRewindConversation && resumable,
supportsRewindBoth: capabilities.supportsRewindBoth && resumable,
};
}
get features(): AgentFeature[] {
@@ -1211,6 +1271,7 @@ class PluginAgentSession implements AgentSession {
prompt: AgentPromptInput,
options: AgentRunOptions = {},
): Promise<{ turnId: string }> {
this.assertReplayComplete();
const clientMessageId = options.clientMessageId ?? randomUUID();
const result = await this.bridge.prompt({
clientMessageId,
@@ -1233,6 +1294,7 @@ class PluginAgentSession implements AgentSession {
prompt: AgentPromptInput,
options: SteerActiveTurnOptions,
): Promise<SteerResult> {
this.assertReplayComplete();
if (!this.bridge.negotiatedCapabilities.includes("prompt.steer")) {
return { status: "unavailable" };
}
@@ -1256,6 +1318,7 @@ class PluginAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
await this.ensureReplay();
for (const event of this.history.read(this.provider)) yield event;
}
@@ -1283,6 +1346,7 @@ class PluginAgentSession implements AgentSession {
}
async setMode(modeId: string): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ mode: modeId });
}
@@ -1319,17 +1383,10 @@ class PluginAgentSession implements AgentSession {
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
this.unsubscribe?.();
this.unsubscribe = null;
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();
await this.replaying?.catch(() => undefined);
await this.clearBinding();
this.replay = null;
this.listeners.clear();
this.onClose();
await this.bridge.close();
@@ -1343,15 +1400,19 @@ class PluginAgentSession implements AgentSession {
}));
}
async setModel(modelId: string | null): Promise<void> {
async setModel(modelId: string | null): Promise<{ model: string | null }> {
this.assertReplayComplete();
await this.bridge.configure({ model: modelId });
return { model: this.bridge.config.model ?? null };
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ thinkingOption: thinkingOptionId });
}
async setFeature(featureId: string, value: unknown): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
});
@@ -1415,10 +1476,10 @@ class PluginAgentSession implements AgentSession {
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(
child.id,
child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)),
);
this.children.set(child.id, {
session: child,
unsubscribe: child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)),
});
}
private accept(event: ProviderEvent, live: boolean): void {
@@ -1636,7 +1697,84 @@ class PluginAgentSession implements AgentSession {
}
}
private async clearBinding(): Promise<void> {
this.unsubscribe?.();
this.unsubscribe = null;
for (const child of this.children.values()) {
child.unsubscribe();
await child.session.close();
}
this.children.clear();
this.subagentIdsBySession.clear();
this.history.clear();
this.timelineSnapshots.clear();
this.childSnapshots.clear();
this.revertTokens.clear();
this.pendingPermissions.clear();
this.permissionResponses.clear();
this.currentTurnId = null;
}
private assertReplayComplete(): void {
if (this.replay) {
throw new Error(
"Plugin rewind history refresh is incomplete. Retry the rewind or reload the agent before continuing.",
);
}
}
// A native rewind has already committed. Until replay succeeds, neither history
// nor a new prompt may use the retired binding. Keep its latest handle for retry.
private async ensureReplay(): Promise<void> {
if (!this.replay) return;
if (this.closed) throw new StaleProviderSessionError(this.id);
if (!this.replaying) {
this.replaying = this.replayBinding().finally(() => {
this.replaying = null;
});
}
await this.replaying;
}
private async replayBinding(): Promise<void> {
const replay = this.replay!;
if (!replay.closed) {
for (const request of this.pendingPermissions.values()) {
await this.respondToPermission(request.id, {
behavior: "deny",
message: "Session replaced after rewind",
});
this.pendingPermissions.delete(request.id);
this.permissionResponses.delete(request.id);
}
await this.clearBinding();
await this.bridge.close();
replay.closed = true;
}
const bridge = await this.reopen(replay.persistence, replay.config);
this.bridge = bridge;
this.subagentIdsBySession.set(bridge.id, null);
for (const event of bridge.history) this.accept(event, false);
this.unsubscribe = bridge.onEvent((event) => this.accept(event, true));
this.onBind(bridge);
this.replay = null;
}
private get canReplayHistory(): boolean {
return (
this.bridge.restoration === "core" &&
this.bridge.persistence !== null &&
this.bridge.negotiatedCapabilities.includes("session.persistence")
);
}
private async revert(messageId: string, scope: "conversation" | "files" | "both") {
const pending = this.replay;
await this.ensureReplay();
if (pending?.messageId === messageId && pending.scope === scope) return;
if (scope !== "files" && !this.canReplayHistory) {
throw new Error("Plugin conversation rewind requires session persistence for history replay");
}
const token = this.revertTokens.get(messageId);
if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`);
await this.bridge.revert({
@@ -1646,6 +1784,12 @@ class PluginAgentSession implements AgentSession {
token,
scope,
});
if (scope !== "files") {
const persistence = this.bridge.persistence;
if (!persistence) throw new Error("Plugin rewind removed session persistence");
this.replay = { persistence, config: this.bridge.config, messageId, scope, closed: false };
await this.ensureReplay();
}
}
private publish(event: AgentStreamEvent): void {
@@ -468,6 +468,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response),
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
interrupt: () => inner.interrupt(),
stopBackgroundWork: inner.stopBackgroundWork?.bind(inner),
close: () => inner.close(),
canStopRuntime: inner.canStopRuntime?.bind(inner),
listCommands: inner.listCommands?.bind(inner),
@@ -22,7 +22,12 @@ interface CreateAgentProviderRuntimeOptions {
export async function createAgentProviderRuntime(
options: CreateAgentProviderRuntimeOptions,
): Promise<AgentProviderRuntime> {
const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger });
const configuredPort =
options.snapshotManager.runtimeSettings?.opencode?.env?.PASEO_OPENCODE_BRIDGE_PORT;
const port = configuredPort === undefined ? undefined : Number(configuredPort);
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535))
throw new Error("PASEO_OPENCODE_BRIDGE_PORT must be an integer between 1 and 65535");
const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger, port });
try {
await bridge.start();
const snapshotManager = new ProviderSnapshotManager({
@@ -80,7 +80,7 @@ describe("ProviderSubagentStore", () => {
const [row] = subagents.fetchTimeline("parent-a", "child-1").rows;
expect(row?.item).toMatchObject({
type: "tool_call",
detail: { type: "shell", output: "x".repeat(64 * 1024) },
detail: { type: "shell", output: expect.stringMatching(/^x+…\[truncated \d+\.\d KB\]$/) },
});
});
@@ -0,0 +1,298 @@
import { afterEach, describe, expect, test } from "vitest";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import type { SessionConfigOption, SessionUpdate } from "@agentclientprotocol/sdk";
import { ACPAgentClient, type SessionStateResponse } from "./acp-agent.js";
import { CopilotACPAgentClient, COPILOT_ALLOW_ALL_MODE_ID } from "./copilot-acp-agent.js";
import type { AgentSession } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
const modes = {
currentModeId: "accept_edits",
availableModes: [
{ id: "default", name: "Default" },
{ id: "accept_edits", name: "Accept Edits" },
{ id: "dont_ask", name: "Don't Ask" },
],
};
function editOptions(currentValue = "workspace_session"): SessionConfigOption[] {
return [
{
id: "edit_approval_policy",
name: "Edits",
category: "mode",
type: "select",
currentValue,
options: [
{ value: "ask", name: "Default" },
{ value: "workspace_session", name: "Accept Edits" },
{ value: "session", name: "Don't Ask" },
],
},
];
}
// A controlled ACP process, speaking the same stdio protocol as a provider.
// Notifications precede session/new's response to exercise queued initialization updates.
const modePeerScript = String.raw`
import { createInterface } from "node:readline";
import { readFileSync, appendFileSync } from "node:fs";
const scenario = JSON.parse(readFileSync(process.argv[2], "utf8"));
let configOptions = scenario.state.configOptions;
const send = (message) => process.stdout.write(JSON.stringify(message) + "\n");
createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
const reply = (result) => send({jsonrpc: "2.0", id: request.id, result});
const reject = (message) => send({jsonrpc: "2.0", id: request.id, error: {code: -32602, message}});
switch (request.method) {
case "initialize":
reply({protocolVersion: 1, agentCapabilities: {}});
break;
case "session/new":
for (const update of scenario.updates) {
send({jsonrpc: "2.0", method: "session/update", params: {sessionId: "mode-session", update}});
}
reply({sessionId: "mode-session", ...scenario.state});
break;
case "session/set_mode":
appendFileSync(process.argv[3], JSON.stringify(request) + "\n");
if (request.params.modeId === scenario.rejectedMode) {
reject("mode denied");
} else if (!scenario.state.modes?.availableModes.some(mode => mode.id === request.params.modeId)) {
reject("invalid_params");
} else {
reply({});
}
break;
case "session/set_config_option":
appendFileSync(process.argv[3], JSON.stringify(request) + "\n");
configOptions = configOptions.map(option => option.id === request.params.configId
? {...option, currentValue: request.params.value} : option);
reply({configOptions});
break;
default:
reject("Unexpected request: " + request.method);
}
});
`;
interface ModeCall {
method: string;
params: { sessionId: string; modeId?: string; configId?: string; value?: string };
}
let session: AgentSession | undefined;
let directory: string | undefined;
afterEach(async () => {
try {
await session?.close();
} finally {
session = undefined;
if (directory) await rm(directory, { recursive: true, force: true });
directory = undefined;
}
});
async function fixture(
options: {
state?: Omit<SessionStateResponse, "sessionId">;
updates?: SessionUpdate[];
rejectedMode?: string;
startupMode?: string;
copilot?: boolean;
} = {},
) {
const artifacts = path.resolve(".artifacts");
await mkdir(artifacts, { recursive: true });
directory = await mkdtemp(path.join(artifacts, "acp-mode-"));
const script = path.join(directory, "peer.mjs");
const scenario = path.join(directory, "scenario.json");
const calls = path.join(directory, "calls.jsonl");
await writeFile(script, modePeerScript);
await writeFile(calls, "");
await writeFile(
scenario,
JSON.stringify({
state: options.state ?? { modes, configOptions: editOptions() },
updates: options.updates ?? [
{ sessionUpdate: "config_option_update", configOptions: editOptions() },
],
rejectedMode: options.rejectedMode,
}),
);
const command: [string, ...string[]] = [process.execPath, script, scenario, calls];
const client = options.copilot
? new CopilotACPAgentClient({
logger: createTestLogger(),
runtimeSettings: { command: { mode: "replace", argv: command } },
})
: new ACPAgentClient({
provider: "mode-peer",
logger: createTestLogger(),
defaultCommand: command,
});
session = await client.createSession({
provider: options.copilot ? "copilot" : "mode-peer",
cwd: directory,
modeId: options.startupMode,
});
return {
session,
async calls(): Promise<ModeCall[]> {
const lines = (await readFile(calls, "utf8")).trim();
return lines
? lines.split("\n").map((line) => {
const { method, params } = JSON.parse(line) as ModeCall;
return { method, params };
})
: [];
},
};
}
const modeCall = (modeId: string) => ({
method: "session/set_mode",
params: { sessionId: "mode-session", modeId },
});
const configCall = (configId: string, value: string) => ({
method: "session/set_config_option",
params: { sessionId: "mode-session", configId, value },
});
describe("ACP mode and config-value namespaces over stdio", () => {
test("selects Don't Ask from the advertised catalogue after a queued config update", async () => {
const peer = await fixture();
const available = await peer.session.getAvailableModes();
const dontAsk = available.find((mode) => mode.label === "Don't Ask");
expect(dontAsk).toBeDefined();
await peer.session.setMode(dontAsk!.id);
expect(await peer.calls()).toEqual([modeCall("dont_ask")]);
expect(await peer.session.getCurrentMode()).toBe("dont_ask");
expect(available.map((mode) => mode.id)).toEqual(["default", "accept_edits", "dont_ask"]);
});
test("keeps a pushed current mode through separate-policy and empty config updates", async () => {
const peer = await fixture({
updates: [
{ sessionUpdate: "current_mode_update", currentModeId: "dont_ask" },
{ sessionUpdate: "config_option_update", configOptions: editOptions("session") },
{ sessionUpdate: "config_option_update", configOptions: [] },
],
});
expect(await peer.session.getCurrentMode()).toBe("dont_ask");
expect((await peer.session.getAvailableModes()).map((mode) => mode.id)).toEqual([
"default",
"accept_edits",
"dont_ask",
]);
expect(await peer.calls()).toEqual([]);
});
test("ignores policy values outside the advertised session modes without an RPC", async () => {
const peer = await fixture();
await peer.session.setMode("session");
expect(await peer.calls()).toEqual([]);
expect(await peer.session.getCurrentMode()).toBe("accept_edits");
});
test("retains confirmed state after rejection without falling back to a config write", async () => {
const peer = await fixture({ rejectedMode: "dont_ask", updates: [] });
await expect(peer.session.setMode("dont_ask")).rejects.toThrow("mode denied");
expect(await peer.session.getCurrentMode()).toBe("accept_edits");
expect(await peer.calls()).toEqual([modeCall("dont_ask")]);
});
test("selects a config-only mode through its owning config option", async () => {
const peer = await fixture({ state: { configOptions: editOptions() } });
await peer.session.setMode("session");
expect(await peer.calls()).toEqual([configCall("edit_approval_policy", "session")]);
expect(await peer.session.getCurrentMode()).toBe("session");
});
test.each([true, false])("routes startup overrides with session modes: %s", async (withModes) => {
const peer = await fixture({
state: { modes: withModes ? modes : undefined, configOptions: editOptions() },
startupMode: withModes ? "dont_ask" : "session",
});
expect(await peer.calls()).toEqual([
withModes ? modeCall("dont_ask") : configCall("edit_approval_policy", "session"),
]);
expect(await peer.session.getCurrentMode()).toBe(withModes ? "dont_ask" : "session");
});
});
const agentMode = "https://agentclientprotocol.com/protocol/session-modes#agent";
const planMode = "https://agentclientprotocol.com/protocol/session-modes#plan";
function copilotConfig(allowAll: string): SessionConfigOption[] {
return [
{
id: "mode",
name: "Mode",
category: "mode",
type: "select",
currentValue: agentMode,
options: [
{ value: agentMode, name: "Agent" },
{ value: planMode, name: "Plan" },
],
},
{
id: "allow_all",
name: "Allow All",
type: "select",
currentValue: allowAll,
options: [
{ value: "on", name: "On" },
{ value: "off", name: "Off" },
],
},
];
}
function copilotFixture(initial: string, pushed: string) {
return fixture({
copilot: true,
state: {
modes: {
currentModeId: agentMode,
availableModes: [
{ id: agentMode, name: "Agent" },
{ id: planMode, name: "Plan" },
],
},
configOptions: copilotConfig(initial),
},
updates: [{ sessionUpdate: "config_option_update", configOptions: copilotConfig(pushed) }],
});
}
describe("Copilot Allow All through the public client", () => {
test("adopts an Allow All notification and disables it before switching modes", async () => {
const peer = await copilotFixture("off", "on");
expect(await peer.session.getCurrentMode()).toBe(COPILOT_ALLOW_ALL_MODE_ID);
await peer.session.setMode(planMode);
expect(await peer.calls()).toEqual([configCall("allow_all", "off"), modeCall(planMode)]);
expect(await peer.session.getCurrentMode()).toBe(planMode);
});
test("adopts an Allow All off notification after initializing with it enabled", async () => {
const peer = await copilotFixture("on", "off");
expect(await peer.session.getCurrentMode()).toBe(agentMode);
expect(await peer.calls()).toEqual([]);
});
test("selects Allow All and switches away in provider RPC order", async () => {
const peer = await copilotFixture("off", "off");
await peer.session.setMode(COPILOT_ALLOW_ALL_MODE_ID);
expect(await peer.session.getCurrentMode()).toBe(COPILOT_ALLOW_ALL_MODE_ID);
await peer.session.setMode(planMode);
expect(await peer.calls()).toEqual([
modeCall(agentMode),
configCall("allow_all", "on"),
configCall("allow_all", "off"),
modeCall(planMode),
]);
expect(await peer.session.getCurrentMode()).toBe(planMode);
});
});
@@ -1,6 +1,6 @@
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { EventEmitter, once } from "node:events";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -99,13 +99,34 @@ describe("buildACPClientCapabilities", () => {
interface ACPSessionInternals {
sessionId: string | null;
connection: { prompt: (...args: unknown[]) => Promise<PromptResponse> };
connection: {
prompt: (...args: unknown[]) => Promise<PromptResponse>;
signal: AbortSignal;
};
activeForegroundTurnId: string | null;
configOptions: SessionConfigOption[];
translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[];
acpMcpServers(): unknown[];
}
function openPromptConnection(
prompt: (...args: unknown[]) => Promise<unknown>,
): ACPSessionInternals["connection"] {
return {
prompt: prompt as ACPSessionInternals["connection"]["prompt"],
signal: new AbortController().signal,
};
}
function terminalEvents(events: AgentStreamEvent[]): AgentStreamEvent[] {
return events.filter(
(event) =>
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled",
);
}
interface ACPModelSelectionInternals {
sessionId: string | null;
connection: {
@@ -119,6 +140,7 @@ interface ACPModelSelectionInternals {
}
interface ACPConfiguredOverrideInternals {
applySessionState(response: SessionStateResponse): void;
sessionId: string | null;
connection: {
setSessionMode: (input: { sessionId: string; modeId: string }) => Promise<void>;
@@ -423,6 +445,24 @@ function prepareConfiguredOverrideSession(
configOptions: options.configOptions ?? [],
}));
const internals = asInternals<ACPConfiguredOverrideInternals>(session);
const availableModes = options.availableModes ?? [];
const modes =
availableModes.length > 0
? {
currentModeId: options.currentMode ?? availableModes[0]!.id,
availableModes: availableModes.map((mode) => ({
id: mode.id,
label: mode.label,
description: mode.description,
name: mode.label,
})),
}
: undefined;
internals.applySessionState({
sessionId: "session-1",
modes,
configOptions: options.configOptions ?? [],
});
internals.sessionId = "session-1";
internals.connection = {
setSessionMode,
@@ -480,7 +520,7 @@ test("ACP setModel only uses config-option fallback when the matching select cho
setSessionConfigOption.mockClear();
await expect(session.setModel("new-provider-model")).resolves.toBeUndefined();
await expect(session.setModel("new-provider-model")).resolves.toEqual({ model: "sonnet" });
expect(childLogger.warn).toHaveBeenCalledWith(
{ value: "new-provider-model" },
expect.stringContaining("is not a valid claude-acp model config option"),
@@ -2634,6 +2674,72 @@ describe("ACPAgentSession slash commands", () => {
},
]);
});
test("lists late command batches through the public ACP client", async () => {
const artifacts = path.resolve(".artifacts");
await mkdir(artifacts, { recursive: true });
const directory = await mkdtemp(path.join(artifacts, "acp-command-inventory-"));
const script = path.join(directory, "agent.mjs");
await writeFile(
script,
`
import { createInterface } from "node:readline";
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
const help = { name: "help", description: "Show available commands" };
const skill = { name: "xlsx", description: "Work with spreadsheets", input: { hint: "[instructions]" } };
const publish = (commands) => send({
jsonrpc: "2.0", method: "session/update", params: {
sessionId: "inventory-session", update: {
sessionUpdate: "available_commands_update", availableCommands: commands,
},
},
});
createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
if (request.method === "initialize") {
send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: 1, agentCapabilities: {} } });
} else if (request.method === "session/new") {
// The first batch is already cached when createSession returns.
publish([help]);
send({ jsonrpc: "2.0", id: request.id, result: { sessionId: "inventory-session" } });
setTimeout(() => publish([help]), 100);
setTimeout(() => publish([help, skill]), 350);
} else if (request.id !== undefined) {
send({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } });
}
});
`,
);
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [process.execPath, script],
});
try {
const session = await client.createSession({ provider: "acp", cwd: directory });
try {
const commands = await session.listCommands!();
expect(commands).toEqual([
{
name: "help",
description: "Show available commands",
argumentHint: "",
kind: "command",
},
{
name: "xlsx",
description: "Work with spreadsheets",
argumentHint: "[instructions]",
kind: "command",
},
]);
expect(await session.listCommands!()).toEqual(commands);
} finally {
await session.close();
}
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
describe("ACPAgentSession", () => {
@@ -2987,7 +3093,7 @@ describe("ACPAgentSession", () => {
);
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event as { type: string; turnId?: string });
@@ -3025,7 +3131,7 @@ describe("ACPAgentSession", () => {
);
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event);
@@ -3074,7 +3180,7 @@ describe("ACPAgentSession", () => {
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event);
@@ -3101,7 +3207,7 @@ describe("ACPAgentSession", () => {
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event);
@@ -3145,7 +3251,7 @@ describe("ACPAgentSession", () => {
);
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event);
@@ -3206,7 +3312,7 @@ describe("ACPAgentSession", () => {
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event);
@@ -3245,7 +3351,7 @@ describe("ACPAgentSession", () => {
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event);
@@ -3298,7 +3404,7 @@ describe("ACPAgentSession", () => {
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event);
@@ -3347,7 +3453,7 @@ describe("ACPAgentSession", () => {
);
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event as { type: string; turnId?: string; error?: string });
@@ -3380,7 +3486,7 @@ describe("ACPAgentSession", () => {
);
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => events.push(event));
const { turnId } = await session.startTurn([
@@ -3485,6 +3591,148 @@ describe("ACPAgentSession", () => {
error: expect.not.stringContaining("[object Object]"),
});
});
function fakeACPAgent(prompt: Agent["prompt"]): Agent {
return {
async initialize() {
return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: {} };
},
async newSession() {
return { sessionId: "session-1" };
},
prompt,
async authenticate() {},
async cancel() {},
};
}
async function connectInMemoryACPAgent(
agent: Agent,
options: { closeAfterPromptResponse?: boolean } = {},
): Promise<{
agentConnection: AgentSideConnection;
connection: ClientSideConnection;
sessionId: string;
closeTransport: () => Promise<void>;
}> {
const clientToAgent = new TransformStream<Uint8Array, Uint8Array>();
const agentToClient = new TransformStream<Uint8Array, Uint8Array>();
const agentOutput = agentToClient.writable.getWriter();
const decoder = new TextDecoder();
const agentConnection = new AgentSideConnection(
() => agent,
ndJsonStream(
new WritableStream<Uint8Array>({
async write(chunk) {
await agentOutput.write(chunk);
if (options.closeAfterPromptResponse && decoder.decode(chunk).includes("stopReason")) {
await agentOutput.close();
}
},
}),
clientToAgent.readable,
),
);
const connection = new ClientSideConnection(
() => ({
async requestPermission() {
return { outcome: { outcome: "cancelled" } };
},
async sessionUpdate() {},
}),
ndJsonStream(clientToAgent.writable, agentToClient.readable),
);
await connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {},
clientInfo: { name: "Paseo test", version: "dev" },
});
const { sessionId } = await connection.newSession({
cwd: "/tmp/paseo-acp-test",
mcpServers: [],
});
return {
agentConnection,
connection,
sessionId,
closeTransport: () => agentOutput.close(),
};
}
test("startTurn fails instead of hanging when the ACP connection has closed", async () => {
const agent = await connectInMemoryACPAgent(
fakeACPAgent(async () => ({ stopReason: "end_turn" })),
);
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals<ACPSessionInternals>(session).sessionId = agent.sessionId;
asInternals<ACPSessionInternals>(session).connection = agent.connection;
await agent.closeTransport();
await agent.connection.closed;
await expect(session.startTurn("second")).rejects.toThrow("ACP agent disconnected");
expect(events.some((event) => event.type === "turn_started")).toBe(false);
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
}, 2_000);
test("a prompt whose ACP transport closes mid-turn fails the turn", async () => {
let promptReceived!: () => void;
const received = new Promise<void>((resolve) => {
promptReceived = resolve;
});
const agent = await connectInMemoryACPAgent(
fakeACPAgent(() => {
promptReceived();
return new Promise(() => {});
}),
);
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals<ACPSessionInternals>(session).sessionId = agent.sessionId;
asInternals<ACPSessionInternals>(session).connection = agent.connection;
const { turnId } = await session.startTurn("hello");
await received;
await agent.closeTransport();
await vi.waitFor(
() =>
expect(terminalEvents(events)).toEqual([
expect.objectContaining({
type: "turn_failed",
turnId,
error: "ACP agent disconnected",
}),
]),
{ timeout: 1_000 },
);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(terminalEvents(events)).toHaveLength(1);
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
}, 3_000);
test("a turn answered just before its ACP transport closes still completes", async () => {
const agent = await connectInMemoryACPAgent(
fakeACPAgent(async () => ({ stopReason: "end_turn" })),
{ closeAfterPromptResponse: true },
);
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals<ACPSessionInternals>(session).sessionId = agent.sessionId;
asInternals<ACPSessionInternals>(session).connection = agent.connection;
const { turnId } = await session.startTurn("hello");
await agent.connection.closed;
await new Promise((resolve) => setTimeout(resolve, 50));
expect(terminalEvents(events)).toEqual([
expect.objectContaining({ type: "turn_completed", turnId }),
]);
}, 3_000);
});
interface ACPCloseInternals {
@@ -4424,6 +4672,109 @@ describe("ACPAgentSession close() with an unresponsive provider", () => {
}, 15_000);
});
const EXITING_ACP_AGENT = `
import { Readable, Writable } from "node:stream";
const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL);
new AgentSideConnection(
() => ({
async initialize() {
return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: {} };
},
async newSession() {
return { sessionId: "exiting-session" };
},
async authenticate() {},
async cancel() {},
async prompt({ prompt }) {
if (prompt[0]?.text === "crash") {
process.stderr.write("fixture crash\\n");
process.exit(3);
}
setTimeout(() => process.exit(0), 100);
return { stopReason: "end_turn" };
},
}),
ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)),
);
`;
describe("ACPAgentSession after its provider process exits", () => {
let dir: string | null = null;
let session: ACPAgentSession | null = null;
afterEach(async () => {
await session?.close();
session = null;
if (dir) await rm(dir, { recursive: true, force: true });
dir = null;
});
async function startExitingProvider(): Promise<ACPAgentSession> {
dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-exiting-"));
const agentScript = path.join(dir, "agent.mjs");
await writeFile(agentScript, EXITING_ACP_AGENT);
session = new ACPAgentSession(
{ provider: "exiting-acp", cwd: dir },
{
provider: "exiting-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,
},
},
);
await session.initializeNewSession();
return session;
}
test("startTurn after the provider exits while idle fails instead of hanging", async () => {
const provider = await startExitingProvider();
const events: AgentStreamEvent[] = [];
provider.subscribe((event) => events.push(event));
const internals = asInternals<{ child: ChildProcess; connection: ClientSideConnection }>(
provider,
);
const { child, connection } = internals;
const { turnId } = await provider.startTurn("first");
await vi.waitFor(() =>
expect(terminalEvents(events)).toEqual([
expect.objectContaining({ type: "turn_completed", turnId }),
]),
);
if (child.exitCode === null) await once(child, "exit");
await connection.closed;
await expect(provider.startTurn("second")).rejects.toThrow(
"ACP agent disconnected (exit code 0)",
);
expect(terminalEvents(events)).toHaveLength(1);
}, 10_000);
test("a provider that crashes mid-turn still reports its exit status", async () => {
const provider = await startExitingProvider();
const events: AgentStreamEvent[] = [];
provider.subscribe((event) => events.push(event));
const { turnId } = await provider.startTurn("crash");
await vi.waitFor(() => expect(terminalEvents(events)).toHaveLength(1), { timeout: 3_000 });
await new Promise((resolve) => setTimeout(resolve, 50));
expect(terminalEvents(events)).toEqual([
expect.objectContaining({
type: "turn_failed",
turnId,
error: "ACP agent exited unexpectedly (3)",
}),
]);
}, 10_000);
});
// An ACP agent that advertises two models (or none, with ACP_ADVERTISE_MODELS=none) but,
// like Cline, runs any model id it is given.
const PERMISSIVE_MODEL_ACP_AGENT = `
@@ -1,10 +1,12 @@
import { ACPProviderOptionsSchema } from "./acp-options.js";
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import { once } from "node:events";
import fs from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { Readable, Writable } from "node:stream";
import { setImmediate } from "node:timers/promises";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
import type { ProcessTerminator } from "../../../utils/tree-kill.js";
@@ -120,6 +122,7 @@ import {
} from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { resolveResumeProcessCwd } from "./resume-process-cwd.js";
import {
buildStringCommandShellInvocation,
createStringCommandShellEnvOverlay,
@@ -142,27 +145,37 @@ function assertChildWithPipes(
}
}
// Node reports a failed spawn (missing binary or missing cwd) as a child
// `error` event; without a listener it becomes an uncaught exception.
function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promise<never> {
// Watch process failures as well as spawn errors: the SDK leaves initialize
// pending when the child exits without replying. `close` waits for stderr to
// drain, so the failure includes the launcher's diagnostic.
function rejectOnProcessFailure(child: ChildProcess, stderrChunks: string[]): Promise<never> {
return new Promise<never>((_, reject) => {
child.once("error", (error) => {
const stderr = stderrChunks.join("").trim();
reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error)));
});
child.once("close", (code, signal) => {
const stderr = stderrChunks.join("").trim();
const status = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
reject(
new Error(
`ACP agent process exited before initialization completed (${status})${
stderr ? `\n${stderr}` : ""
}`,
),
);
});
});
}
async function isDirectory(target: string): Promise<boolean> {
try {
return (await fs.stat(target)).isDirectory();
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return false;
}
throw error;
function describeACPDisconnect(child: ChildProcess | null): string {
if (child?.signalCode) {
return `ACP agent disconnected (signal ${child.signalCode})`;
}
if (child?.exitCode != null) {
return `ACP agent disconnected (exit code ${child.exitCode})`;
}
return "ACP agent disconnected";
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -322,9 +335,18 @@ const ACP_PROBE_CLOSE_TIMEOUT_MS = 2_000;
// request when the provider never answers, so these calls must be bounded
// or the provider process is never terminated.
const ACP_CLOSE_REQUEST_TIMEOUT_MS = 2_000;
const ACP_DISCONNECT_EXIT_GRACE_MS = 1_000;
const ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS = 30_000;
const ACP_IMPORT_HISTORY_BUDGET_MS = 60_000;
/**
* An ACP agent publishes its slash-command inventory incrementally. Wait this
* long without another batch before treating the inventory as complete. MiniMax
* publishes its skills about 350ms after its built-in commands.
*/
const COMMAND_INVENTORY_QUIET_MS = 600;
const COMMAND_INVENTORY_POLL_MS = 25;
function summarizeMalformedACPStdoutError(error: unknown): { type: string; message: string } {
return {
type: error instanceof Error ? error.name : typeof error,
@@ -542,7 +564,7 @@ interface ACPProcessTransport {
connection: ClientSideConnection;
stderrChunks: string[];
spawnReady: Promise<void>;
spawnError: Promise<never>;
processError: Promise<never>;
}
interface ACPImportPromptPreviews {
@@ -634,6 +656,7 @@ interface PendingPermission {
resolve: (response: RequestPermissionResponse) => void;
reject: (error: Error) => void;
turnId: string | null;
toolCallId: string;
}
interface PendingUserMessage {
@@ -1380,6 +1403,7 @@ export class ACPAgentClient implements AgentClient {
client?: ACPClient;
},
): Promise<SpawnedACPProcess> {
const configuredOptions = ACPProviderOptionsSchema.parse(options?.providerOptions ?? {});
const transport = await this.spawnTransport(launchEnv, options?.client);
const probe: UninitializedACPProcess = {
child: transport.child,
@@ -1391,7 +1415,7 @@ export class ACPAgentClient implements AgentClient {
const initialize = await this.initializeTransport(
transport,
options?.initializeTimeoutMs,
options?.providerOptions,
configuredOptions.clientCapabilities,
);
const initializedProbe: SpawnedACPProcess = {
...probe,
@@ -1425,7 +1449,7 @@ export class ACPAgentClient implements AgentClient {
stderrChunks.push(chunk.toString());
});
const spawnErrorPromise = rejectOnSpawnError(child, stderrChunks);
const processErrorPromise = rejectOnProcessFailure(child, stderrChunks);
const spawnReadyPromise = new Promise<void>((resolve) => {
child.once("spawn", () => {
resolve();
@@ -1444,16 +1468,15 @@ export class ACPAgentClient implements AgentClient {
connection,
stderrChunks,
spawnReady: spawnReadyPromise,
spawnError: spawnErrorPromise,
processError: processErrorPromise,
};
}
protected async initializeTransport(
transport: ACPProcessTransport,
initializeTimeoutMs?: number,
providerOptions?: Record<string, unknown>,
clientCapabilities?: ACPClientCapabilities,
): Promise<InitializeResponse> {
const configuredOptions = ACPProviderOptionsSchema.parse(providerOptions ?? {});
let timeout: ReturnType<typeof setTimeout> | null = null;
const initializeTimeoutPromise = initializeTimeoutMs
? new Promise<never>((_, reject) => {
@@ -1470,11 +1493,11 @@ export class ACPAgentClient implements AgentClient {
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: buildACPClientCapabilities(
this.clientCapabilityMeta,
configuredOptions.clientCapabilities ?? this.clientCapabilities,
clientCapabilities ?? this.clientCapabilities,
),
clientInfo: { name: "Paseo", version: "dev" },
}),
transport.spawnError,
transport.processError,
...(initializeTimeoutPromise ? [initializeTimeoutPromise] : []),
]),
);
@@ -1555,7 +1578,7 @@ export class ACPAgentClient implements AgentClient {
try {
transport = await this.spawnTransport(PROBE_ENV);
await withTimeout(
Promise.race([transport.spawnReady, transport.spawnError]),
Promise.race([transport.spawnReady, transport.processError]),
phaseTimeoutMs,
`ACP spawn timed out after ${phaseTimeoutMs}ms`,
);
@@ -1746,6 +1769,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private readonly earlySessionUpdates: SessionNotification[] = [];
private currentMode: string | null = null;
private availableModes: AgentMode[];
private sessionModeState: SessionStateResponse["modes"] = null;
private currentModel: string | null = null;
private availableModels: AvailableACPModel[] | null = null;
private thinkingOptionId: string | null = null;
@@ -1753,7 +1777,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private lastActivityAt: string | null = null;
private configOptions: SessionConfigOption[] = [];
private cachedCommands: AgentSlashCommand[] = [];
private commandsReadyDeferred: { promise: Promise<void>; resolve: () => void } | null = null;
/** When the last command batch arrived; null while nothing has been published. */
private commandsUpdatedAt: number | null = null;
private commandsReadySettled = false;
private waitForInitialCommands: boolean;
private initialCommandsWaitTimeoutMs: number;
@@ -1967,6 +1992,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (!this.connection || !this.sessionId) {
throw new Error(`${this.provider} session is not initialized`);
}
if (this.connection.signal.aborted) {
throw new Error(describeACPDisconnect(this.child));
}
if (this.activeForegroundTurnId) {
throw new Error("A foreground turn is already active");
}
@@ -1981,7 +2009,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.emitSubmittedUserMessage(prompt, messageId, turnId, options?.clientMessageId);
void this.connection
const connection = this.connection;
const child = this.child;
const failOnDisconnect = () => void this.failDisconnectedTurn(turnId, child);
connection.signal.addEventListener("abort", failOnDisconnect, { once: true });
void connection
.prompt({
sessionId: this.sessionId,
messageId,
@@ -2001,11 +2033,35 @@ export class ACPAgentSession implements AgentSession, ACPClient {
diagnostic: this.collectDiagnostic(summary.diagnostic ?? summary.message),
turnId,
});
});
})
.finally(() => connection.signal.removeEventListener("abort", failOnDisconnect));
return { turnId };
}
// The SDK aborts the connection without rejecting the pending prompt, and before the child's
// exit event, so wait for the exit status before failing the turn.
private async failDisconnectedTurn(turnId: string, child: ChildProcess | null): Promise<void> {
await setImmediate();
if (child && child.exitCode === null && child.signalCode === null) {
await once(child, "exit", {
signal: AbortSignal.timeout(ACP_DISCONNECT_EXIT_GRACE_MS),
}).catch(() => undefined);
}
if (this.closed || this.activeForegroundTurnId !== turnId) {
return;
}
this.synthesizeCanceledToolCalls();
const error = describeACPDisconnect(child);
this.finishTurn({
type: "turn_failed",
provider: this.provider,
error,
diagnostic: this.collectDiagnostic(error),
turnId,
});
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
if (this.sessionId) {
@@ -2051,58 +2107,39 @@ export class ACPAgentSession implements AgentSession, ACPClient {
];
}
private ensureCommandsReadyDeferred(): void {
if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) {
return;
}
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
this.commandsReadyDeferred = { promise, resolve };
}
private settleCommandsReady(): void {
if (this.commandsReadySettled) {
return;
}
this.commandsReadySettled = true;
this.commandsReadyDeferred?.resolve();
this.commandsReadyDeferred = null;
}
private async waitForCommandsReady(): Promise<void> {
const deferred = this.commandsReadyDeferred;
if (!deferred) {
return;
}
let timer: ReturnType<typeof setTimeout> | null = null;
try {
await Promise.race([
deferred.promise,
new Promise<void>((resolve) => {
timer = setTimeout(resolve, this.initialCommandsWaitTimeoutMs);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
/**
* Providers publish their command inventory incrementally. MiniMax sends its
* built-in commands twice, then a third batch about 350ms later that merges in
* every discovered skill. Returning after the first batch loses the skills.
*
* Wait for the inventory to go quiet rather than for the first batch. The
* quiet window must exceed the gap between a provider's own batches; 600ms
* covers MiniMax with margin. `initialCommandsWaitTimeoutMs` still bounds the
* whole wait for providers that publish once or never publish at all.
*/
private async waitForCommandsToSettle(): Promise<void> {
const deadline = Date.now() + this.initialCommandsWaitTimeoutMs;
while (!this.commandsReadySettled && !this.closed) {
const remaining = deadline - Date.now();
if (remaining <= 0) return;
const quietFor = this.commandsUpdatedAt === null ? 0 : Date.now() - this.commandsUpdatedAt;
if (quietFor >= COMMAND_INVENTORY_QUIET_MS) return;
await new Promise<void>((resolve) =>
setTimeout(resolve, Math.min(remaining, COMMAND_INVENTORY_POLL_MS)),
);
}
}
async listCommands(): Promise<AgentSlashCommand[]> {
if (this.cachedCommands.length > 0) {
return this.cachedCommands;
}
if (!this.waitForInitialCommands || this.closed) {
if (this.commandsReadySettled || this.closed || !this.waitForInitialCommands) {
return this.cachedCommands;
}
this.ensureCommandsReadyDeferred();
await this.waitForCommandsReady();
await this.waitForCommandsToSettle();
this.settleCommandsReady();
return this.cachedCommands;
}
@@ -2112,11 +2149,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
throw new Error("ACP session not initialized");
}
const selection = resolveACPModeSelection({
modeId,
availableModes: this.availableModes,
configOptions: this.configOptions,
});
const selection = this.resolveModeSelection(modeId);
await this.setModeWithSelection({ modeId, selection });
}
@@ -2142,7 +2175,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (providerResult.configOptions) {
this.configOptions = this.transformConfigOptions(providerResult.configOptions);
}
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
this.availableModes = this.modesFromConfig().modes;
this.pushEvent({
type: "mode_changed",
provider: this.provider,
@@ -2221,7 +2254,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: modeId,
label: "mode",
});
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
this.availableModes = this.modesFromConfig().modes;
this.pushEvent({
type: "mode_changed",
provider: this.provider,
@@ -2248,16 +2281,17 @@ export class ACPAgentSession implements AgentSession, ACPClient {
};
}
async setModel(modelId: string | null): Promise<void> {
async setModel(modelId: string | null): Promise<{ model: string | null }> {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
}
if (!modelId) {
this.currentModel = null;
return;
return { model: this.currentModel };
}
await this.setModelWithSelection({ modelId, selection: this.resolveModelSelection(modelId) });
return { model: this.currentModel };
}
private resolveModelSelection(modelId: string): ACPModelSelection {
@@ -2653,6 +2687,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
resolve,
reject,
turnId: this.activeForegroundTurnId,
toolCallId: params.toolCall.toolCallId,
});
});
@@ -2733,14 +2768,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
}
// Cache an asynchronously-delivered slash-command batch and unblock any
// listCommands() call that is waiting on the initial batch. Used when a
// provider supplies an extensionCommandsParser whose result arrives after
// session/new (e.g. via a vendor extension notification). The ready gate is
// always settled — even for an empty batch — so a provider that legitimately
// reports no commands does not leave listCommands() blocked for the full
// initial-commands timeout. An optional sessionId scopes the batch to this
// session; notifications addressed to a different session are ignored.
// Cache an asynchronously-delivered slash-command batch from a provider that
// supplies an extensionCommandsParser (e.g. a vendor extension notification).
// An empty batch is the provider saying it has no commands, so it settles the
// waiter immediately instead of letting it wait out the quiet window. An
// optional sessionId scopes the batch to this session; notifications
// addressed to a different session are ignored.
private applyResolvedCommands(
commands: AgentSlashCommand[],
options?: { sessionId?: string },
@@ -2754,7 +2787,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
if (commands.length > 0) {
this.commandsUpdatedAt = Date.now();
this.cachedCommands = commands;
// A non-empty batch may be followed by more. Let the quiet window decide
// when the inventory is complete rather than settling here; an empty
// batch is the provider saying it has nothing, which must still unblock
// the waiter immediately.
return;
}
this.settleCommandsReady();
}
@@ -2877,11 +2916,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
// Loading an archived agent's history runs nothing in its working directory, which
// may have been removed with its worktree. session/load still names the original
// directory; only the process starts from the home directory.
private async resolveProcessCwd(): Promise<string> {
if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) {
return this.config.cwd;
}
return homedir();
private resolveProcessCwd(): Promise<string> {
return resolveResumeProcessCwd(this.config.cwd, this.resumePurpose);
}
private async spawnProcess(): Promise<SpawnedACPProcess> {
@@ -2910,7 +2946,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
child.stderr.on("data", (chunk: Buffer | string) => {
stderrChunks.push(chunk.toString());
});
const spawnError = rejectOnSpawnError(child, stderrChunks);
const processError = rejectOnProcessFailure(child, stderrChunks);
child.once("exit", (code, signal) => {
if (this.closed || this.historyRuntimeReleased) {
return;
@@ -2947,7 +2983,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
),
clientInfo: { name: "Paseo", version: "dev" },
}),
spawnError,
processError,
]),
);
@@ -2972,6 +3008,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
: response;
this.configOptions = this.transformConfigOptions(transformed.configOptions ?? []);
this.sessionModeState = transformed.modes ?? null;
const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions);
this.availableModes = modeInfo.modes;
@@ -2997,11 +3034,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private async applyConfiguredOverrides(): Promise<void> {
const configuredModeId = this.config.modeId;
if (configuredModeId && configuredModeId !== this.currentMode) {
const selection = resolveACPModeSelection({
modeId: configuredModeId,
availableModes: this.availableModes,
configOptions: this.configOptions,
});
const selection = this.resolveModeSelection(configuredModeId);
await this.setModeWithSelection({ modeId: configuredModeId, selection });
}
const configuredModelId = this.config.model;
@@ -3138,13 +3171,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.handleUsageUpdate(update);
return pendingUserEvents;
case "available_commands_update":
this.commandsUpdatedAt = Date.now();
this.cachedCommands = update.availableCommands.map((command) => ({
name: command.name,
description: command.description,
argumentHint: command.input?.hint ?? "",
kind: "command",
}));
this.settleCommandsReady();
// An empty batch means the agent has no commands and would otherwise
// wait out the whole quiet window. A non-empty batch may be one of
// several, so completion belongs to the quiet window.
if (update.availableCommands.length === 0) {
this.settleCommandsReady();
}
return pendingUserEvents;
default:
return pendingUserEvents;
@@ -3209,6 +3248,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
snapshot = this.toolSnapshotTransformer(snapshot);
}
this.toolCalls.set(toolCallId, snapshot);
if (update.status === "completed" || update.status === "failed") {
this.cancelPendingPermissions((pending) => pending.toolCallId === toolCallId);
}
return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))];
}
@@ -3249,9 +3291,37 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.currentMode = this.transformModeId(update.currentModeId);
}
private modesFromConfig() {
let modes = this.sessionModeState;
if (modes) {
let currentModeId = this.currentMode ?? modes.currentModeId;
const configMode = findSelectConfigOption({
configOptions: this.configOptions,
category: "mode",
});
// Copilot config updates name real modes; Hermes config values are a separate policy namespace.
if (configMode && modes.availableModes.some((mode) => mode.id === configMode.currentValue)) {
currentModeId = configMode.currentValue;
}
modes = { ...modes, currentModeId };
}
return deriveModesFromACP(this.defaultModes, modes, this.configOptions);
}
private resolveModeSelection(modeId: string): ACPModeSelection {
const hasSessionModes = Boolean(this.sessionModeState?.availableModes.length);
const modeConfig = findSelectConfigOption({
configOptions: this.configOptions,
category: "mode",
});
const configOnly = !hasSessionModes && modeConfig !== null;
const availableModes = configOnly ? [] : this.availableModes;
return resolveACPModeSelection({ modeId, availableModes, configOptions: this.configOptions });
}
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
this.configOptions = this.transformConfigOptions(update.configOptions);
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
const modeInfo = this.modesFromConfig();
const nextMode = modeInfo.currentModeId;
const nextModel = deriveCurrentConfigValue(this.configOptions, "model");
const nextThinkingOptionId = deriveCurrentConfigValue(this.configOptions, "thought_level");
@@ -3416,11 +3486,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
): void {
this.deliverTranslatedEvents(this.flushPendingUserMessage());
// A turn that ends without completing leaves no way to answer its open
// permission requests, so tell the agent they were cancelled.
if (event.type !== "turn_completed") {
this.cancelPendingPermissions();
}
// Failed and canceled turns clear every request in the manager. Answer the
// same set here so an outside-turn request cannot keep the provider blocked.
this.cancelPendingPermissions(
(pending) => event.type !== "turn_completed" || pending.turnId === event.turnId,
);
this.activeForegroundTurnId = null;
this.fallbackAssistantMessageId = null;
if (this.submittedUserMessageTurnId === event.turnId) {
@@ -3429,11 +3499,21 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.pushEvent(event);
}
private cancelPendingPermissions(): void {
for (const pending of this.pendingPermissions.values()) {
private cancelPendingPermissions(
matches: (pending: PendingPermission) => boolean = () => true,
): void {
for (const [requestId, pending] of this.pendingPermissions) {
if (!matches(pending)) continue;
this.pendingPermissions.delete(requestId);
pending.resolve({ outcome: { outcome: "cancelled" } });
this.pushEvent({
type: "permission_resolved",
provider: this.provider,
requestId,
resolution: { behavior: "deny", message: "Permission request canceled" },
turnId: pending.turnId ?? undefined,
});
}
this.pendingPermissions.clear();
}
private emitBootstrapThreadEvent(): void {
@@ -0,0 +1,112 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { describe, expect, test } from "vitest";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
// Exercise the public catalog API with a real child, including stdout EOF and
// stderr draining. No provider installation or external service is involved.
const failingCommand: [string, ...string[]] = [
process.execPath,
"-e",
"process.stdin.once('data', () => { process.stderr.write('launcher failed: fixture startup error\\n'); process.exitCode = 1; process.stdin.destroy(); });",
];
describe("ACP catalog startup failures", () => {
test("reports a process that exits during initialize with its stderr", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: failingCommand,
});
await expect(
client.fetchCatalog({ scope: "workspace", cwd: process.cwd(), force: true }),
).rejects.toThrow(/launcher failed: fixture startup error/);
}, 5_000);
test("session startup reports the same process exit with its stderr", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: failingCommand,
});
await expect(client.createSession({ provider: "acp", cwd: process.cwd() })).rejects.toThrow(
/launcher failed: fixture startup error/,
);
}, 5_000);
test("provider diagnostics report the startup exit instead of an initialize timeout", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: failingCommand,
diagnosticPhaseTimeoutMs: 2_000,
});
const { diagnostic } = await client.getDiagnostic();
expect(diagnostic).toContain(
"ACP agent process exited before initialization completed (exit code 1)",
);
expect(diagnostic).toContain("launcher failed: fixture startup error");
expect(diagnostic).not.toContain("initialize timed out");
});
test("invalid provider options reject without crashing the host process", async () => {
const clientModule = new URL("./generic-acp-agent.ts", import.meta.url).href;
const loggerModule = new URL("../../../test-utils/test-logger.ts", import.meta.url).href;
const script = `
import { GenericACPAgentClient } from ${JSON.stringify(clientModule)};
import { createTestLogger } from ${JSON.stringify(loggerModule)};
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [process.execPath, '-e', 'setInterval(() => {}, 1000)'],
});
try {
await client.fetchCatalog({
scope: 'workspace', cwd: process.cwd(), force: true,
providerOptions: { clientCapabilities: { fs: 'invalid' } },
});
throw new Error('Expected invalid options to reject');
} catch (error) {
if (error.name !== 'ZodError') throw error;
}
// Let child cleanup and Node's unhandled-rejection check complete.
await new Promise(resolve => setTimeout(resolve, 100));
console.log('host stayed alive');
`;
const result = await promisify(execFile)(
process.execPath,
["--import", "tsx", "--input-type=module", "-e", script],
{ timeout: 10_000 },
);
expect(result.stdout).toContain("host stayed alive");
expect(result.stderr).toBe("");
});
test("a successful catalog probe still returns its models and closes cleanly", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [
process.execPath,
"-e",
`
require('node:readline').createInterface({ input: process.stdin }).on('line', (line) => {
const request = JSON.parse(line);
const result = request.method === 'initialize'
? { protocolVersion: 1, agentCapabilities: {} }
: { sessionId: 'fixture', models: {
currentModelId: 'fixture-model',
availableModels: [{ modelId: 'fixture-model', name: 'Fixture model' }]
} };
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n');
});
`,
],
});
const catalog = await client.fetchCatalog({
scope: "workspace",
cwd: process.cwd(),
force: true,
});
expect(catalog.models).toEqual([
expect.objectContaining({ id: "fixture-model", label: "Fixture model" }),
]);
});
});
@@ -466,13 +466,13 @@ describe("background Claude subagents", () => {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "[Bash] ls -1",
log: "",
},
});
});
test.each(["before", "after"])(
"keeps one logged Task card when child tools start %s the parent finishes",
"keeps one Task card when child tools start %s the parent finishes",
async (timing) => {
// A child can stream across the main session's turn boundary. Claude opens the next
// main-session turn with system/init; bare child frames do not open a turn.
@@ -605,7 +605,7 @@ describe("background Claude subagents", () => {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "[Bash] ls -1",
log: "",
},
});
},
@@ -712,19 +712,36 @@ 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(observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" })).toEqual([]);
expect(safety.canStopRuntime()).toBe(true);
expect(
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" },
],
}),
).toEqual([
{ id: "quiet-shell", kind: "shell", description: "sleep 7200", canStop: true },
{ id: "monitor", kind: "shell", description: "watch build", canStop: true },
]);
expect(safety.canStopRuntime()).toBe(false);
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" },
],
subtype: "task_started",
task_id: "monitor",
tool_use_id: "tool-monitor",
});
expect(safety.canStopRuntime()).toBe(false);
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
expect(
observe({
type: "assistant",
message: { content: [{ type: "tool_use", id: "tool-monitor", name: "Monitor" }] },
})?.find((item) => item.id === "monitor")?.kind,
).toBe("monitor");
expect(observe({ type: "system", subtype: "background_tasks_changed", tasks: [] })).toEqual([]);
expect(safety.canStopRuntime()).toBe(false);
for (const task_id of ["quiet-shell", "monitor"])
observe({ type: "system", subtype: "task_notification", task_id });
@@ -736,6 +753,108 @@ test("Claude runtime safety protects silent shells, monitors and pending complet
expect(safety.canStopRuntime()).toBe(false);
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
expect(safety.canStopRuntime()).toBe(true);
safety.retire();
expect(
observe({
type: "system",
subtype: "background_tasks_changed",
tasks: [{ task_id: "late", task_type: "local_bash" }],
}),
).toBeUndefined();
safety.reset();
expect(
observe({
type: "system",
subtype: "background_tasks_changed",
tasks: [{ task_id: "new", task_type: "local_bash" }],
}),
).toEqual([{ id: "new", kind: "shell", description: null, canStop: true }]);
});
test("emits inventory only when relevant observations change it", () => {
const safety = new ClaudeRuntimeSafety();
const observe = (message: unknown) => safety.observe(message as SDKMessage);
expect(observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" })).toEqual([]);
expect(
observe({
type: "system",
subtype: "background_tasks_changed",
tasks: [{ task_id: "m", task_type: "local_bash" }],
}),
).toEqual([{ id: "m", kind: "shell", description: null, canStop: true }]);
expect(
observe({
type: "system",
subtype: "background_tasks_changed",
tasks: [{ task_id: "m", task_type: "local_bash" }],
}),
).toBeUndefined();
expect(
observe({ type: "system", subtype: "task_started", task_id: "m", tool_use_id: "tool" }),
).toBeUndefined();
expect(
observe({
type: "assistant",
message: { content: [{ type: "tool_use", id: "tool", name: "Monitor" }] },
}),
).toEqual([{ id: "m", kind: "monitor", description: null, canStop: true }]);
expect(
observe({ type: "assistant", message: { content: [{ type: "text", text: "streamed" }] } }),
).toBeUndefined();
expect(
observe({ type: "system", subtype: "task_started", task_id: "m", tool_use_id: "other" }),
).toEqual([{ id: "m", kind: "shell", description: null, canStop: true }]);
});
test("Claude runtime safety releases a process that has not been sent a turn", () => {
const safety = new ClaudeRuntimeSafety();
// A resumed process that only answered control requests never sends init.
expect(safety.canStopRuntime()).toBe(true);
safety.notePromptSent();
expect(safety.canStopRuntime()).toBe(false);
safety.observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" } as SDKMessage);
expect(safety.canStopRuntime()).toBe(true);
safety.reset();
expect(safety.canStopRuntime()).toBe(true);
// A CLI without a roster that ran a turn may own background work.
safety.observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" } as SDKMessage);
expect(safety.canStopRuntime()).toBe(false);
safety.retire();
expect(safety.canStopRuntime()).toBe(false);
});
test("Claude session started by a control request without a turn can stop its runtime", async () => {
let launched = 0;
const query = {
next: () => new Promise<IteratorResult<SDKMessage>>(() => {}),
return: async () => ({ done: true, value: undefined }),
close: () => {},
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");
launched++;
return query;
},
}).createSession({ provider: "claude", cwd: process.cwd() });
try {
// Reopening a chat lists its commands, which launches Claude with no prompt.
await session.listCommands!();
expect(launched).toBe(1);
expect(await session.canStopRuntime!()).toBe(true);
} finally {
await session.close();
}
});
test("Claude session runtime safety observes pumped background work and autonomous replies", async () => {
@@ -0,0 +1,226 @@
import { expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryFactory } from "./query.js";
const LIMIT = "You've hit your session limit · resets 5:20pm (Asia/Singapore)";
interface ScriptedTurn {
// An API error reply as Claude Code writes it: a synthetic assistant message with `error`.
errorReply?: string;
// Assistant text the model wrote before or after it.
before?: string;
after?: string;
isError: boolean;
subtype?:
| "error_max_turns"
| "error_during_execution"
| "error_max_budget_usd"
| "error_max_structured_output_retries";
usage?: {
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
totalCostUsd: number;
};
}
// A query dependency that speaks Claude Code's stream interface, one scripted turn per prompt.
function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory {
return ({ prompt }) => {
let index = 0;
const assistant = (id: string, text: string, error?: string) => ({
type: "assistant",
session_id: "error-session",
parent_tool_use_id: null,
uuid: id,
message: {
id,
role: "assistant",
model: error ? "<synthetic>" : "claude-opus-5-5",
content: [{ type: "text", text }],
stop_reason: "stop_sequence",
usage: { input_tokens: 0, output_tokens: 0 },
},
...(error ? { error } : {}),
});
const messages = (async function* () {
if (typeof prompt === "string") throw new Error("Expected streaming input");
for await (const _message of prompt) {
const turn = turns[index];
index += 1;
yield {
type: "system",
subtype: "init",
session_id: "error-session",
permissionMode: "default",
model: "claude-opus-5-5",
};
if (turn.before) yield assistant(`before-${index}`, turn.before);
if (turn.errorReply) yield assistant(`error-${index}`, turn.errorReply, "rate_limit");
if (turn.after) yield assistant(`after-${index}`, turn.after);
yield {
type: "result",
subtype: turn.subtype ?? "success",
session_id: "error-session",
duration_ms: 1,
duration_api_ms: 0,
is_error: turn.isError,
...(turn.subtype ? { errors: ["Reached maximum number of turns (1)"] } : {}),
num_turns: 1,
result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""),
stop_reason: "stop_sequence",
total_cost_usd: turn.usage?.totalCostUsd ?? 0,
usage: {
input_tokens: turn.usage?.inputTokens ?? 0,
output_tokens: turn.usage?.outputTokens ?? 0,
cache_read_input_tokens: turn.usage?.cachedInputTokens ?? 0,
},
modelUsage: {},
permission_denials: [],
uuid: `result-${index}`,
};
}
})();
return Object.assign(messages, {
close: () => {
void messages.return();
},
interrupt: async () => undefined,
setModel: async () => undefined,
setPermissionMode: async () => undefined,
supportedModels: async () => [],
supportedCommands: async () => [],
}) as ReturnType<ClaudeQueryFactory>;
};
}
async function runTurns(turns: ScriptedTurn[]) {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: scriptedQuery(turns),
resolveBinary: async () => process.execPath,
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
try {
const results = [];
for (const [index] of turns.entries()) {
results.push(await collectSessionTurnEvents(session, `turn ${index + 1}`));
}
return results;
} finally {
await session.close();
}
}
function assistantTexts(events: Awaited<ReturnType<typeof runTurns>>[number]): string[] {
return events.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
);
}
function terminal(events: Awaited<ReturnType<typeof runTurns>>[number]) {
return events.find(
(event) =>
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled",
);
}
test("a turn Claude Code ends on an API error fails with the error, shown once", async () => {
const [limited, overloaded, next] = await runTurns([
{ errorReply: LIMIT, isError: true },
{
before: "Checking the deploy doc.",
errorReply: "API Error: 529 Overloaded",
isError: true,
},
{ after: "Done.", isError: false },
]);
expect(terminal(limited)).toMatchObject({
type: "turn_failed",
error: LIMIT,
});
expect(assistantTexts(limited)).toEqual([]);
expect(terminal(overloaded)).toMatchObject({
type: "turn_failed",
error: "API Error: 529 Overloaded",
});
expect(assistantTexts(overloaded)).toEqual(["Checking the deploy doc."]);
// The session keeps working after a failed turn.
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(next)).toEqual(["Done."]);
});
test("a failed turn retains its final token counts and cost", async () => {
const usage = {
inputTokens: 120,
cachedInputTokens: 80,
outputTokens: 30,
totalCostUsd: 0.012,
};
const [failed] = await runTurns([
{ before: "Checking the deploy doc.", errorReply: LIMIT, isError: true, usage },
]);
expect(failed).toContainEqual(
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
);
expect(terminal(failed)).toMatchObject({ type: "turn_failed", error: LIMIT });
expect(assistantTexts(failed)).toEqual(["Checking the deploy doc."]);
});
test("an error reply the turn moves past is still shown", async () => {
const [recovered, completed] = await runTurns([
{
errorReply: "API Error: 529 Overloaded",
after: "Recovered.",
isError: false,
},
{ errorReply: LIMIT, isError: false },
]);
expect(terminal(recovered)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(recovered)).toEqual(["API Error: 529 Overloaded", "Recovered."]);
expect(terminal(completed)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(completed)).toEqual([LIMIT]);
});
test.each([
"error_max_turns",
"error_during_execution",
"error_max_budget_usd",
"error_max_structured_output_retries",
] as const)("a %s result publishes final usage before failure", async (subtype) => {
const usage = {
inputTokens: 2,
cachedInputTokens: 12577,
outputTokens: 110,
totalCostUsd: 0.00365537,
};
const [failed, next] = await runTurns([
{ before: "Running the command.", isError: true, subtype, usage },
{ after: "Done.", isError: false },
]);
// Collection ends at the terminal event, so this also checks ordering.
expect(failed).toContainEqual(
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
);
expect(terminal(failed)).toMatchObject({
type: "turn_failed",
error: "Reached maximum number of turns (1)",
});
expect(assistantTexts(failed)).toEqual(["Running the command."]);
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(next)).toEqual(["Done."]);
});
@@ -614,6 +614,43 @@ describe("ClaudeAgentSession persisted history load", () => {
);
});
test("replays a message steered into a running turn as the user's message", async () => {
const queued = (commandMode: string, prompt: unknown, sourceUuid?: string) => ({
type: "attachment",
uuid: `attachment-${commandMode}`,
parentUuid: "a1",
attachment: {
type: "queued_command",
commandMode,
prompt,
...(sourceUuid ? { source_uuid: sourceUuid } : {}),
},
});
const entries = [
{ type: "user", uuid: "u1", parentUuid: null, message: { content: "first" } },
{ type: "assistant", uuid: "a1", parentUuid: "u1", message: { content: "working" } },
queued("prompt", [{ type: "text", text: "steered while working" }], "steered-1"),
queued("task-notification", "<task-notification><task-id>t1</task-id></task-notification>"),
{ type: "assistant", uuid: "a2", parentUuid: "a1", message: { content: "answer" } },
];
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n"));
const session = await resume();
try {
const users = [];
for await (const event of session.streamHistory()) {
if (event.type === "timeline" && event.item.type === "user_message") users.push(event.item);
}
expect(users).toEqual([
{ type: "user_message", text: "first", messageId: "u1" },
{ type: "user_message", text: "steered while working", messageId: "steered-1" },
]);
} finally {
await session.close();
}
});
test("loads readable history when the temporary directory is unavailable", async () => {
const file = transcriptPath();
const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } };
@@ -1,3 +1,4 @@
import { constants as bufferConstants } from "node:buffer";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
@@ -236,4 +237,64 @@ describe("importing a renamed Claude session", () => {
await expect(importSession(sessionId)).rejects.toThrow();
});
test("reads the rename from a transcript too large to hold as one string", async () => {
const sessionId = "oversized-transcript";
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
await fs.mkdir(projectDir, { recursive: true });
const transcript = path.join(projectDir, `${sessionId}.jsonl`);
// A hole stands in for the long session this used to fail on: the file only
// has to be longer than a string can be, and the rename sits at its end.
await fs.writeFile(transcript, "");
await fs.truncate(transcript, bufferConstants.MAX_STRING_LENGTH + 1);
await fs.appendFile(
transcript,
`\n${[
userRecord({ sessionId, content: "Trace the slow query" }),
{ type: "custom-title", customTitle: "Slow query hunt", sessionId },
]
.map((record) => JSON.stringify(record))
.join("\n")}\n`,
);
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Slow query hunt");
});
test("reads the rename from behind more records than one read covers", async () => {
const sessionId = "late-records-rename";
await writeSession({
sessionId,
records: [
userRecord({ sessionId, content: "Review this project" }),
{ type: "custom-title", customTitle: "Scrollback review", sessionId },
// One record longer than a single read, then enough records to leave the
// rename several reads back from the end of the file.
userRecord({ sessionId, content: "x".repeat(128 * 1024) }),
...Array.from({ length: 64 }, (_, index) =>
userRecord({ sessionId, content: `${"y".repeat(4 * 1024)} ${index}` }),
),
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Scrollback review");
});
test("imports a large record after the last rename without repeatedly copying it", async () => {
const sessionId = "large-record-after-rename";
await writeSession({
sessionId,
records: [
{ type: "custom-title", customTitle: "Large attachment review", sessionId },
userRecord({ sessionId, content: "x".repeat(96 * 1024 * 1024) }),
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Large attachment review");
});
});
@@ -349,8 +349,11 @@ async function startSteeredTurn(sessionId: string): Promise<{
expectedTurnId: start.value.turnId,
clientMessageId: "steer-client",
});
expect(steered).toEqual({ status: "accepted" });
await waitFor(() => query?.prompts.length === 2);
expect(steered).toEqual({
status: "accepted",
providerMessageId: query?.prompts[1]?.uuid,
});
return { session, query: () => query, turn };
}
@@ -972,7 +975,10 @@ test("steers an autonomous turn through its existing query without restarting it
const steeredTimeline = await autonomousEvents.next();
const completion = await autonomousEvents.next();
expect(steer).toEqual({ status: "accepted" });
expect(steer).toEqual({
status: "accepted",
providerMessageId: queryRef?.prompts[1]?.uuid,
});
expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queryRef?.interrupt).not.toHaveBeenCalled();
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual(["seed prompt", "steer prompt"]);
@@ -143,6 +143,13 @@ function getLatestCompletedBashCall(events: AgentStreamEvent[]): ToolCallTimelin
.find((item) => item.status === "completed" && item.name.toLowerCase() === "bash");
}
function hasBackgroundShell(events: AgentStreamEvent[]): boolean {
return events.some(
(event) =>
event.type === "background_work" && event.items?.some((item) => item.kind === "shell"),
);
}
function getInternalQuery(session: AgentSession): unknown {
return (session as AgentSession & { query?: unknown }).query ?? null;
}
@@ -419,6 +426,39 @@ describe("ClaudeAgentSession integration", () => {
}
}, 180_000);
test("lists a background shell as background work until it exits", async () => {
const handle = await createSession({ cwdPrefix: "claude-agent-background-work-" });
try {
const liveEventsPromise = collectSubscribedUntil(
handle.session,
(event, events) =>
event.type === "background_work" &&
event.items?.length === 0 &&
hasBackgroundShell(events),
90_000,
);
await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Bash tool with run_in_background.",
"Run exactly: sleep 8",
"Do not wait for the task result.",
"Reply immediately with exactly: SPAWNED",
].join(" "),
),
{ timeoutMs: 90_000 },
);
const liveEvents = await liveEventsPromise;
expect(hasBackgroundShell(liveEvents)).toBe(true);
expect(liveEvents.at(-1)).toMatchObject({ type: "background_work", items: [] });
} finally {
await cleanupSession(handle);
}
}, 180_000);
test("surfaces permission requests and resumes after approval", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-permission-",
@@ -1,7 +1,11 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDir } from "./project-dir.js";
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
import type { AgentSession } from "../../agent-sdk-types.js";
@@ -267,3 +271,49 @@ describe("Claude rewind after a turn whose last assistant message came from a su
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]);
});
});
describe("Claude rewind to a message steered into a running turn", () => {
test("forks at the entry Claude read the message after", async () => {
const configDir = mkdtempSync(path.join(tmpdir(), "claude-rewind-steer-"));
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = configDir;
const conversation = createConversation([{ assistantMessageId: "assistant-1" }]);
const rewindSdk = new FakeClaudeSdk();
const session = await createSession(conversation, rewindSdk);
try {
await runTurns(session, 1);
// A steered message opens no turn: Claude Code records it as a queued_command attachment
// under the uuid Paseo minted for it, hung off the entry it was read after.
const projectDir = await claudeProjectDir(process.cwd(), { configDir });
mkdirSync(projectDir, { recursive: true });
const entries = [
{ type: "user", uuid: conversation.userMessageIds[0], parentUuid: null },
{ type: "assistant", uuid: "assistant-1", parentUuid: conversation.userMessageIds[0] },
{ type: "user", uuid: "tool-result-1", parentUuid: "assistant-1" },
{
type: "attachment",
uuid: "queued-command-1",
parentUuid: "tool-result-1",
attachment: { type: "queued_command", source_uuid: "steered-message-1" },
},
];
writeFileSync(
path.join(projectDir, `${SESSION_ID}.jsonl`),
entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""),
);
await session.revertConversation?.({ messageId: "steered-message-1" });
} finally {
await session.close();
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
}
rmSync(configDir, { recursive: true, force: true });
}
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "tool-result-1" }]);
});
});
@@ -118,6 +118,22 @@ const RUNNING_WORKFLOW_TURN_EVENTS = [
COMPLETED_TURN_EVENTS[2],
];
const BACKGROUND_SHELL_TURN_EVENTS = [
COMPLETED_TURN_EVENTS[0],
{
type: "system",
subtype: "background_tasks_changed",
tasks: [
{ task_id: "bash-1", task_type: "local_bash", description: "sleep 20" },
{ task_id: "wf-1", task_type: "local_workflow", description: "Run the spec workflow" },
],
uuid: "00000000-0000-4000-8000-000000000001",
session_id: "claude-runtime-exit-session",
},
COMPLETED_TURN_EVENTS[1],
COMPLETED_TURN_EVENTS[2],
];
const MISSING_RESUMED_CONVERSATION_RESULT = {
type: "result",
subtype: "error_during_execution",
@@ -272,6 +288,155 @@ describe("Claude runtime exit", () => {
}
});
test("reports background tasks except subagents as background work", async () => {
const queryFactory = vi.fn(() => createQueryMock(BACKGROUND_SHELL_TURN_EVENTS));
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(createChildProcessStub());
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.run("start a background shell");
expect(events).toContainEqual(
expect.objectContaining({
type: "background_work",
provider: "claude",
items: [{ id: "bash-1", kind: "shell", description: "sleep 20", canStop: true }],
}),
);
} finally {
await session.close();
}
});
test("empties background work when the process dies while idle", async () => {
let capturedOptions: Options | undefined;
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
capturedOptions = options;
return createQueryMock(BACKGROUND_SHELL_TURN_EVENTS);
});
const child = createChildProcessStub();
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
await session.run("start a background shell");
capturedOptions?.spawnClaudeCodeProcess?.(SPAWN_OPTIONS);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
child.emit("exit", 1, null);
expect(events).toContainEqual(
expect.objectContaining({ type: "background_work", provider: "claude", items: null }),
);
} finally {
await session.close();
}
});
test("empties background work when a query restart retires the process", async () => {
let capturedOptions: Options | undefined;
const child = createChildProcessStub();
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
capturedOptions = options;
return createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, {
tail: new Promise<never>(() => undefined),
onReturn: () => child.emit("exit", 0, null),
});
});
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
await session.run("start a background shell");
capturedOptions?.spawnClaudeCodeProcess?.(SPAWN_OPTIONS);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.setThinkingOption(null);
await session.listCommands();
expect(events).toContainEqual(
expect.objectContaining({ type: "background_work", provider: "claude", items: null }),
);
} finally {
await session.close();
}
});
test("ignores background work a retired query reports after its restart", async () => {
let capturedOptions: Options | undefined;
let deliverLateEvent: ((event: unknown) => void) | undefined;
let retiredQuery: Query | undefined;
const child = createChildProcessStub();
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
capturedOptions = options;
if (retiredQuery) {
return createQueryMock([], { tail: new Promise<never>(() => undefined) });
}
retiredQuery = createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, {
tail: new Promise<unknown>((resolve) => {
deliverLateEvent = resolve;
}),
onReturn: () => child.emit("exit", 0, null),
});
return retiredQuery;
});
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
await session.run("start a background shell");
capturedOptions?.spawnClaudeCodeProcess?.(SPAWN_OPTIONS);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.setThinkingOption(null);
await session.listCommands();
expect(queryFactory).toHaveBeenCalledTimes(2);
// The retired query still had this queued when the restart emptied the list.
deliverLateEvent?.(BACKGROUND_SHELL_TURN_EVENTS[1]);
// One next() per scripted event and one for the late event; the pump stops
// reading a query it no longer owns instead of routing that event.
await vi.waitFor(() =>
expect(retiredQuery?.next).toHaveBeenCalledTimes(BACKGROUND_SHELL_TURN_EVENTS.length + 1),
);
// Let a routed late event reach subscribers before checking the list.
await new Promise<void>((resolve) => setImmediate(resolve));
const backgroundWork = events.filter((event) => event.type === "background_work");
expect(backgroundWork.at(-1)).toEqual(
expect.objectContaining({ type: "background_work", provider: "claude", items: null }),
);
} finally {
await session.close();
}
});
test("tree-kills the retired process when the resumed conversation is gone", async () => {
let capturedOptions: Options | undefined;
let deliverMissingConversation: ((event: unknown) => void) | undefined;
@@ -348,4 +513,77 @@ describe("Claude runtime exit", () => {
await session.close();
}
});
test("waits for a stopped task notification after the stop request is accepted", async () => {
let deliver!: (message: unknown) => void;
const tail = new Promise<unknown>((resolve) => {
deliver = resolve;
});
const query = createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, { tail });
query.stopTask = async () => {};
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: () => query,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
await session.run("start a background shell");
let settled = false;
const pending = session.stopBackgroundWork!("bash-1").then(() => {
settled = true;
return undefined;
});
await Promise.resolve();
expect(settled).toBe(false);
deliver({
type: "system",
subtype: "task_notification",
task_id: "bash-1",
status: "stopped",
output_file: "",
summary: "Stopped",
uuid: "00000000-0000-4000-8000-000000000002",
session_id: "claude-runtime-exit-session",
});
await pending;
expect(settled).toBe(true);
await expect(session.stopBackgroundWork!("wf-1")).rejects.toThrow();
} finally {
await session.close();
}
});
test.each(["completed", "failed"])(
"reports the task's %s outcome when it races a stop",
async (status) => {
let deliver!: (message: unknown) => void;
const tail = new Promise<unknown>((resolve) => {
deliver = resolve;
});
const query = createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, { tail });
query.stopTask = async () => {};
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: () => query,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
await session.run("start a background shell");
const result = session.stopBackgroundWork!("bash-1");
deliver({
type: "system",
subtype: "task_notification",
task_id: "bash-1",
status,
output_file: "",
summary: "Finished",
uuid: "00000000-0000-4000-8000-000000000003",
session_id: "claude-runtime-exit-session",
});
await expect(result).rejects.toThrow(status);
} finally {
await session.close();
}
},
);
});
@@ -0,0 +1,48 @@
import pino from "pino";
import { describe, expect, test } from "vitest";
import { ClaudeAgentClient } from "./agent.js";
describe("Claude runtime info before a turn", () => {
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
test("reports the explicitly configured model before the first turn", async () => {
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-haiku-5-5",
});
try {
expect(await session.getRuntimeInfo()).toMatchObject({ model: "claude-haiku-5-5" });
} finally {
await session.close();
}
});
test("reports the persisted configured model after rebuilding an idle session", async () => {
const session = await client.resumeSession({
provider: "claude",
sessionId: "runtime-model-before-turn",
metadata: { cwd: process.cwd(), model: "claude-haiku-5-5" },
});
try {
expect(await session.getRuntimeInfo()).toMatchObject({
sessionId: "runtime-model-before-turn",
model: "claude-haiku-5-5",
});
expect((await session.getRuntimeInfo()).model).toBe("claude-haiku-5-5");
expect((await session.getRuntimeInfo()).extra?.runtimeModel).toBeUndefined();
} finally {
await session.close();
}
});
test("leaves the model unknown when Claude will choose its default", async () => {
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
expect((await session.getRuntimeInfo()).model).toBeNull();
} finally {
await session.close();
}
});
});
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
@@ -77,6 +78,29 @@ function sidechainEntry(options: { agentId?: string; stopReason?: string | null
});
}
function buildQueryMock(events: unknown[]) {
let index = 0;
return {
next: vi.fn(async () => {
if (index >= events.length) return { done: true, value: undefined };
const value = events[index];
index += 1;
return { done: false, value };
}),
interrupt: vi.fn(async () => undefined),
return: vi.fn(async () => undefined),
close: vi.fn(() => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => []),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
[Symbol.asyncIterator]() {
return this;
},
};
}
describe("ClaudeAgentSession persisted subagent replay", () => {
const logger = createTestLogger();
const queryFactory = vi.fn(() => {
@@ -737,7 +761,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
);
}, 120_000);
test("limits retained child shell output to the timeline's existing content limit", async () => {
test("limits retained child shell output to the timeline item budget", async () => {
const output = "x".repeat(128 * 1024);
writeSession({
parentLines: [taskToolUse(), taskToolResult()],
@@ -782,7 +806,10 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
type: "timeline",
id: TOOL_USE_ID,
item: expect.objectContaining({
detail: expect.objectContaining({ type: "shell", output: output.slice(0, 64 * 1024) }),
detail: expect.objectContaining({
type: "shell",
output: expect.stringMatching(/^x+…\[truncated \d+\.\d KB\]$/),
}),
}),
}),
]);
@@ -1259,4 +1286,106 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
);
expect(items).toEqual([{ type: "assistant_message", text: report }]);
});
test("keeps a subagent resumed after the session reopens on the row its transcript restored", async () => {
// Verified on the wire (Claude Code 2.1.309): SendMessage resumes a finished subagent under its
// original task_id but a new tool_use_id, and the child's frames still name the original Task
// call as their parent. A reopened session only knows that child from its transcript.
writeSession({
parentLines: [taskToolUse(), taskToolResult()],
meta: JSON.stringify({ agentType: "general-purpose", toolUseId: TOOL_USE_ID }),
sidechainLines: [
JSON.stringify({
type: "assistant",
isSidechain: true,
agentId: AGENT_ID,
sessionId: "replay-session",
timestamp: "2026-07-26T06:27:50.000Z",
effort: "high",
message: {
role: "assistant",
model: "claude-sonnet-5",
content: [{ type: "text", text: "summary of the docs" }],
stop_reason: "end_turn",
},
}),
],
});
const liveQuery = vi.fn(() =>
buildQueryMock([
{
type: "system",
subtype: "init",
session_id: "replay-session",
permissionMode: "default",
},
{
type: "system",
subtype: "task_started",
task_id: AGENT_ID,
tool_use_id: "toolu_send_message",
description: "Summarize the docs",
subagent_type: "general-purpose",
task_type: "local_agent",
is_backgrounded: true,
prompt: "Summarize them again",
},
{
type: "assistant",
parent_tool_use_id: TOOL_USE_ID,
session_id: "replay-session",
message: {
id: "msg_resumed_child",
role: "assistant",
model: "claude-opus-5",
content: [{ type: "text", text: "summary again" }],
},
},
{
type: "system",
subtype: "task_updated",
task_id: AGENT_ID,
patch: { status: "completed" },
},
{
type: "result",
subtype: "success",
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
total_cost_usd: 0,
},
]),
);
const session = await new ClaudeAgentClient({
logger,
queryFactory: liveQuery,
resolveVersion: async () => "2.1.309",
resolveBinary: async () => "/test/claude/bin",
}).resumeSession({ provider: "claude", sessionId: "replay-session" }, { cwd });
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(session, "Ask it to summarize again")) {
events.push(event);
if (event.type === "turn_completed" || event.type === "turn_failed") break;
}
await session.close();
const live = events.flatMap((event) =>
event.type === "provider_subagent" ? [event.event] : [],
);
expect(new Set(live.map((event) => event.id))).toEqual(new Set([TOOL_USE_ID]));
expect(live).toContainEqual(
expect.objectContaining({
type: "upsert",
id: TOOL_USE_ID,
subtitle: "general-purpose · Opus 5 · High",
}),
);
expect(live).toContainEqual(
expect.objectContaining({
type: "timeline",
id: TOOL_USE_ID,
item: expect.objectContaining({ type: "assistant_message", text: "summary again" }),
}),
);
expect(live.at(-1)).toMatchObject({ type: "upsert", id: TOOL_USE_ID, status: "completed" });
});
});
@@ -273,7 +273,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
queryFactory.mockReset();
});
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
test("labels the Task card from its input and preserves callId lifecycle collapse", async () => {
const session = await new ClaudeAgentClient({
logger,
queryFactory,
@@ -307,9 +307,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
expect(latest.detail.subAgentType).toBe("Explore");
expect(latest.detail.description).toBe("Inspect repository structure");
expect(latest.detail.log).toContain("[Read] README.md");
expect(latest.detail.log).toContain("[Edit] src/index.ts");
expect(latest.detail.log).not.toContain("VERY_LARGE_OLD_STRING");
expect(latest.detail.log).toBe("");
const rows: AgentTimelineRow[] = timelineToolCalls.map((item, index) => ({
seq: index + 1,
@@ -396,10 +394,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
.map((event) => event.item)
.at(-1);
expect(latestSubAgentUpdate?.detail).toMatchObject({
type: "sub_agent",
log: expect.stringContaining("[Read] README.md"),
});
expect(latestSubAgentUpdate?.detail).toMatchObject({ type: "sub_agent", log: "" });
});
test("routes a resumed SendMessage sidechain to the original child without a second Task card", async () => {
@@ -553,7 +548,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" });
});
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
test("keeps the Task card the same size while the child runs", async () => {
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
const session = await new ClaudeAgentClient({
@@ -568,24 +563,27 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
await session.close();
const timelineToolCalls = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "tool_call",
const runningCards = events
.flatMap((event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.callId === "task-tail-1" &&
event.item.status === "running" &&
event.item.detail.type === "sub_agent"
? [event.item]
: [],
)
.map((event) => event.item)
.filter((item) => item.callId === "task-tail-1");
const subAgentUpdates = timelineToolCalls.filter((item) => item.detail.type === "sub_agent");
const latest = subAgentUpdates[subAgentUpdates.length - 1];
expect(latest).toBeDefined();
if (!latest || latest.detail.type !== "sub_agent") {
throw new Error("expected sub_agent detail");
}
.map((item) => JSON.stringify(item));
expect(runningCards).toHaveLength(1);
expect(latest.detail.log).not.toContain("[Read] file-1.md");
expect(latest.detail.log).not.toContain("[Read] file-5.md");
expect(latest.detail.log).toContain("[Read] file-6.md");
expect(latest.detail.log).toContain("[Read] file-205.md");
const childToolCalls = events.filter(
(event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.id === "task-tail-1" &&
event.event.item.type === "tool_call",
);
expect(childToolCalls).toHaveLength(205);
});
test("keeps a long main-thread tool call out of the subagents track", async () => {
@@ -729,6 +729,196 @@ describe("ClaudeAgentSession features", () => {
return { queryFactory, queryMock, launches };
}
function createStreamingQueryMock() {
const mock = createQueryMock();
const messages: SDKMessage[] = [];
let pending: ((result: IteratorResult<SDKMessage, void>) => void) | null = null;
let ended = false;
const finish = () => {
ended = true;
pending?.({ done: true, value: undefined });
pending = null;
};
mock.queryMock.close.mockImplementation(finish);
mock.queryMock.return.mockImplementation(async () => finish());
mock.queryMock[Symbol.asyncIterator] = () => ({
next: async () => {
const message = messages.shift();
if (message) return { done: false, value: message };
if (ended) return { done: true, value: undefined };
return new Promise<IteratorResult<SDKMessage, void>>((resolve) => {
pending = resolve;
});
},
});
return {
...mock,
push(message: SDKMessage) {
if (pending) {
pending({ done: false, value: message });
pending = null;
} else {
messages.push(message);
}
},
};
}
async function startToolForClosure() {
const mock = createStreamingQueryMock();
const session = await new ClaudeAgentClient({
logger,
queryFactory: mock.queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
const events: AgentStreamEvent[] = [];
let toolStarted!: () => void;
const running = new Promise<void>((resolve) => {
toolStarted = resolve;
});
const unsubscribe = session.subscribe((event) => {
events.push(event);
if (
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "running"
)
toolStarted();
});
await session.startTurn("run the tool");
mock.push({
type: "stream_event",
session_id: "closure-session",
uuid: "00000000-0000-4000-8000-000000000001",
parent_tool_use_id: null,
event: {
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "closure-tool", name: "Bash", input: {} },
},
});
mock.push({
type: "stream_event",
session_id: "closure-session",
uuid: "00000000-0000-4000-8000-000000000002",
parent_tool_use_id: null,
event: { type: "content_block_stop", index: 0 },
});
await running;
return { ...mock, session, events, unsubscribe };
}
test("runtime close publishes tool cleanup without reporting a user turn cancellation", async () => {
const fixture = await startToolForClosure();
try {
await fixture.session.close();
expect(fixture.queryMock.close).toHaveBeenCalledTimes(1);
expect(
fixture.events.filter(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "canceled",
),
).toHaveLength(1);
expect(
fixture.events.filter(
(event) =>
event.type === "turn_canceled" ||
event.type === "turn_completed" ||
event.type === "turn_failed",
),
).toEqual([]);
} finally {
fixture.unsubscribe();
await fixture.session.close();
}
});
test("explicit interruption still publishes tool cleanup and one turn cancellation", async () => {
const fixture = await startToolForClosure();
try {
await fixture.session.interrupt();
expect(
fixture.events.filter(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "canceled",
),
).toHaveLength(1);
expect(fixture.events.filter((event) => event.type === "turn_canceled")).toHaveLength(1);
} finally {
fixture.unsubscribe();
await fixture.session.close();
}
});
test("runtime close resolves permissions once without reporting a user turn cancellation", async () => {
const { queryFactory } = createQueryMock();
const session = await new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
const abort = new AbortController();
try {
await session.startTurn("run the tool");
const canUseTool = queryFactory.mock.calls[0]?.[0].options.canUseTool;
if (!canUseTool) throw new Error("Expected canUseTool callback");
const permission = canUseTool(
"Bash",
{ command: "printf test" },
{ signal: abort.signal, toolUseID: "tool-closed" },
);
const rejected = expect(permission).rejects.toThrow("Claude session closed");
await session.close();
abort.abort();
await rejected;
expect(session.getPendingPermissions()).toEqual([]);
expect(events.filter(isPermissionResolvedEvent)).toHaveLength(1);
expect(events.filter((event) => event.type === "turn_canceled")).toEqual([]);
} finally {
unsubscribe();
await session.close();
}
});
test("runtime close prevents a query from launching after pending options resolve", async () => {
const { queryFactory } = createQueryMock();
let optionsStarted!: () => void;
let allowOptions!: () => void;
const started = new Promise<void>((resolve) => {
optionsStarted = resolve;
});
const allowed = new Promise<void>((resolve) => {
allowOptions = resolve;
});
const session = await new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => {
optionsStarted();
await allowed;
return "/test/claude/bin";
},
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
const turn = session.startTurn("must not run after closure");
try {
await started;
await session.close();
allowOptions();
await turn;
expect(queryFactory).not.toHaveBeenCalled();
} finally {
allowOptions();
await turn;
await session.close();
}
});
test("publishes a resolution when the SDK aborts a permission callback", async () => {
const { queryFactory } = createQueryMock();
const session = await new ClaudeAgentClient({
@@ -1224,16 +1414,17 @@ describe("ClaudeAgentSession features", () => {
const iterator = input[Symbol.asyncIterator]();
await expect(iterator.next()).resolves.toMatchObject({ value: { type: "user" } });
await expect(
session.steerActiveTurn?.("same turn follow-up", {
expectedTurnId: turnId,
clientMessageId: "steer-client-id",
}),
).resolves.toEqual({ status: "accepted" });
const admission = await session.steerActiveTurn?.("same turn follow-up", {
expectedTurnId: turnId,
clientMessageId: "steer-client-id",
});
expect(admission).toEqual({ status: "accepted", providerMessageId: expect.any(String) });
if (admission?.status !== "accepted") throw new Error("Expected steer admission");
await expect(iterator.next()).resolves.toMatchObject({
value: {
type: "user",
priority: "next",
uuid: admission.providerMessageId,
message: { content: [{ type: "text", text: "same turn follow-up" }] },
},
});
@@ -1291,7 +1482,7 @@ describe("ClaudeAgentSession features", () => {
expectedTurnId: turnId,
clearPendingPermissions: true,
}),
).resolves.toEqual({ status: "accepted" });
).resolves.toEqual({ status: "accepted", providerMessageId: expect.any(String) });
await expect(firstPermission).resolves.toMatchObject({
behavior: "deny",
interrupt: undefined,
@@ -1350,7 +1541,7 @@ describe("ClaudeAgentSession features", () => {
const permission = internal.handlePermissionRequest("Write", {}, { toolUseID: "tool-1" });
await expect(
session.steerActiveTurn?.("system notification", { expectedTurnId: turnId }),
).resolves.toEqual({ status: "accepted" });
).resolves.toEqual({ status: "accepted", providerMessageId: expect.any(String) });
expect(session.getPendingPermissions()).toHaveLength(1);
const requestId = session.getPendingPermissions()[0]!.id;
await session.respondToPermission(requestId, { behavior: "deny", message: "test cleanup" });
@@ -2717,8 +2908,13 @@ describe("ClaudeAgentSession context window usage", () => {
await session.startTurn("turn", { clientMessageId: "client-message-1" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(events.slice(0, 2).map((event) => event.type)).toEqual(["turn_started", "timeline"]);
expect(events[1]).toMatchObject({
const started = events.findIndex((event) => event.type === "turn_started");
const userMessage = events.findIndex(
(event) => event.type === "timeline" && event.item.type === "user_message",
);
expect(started).toBeGreaterThanOrEqual(0);
expect(userMessage).toBeGreaterThan(started);
expect(events[userMessage]).toMatchObject({
type: "timeline",
item: { type: "user_message", clientMessageId: "client-message-1" },
});
@@ -2789,7 +2985,7 @@ describe("ClaudeAgentSession context window usage", () => {
expect(persistedQueryFactory.mock.calls[0]?.[0].options.persistSession).toBe(true);
});
test("classifies Claude root-only commands separately from inline skills", async () => {
test("classifies Claude root-only commands separately from inline skills and hides internal ones", async () => {
const queryFactory = vi.fn(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
void prompt;
return {
@@ -2822,6 +3018,11 @@ describe("ClaudeAgentSession context window usage", () => {
description: "Start a new session with empty context",
argumentHint: "",
},
{
name: "__remote-workflow",
description: "Run a remote workflow (server-launched sessions only)",
argumentHint: "",
},
],
rewindFiles: async () => ({ canRewind: true }),
[Symbol.asyncIterator]() {
@@ -82,6 +82,7 @@ import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { discoverClaudeModels } from "./model-discovery.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import {
findSteeredMessageParent,
realClaudeRewindSdk,
revertClaudeConversation,
revertClaudeFiles,
@@ -441,6 +442,12 @@ interface SlashCommandInvocation {
rawInput: string;
}
// Claude Code reserves `__`-prefixed commands for its own server-launched sessions
// (for example `/__remote-workflow`); a user cannot run them from a Paseo agent.
function isClaudeInternalSlashCommand(commandName: string): boolean {
return commandName.startsWith("__");
}
function classifyClaudeSlashCommand(commandName: string): AgentSlashCommand["kind"] {
// Claude exposes commands and skills as one flat SDK list, without structured source
// metadata. Keep obvious root-only/session controls out of inline autocomplete and
@@ -2293,6 +2300,7 @@ class ClaudeAgentSession implements AgentSession {
private readonly resolveBinary: () => Promise<string>;
private query: Query | null = null;
private readonly runtimeSafety = new ClaudeRuntimeSafety();
private readonly backgroundStops = new Map<string, (outcome: string) => void>();
private processingSdkMessages = 0;
private readonly harnessEnvironment: Record<string, string>;
private readonly usageSessionKey = randomUUID();
@@ -2388,6 +2396,12 @@ class ClaudeAgentSession implements AgentSession {
private mainTurnInFlight = false;
private foregroundHasVisibleActivity = false;
private activeTurnHasAssistantText = false;
/**
* The reply Claude Code writes when a turn stops on an API error ("You've hit your session
* limit", "API Error: 529 ..."), held back until the result says whether the turn failed: a
* failed turn shows the same text as its error, so showing both would repeat it.
*/
private withheldErrorReply: AgentTimelineItem[] = [];
private readonly contextUsage: ClaudeContextUsageState;
private userMessageIds: string[] = [];
private readonly emittedUserMessageIds = new Set<string>();
@@ -2459,7 +2473,7 @@ class ClaudeAgentSession implements AgentSession {
const info: AgentRuntimeInfo = {
provider: "claude",
sessionId: this.claudeSessionId,
model: this.lastOptionsModel,
model: this.lastOptionsModel ?? this.config.model ?? null,
modeId: this.currentMode ?? null,
...(this.lastRuntimeModel
? {
@@ -2530,6 +2544,7 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false;
this.activeTurnHasAssistantText = false;
this.withheldErrorReply = [];
this.contextUsage.beginTurn();
this.transitionTurnState("foreground", "foreground turn started");
this.clearRecentStderr();
@@ -2576,6 +2591,7 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundQuery = this.query;
this.activeForegroundInput = this.input;
this.startQueryPump();
this.runtimeSafety.notePromptSent();
this.input.push(sdkMessage);
setTimeout(() => {
if (this.activeForegroundTurnId === turnId) {
@@ -2627,7 +2643,7 @@ class ClaudeAgentSession implements AgentSession {
return { status: "unavailable" };
}
this.enqueueSteer(input, message, options.clearPendingPermissions === true);
return { status: "accepted" };
return { status: "accepted", ...(message.uuid ? { providerMessageId: message.uuid } : {}) };
}
private enqueueSteer(
@@ -2661,6 +2677,33 @@ class ClaudeAgentSession implements AgentSession {
};
}
async stopBackgroundWork(id: string): Promise<void> {
const query = this.query;
if (!query || !this.runtimeSafety.canStop(id))
throw new Error("Background work is no longer available");
if (this.backgroundStops.has(id)) throw new Error("Stop is already pending");
let timer: ReturnType<typeof setTimeout> | undefined;
let finish!: (outcome: string) => void;
const outcome = new Promise<string>((resolve) => {
finish = resolve;
this.backgroundStops.set(id, resolve);
});
try {
const result = await Promise.race([
Promise.all([query.stopTask(id), outcome]).then(([, value]) => value),
outcome.then((value) => (value === "stopped" ? new Promise<string>(() => {}) : value)),
new Promise<string>((resolve) => {
timer = setTimeout(() => resolve("timeout"), 15000);
}),
]);
if (result !== "stopped") throw new Error(`Background work stop: ${result}`);
if (this.query !== query) throw new Error("Background work connection changed");
} finally {
clearTimeout(timer);
if (this.backgroundStops.get(id) === finish) this.backgroundStops.delete(id);
}
}
async interrupt(): Promise<void> {
if (this.cancelCurrentTurn) {
this.cancelCurrentTurn();
@@ -2740,7 +2783,7 @@ class ClaudeAgentSession implements AgentSession {
this.currentMode = normalized;
}
async setModel(modelId: string | null): Promise<void> {
async setModel(modelId: string | null): Promise<{ model: string | null }> {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId.trim() : null;
const activeQuery = await this.ensureQuery();
@@ -2758,6 +2801,7 @@ class ClaudeAgentSession implements AgentSession {
this.cachedRuntimeInfo = null;
// Model change affects persistence metadata, so invalidate cached handle.
this.persistence = null;
return { model: this.lastOptionsModel ?? this.config.model ?? null };
}
private reconcileThinkingOptionForModel(modelId: string | null): void {
@@ -3010,7 +3054,9 @@ class ClaudeAgentSession implements AgentSession {
this.closed = true;
await this.ensureHistoryReady();
this.rejectAllPendingPermissions(new Error("Claude session closed"));
this.cancelCurrentTurn?.();
// AgentManager settles the run after runtime release. Preserve cleanup records
// without reporting this closure as an explicit turn cancellation.
this.flushPendingToolCalls();
this.subscribers.clear();
this.activeForegroundTurnId = null;
this.activeForegroundQuery = null;
@@ -3077,6 +3123,7 @@ class ClaudeAgentSession implements AgentSession {
const commands = await q.supportedCommands();
const commandMap = new Map<string, AgentSlashCommand>();
for (const cmd of commands) {
if (isClaudeInternalSlashCommand(cmd.name)) continue;
if (!commandMap.has(cmd.name)) {
commandMap.set(cmd.name, {
name: cmd.name,
@@ -3094,7 +3141,7 @@ class ClaudeAgentSession implements AgentSession {
async revertConversation(input: { messageId: string }): Promise<void> {
await this.ensureHistoryReady();
const target = this.resolveConversationRewindTarget(input.messageId);
const target = await this.resolveConversationRewindTarget(input.messageId);
if (target.kind === "fresh-session") {
this.startFreshConversationSession();
return;
@@ -3429,12 +3476,24 @@ class ClaudeAgentSession implements AgentSession {
return messageId;
}
private resolveConversationRewindTarget(messageId: string): ClaudeConversationRewindTarget {
private async resolveConversationRewindTarget(
messageId: string,
): Promise<ClaudeConversationRewindTarget> {
const targetUserMessageId = this.resolveClaudeMessageId(messageId);
const index = this.rewindTurnAnchors.findIndex(
(anchor) => anchor.userMessageId === targetUserMessageId,
);
if (index < 0) {
// A message steered into a running turn opens no turn of its own.
const transcriptPath = this.claudeSessionId
? await this.resolveHistoryPath(this.claudeSessionId)
: null;
const steerParent = transcriptPath
? await findSteeredMessageParent(transcriptPath, targetUserMessageId)
: null;
if (steerParent) {
return { kind: "fork", messageId: steerParent };
}
throw new Error(`Claude rewind target ${messageId} is not in the tracked conversation`);
}
@@ -3456,6 +3515,7 @@ class ClaudeAgentSession implements AgentSession {
const oldQuery = this.query;
if (!oldQuery) return;
const oldInput = this.input;
for (const finish of this.backgroundStops.values()) finish("connection lost");
// Null out query/input BEFORE awaiting the old iterator's return so the
// old pump sees this.query !== activeQuery and skips failActiveTurns.
this.query = null;
@@ -3505,9 +3565,12 @@ class ClaudeAgentSession implements AgentSession {
const input = createAsyncMessageInput<SDKUserMessage>();
const options = await this.buildOptions(launchMode);
// Closure can finish while launch options are being prepared.
if (this.closed) throw new Error("Claude session is closed");
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
this.input = input;
this.runtimeSafety.reset();
this.dispatchEvents([{ type: "background_work", provider: "claude", items: null }]);
// A fresh Claude process has no turn of its own in flight.
this.mainTurnInFlight = false;
this.query = claudeQuery(
@@ -4103,11 +4166,15 @@ class ClaudeAgentSession implements AgentSession {
}
private failRunningRuntimeTasks(): void {
this.dispatchEvents(
foldSubagentObservations(this.taskProtocolSource.failRunningTasks()).map(
for (const finish of this.backgroundStops.values()) finish("connection lost");
this.runtimeSafety.retire();
this.dispatchEvents([
...foldSubagentObservations(this.taskProtocolSource.failRunningTasks()).map(
(event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event }),
),
);
// The SDK resets its background task set when the CLI process (re)starts.
{ type: "background_work", provider: "claude", items: null },
]);
}
private startQueryPump(): void {
@@ -4175,12 +4242,19 @@ class ClaudeAgentSession implements AgentSession {
if (!this.ownsQuery(activeQuery)) return true;
this.processingSdkMessages++;
try {
this.runtimeSafety.observe(message);
const backgroundWork = this.runtimeSafety.observe(message);
if (message.type === "system" && message.subtype === "task_notification")
this.backgroundStops.get(message.task_id)?.(message.status);
logRawMessage(message);
consecutiveInterruptAbortRecoveries = 0;
if (await this.handleMissingResumedConversation(message, activeQuery)) return true;
if (!this.ownsQuery(activeQuery)) return true;
await this.routeSdkMessageFromPump(message);
if (!this.ownsQuery(activeQuery)) return true;
if (backgroundWork && this.runtimeSafety.isCurrentInventory(backgroundWork))
this.dispatchEvents([
{ type: "background_work", provider: "claude", items: backgroundWork },
]);
return false;
} finally {
this.processingSdkMessages--;
@@ -4406,6 +4480,21 @@ class ClaudeAgentSession implements AgentSession {
}) satisfies AgentStreamEvent,
);
if (!readClaudeParentToolUseId(message)) {
if (message.type === "assistant" && message.error) {
this.withheldErrorReply.push(...assistantTimelineEvents.map((event) => event.item));
return messageEvents;
}
if (
message.type === "assistant" ||
message.type === "user" ||
message.type === "stream_event"
) {
const released: AgentStreamEvent[] = [];
this.releaseWithheldErrorReply(released);
return [...released, ...messageEvents, ...assistantTimelineEvents];
}
}
return [...messageEvents, ...assistantTimelineEvents];
}
@@ -4667,7 +4756,7 @@ class ClaudeAgentSession implements AgentSession {
/**
* The Task card in the parent transcript, built from the declaration.
*
* The sidechain tracker also emits this card, enriched with the child's action log, and the two
* The sidechain tracker also emits this card when it first sees the child, and the two
* collapse on the shared tool-call id. Emitting it at declaration matters because a
* backgrounded subagent produces no sidechain frames at all — verified on the wire — so the
* tracker never runs for one and its card would otherwise stay an unlabeled "Task".
@@ -4802,6 +4891,13 @@ class ClaudeAgentSession implements AgentSession {
this.appendTaskNotificationEvents(message, events);
return;
}
if (message.subtype === "background_tasks_changed") {
this.logger.debug(
{ tasks: message.tasks.map(({ task_id, task_type }) => ({ task_id, task_type })) },
"claude.background_tasks_changed",
);
return;
}
if (message.subtype === "task_progress") {
return;
}
@@ -4953,6 +5049,23 @@ class ClaudeAgentSession implements AgentSession {
events: AgentStreamEvent[],
): void {
const usage = this.convertUsage(message);
// Claude Code reports a turn that stopped on an API error (a usage limit, an overloaded or
// rejected request) as `success` with `is_error`, carrying the error text in `result`.
if (message.subtype === "success" && message.is_error) {
const withheld = this.withheldErrorReply;
this.withheldErrorReply = [];
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const withheldText = withheld
.map((item) => (item.type === "assistant_message" ? item.text : ""))
.join("\n")
.trim();
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
events.push(...this.sidechainTracker.finishAll("failed"));
events.push(this.buildTurnFailedEvent(resultText || withheldText || "Claude run failed"));
return;
}
const releasedErrorReply = this.withheldErrorReply.length > 0;
this.releaseWithheldErrorReply(events);
if (message.subtype === "success") {
events.push(...this.sidechainTracker.finishAll("completed"));
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
@@ -4962,7 +5075,12 @@ class ClaudeAgentSession implements AgentSession {
// accounting from provider gateways does not duplicate streamed output.
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const outputTokens = message.usage?.output_tokens;
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
if (
resultText.length > 0 &&
outputTokens === 0 &&
!this.activeTurnHasAssistantText &&
!releasedErrorReply
) {
events.push({
type: "timeline",
provider: "claude",
@@ -4980,10 +5098,19 @@ class ClaudeAgentSession implements AgentSession {
"errors" in message && Array.isArray(message.errors) && message.errors.length > 0
? message.errors.join("\n")
: "Claude run failed";
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
events.push(...this.sidechainTracker.finishAll("failed"));
events.push(this.buildTurnFailedEvent(errorMessage));
}
private releaseWithheldErrorReply(events: AgentStreamEvent[]): void {
if (this.withheldErrorReply.length === 0) return;
for (const item of this.withheldErrorReply) {
events.push({ type: "timeline", item, provider: "claude" });
}
this.withheldErrorReply = [];
}
private createClaudeSessionChangedNotice(
oldSessionId: string,
newSessionId: string,
@@ -5442,6 +5569,7 @@ class ClaudeAgentSession implements AgentSession {
convertEntry: (entry) => this.convertHistoryEntry(entry),
})),
];
this.taskProtocolSource.restore(subagentReplay.restored);
const restoredProviderSubagentIds = new Set(
observations
.filter((observation) => observation.kind === "declared")
@@ -6276,6 +6404,7 @@ function buildClaudeSubagentCardDetail(
type: "sub_agent",
...(facts.title ? { subAgentType: facts.title } : {}),
...(facts.description ? { description: facts.description } : {}),
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
};
@@ -6430,6 +6559,10 @@ function convertClaudeHistoryEntryPreamble(
if (entry.type === "user" && isSyntheticHistoryUserEntry(entry)) {
return { shortCircuit: [] };
}
const steeredPrompt = readSteeredPromptHistoryItem(entry);
if (steeredPrompt) {
return { shortCircuit: [steeredPrompt] };
}
// Only user and assistant records are conversation turns. Claude Code 2.1.x also journals each
// API request as `api-request-blob` records whose message replays the system context.
if (entry.type !== "user" && entry.type !== "assistant") {
@@ -6456,6 +6589,21 @@ function isProviderImageMessage(item: AgentTimelineItem): boolean {
return item.type === "assistant_message" && isProviderImageMarkdown(item.text);
}
/**
* A message steered into a running turn. Claude Code gives it no user entry: it records a
* queued_command attachment in prompt mode, under the uuid Paseo minted for the message, which is
* also what a rewind to it resolves. The other modes carry task notifications, not user messages.
*/
function readSteeredPromptHistoryItem(entry: ClaudeHistoryEntry): AgentTimelineItem | null {
if (entry.type !== "attachment") return null;
const attachment = toObjectRecord(entry.attachment);
if (attachment?.type !== "queued_command" || attachment.commandMode !== "prompt") return null;
const text = extractUserMessageText(attachment.prompt);
if (!text) return null;
const messageId = readTrimmedString(attachment.source_uuid);
return { type: "user_message", text, ...(messageId ? { messageId } : {}) };
}
export async function convertClaudeHistoryEntry(
entry: ClaudeHistoryEntry,
mapBlocks: (
@@ -6819,40 +6967,79 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
}
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
const CLAUDE_CUSTOM_TITLE_NEEDLE = Buffer.from(`"${CLAUDE_CUSTOM_TITLE_TYPE}"`);
const CLAUDE_TITLE_SCAN_CHUNK_BYTES = 64 * 1024;
function readClaudeCustomTitleFromRecord(record: Buffer): string | null {
// The needle also matches a prompt that quotes it, so a record still has to
// parse as a `custom-title` entry carrying a usable name. Records without the
// needle are never decoded, so a long one costs a byte search and nothing more.
if (!record.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(record.toString("utf8").trim());
} catch {
return null;
}
const entry = toObjectRecord(parsed);
if (!entry || entry.isSidechain || entry.type !== CLAUDE_CUSTOM_TITLE_TYPE) {
return null;
}
return normalizeClaudeSessionTitle(
typeof entry.customTitle === "string" ? entry.customTitle : null,
);
}
/**
* The `custom-title` record is re-emitted every time the session is saved, so
* the current name is the last usable one in the file. Searching backwards finds it without
* walking the transcript, which matters because sessions reach tens of
* megabytes.
* the current name is the last usable one in the file. Reading backwards a chunk
* at a time finds it without walking the transcript, which matters because
* sessions reach tens of megabytes, and never holds the file as one string, so a
* transcript past V8's maximum string length still imports.
*/
function readLastClaudeCustomTitle(content: string): string | null {
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
let at = content.lastIndexOf(needle);
while (at >= 0) {
const lineStart = content.lastIndexOf("\n", at) + 1;
const lineEnd = content.indexOf("\n", at);
const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd);
let parsed: unknown;
try {
parsed = JSON.parse(line.trim());
} catch {
parsed = undefined;
}
const entry = toObjectRecord(parsed);
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
const title = normalizeClaudeSessionTitle(
typeof entry.customTitle === "string" ? entry.customTitle : null,
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
const handle = await fsPromises.open(filePath, "r");
try {
const stats = await handle.stat();
if (!stats.isFile()) {
// Opening a directory succeeds on Windows even though reading it does not,
// so reject the way reading the transcript as a file would.
throw Object.assign(
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
{ code: "EISDIR", path: filePath },
);
if (title) {
return title;
}
}
// The needle also matches a prompt that quotes it, and a record can carry no
// usable name, so keep looking backwards for one that does.
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
let end = stats.size;
// Retain unfinished record pieces in reverse file order. Assemble each
// complete record once, so a large tool/image record costs linear work.
let pieces: Buffer[] = [];
while (end > 0) {
const length = Math.min(CLAUDE_TITLE_SCAN_CHUNK_BYTES, end);
const chunk = Buffer.alloc(length);
await handle.read(chunk, 0, length, end - length);
let recordEnd = chunk.length;
for (
let at = chunk.lastIndexOf(10);
at >= 0;
at = at === 0 ? -1 : chunk.lastIndexOf(10, at - 1)
) {
pieces.push(chunk.subarray(at + 1, recordEnd));
const record = Buffer.concat(pieces.toReversed());
const title = readClaudeCustomTitleFromRecord(record);
if (title) return title;
pieces = [];
recordEnd = at;
}
if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd));
end -= length;
}
// The first record in the file has no newline in front of it.
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed()));
} finally {
await handle.close();
}
return null;
}
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
@@ -6864,9 +7051,8 @@ async function readClaudeRenamedSessionTitle(input: {
sessionId: string;
configDir: string;
}): Promise<string | null> {
let content: string;
try {
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
} catch (error) {
// A session with no transcript on disk has no rename to carry over. Every
// other read failure is a real fault and stays explicit.
@@ -6875,7 +7061,6 @@ async function readClaudeRenamedSessionTitle(input: {
}
throw error;
}
return readLastClaudeCustomTitle(content);
}
function normalizeImportablePromptPreview(text: string): string | null {
@@ -0,0 +1,21 @@
import { describe, expect, test } from "vitest";
import { toBackgroundWorkInputs } from "./background-work.js";
describe("toBackgroundWorkInputs", () => {
test("maps shells, drops subagents and workflows", () => {
expect(
toBackgroundWorkInputs([
{ task_id: "bash-1", task_type: "local_bash", description: "sleep 20" },
{ task_id: "agent-1", task_type: "local_agent", description: "Explore" },
{ task_id: "wf-1", task_type: "local_workflow", description: "Run the spec workflow" },
{ task_id: "x-1", task_type: "remote_something", description: " " },
{ task_id: "bash-2", task_type: "local_bash" },
]),
).toEqual([
{ id: "bash-1", kind: "shell", description: "sleep 20", canStop: true },
{ id: "x-1", kind: "other", description: null, canStop: false },
{ id: "bash-2", kind: "shell", description: null, canStop: true },
]);
});
});
@@ -0,0 +1,36 @@
import type { AgentBackgroundWorkInput } from "../../background-work/index.js";
import { CLAUDE_SUBAGENT_TASK_TYPE, CLAUDE_WORKFLOW_TASK_TYPE } from "./subagents/live-source.js";
export interface ClaudeBackgroundTask {
task_id: string;
task_type: string;
/** The user's `claude` binary can differ from the SDK typings, so this is read defensively. */
description?: unknown;
}
/** Subagents and workflows already live in the subagents track. */
const SUBAGENT_TASK_TYPES = new Set([CLAUDE_SUBAGENT_TASK_TYPE, CLAUDE_WORKFLOW_TASK_TYPE]);
/** Maps `background_tasks_changed.tasks` to provider-agnostic background work. */
export function toBackgroundWorkInputs(
tasks: readonly ClaudeBackgroundTask[],
): AgentBackgroundWorkInput[] {
return tasks
.filter((task) => !SUBAGENT_TASK_TYPES.has(task.task_type))
.map((task) => ({
id: task.task_id,
kind: toKind(task.task_type),
canStop: task.task_type === "local_bash",
description: readDescription(task.description),
}));
}
/** The inventory owner refines shell entries when launch metadata identifies a monitor. */
function toKind(taskType: string): string {
if (taskType === "local_bash") return "shell";
return "other";
}
function readDescription(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
@@ -365,7 +365,21 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
);
});
it("appends concrete models from Claude settings.json", async () => {
it("appends concrete models from Claude settings.json with thinking options", async () => {
const customModelThinkingOptions = [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
{ id: "max", label: "Max" },
];
const fableThinkingOptions = [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
{ id: "xhigh", label: "Extra High" },
{ id: "max", label: "Max" },
{ id: CLAUDE_ULTRACODE_THINKING_OPTION_ID, label: "Ultra Code" },
];
const configDir = await createClaudeConfigDir({
model: "us.anthropic.claude-opus-4-7[1m]",
env: {
@@ -393,42 +407,49 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
id: "us.anthropic.claude-opus-4-7[1m]",
label: "us.anthropic.claude-opus-4-7[1m]",
description: "From Claude settings.json model",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "openrouter/anthropic/claude-sonnet-4.5",
label: "openrouter/anthropic/claude-sonnet-4.5",
description: "From Claude settings.json env.ANTHROPIC_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "ollama/qwen3-coder",
label: "ollama/qwen3-coder",
description: "From Claude settings.json env.ANTHROPIC_SMALL_FAST_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "claude-fable-5-1[1M]",
label: "claude-fable-5-1[1M]",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_FABLE_MODEL",
thinkingOptions: fableThinkingOptions,
},
{
provider: "claude",
id: "bedrock-opus-from-env",
label: "bedrock-opus-from-env",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_OPUS_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "glm-5.1",
label: "glm-5.1",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_SONNET_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "glm-5",
label: "glm-5",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_HAIKU_MODEL",
thinkingOptions: customModelThinkingOptions,
},
]);
expect(models.filter((model) => model.id === "claude-fable-5-1[1M]")).toHaveLength(1);
@@ -83,7 +83,7 @@ export async function getClaudeModelsWithSettings(
const manifestModel = getClaudeModels(claudeCodeVersion).find(
(candidate) => candidate.id === model.id,
);
models.push({ ...manifestModel, ...model });
models.push(resolveConfiguredClaudeModel({ ...manifestModel, ...model }));
}
return models;
@@ -1,11 +1,20 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
readlinkSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import type { Query } from "@anthropic-ai/claude-agent-sdk";
import {
findSteeredMessageParent,
revertClaudeConversation,
revertClaudeConversationAndFiles,
revertClaudeFiles,
@@ -217,4 +226,96 @@ describe("Claude rewind against the Claude SDK", () => {
"first reply",
]);
});
test("forks just before a message steered into a running turn", async () => {
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-steer-"));
delete process.env.CLAUDE_CONFIG_DIR;
const projectDir = path.join(tempRoot, "projects", "-repo");
mkdirSync(projectDir, { recursive: true });
const sessionId = randomUUID();
const firstUser = randomUUID();
const toolUse = randomUUID();
const toolResult = randomUUID();
const steeredMessage = randomUUID();
const queuedCommand = randomUUID();
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
writeTranscript(transcriptPath, sessionId, [
{ type: "user", uuid: firstUser, parentUuid: null, text: "first" },
{ type: "assistant", uuid: toolUse, parentUuid: firstUser, text: "running a tool" },
{ type: "user", uuid: toolResult, parentUuid: toolUse, text: "tool output" },
// What Claude Code writes when it reads a steered message between two tool calls.
JSON.stringify({
type: "attachment",
uuid: queuedCommand,
parentUuid: toolResult,
sessionId,
attachment: {
type: "queued_command",
prompt: [{ type: "text", text: "steered" }],
source_uuid: steeredMessage,
},
}),
{ type: "assistant", uuid: randomUUID(), parentUuid: queuedCommand, text: "reply to steer" },
]);
const parent = await findSteeredMessageParent(transcriptPath, steeredMessage);
expect(parent).toBe(toolResult);
expect(await findSteeredMessageParent(transcriptPath, randomUUID())).toBeNull();
let currentSessionId = sessionId;
await revertClaudeConversation({
sdk: realClaudeRewindSdk,
sessionId,
transcriptPath,
messageId: parent!,
setSessionId: (nextSessionId) => {
currentSessionId = nextSessionId;
},
});
expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([
"first",
"running a tool",
"tool output",
]);
});
});
test.runIf(process.platform === "linux")(
"releases the transcript descriptor after an early steer match",
async () => {
const artifacts = path.resolve(".artifacts");
mkdirSync(artifacts, { recursive: true });
const root = mkdtempSync(path.join(artifacts, "claude-rewind-descriptor-"));
const transcriptPath = path.join(root, "session.jsonl");
const matchingEntry = {
type: "attachment",
parentUuid: "before-steer",
attachment: { type: "queued_command", source_uuid: "steered-message" },
};
const unreadEntry = JSON.stringify({ type: "assistant", message: "x".repeat(1024) });
writeFileSync(
transcriptPath,
JSON.stringify(matchingEntry) + "\n" + (unreadEntry + "\n").repeat(1200),
);
const openDescriptors = () =>
readdirSync("/proc/self/fd").filter((descriptor) => {
try {
return readlinkSync(path.join("/proc/self/fd", descriptor)) === transcriptPath;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}).length;
try {
expect(openDescriptors()).toBe(0);
for (let attempt = 0; attempt < 5; attempt++) {
await expect(findSteeredMessageParent(transcriptPath, "steered-message")).resolves.toBe(
"before-steer",
);
}
await expect.poll(openDescriptors, { timeout: 1000 }).toBe(0);
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
@@ -1,5 +1,7 @@
import { createReadStream } from "node:fs";
import { appendFile, readFile } from "node:fs/promises";
import path from "node:path";
import { createInterface } from "node:readline";
import {
forkSession as claudeForkSession,
type Query,
@@ -70,6 +72,54 @@ function isSessionStoreEntry(value: unknown): value is SessionStoreEntry {
);
}
/**
* The entry a message steered into a running turn follows. Claude Code gives such a message no
* entry of its own: it records a queued_command attachment, under the uuid Paseo minted for the
* message, hung off the entry it was read after. Forking at that entry drops the message and
* everything after it. Null when the transcript holds no such attachment.
*/
export async function findSteeredMessageParent(
transcriptPath: string,
messageId: string,
): Promise<string | null> {
const input = createReadStream(transcriptPath, { encoding: "utf8" });
const lines = createInterface({
input,
crlfDelay: Infinity,
});
try {
for await (const line of lines) {
if (!line.includes(messageId) || !line.includes('"queued_command"')) continue;
let entry: unknown;
try {
entry = JSON.parse(line);
} catch {
continue;
}
const record = entry as {
type?: unknown;
parentUuid?: unknown;
attachment?: { type?: unknown; source_uuid?: unknown };
};
if (
record.type === "attachment" &&
record.attachment?.type === "queued_command" &&
record.attachment.source_uuid === messageId &&
typeof record.parentUuid === "string"
) {
return record.parentUuid;
}
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
} finally {
lines.close();
input.destroy();
}
return null;
}
export async function revertClaudeConversation(input: {
sdk: ClaudeRewindSdk;
sessionId: string | null;
@@ -0,0 +1,48 @@
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { expect, test } from "vitest";
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
const message = (value: unknown) => value as SDKMessage;
test("enriches only live tasks with their correlated launch metadata", () => {
const runtime = new ClaudeRuntimeSafety();
runtime.observe(
message({
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "tool-1",
name: "Monitor",
input: { description: "Watch build", command: "tail -f build.log" },
},
{ type: "tool_use", id: "old", name: "Bash", input: { command: "old command" } },
],
},
}),
);
runtime.observe(
message({ type: "system", subtype: "task_started", task_id: "task-1", tool_use_id: "tool-1" }),
);
expect(
runtime.observe(
message({
type: "system",
subtype: "background_tasks_changed",
tasks: [{ task_id: "task-1", task_type: "local_bash", description: "Watch build" }],
}),
),
).toEqual([
{
id: "task-1",
kind: "monitor",
description: "Watch build",
command: "tail -f build.log",
canStop: true,
},
]);
expect(
runtime.observe(message({ type: "system", subtype: "background_tasks_changed", tasks: [] })),
).toEqual([]);
});
@@ -1,42 +1,172 @@
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AgentBackgroundWorkInput } from "../../background-work/index.js";
import { parseClaudeCodeVersion } from "./model-manifest.js";
import { toBackgroundWorkInputs, type ClaudeBackgroundTask } from "./background-work.js";
/** Process-local background ownership. Completion delivery can follow an empty roster. */
export class ClaudeRuntimeSafety {
private supported = false;
private liveTasks = new Set<string>();
private inventoryState: "unknown" | "supported" | "retired" = "unknown";
// Background work starts inside a turn, and init only arrives with a turn. A process that
// has neither been sent a prompt nor reported init (a resumed chat that only answered
// control requests) owns no background work.
private turnSeen = false;
private liveTasks: readonly ClaudeBackgroundTask[] = [];
private published: AgentBackgroundWorkInput[] | undefined;
private readonly awaitingNotifications = new Set<string>();
private readonly monitorTools = new Set<string>();
private readonly toolInputs = new Map<string, { description?: string; command?: string }>();
private readonly taskTools = new Map<string, string>();
reset(): void {
this.supported = false;
this.liveTasks.clear();
this.inventoryState = "unknown";
this.turnSeen = false;
this.liveTasks = [];
this.published = undefined;
this.awaitingNotifications.clear();
this.monitorTools.clear();
this.toolInputs.clear();
this.taskTools.clear();
}
observe(message: SDKMessage): void {
if (message.type !== "system") return;
retire(): void {
this.reset();
this.inventoryState = "retired";
}
/** Called before a prompt is written to this process. */
notePromptSent(): void {
this.turnSeen = true;
}
/** Returns a full replacement only when the visible inventory changes. */
observe(message: SDKMessage): AgentBackgroundWorkInput[] | undefined {
if (this.inventoryState === "retired") return undefined;
const changed = this.observeTools(message);
const systemChanged = message.type === "system" ? this.observeSystem(message) : false;
if ((!changed && !systemChanged) || this.inventoryState !== "supported") return undefined;
const items = this.buildInventory();
if (this.published && sameInventory(this.published, items)) return undefined;
this.published = items;
return items;
}
private observeTools(message: SDKMessage): boolean {
if (message.type === "assistant") {
let relevant = false;
for (const block of message.message.content) {
if (block.type === "tool_use" && (block.name === "Monitor" || block.name === "Bash")) {
if (block.name === "Monitor") relevant = this.observeMonitor(block.id) || relevant;
relevant = this.observeInput(block.id, block.input) || relevant;
}
}
return relevant;
}
if (message.type === "stream_event" && message.event.type === "content_block_start") {
const block = message.event.content_block;
if (block.type === "tool_use" && block.name === "Monitor")
return this.observeMonitor(block.id);
}
return false;
}
private observeInput(id: string, value: unknown): boolean {
const input = value as Record<string, unknown> | null;
const description = typeof input?.description === "string" ? input.description.trim() : "";
const command = typeof input?.command === "string" ? input.command.trim() : "";
const previous = this.toolInputs.get(id);
if (description === (previous?.description ?? "") && command === (previous?.command ?? ""))
return false;
this.toolInputs.set(id, {
...(description ? { description } : {}),
...(command ? { command } : {}),
});
return this.liveTasks.some((task) => this.taskTools.get(task.task_id) === id);
}
private observeMonitor(id: string): boolean {
if (this.monitorTools.has(id)) return false;
this.monitorTools.add(id);
return this.liveTasks.some((task) => this.taskTools.get(task.task_id) === id);
}
private observeSystem(message: Extract<SDKMessage, { type: "system" }>): boolean {
if (message.subtype === "task_started" && message.tool_use_id) {
const previous = this.taskTools.get(message.task_id);
this.taskTools.set(message.task_id, message.tool_use_id);
return (
previous !== message.tool_use_id &&
(this.monitorTools.has(message.tool_use_id) ||
this.toolInputs.has(message.tool_use_id) ||
(previous !== undefined &&
(this.monitorTools.has(previous) || this.toolInputs.has(previous)))) &&
this.liveTasks.some((task) => task.task_id === message.task_id)
);
}
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.
this.turnSeen = true;
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;
) {
const changed = this.inventoryState !== "supported";
this.inventoryState = "supported";
return changed;
}
}
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);
this.inventoryState = "supported";
this.liveTasks = message.tasks;
for (const task of this.liveTasks) this.awaitingNotifications.add(task.task_id);
return true;
}
if (message.subtype === "task_notification") this.awaitingNotifications.delete(message.task_id);
return false;
}
canStopRuntime(): boolean {
return this.supported && this.liveTasks.size === 0 && this.awaitingNotifications.size === 0;
if (this.inventoryState === "unknown" && !this.turnSeen) return true;
return (
this.inventoryState === "supported" &&
this.liveTasks.length === 0 &&
this.awaitingNotifications.size === 0
);
}
canStop(id: string): boolean {
return this.buildInventory().some((item) => item.id === id && item.canStop);
}
isCurrentInventory(items: AgentBackgroundWorkInput[]): boolean {
return this.inventoryState === "supported" && this.published === items;
}
private buildInventory(): AgentBackgroundWorkInput[] {
return toBackgroundWorkInputs(this.liveTasks).map((item) => {
const tool = this.taskTools.get(item.id) ?? "";
if (this.monitorTools.has(tool)) item.kind = "monitor";
const input = this.toolInputs.get(tool);
if (input?.description) item.description = input.description;
if (input?.command) item.command = input.command;
return item;
});
}
}
function sameInventory(
left: readonly AgentBackgroundWorkInput[],
right: readonly AgentBackgroundWorkInput[],
): boolean {
return (
left.length === right.length &&
left.every(
(item, index) =>
item.id === right[index].id &&
item.kind === right[index].kind &&
item.description === right[index].description &&
item.command === right[index].command &&
item.canStop === right[index].canStop,
)
);
}
@@ -4,7 +4,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
describe("ClaudeSidechainTracker", () => {
it("retains a background child's actions across the parent finishing", () => {
it("keeps a background child's card across the parent finishing", () => {
const tracker = new ClaudeSidechainTracker({
getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }),
isDescriptorOwnedElsewhere: () => true,
@@ -15,27 +15,67 @@ describe("ClaudeSidechainTracker", () => {
parent_tool_use_id: "task-1",
message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] },
}) as unknown as SDKMessage;
tracker.handleMessage(action("ls", "ls -1"), "task-1");
const parentCards = (events: ReturnType<typeof tracker.handleMessage>) =>
events.filter((event) => event.type === "timeline");
const childCallIds = (events: ReturnType<typeof tracker.handleMessage>) =>
events.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.item.type === "tool_call"
? [event.event.item.callId]
: [],
);
expect(parentCards(tracker.handleMessage(action("ls", "ls -1"), "task-1"))).toHaveLength(1);
expect(tracker.finishAll("completed")).toEqual([]);
const events = tracker.handleMessage(action("pwd", "pwd"), "task-1");
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }),
}),
}),
);
const afterFinish = tracker.handleMessage(action("pwd", "pwd"), "task-1");
expect(parentCards(afterFinish)).toEqual([]);
expect(childCallIds(afterFinish)).toEqual(["pwd"]);
tracker.delete("task-1");
const next = tracker.handleMessage(action("next", "ls"), "task-1");
expect(next).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
detail: expect.objectContaining({ log: "[Bash] ls" }),
}),
}),
);
expect(parentCards(next)).toHaveLength(1);
expect(childCallIds(next)).toEqual(["next"]);
});
it("shows a streamed child tool call's input once the assistant message brings it", () => {
const tracker = new ClaudeSidechainTracker({ getToolInput: () => null });
const frames = [
{
type: "stream_event",
parent_tool_use_id: "task-1",
event: {
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "bash-1", name: "Bash", input: {} },
},
},
{
type: "assistant",
parent_tool_use_id: "task-1",
message: {
content: [
{ type: "tool_use", id: "bash-1", name: "Bash", input: { command: "echo child" } },
],
},
},
] as unknown as SDKMessage[];
const childCards = frames
.flatMap((frame) => tracker.handleMessage(frame, "task-1"))
.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.item.type === "tool_call"
? [event.event.item]
: [],
);
expect(childCards.at(-1)).toMatchObject({
callId: "bash-1",
status: "running",
detail: { type: "shell", command: "echo child" },
});
});
it("uses Claude's native agent name for the provider subagent title", () => {
@@ -5,7 +5,6 @@ import {
mapClaudeFailedToolCall,
mapClaudeRunningToolCall,
} from "./tool-call-mapper.js";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import {
isClaudeSubagentHandbackToolName,
readClaudeSubagentHandback,
@@ -20,21 +19,17 @@ interface ClaudeContentChunk {
type ClaudeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
interface SubAgentActionEntry {
index: number;
interface SubAgentChildCall {
toolName: string;
input: unknown;
summary?: string;
}
interface SubAgentActivityState {
name?: string;
subAgentType?: string;
description?: string;
actions: SubAgentActionEntry[];
actionKeys: string[];
nextActionIndex: number;
actionIndexByKey: Map<string, number>;
/** The child's tool calls awaiting results, which carry no input of their own. */
runningCalls: Map<string, SubAgentChildCall>;
completedActionKeys: Set<string>;
}
@@ -44,7 +39,6 @@ interface SubAgentActionCandidate {
input: unknown;
}
const MAX_SUB_AGENT_LOG_ENTRIES = 200;
const MAX_SUB_AGENT_SUMMARY_CHARS = 160;
function readTrimmedString(value: unknown): string | undefined {
@@ -79,8 +73,7 @@ export class ClaudeSidechainTracker {
needsSyntheticParentToolCard?: (toolUseId: string) => boolean;
/**
* The parent's tool call once Claude has answered it. A background child streams its frames
* after that answer, so its card keeps the settled status and output and gains the child's
* action log.
* after that answer, so its card keeps the settled status and output.
*/
getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined;
}) {
@@ -91,28 +84,27 @@ export class ClaudeSidechainTracker {
}
handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] {
const existingState = this.activeSidechains.get(parentToolUseId);
const state =
this.activeSidechains.get(parentToolUseId) ??
existingState ??
({
actions: [],
actionKeys: [],
nextActionIndex: 1,
actionIndexByKey: new Map<string, number>(),
runningCalls: new Map<string, SubAgentChildCall>(),
completedActionKeys: new Set<string>(),
} satisfies SubAgentActivityState);
this.activeSidechains.set(parentToolUseId, state);
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
// The parent card is a launch marker: it changes only when the child is first seen or the
// task input names it differently, never with the child's activity.
const cardUpdated =
this.updateSubAgentContextFromTaskInput(state, parentToolUseId) || !existingState;
const actionCandidates = this.extractSubAgentActionCandidates(message);
const childTimelineItems = [
...this.extractSubAgentTimelineItems(message, state),
...this.extractSubAgentToolResults(message, state),
];
let actionUpdated = false;
for (const action of actionCandidates) {
if (state.completedActionKeys.has(action.key)) continue;
if (this.appendSubAgentAction(state, action)) {
actionUpdated = true;
if (this.trackChildCall(state, action)) {
const toolCall = mapClaudeRunningToolCall({
name: action.toolName,
callId: action.key,
@@ -125,7 +117,7 @@ export class ClaudeSidechainTracker {
}
}
if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) {
if (!cardUpdated && childTimelineItems.length === 0) {
return [];
}
@@ -145,11 +137,8 @@ export class ClaudeSidechainTracker {
type: "sub_agent",
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
...(state.description ? { description: state.description } : {}),
log: state.actions
.map((action) =>
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
)
.join("\n"),
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
};
@@ -170,18 +159,19 @@ export class ClaudeSidechainTracker {
},
];
const parentCard: AgentStreamEvent[] = this.needsSyntheticParentToolCard(parentToolUseId)
? [
{
type: "timeline",
item: {
...toolCall,
detail,
const parentCard: AgentStreamEvent[] =
cardUpdated && this.needsSyntheticParentToolCard(parentToolUseId)
? [
{
type: "timeline",
item: {
...toolCall,
detail,
},
provider: "claude",
},
provider: "claude",
},
]
: [];
]
: [];
return [
...descriptorEvents,
@@ -303,8 +293,7 @@ export class ClaudeSidechainTracker {
if (!isClaudeContentChunk(block) || !block.type.endsWith("tool_result")) continue;
const callId = readTrimmedString(block.tool_use_id);
if (!callId || state.completedActionKeys.has(callId)) continue;
const actionIndex = state.actionIndexByKey.get(callId);
const action = actionIndex === undefined ? undefined : state.actions[actionIndex];
const action = state.runningCalls.get(callId);
const toolName = action?.toolName ?? readTrimmedString(block.tool_name);
if (!toolName) continue;
const params = {
@@ -317,6 +306,7 @@ export class ClaudeSidechainTracker {
? mapClaudeFailedToolCall({ ...params, error: block })
: mapClaudeCompletedToolCall(params);
if (toolCall) {
state.runningCalls.delete(callId);
state.completedActionKeys.add(callId);
items.push(toolCall);
}
@@ -412,13 +402,12 @@ export class ClaudeSidechainTracker {
const key =
readTrimmedString(block.id) ??
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
return [
{
key,
toolName: block.name,
input: block.input ?? null,
},
];
// A streamed call starts with `{}` until its input arrives, so an empty input is not final.
const input =
block.input && typeof block.input === "object" && Object.keys(block.input).length > 0
? block.input
: null;
return [{ key, toolName: block.name, input }];
}
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
@@ -448,84 +437,20 @@ export class ClaudeSidechainTracker {
return [];
}
private appendSubAgentAction(
/** Returns whether the call is new or has gained its input, so its running card changes. */
private trackChildCall(
state: SubAgentActivityState,
candidate: SubAgentActionCandidate,
): boolean {
const normalizedToolName = readTrimmedString(candidate.toolName);
if (!normalizedToolName) {
const toolName = readTrimmedString(candidate.toolName);
if (!toolName) {
return false;
}
const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input);
const existingIndex = state.actionIndexByKey.get(candidate.key);
if (existingIndex !== undefined) {
const existing = state.actions[existingIndex];
if (!existing) {
return false;
}
const nextSummary = existing.summary ?? summary;
if (existing.toolName === normalizedToolName && existing.summary === nextSummary) {
return false;
}
state.actions[existingIndex] = {
...existing,
toolName: normalizedToolName,
input: existing.input ?? candidate.input,
...(nextSummary ? { summary: nextSummary } : {}),
};
return true;
const existing = state.runningCalls.get(candidate.key);
if (existing && (existing.input !== null || candidate.input === null)) {
return false;
}
state.actions.push({
index: state.nextActionIndex,
toolName: normalizedToolName,
input: candidate.input,
...(summary ? { summary } : {}),
});
state.nextActionIndex += 1;
state.actionKeys.push(candidate.key);
this.trimSubAgentTail(state);
this.rebuildSubAgentActionIndex(state);
state.runningCalls.set(candidate.key, { toolName, input: candidate.input });
return true;
}
private trimSubAgentTail(state: SubAgentActivityState): void {
while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) {
state.actions.shift();
const removedKey = state.actionKeys.shift();
if (removedKey) state.completedActionKeys.delete(removedKey);
}
}
private rebuildSubAgentActionIndex(state: SubAgentActivityState): void {
state.actionIndexByKey.clear();
for (let index = 0; index < state.actionKeys.length; index += 1) {
const key = state.actionKeys[index];
if (key) {
state.actionIndexByKey.set(key, index);
}
}
}
private deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined {
const runningToolCall = mapClaudeRunningToolCall({
name: toolName,
callId: `sub-agent-summary-${toolName}`,
input,
output: null,
});
if (!runningToolCall) {
return undefined;
}
const display = buildToolCallDisplayModel({
name: runningToolCall.name,
status: runningToolCall.status,
error: runningToolCall.error,
detail: runningToolCall.detail,
metadata: runningToolCall.metadata,
});
return this.normalizeSubAgentText(display.summary);
}
}
@@ -156,6 +156,42 @@ describe("ClaudeTaskProtocolSource", () => {
]);
});
it("resumes a child a reopened session restored on its original row", () => {
const source = new ClaudeTaskProtocolSource();
source.restore([
{
taskId: "a1730a6215e1f5cf6",
id: "toolu_original",
presentation: { title: "general-purpose", model: "claude-sonnet-5", effort: "high" },
},
]);
// Restored is not running: a canceled turn and an unannounced frame leave it alone.
expect(source.cancelRunningForegroundTasks()).toEqual([]);
expect(source.resolveSubagentId("toolu_original")).toBeUndefined();
expect(
source.observe(taskStarted({ tool_use_id: "toolu_resumed", prompt: "Resumed prompt" })),
).toEqual([
{ kind: "status", id: "toolu_original", status: "running" },
{
kind: "timeline",
id: "toolu_original",
item: { type: "user_message", text: "Resumed prompt" },
},
]);
expect(source.resolveSubagentId("toolu_original")).toBe("toolu_original");
expect(source.resolveSubagentId("toolu_resumed")).toBe("toolu_original");
expect(
source.observeSidechainFrame(
{ type: "assistant", message: { model: "claude-opus-5" } } as unknown as SDKMessage,
"toolu_original",
),
).toEqual([
{ kind: "subtitle", id: "toolu_original", subtitle: "general-purpose · Opus 5 · High" },
]);
});
it("declares a distinct task separately", () => {
const source = new ClaudeTaskProtocolSource();
source.observe(taskStarted({ task_id: "first", tool_use_id: "toolu_first" }));
@@ -49,9 +49,9 @@ interface TaskStartedMessage {
}
/** Task-tool subagents. Backgrounded shell commands announce as `local_bash`. */
const CLAUDE_SUBAGENT_TASK_TYPE = "local_agent";
export const CLAUDE_SUBAGENT_TASK_TYPE = "local_agent";
/** Workflow executions use the same announced task lifecycle as Task-tool subagents. */
const CLAUDE_WORKFLOW_TASK_TYPE = "local_workflow";
export const CLAUDE_WORKFLOW_TASK_TYPE = "local_workflow";
/**
* Not every announced task belongs in the subagents track. Verified on the wire:
@@ -121,6 +121,16 @@ function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
/**
* A subagent a reopened session rebuilt from its transcript. Its `taskId` (the transcript's
* agentId) is the task id Claude announces again when SendMessage resumes the child.
*/
export interface ClaudeRestoredSubagent {
taskId: string;
id: string;
presentation: ClaudeSubagentPresentationFacts;
}
export interface ClaudeTaskProtocolSourceInput {
/**
* The parent's Task tool input, by tool_use id.
@@ -162,6 +172,14 @@ export class ClaudeTaskProtocolSource {
private readonly backgroundedIds = new Set<string>();
/** Last status emitted per subagent, so a redundant announcement is not re-broadcast. */
private readonly lastStatusById = new Map<string, ProviderSubagentStatus>();
/**
* Children a reopened session restored from its transcript, by task id. Claude resumes one under
* its old task id with a new tool id, while its frames keep the original Task call as parent.
* Without this the resume reads as a new task: a second row, and every frame dropped as
* undeclared. Held apart from the declared table so a restored child is left alone until
* Claude announces it again.
*/
private readonly restoredByTaskId = new Map<string, ClaudeRestoredSubagent>();
/** Claude facts stay inside the provider boundary; clients receive one compact subtitle. */
private readonly presentationById = new Map<string, ClaudeSubagentPresentationFacts>();
private readonly lastSubtitleById = new Map<string, string>();
@@ -246,6 +264,14 @@ export class ClaudeTaskProtocolSource {
}
}
/** Remember the children a reopened session restored, for Claude to resume by task id. */
restore(subagents: Iterable<ClaudeRestoredSubagent>): void {
for (const subagent of subagents) {
if (this.subagentIdByTaskId.has(subagent.taskId)) continue;
this.restoredByTaskId.set(subagent.taskId, subagent);
}
}
/**
* Forget the session.
*
@@ -259,6 +285,7 @@ export class ClaudeTaskProtocolSource {
this.ownerSubagentIdByToolUseId.clear();
this.ownerSubagentIdByTaskId.clear();
this.declaredIds.clear();
this.restoredByTaskId.clear();
this.workflowTaskIds.clear();
this.lastWorkflowResultByTaskId.clear();
this.idsWithExistingParentToolCard.clear();
@@ -315,7 +342,8 @@ export class ClaudeTaskProtocolSource {
if (!id || message.skip_transcript === true || !isProviderSubagentTask(message)) return [];
this.sawTaskStarted = true;
const existingId = this.subagentIdByTaskId.get(message.task_id);
const existingId =
this.subagentIdByTaskId.get(message.task_id) ?? this.adoptRestored(message.task_id);
this.recordBackgrounded(existingId ?? id, message.is_backgrounded);
if (existingId) {
return this.observeExistingTaskStart(message, id, existingId);
@@ -324,6 +352,20 @@ export class ClaudeTaskProtocolSource {
return this.observeNewTaskStart(message, id, parentSubagentId);
}
/** Declare a restored child under the id its transcript gave it, with what it already showed. */
private adoptRestored(taskId: string): string | undefined {
const restored = this.restoredByTaskId.get(taskId);
if (!restored) return undefined;
this.restoredByTaskId.delete(taskId);
this.subagentIdByTaskId.set(taskId, restored.id);
this.canonicalIdByToolUseId.set(restored.id, restored.id);
this.declaredIds.add(restored.id);
this.presentationById.set(restored.id, restored.presentation);
const subtitle = buildClaudeSubagentSubtitle(restored.presentation);
if (subtitle) this.lastSubtitleById.set(restored.id, subtitle);
return restored.id;
}
private observeExistingTaskStart(
message: TaskStartedMessage,
toolUseId: string,
@@ -85,6 +85,50 @@ describe("parseClaudeSubagentMeta", () => {
});
describe("observeReplaySubagents", () => {
it("restores a resumed child on its original row after consuming async history", async () => {
async function* entries(): AsyncGenerator<ClaudeReplayEntry> {
yield { type: "assistant", message: { model: "claude-opus-5" } };
yield {
type: "assistant",
effort: "high",
message: { model: "claude-sonnet-5", usage: { input_tokens: 10, output_tokens: 2 } },
};
}
const replay = await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: entries() }],
parent: parentWithTaskCall(),
convertEntry: async () => [],
});
expect(replay.restored).toEqual([
{
taskId: AGENT_ID,
id: TOOL_USE_ID,
presentation: {
title: "general-purpose",
model: "claude-sonnet-5",
effort: "high",
usage: { totalTokens: 12 },
},
},
]);
const live = new ClaudeTaskProtocolSource();
live.restore(replay.restored);
expect(
live.observe({
type: "system",
subtype: "task_started",
task_id: AGENT_ID,
tool_use_id: "resumed-tool",
description: "Resume the captured task",
task_type: "local_agent",
is_backgrounded: true,
uuid: "00000000-0000-0000-0000-000000000000",
session_id: "reopened-session",
}),
).toEqual([{ kind: "status", id: TOOL_USE_ID, status: "running" }]);
expect(live.resolveSubagentId("resumed-tool")).toBe(TOOL_USE_ID);
});
it("links a subagent to its Task call through meta.toolUseId", async () => {
const observations = (
await observeReplaySubagents({
@@ -5,6 +5,7 @@ import { normalizeProviderReplayTimestamp } from "../../../provider-history-time
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { isClaudeInterruptPlaceholderText } from "../interrupt-placeholder.js";
import { resolveObservedClaudeModelId } from "../models.js";
import type { ClaudeRestoredSubagent } from "./live-source.js";
import type { SubagentObservation } from "./observation.js";
import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js";
@@ -246,6 +247,7 @@ async function observeSubagent(
parent: ClaudeReplayParentFacts,
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
toolOwners: Map<string, string>,
restored: ClaudeRestoredSubagent[],
parentSubagentId?: string,
): Promise<SubagentObservation[]> {
const link = resolveParentLink(subagent, parent);
@@ -276,13 +278,14 @@ async function observeSubagent(
const observations = [
declareSubagent(subagent, link, toolCall, parentSubagentId, firstTimestamp),
];
const subtitle = observeSubtitle(
link,
toolCall?.title ?? subagent.meta?.agentType,
runtime,
usage,
lastTimestamp,
);
const title = toolCall?.title ?? subagent.meta?.agentType;
// The agentId is the task_id Claude resumes this child under, so live can find it again.
restored.push({
taskId: subagent.agentId,
id: link.id,
presentation: { ...(title ? { title } : {}), ...runtime, ...(usage ? { usage } : {}) },
});
const subtitle = observeSubtitle(link, title, runtime, usage, lastTimestamp);
if (subtitle) observations.push(subtitle);
for (const observation of timeline) observations.push(observation);
const terminalStatus = link.status ?? childTerminalStatus;
@@ -337,9 +340,14 @@ export async function observeReplaySubagents(input: {
subagents: readonly ClaudeReplaySubagentInput[];
parent: ClaudeReplayParentFacts;
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> {
}): Promise<{
observations: SubagentObservation[];
toolOwners: ReadonlyMap<string, string>;
restored: readonly ClaudeRestoredSubagent[];
}> {
const observations: SubagentObservation[] = [];
const toolOwners = new Map<string, string>();
const restored: ClaudeRestoredSubagent[] = [];
const unresolved = [...input.subagents].sort(
(left, right) => (left.meta?.spawnDepth ?? 1) - (right.meta?.spawnDepth ?? 1),
);
@@ -364,6 +372,7 @@ export async function observeReplaySubagents(input: {
parent,
input.convertEntry,
toolOwners,
restored,
ownerId,
)) {
observations.push(observation);
@@ -373,7 +382,7 @@ export async function observeReplaySubagents(input: {
madeProgress = true;
}
}
return { observations, toolOwners };
return { observations, toolOwners, restored };
}
function resolveReplayOwner(
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { createServer } from "node:http";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { once } from "node:events";
import { AgentManager } from "../agent-manager.js";
@@ -217,6 +217,33 @@ function waitForEvent<TEvent extends AgentStreamEvent>(params: {
}
describe("Codex app-server provider (local e2e)", () => {
test.runIf(isCodexInstalled())(
"concurrent import scans initialize successfully in a fresh shared Codex home",
async () => {
const artifacts = path.resolve(".artifacts");
mkdirSync(artifacts, { recursive: true });
const root = mkdtempSync(path.join(artifacts, "codex-concurrent-startup-"));
try {
for (let round = 0; round < 4; round++) {
const codexHome = path.join(root, String(round));
mkdirSync(codexHome);
const provider = new CodexAppServerAgentClient(createTestLogger(), {
env: { CODEX_HOME: codexHome },
});
const results = await Promise.allSettled(
Array.from({ length: 24 }, () => provider.listImportableSessions({ limit: 1 })),
);
expect(results).toEqual(
Array.from({ length: 24 }, () => ({ status: "fulfilled", value: [] })),
);
}
} finally {
rmSync(root, { recursive: true, force: true });
}
},
60_000,
);
test.runIf(isCodexInstalled())(
"reloads a persisted idle thread repeatedly without overlapping writers",
async () => {
@@ -264,6 +291,98 @@ describe("Codex app-server provider (local e2e)", () => {
30_000,
);
test.runIf(isCodexInstalled())(
"reopens a promptless agent repeatedly before its first model request",
async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-promptless-cwd-"));
const logger = createTestLogger();
const storage = new AgentStorage(path.join(cwd, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new CodexAppServerAgentClient(logger) },
registry: storage,
logger,
});
let agentId: string | undefined;
try {
const created = await manager.createAgent(
{ provider: "codex", cwd, modeId: "auto", model: "mock-model" },
undefined,
{ workspaceId: undefined },
);
agentId = created.id;
// A real app-server rejects an unsaved thread on resume. No model request is
// needed to prove that reopening the agent remains safe before its first turn.
for (let i = 0; i < 2; i++) {
const reopened = await manager.reloadAgentSession(agentId);
expect(reopened.lifecycle).toBe("idle");
expect(reopened.persistence).toBeNull();
}
await storage.flush();
expect((await storage.get(agentId))?.persistence).toBeNull();
} finally {
if (agentId && manager.getAgent(agentId)) await manager.closeAgent(agentId);
await storage.flush();
rmSync(cwd, { recursive: true, force: true });
}
},
30_000,
);
test.runIf(isCodexInstalled())(
"reopens an agent that was never prompted without resuming its unsaved thread",
async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-unprompted-cwd-"));
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-unprompted-home-"));
const mockServer = await startMockResponsesServer([
assistantMessageSse("first"),
assistantMessageSse("second"),
]);
const logger = createTestLogger();
const storage = new AgentStorage(path.join(cwd, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new CodexAppServerAgentClient(logger) },
registry: storage,
logger,
});
let agentId: string | undefined;
try {
writeMockCodexConfig(codexHome, mockServer.url);
vi.stubEnv("CODEX_HOME", codexHome);
const created = await manager.createAgent(
{ provider: "codex", cwd, modeId: "auto", model: "mock-model" },
undefined,
{ workspaceId: undefined },
);
agentId = created.id;
// Codex writes a thread's rollout at its first turn, so there is nothing to resume yet.
expect(manager.getAgent(agentId)?.persistence).toBeNull();
await storage.flush();
expect((await storage.get(agentId))?.persistence).toBeNull();
await manager.reloadAgentSession(agentId);
await manager.runAgent(agentId, "first prompt after reopening");
const threadId = manager.getAgent(agentId)?.persistence?.sessionId;
expect(threadId).toEqual(expect.any(String));
await storage.flush();
expect((await storage.get(agentId))?.persistence?.sessionId).toBe(threadId);
const reloaded = await manager.reloadAgentSession(agentId);
expect(reloaded.persistence?.sessionId).toBe(threadId);
await manager.runAgent(agentId, "continue the saved thread");
expect(manager.getAgent(agentId)?.lifecycle).toBe("idle");
expect(mockServer.requestBodies.at(-1)).toContain("first prompt after reopening");
} finally {
if (agentId && manager.getAgent(agentId)) await manager.closeAgent(agentId);
await storage.flush();
vi.unstubAllEnvs();
await mockServer.close();
rmSync(cwd, { recursive: true, force: true });
rmSync(codexHome, { recursive: true, force: true });
}
},
30_000,
);
test.runIf(isCodexInstalled())(
"surfaces request_user_input from the app-server as question permissions and timeline tool calls",
async () => {
@@ -587,6 +587,10 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
createFakeCodexAppServer(),
];
let spawnCount = 0;
let markReconnectStarted!: () => void;
const reconnectStarted = new Promise<void>((resolve) => {
markReconnectStarted = resolve;
});
let releaseReconnect: (() => void) | undefined;
const reconnectGate = new Promise<void>((resolve) => {
releaseReconnect = resolve;
@@ -602,6 +606,7 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
}
spawnCount += 1;
if (spawnCount > 1) {
markReconnectStarted();
await reconnectGate;
}
return appServer.child;
@@ -614,12 +619,14 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
const runtimeInfo = session.getRuntimeInfo();
const turnStart = session.startTurn("continue after reconnect");
await reconnectStarted;
const reconnectSpawnCount = spawnCount - 1;
releaseReconnect?.();
await expect(runtimeInfo).resolves.toMatchObject({ provider: "codex" });
await expect(turnStart).resolves.toMatchObject({ turnId: expect.any(String) });
expect(reconnectSpawnCount).toBe(1);
expect(spawnCount).toBe(2);
} finally {
releaseReconnect?.();
await session.close();
@@ -77,12 +77,19 @@ CHILD_SENTINEL in your own response.
const completedChildIndex = events.findIndex(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "completed" &&
event.item.detail.type === "sub_agent" &&
event.item.detail.log.includes("CHILD_SENTINEL"),
event.type === "provider_subagent" &&
event.event.type === "upsert" &&
event.event.status === "completed",
);
const childAssistantText = events
.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.item.type === "assistant_message"
? [event.event.item.text]
: [],
)
.join("");
const rootMessageIndexes = events.flatMap((event, index) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [index] : [],
);
@@ -105,6 +112,7 @@ CHILD_SENTINEL in your own response.
.map((item) => item.text)
.join("");
expect(topLevelAssistantText).not.toContain("CHILD_SENTINEL");
expect(childAssistantText).toContain("CHILD_SENTINEL");
expect(completedChildIndex).toBeGreaterThanOrEqual(0);
expect(rootMessageIndexes[0]).toBeGreaterThan(completedChildIndex);
expect(terminalIndexes).toHaveLength(1);
@@ -35,15 +35,14 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
});
test("fetchCatalog settles when the refresh deadline aborts an unanswered initialize", async () => {
const client = new CodexAppServerAgentClient(logger, {
command: {
mode: "replace",
argv: ["/nonexistent/codex-binary-that-does-not-exist"],
},
});
const child = createCodexAppServerChildProcess();
const kill = vi.spyOn(child, "kill");
Object.assign(client, { spawnAppServer: async () => child });
const client = new CodexAppServerAgentClient(logger, undefined, {
appServerProcess: {
resolvePrefix: async () => ({ command: "codex", args: [] }),
spawn: () => child,
},
});
await expect(
runProviderRefreshWithDeadline({
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,8 @@ import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js";
// Runs the real `codex` binary against a local Responses endpoint standing in
// for a custom provider's OPENAI_BASE_URL, so no login is needed.
@@ -88,52 +89,106 @@ describe("Codex custom provider import list", () => {
for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
});
test("lists a custom provider's session under that provider and imports it there", async () => {
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-"));
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-"));
cleanups.push(() => rmSync(codexHome, { recursive: true, force: true }));
cleanups.push(() => rmSync(cwd, { recursive: true, force: true }));
const { baseUrl, server, requestCount } = await startResponsesEndpoint();
cleanups.push(() => closeServer(server));
test.each([
{ providerId: "my-codex", seedLegacy: false, seedBuiltin: false },
{ providerId: "ollama", seedLegacy: false, seedBuiltin: false },
{ providerId: "my-codex", seedLegacy: true, seedBuiltin: false },
{ providerId: "ollama", seedLegacy: false, seedBuiltin: true },
])(
"lists and imports sessions for $providerId (legacy: $seedLegacy, native: $seedBuiltin)",
async ({ providerId, seedLegacy, seedBuiltin }) => {
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-"));
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-"));
cleanups.push(() => rmSync(codexHome, { recursive: true, force: true }));
cleanups.push(() => rmSync(cwd, { recursive: true, force: true }));
const { baseUrl, server, requestCount } = await startResponsesEndpoint();
cleanups.push(() => closeServer(server));
const logger = createTestLogger();
const customCodex = new CodexAppServerAgentClient(
logger,
{ env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } },
{ customProvider: { id: "my-codex", label: "My Codex", extends: "codex" } },
);
const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } });
const logger = createTestLogger();
if (seedLegacy || seedBuiltin) {
// Seed an old custom session or a native built-in session with real Codex.
const environment = {
...process.env,
CODEX_HOME: codexHome,
CODEX_OSS_BASE_URL: baseUrl,
OPENAI_API_KEY: "test-key",
};
const legacy = new CodexAppServerAgentSession(
{ provider: "codex", cwd, model: "mock-model" },
null,
logger,
async () =>
spawnProcess("codex", ["app-server"], { cwd, env: environment, stdio: "pipe" }),
{
environment,
customCodexConfig: {
model_provider: providerId,
model_providers: seedBuiltin
? {}
: {
[providerId]: {
name: "My Codex",
base_url: baseUrl,
wire_api: "responses",
env_key: "OPENAI_API_KEY",
requires_openai_auth: false,
},
},
},
},
);
try {
await legacy.run("hello from the legacy provider");
} finally {
await legacy.close();
}
}
const customCodex = new CodexAppServerAgentClient(
logger,
{ env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } },
{ customProvider: { id: providerId, label: "My Codex", extends: "codex" } },
);
const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } });
const session = await customCodex.createSession({
provider: "codex",
cwd,
model: "mock-model",
});
try {
await session.run("hello from the custom provider");
} finally {
await session.close();
}
const session = await customCodex.createSession({
provider: "codex",
cwd,
model: "mock-model",
});
try {
await session.run("hello from the custom provider");
} finally {
await session.close();
}
const customSessions = await customCodex.listImportableSessions({ cwd });
const stockSessions = await stockCodex.listImportableSessions({ cwd });
const customSessions = await customCodex.listImportableSessions({ cwd });
const stockSessions = await stockCodex.listImportableSessions({ cwd });
expect(customSessions.map((entry) => entry.firstPromptPreview)).toEqual([
"hello from the custom provider",
]);
expect(stockSessions).toEqual([]);
expect(customSessions.map((entry) => entry.firstPromptPreview).sort()).toEqual(
seedLegacy
? ["hello from the custom provider", "hello from the legacy provider"]
: ["hello from the custom provider"],
);
expect(stockSessions).toEqual([]);
const config = { provider: "codex", cwd, model: "mock-model" };
const imported = await customCodex.importSession(
{ providerHandleId: customSessions[0].providerHandleId, cwd },
{ config, storedConfig: config },
);
const requestsBeforeImportedTurn = requestCount();
try {
await imported.session.run("hello again after import");
} finally {
await imported.session.close();
}
expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn);
}, 120_000);
const config = { provider: "codex", cwd, model: "mock-model" };
const sessionToImport = seedLegacy
? customSessions.find(
(entry) => entry.firstPromptPreview === "hello from the legacy provider",
)!
: customSessions[0];
const imported = await customCodex.importSession(
{ providerHandleId: sessionToImport.providerHandleId, cwd },
{ config, storedConfig: config },
);
const requestsBeforeImportedTurn = requestCount();
try {
await imported.session.run("hello again after import");
} finally {
await imported.session.close();
}
expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn);
},
120_000,
);
});
@@ -0,0 +1,704 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { AgentSession } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
waitForNextEvent,
waitForTimelineToolCall,
} from "./codex/test-utils/fake-app-server.js";
const THREAD = "thread-1";
const TERMINATE = "thread/backgroundTerminals/terminate";
type Handler = (params: unknown) => unknown;
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function paramsOf(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
let sessions: AgentSession[] = [];
afterEach(async () => {
for (const session of sessions) await session.close().catch(() => undefined);
sessions = [];
});
/**
* A Codex app-server whose interrupt ends the turn the way Codex does: an `interrupted`
* turn completion, then the response. `terminate` answers per process.
*/
function createHarness(
options: {
terminate?: (processId: string) => unknown;
interruptResponse?: (completeTurn: () => void) => unknown;
} = {},
) {
const handlers: Record<string, Handler> = {
"thread/backgroundTerminals/list": () => ({ data: [], nextCursor: null }),
"turn/interrupt": () => {
const completeTurn = () =>
appServer.completeTurn({ threadId: THREAD, status: "interrupted" });
if (options.interruptResponse) return options.interruptResponse(completeTurn);
completeTurn();
return {};
},
[TERMINATE]: (params) =>
options.terminate?.(String(paramsOf(params).processId)) ?? { terminated: true },
};
const appServer: FakeCodexAppServer = createFakeCodexAppServer(handlers);
const session = new CodexAppServerAgentSession(
{
provider: "codex",
cwd: "/workspace/project",
modeId: "auto",
model: "gpt-5.4",
},
null,
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
function requests(method: string): Record<string, unknown>[] {
return appServer
.requests()
.filter((message) => message.method === method && "id" in message)
.map((message) => paramsOf(message.params));
}
function terminated(): string[] {
return requests(TERMINATE)
.map((params) => String(params.processId))
.sort();
}
// Starts a turn and lets Codex identify it as `nativeTurnId`.
async function startTurn(prompt: string, nativeTurnId: string): Promise<void> {
const before = requests("turn/start").length;
const started = session.startTurn(prompt);
await vi.waitFor(() => expect(requests("turn/start")).toHaveLength(before + 1), {
timeout: 5_000,
});
const turnStarted = waitForNextEvent(session, "turn_started");
appServer.startsTurn({ threadId: THREAD, turnId: nativeTurnId });
await started;
await turnStarted;
}
async function runsCommand(params: { turnId: string; processId: string }): Promise<void> {
const itemId = `exec-${params.processId}`;
const shown = waitForTimelineToolCall(session, itemId);
appServer.startsTerminalCommand({
threadId: THREAD,
turnId: params.turnId,
itemId,
processId: params.processId,
command: `sleep ${params.processId}`,
});
await shown;
}
async function completesTurn(): Promise<void> {
const completed = waitForNextEvent(session, "turn_completed");
appServer.completeTurn({ threadId: THREAD });
await completed;
}
// Interrupts the running turn and waits for the session to report it canceled, as a
// caller does before it starts the next turn: Codex notifications are handled in order
// but asynchronously, so the interrupt's answer can arrive before the turn has ended.
async function interrupts(): Promise<void> {
const canceled = waitForNextEvent(session, "turn_canceled");
await session.interrupt();
await canceled;
}
return {
appServer,
session,
requests,
terminated,
startTurn,
runsCommand,
completesTurn,
interrupts,
};
}
// Turn A leaves a server running on purpose and completes. Turn B starts two
// commands, and a subagent on its own thread starts a third, then B is interrupted.
async function interruptTurnB(harness: ReturnType<typeof createHarness>): Promise<void> {
await harness.startTurn("start a server", "native-A");
await harness.runsCommand({ turnId: "native-A", processId: "101" });
await harness.completesTurn();
await harness.startTurn("run two commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
// A subagent thread's command shows no row here; the next command's row shows it
// was read, because notifications are read in order.
harness.appServer.startsTerminalCommand({
threadId: "child-thread",
turnId: "native-B",
itemId: "exec-209",
processId: "209",
command: "sleep 209",
});
await harness.runsCommand({ turnId: "native-B", processId: "202" });
await harness.interrupts();
}
describe("an interrupted Codex turn's commands end before the next turn", () => {
test("terminates exactly the processes the interrupted turn started, by id, and never cleans", async () => {
const harness = createHarness();
await interruptTurnB(harness);
await harness.startTurn("correction", "native-C");
expect(harness.terminated()).toEqual(["201", "202"]);
expect(harness.requests(TERMINATE).every((params) => params.threadId === THREAD)).toBe(true);
// `clean` would end every terminal on the thread, an earlier turn's included.
expect(
harness.appServer
.requests()
.filter(
(message) =>
String(message.method).startsWith("thread/backgroundTerminals/") &&
message.method !== "thread/backgroundTerminals/list",
)
.map((message) => message.method),
).toEqual([TERMINATE, TERMINATE]);
harness.appServer.assertNoErrors();
});
test("leaves the next turn's commands running when Codex starts that turn before acknowledging the interrupt", async () => {
const acknowledge = deferred();
const harness = createHarness({
// Codex ends B, starts a turn of its own (a goal continuation), and only then
// answers the interrupt.
interruptResponse: (completeTurn) => {
completeTurn();
return acknowledge.promise.then(() => ({}));
},
});
await harness.startTurn("run two commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.runsCommand({ turnId: "native-B", processId: "202" });
const interrupting = harness.session.interrupt();
await vi.waitFor(() => expect(harness.requests("turn/interrupt")).toHaveLength(1));
const autonomousTurn = waitForNextEvent(harness.session, "turn_started");
harness.appServer.startsTurn({ threadId: THREAD, turnId: "native-C" });
await autonomousTurn;
await harness.runsCommand({ turnId: "native-C", processId: "301" });
acknowledge.resolve();
await interrupting;
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(harness.terminated()).toEqual(["201", "202"]);
});
test("sends the next turn/start only after the terminates are answered", async () => {
const answer = deferred();
const harness = createHarness({
terminate: () => answer.promise.then(() => ({ terminated: true })),
});
await interruptTurnB(harness);
const next = harness.startTurn("correction", "native-C");
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(harness.requests("turn/start")).toHaveLength(2);
answer.resolve();
await next;
expect(harness.requests("turn/start")).toHaveLength(3);
});
test.each(["initial", "late"] as const)(
"tracks both cleanup sweeps when the %s answer arrives first",
async (answeredFirst) => {
const first = deferred();
const second = deferred();
const harness = createHarness({
terminate: (processId) =>
(processId === "201" ? first : second).promise.then(() => ({ terminated: true })),
});
await harness.startTurn("run commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.interrupts();
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201"]));
const next = harness.startTurn("correction", "native-C");
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
// The next turn is already waiting when another process is announced.
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-202",
processId: "202",
command: "sleep 202",
});
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "duplicate-202",
processId: "202",
command: "sleep 202",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
(answeredFirst === "initial" ? first : second).resolve();
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
(answeredFirst === "initial" ? second : first).resolve();
await next;
expect(harness.terminated()).toEqual(["201", "202"]);
// Accepted state survives the completed sweep and a newer foreground turn.
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-203",
processId: "203",
command: "sleep 203",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202", "203"]));
harness.appServer.assertNoErrors();
},
);
test("keeps the first sweep pending when a late sweep answers before the next start", async () => {
const first = deferred();
const harness = createHarness({
terminate: (processId) =>
processId === "201"
? first.promise.then(() => ({ terminated: true }))
: { terminated: true },
});
await harness.startTurn("run commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.interrupts();
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-202",
processId: "202",
command: "sleep 202",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
const next = harness.startTurn("correction", "native-C");
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
first.resolve();
await next;
harness.appServer.assertNoErrors();
});
test("forgets completed commands but retains failed commands at interrupt", async () => {
const harness = createHarness();
await harness.startTurn("run commands", "native-B");
for (const processId of ["201", "202"]) {
await harness.runsCommand({ turnId: "native-B", processId });
harness.appServer.child.stdout.write(
JSON.stringify({
method: "item/completed",
params: {
threadId: THREAD,
turnId: "native-B",
item: {
id: `exec-${processId}`,
type: "commandExecution",
processId,
command: `sleep ${processId}`,
status: processId === "201" ? "completed" : "failed",
exitCode: processId === "201" ? 0 : null,
},
},
}) + "\n",
);
}
// A following command's row is a barrier for both completion handlers.
await harness.runsCommand({ turnId: "native-B", processId: "203" });
await harness.interrupts();
await harness.startTurn("correction", "native-C");
expect(harness.terminated()).toEqual(["202", "203"]);
harness.appServer.assertNoErrors();
});
test("retains accepted interrupts for only the latest sixteen turns", async () => {
const harness = createHarness();
for (let index = 0; index < 17; index++) {
await harness.startTurn("run a command", `native-${index}`);
await harness.interrupts();
}
await harness.startTurn("correction", "native-current");
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-0",
itemId: "evicted-201",
processId: "201",
command: "sleep 201",
});
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-16",
itemId: "retained-202",
processId: "202",
command: "sleep 202",
});
await harness.runsCommand({ turnId: "native-current", processId: "301" });
expect(harness.terminated()).toEqual(["202"]);
harness.appServer.assertNoErrors();
});
test("a failing or unanswered terminate does not block or fail the next turn", async () => {
const harness = createHarness({
// 201 is refused while the next turn waits; 202 is never answered.
terminate: (processId) =>
processId === "201"
? new Promise((resolve) => setTimeout(resolve, 300)).then(() => ({
__jsonRpcError: { code: -32600, message: "invalid background terminal process id" },
}))
: new Promise(() => {}),
});
await interruptTurnB(harness);
const startedAt = Date.now();
await harness.startTurn("correction", "native-C");
expect(Date.now() - startedAt).toBeLessThan(5_000);
expect(harness.requests("turn/start")).toHaveLength(3);
expect(harness.terminated()).toEqual(["201", "202"]);
}, 15_000);
});
test("publishes every page of the current thread's live background terminals", async () => {
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": (params) => {
const cursor = paramsOf(params).cursor;
return cursor
? { data: [{ itemId: "item-2", processId: "2", command: "sleep 2" }], nextCursor: null }
: { data: [{ itemId: "item-1", processId: "1", command: "sleep 1" }], nextCursor: "next" };
},
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", modeId: "auto", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
const events: import("../agent-sdk-types.js").AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
await vi.waitFor(() =>
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [
{ kind: "shell", description: null, command: "sleep 1" },
{ kind: "shell", description: null, command: "sleep 2" },
],
}),
);
});
test("discards a terminal list overtaken by a command event", async () => {
const first = deferred<unknown>();
let lists = 0;
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": () =>
++lists === 1 ? first.promise : { data: [], nextCursor: null },
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", modeId: "auto", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
const events: import("../agent-sdk-types.js").AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
await vi.waitFor(() => expect(lists).toBe(1));
const shown = waitForTimelineToolCall(session, "item-new");
appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "turn",
itemId: "item-new",
processId: "new",
command: "sleep 1",
});
await shown;
first.resolve({
data: [{ itemId: "old", processId: "old", command: "sleep 2" }],
nextCursor: null,
});
await vi.waitFor(() =>
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [],
}),
);
expect(events.filter((event) => event.type === "background_work")).not.toContainEqual(
expect.objectContaining({ items: [expect.objectContaining({ command: "sleep 2" })] }),
);
});
test("does not publish a terminal response from a lost runtime", async () => {
const first = deferred<unknown>();
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": () => first.promise,
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", modeId: "auto", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
const events: import("../agent-sdk-types.js").AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
await appServer.waitForRequest("thread/backgroundTerminals/list");
appServer.disconnect();
first.resolve({
data: [{ itemId: "old", processId: "old", command: "sleep 2" }],
nextCursor: null,
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(events.filter((event) => event.type === "background_work")).not.toContainEqual(
expect.objectContaining({ items: [expect.objectContaining({ command: "sleep 2" })] }),
);
});
test("accepts a slow terminal snapshot after the polling interval", async () => {
const pending = deferred<unknown>();
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": () => pending.promise,
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
const events: import("../agent-sdk-types.js").AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
await appServer.waitForRequest("thread/backgroundTerminals/list");
await new Promise((resolve) => setTimeout(resolve, 5200));
expect(
appServer.requests().filter((message) => message.method === "thread/backgroundTerminals/list"),
).toHaveLength(1);
pending.resolve({ data: [{ processId: "slow", command: "sleep 120" }], nextCursor: null });
await vi.waitFor(() =>
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [{ command: "sleep 120" }],
}),
);
expect(
appServer.requests().filter((message) => message.method === "thread/backgroundTerminals/list"),
).toHaveLength(1);
}, 12000);
test("does not retry an unsupported inventory on later command events", async () => {
let calls = 0;
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": () => {
calls++;
return { __jsonRpcError: { code: -32601, message: "Method not found" } };
},
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
await session.connect();
await vi.waitFor(() => expect(calls).toBe(1));
const shown = waitForTimelineToolCall(session, "unsupported-item");
appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "turn",
itemId: "unsupported-item",
processId: "1",
command: "sleep 1",
});
await shown;
await new Promise((resolve) => setTimeout(resolve, 100));
expect(calls).toBe(1);
});
test("retries transient inventory failures and cancels retries on stop", async () => {
vi.useFakeTimers();
try {
const { CodexBackgroundInventory } = await import("./codex/background-inventory.js");
let calls = 0;
let fail = true;
const inventory = new CodexBackgroundInventory(() => {});
inventory.start({
client: {
request: async () => {
calls++;
if (fail) throw new Error("temporary");
return { data: [{ processId: "live", command: "sleep 120" }], nextCursor: null };
},
},
thread: THREAD,
});
await vi.advanceTimersByTimeAsync(0);
expect(inventory.snapshot()).toBeNull();
inventory.changed(THREAD);
await vi.advanceTimersByTimeAsync(0);
expect(calls).toBe(1);
fail = false;
await vi.advanceTimersByTimeAsync(5000);
expect(inventory.snapshot()?.items).toMatchObject([{ command: "sleep 120" }]);
fail = true;
await vi.advanceTimersByTimeAsync(5000);
expect(inventory.snapshot()?.items).toMatchObject([{ command: "sleep 120" }]);
inventory.stop();
const stoppedCalls = calls;
await vi.advanceTimersByTimeAsync(15000);
expect(calls).toBe(stoppedCalls);
} finally {
vi.useRealTimers();
}
});
test("does not retry a failure from a stopped inventory generation", async () => {
vi.useFakeTimers();
try {
const { CodexBackgroundInventory } = await import("./codex/background-inventory.js");
let reject!: (error: Error) => void;
let calls = 0;
const response = new Promise<unknown>((_, fail) => {
reject = fail;
});
const inventory = new CodexBackgroundInventory(() => {});
inventory.start({
client: {
request: () => {
calls++;
return response;
},
},
thread: THREAD,
});
inventory.stop();
reject(new Error("late failure"));
await vi.advanceTimersByTimeAsync(15000);
expect(calls).toBe(1);
expect(inventory.snapshot()).toBeNull();
} finally {
vi.useRealTimers();
}
});
test("stops only the selected live background terminal and confirms the roster", async () => {
const live = new Set(["1", "2"]);
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": () => ({
data: [...live].map((processId) => ({ processId, command: `sleep ${processId}` })),
nextCursor: null,
}),
"thread/backgroundTerminals/terminate": (params) => ({
terminated: live.delete(String(paramsOf(params).processId)),
}),
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
const events: import("../agent-sdk-types.js").AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
await vi.waitFor(() =>
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [{ command: "sleep 1" }, { command: "sleep 2" }],
}),
);
const item = events
.findLast((event) => event.type === "background_work")
?.items?.find((entry) => entry.command === "sleep 1");
expect(item).toBeDefined();
await session.stopBackgroundWork(item!.id);
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [{ command: "sleep 2" }],
});
await expect(session.stopBackgroundWork(item!.id)).rejects.toThrow();
});
test("reports a rejected background stop and keeps the item available", async () => {
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": () => ({
data: [{ processId: "1", command: "sleep 1" }],
nextCursor: null,
}),
"thread/backgroundTerminals/terminate": () => ({ terminated: false }),
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
const events: import("../agent-sdk-types.js").AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
await vi.waitFor(() =>
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [{ command: "sleep 1" }],
}),
);
const item = events.findLast((event) => event.type === "background_work")?.items?.[0];
expect(item).toBeDefined();
await expect(session.stopBackgroundWork(item!.id)).rejects.toThrow("not terminated");
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [{ command: "sleep 1" }],
});
});
test("does not confirm a stop after the provider connection is lost", async () => {
const response = deferred<unknown>();
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": () => ({
data: [{ processId: "1", command: "sleep 1" }],
nextCursor: null,
}),
"thread/backgroundTerminals/terminate": () => response.promise,
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
const events: import("../agent-sdk-types.js").AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
await vi.waitFor(() =>
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [{ command: "sleep 1" }],
}),
);
const item = events.findLast((event) => event.type === "background_work")?.items?.[0];
expect(item).toBeDefined();
const pending = session.stopBackgroundWork(item!.id);
await appServer.waitForRequest("thread/backgroundTerminals/terminate");
appServer.disconnect();
response.resolve({ terminated: true });
await expect(pending).rejects.toThrow();
});
@@ -0,0 +1,347 @@
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { createCodexAppServerChildProcess } from "./test-utils/fake-app-server.js";
import { CodexAppServerStartupCoordinator } from "./app-server-startup.js";
import { CodexAppServerExitError } from "./app-server-transport.js";
function sqliteStartupError(): CodexAppServerExitError {
return new CodexAppServerExitError({
exitCode: 1,
exitSignal: null,
stderr: "failed to initialize sqlite state runtime",
});
}
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe("Codex app-server startup coordinator", () => {
test("returns initialize metadata so sessions retain server capabilities", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const client = {
request: async () => ({ userAgent: "codex/0.164.0" }),
notify: () => {},
dispose: async () => {},
};
const started = await coordinator.start({
stateDirKey: "/codex/metadata",
spawn: createCodexAppServerChildProcess,
createClient: () => client,
initializeParams: {},
logger: createTestLogger(),
});
expect(started).toEqual({ client, initialized: { userAgent: "codex/0.164.0" } });
});
test("serializes a burst through initialize but retains all initialized clients", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const firstEntered = deferred();
const releaseFirst = deferred();
let spawned = 0;
let activeInitializations = 0;
let maxActiveInitializations = 0;
let disposed = 0;
const starts = Array.from({ length: 24 }, () =>
coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
spawned++;
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async (method: string) => {
expect(method).toBe("initialize");
activeInitializations++;
maxActiveInitializations = Math.max(maxActiveInitializations, activeInitializations);
if (spawned === 1) {
firstEntered.resolve();
await releaseFirst.promise;
}
activeInitializations--;
return {};
},
notify: vi.fn(),
dispose: async () => {
disposed++;
},
}),
initializeParams: {},
logger: createTestLogger(),
}),
);
await firstEntered.promise;
expect(spawned).toBe(1);
releaseFirst.resolve();
const clients = await Promise.all(starts);
expect(clients).toHaveLength(24);
expect(spawned).toBe(24);
expect(maxActiveInitializations).toBe(1);
expect(disposed).toBe(0);
});
test("allows different Codex homes to initialize concurrently", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const bothEntered = deferred();
const release = deferred();
let entered = 0;
const start = (stateDirKey: string) =>
coordinator.start({
stateDirKey,
spawn: createCodexAppServerChildProcess,
createClient: () => ({
request: async () => {
entered++;
if (entered === 2) bothEntered.resolve();
await release.promise;
return {};
},
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
const first = start("/codex/one");
const second = start("/codex/two");
await bothEntered.promise;
expect(entered).toBe(2);
release.resolve();
await Promise.all([first, second]);
});
test("retries only SQLite startup failures after disposing the failed child", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const events: string[] = [];
let attempt = 0;
const client = await coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
attempt++;
events.push(`spawn ${attempt}`);
return createCodexAppServerChildProcess();
},
createClient: () => {
const number = attempt;
return {
request: async () => {
events.push(`initialize ${number}`);
if (number === 1) throw sqliteStartupError();
return {};
},
notify: () => events.push(`initialized ${number}`),
dispose: async () => {
events.push(`dispose ${number}`);
},
};
},
initializeParams: {},
logger: createTestLogger(),
});
expect(client.client).toEqual(expect.objectContaining({ request: expect.any(Function) }));
expect(events).toEqual([
"spawn 1",
"initialize 1",
"dispose 1",
"spawn 2",
"initialize 2",
"initialized 2",
]);
});
test("does not retry unrelated startup failures", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
let spawned = 0;
let disposed = 0;
await expect(
coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
spawned++;
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => {
throw new Error("database is locked while reading unrelated configuration");
},
notify: vi.fn(),
dispose: async () => {
disposed++;
},
}),
initializeParams: {},
logger: createTestLogger(),
}),
).rejects.toThrow("database is locked while reading unrelated configuration");
expect(spawned).toBe(1);
expect(disposed).toBe(1);
});
test("bounds persistent SQLite failures and leaves the lane usable", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
let spawned = 0;
let disposed = 0;
let locked = true;
const start = () =>
coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
spawned++;
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => {
if (locked) throw sqliteStartupError();
return {};
},
notify: vi.fn(),
dispose: async () => {
disposed++;
},
}),
initializeParams: {},
logger: createTestLogger(),
});
await expect(start()).rejects.toThrow("failed to initialize sqlite state runtime");
expect(spawned).toBe(3);
expect(disposed).toBe(3);
locked = false;
await expect(start()).resolves.toEqual(
expect.objectContaining({
client: expect.objectContaining({ request: expect.any(Function) }),
}),
);
expect(spawned).toBe(4);
expect(disposed).toBe(3);
});
test("a cancelled waiter never spawns or overtakes the active startup", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const firstEntered = deferred();
const releaseFirst = deferred();
const events: string[] = [];
const first = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn first");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => {
firstEntered.resolve();
await releaseFirst.promise;
return {};
},
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
await firstEntered.promise;
const controller = new AbortController();
const queued = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn cancelled");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => ({}),
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
signal: controller.signal,
});
controller.abort(new Error("cancelled"));
await expect(queued).rejects.toThrow("cancelled");
const next = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn next");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => ({}),
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
expect(events).toEqual(["spawn first"]);
releaseFirst.resolve();
await Promise.all([first, next]);
expect(events).toEqual(["spawn first", "spawn next"]);
});
test("a timed-out initialize disposes its child and releases the next waiter", async () => {
vi.useFakeTimers();
try {
const coordinator = new CodexAppServerStartupCoordinator({
attemptTimeoutMs: 10,
queueTimeoutMs: 100,
});
const firstEntered = deferred();
const events: string[] = [];
const first = coordinator.start({
stateDirKey: "/codex/shared",
spawn: createCodexAppServerChildProcess,
createClient: () => ({
request: () => {
firstEntered.resolve();
return new Promise<never>(() => {});
},
notify: vi.fn(),
dispose: async () => {
events.push("dispose first");
},
}),
initializeParams: {},
logger: createTestLogger(),
});
await firstEntered.promise;
const next = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn next");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => ({}),
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
const firstResult = expect(first).rejects.toThrow("Timed out initializing Codex app-server");
await vi.advanceTimersByTimeAsync(10);
await firstResult;
await expect(next).resolves.toEqual(
expect.objectContaining({
client: expect.objectContaining({ request: expect.any(Function) }),
}),
);
expect(events).toEqual(["dispose first", "spawn next"]);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,189 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import { CodexAppServerExitError } from "./app-server-transport.js";
const DEFAULT_QUEUE_TIMEOUT_MS = 60_000;
const DEFAULT_ATTEMPT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_ATTEMPTS = 3;
interface InitializableAppServerClient {
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
notify(method: string, params?: unknown): void;
dispose(): Promise<void>;
}
interface StartupOptions<TClient extends InitializableAppServerClient> {
stateDirKey: string;
spawn: () => ChildProcessWithoutNullStreams | Promise<ChildProcessWithoutNullStreams>;
createClient: (child: ChildProcessWithoutNullStreams) => TClient;
initializeParams: unknown;
logger: Pick<Logger, "warn" | "debug">;
signal?: AbortSignal;
}
interface StartedAppServer<TClient> {
client: TClient;
initialized: unknown;
}
interface CoordinatorOptions {
queueTimeoutMs?: number;
attemptTimeoutMs?: number;
maxAttempts?: number;
}
async function waitForSignal<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
signal.throwIfAborted();
let onAbort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([operation, aborted]);
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
async function disposeUnownedChild(child: ChildProcessWithoutNullStreams): Promise<void> {
const result = await terminateWithTreeKill(child, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 1_000,
});
if (result === "kill-timeout") {
throw new Error("Codex app-server did not report exit after SIGKILL");
}
}
export class CodexAppServerStartupCoordinator {
private readonly tails = new Map<string, Promise<void>>();
private readonly queueTimeoutMs: number;
private readonly attemptTimeoutMs: number;
private readonly maxAttempts: number;
constructor(options: CoordinatorOptions = {}) {
this.queueTimeoutMs = options.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS;
this.attemptTimeoutMs = options.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS;
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
}
async start<TClient extends InitializableAppServerClient>(
options: StartupOptions<TClient>,
): Promise<StartedAppServer<TClient>> {
options.signal?.throwIfAborted();
const previous = this.tails.get(options.stateDirKey) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
// An aborted waiter must not let later waiters pass the current owner.
const tail = previous.then(() => gate);
this.tails.set(options.stateDirKey, tail);
void tail.then(
() => this.tails.get(options.stateDirKey) === tail && this.tails.delete(options.stateDirKey),
);
const queueAbort = new AbortController();
const onAbort = () => queueAbort.abort(options.signal?.reason);
options.signal?.addEventListener("abort", onAbort, { once: true });
const queueTimer = setTimeout(
() => queueAbort.abort(new Error("Timed out waiting to start Codex app-server")),
this.queueTimeoutMs,
);
try {
await waitForSignal(previous, queueAbort.signal);
clearTimeout(queueTimer);
options.signal?.throwIfAborted();
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
try {
return await this.startAttempt(options);
} catch (error) {
const isRetryableStartupFailure =
error instanceof CodexAppServerExitError &&
error.reason === "sqlite_state_initialization";
if (
options.signal?.aborted ||
attempt === this.maxAttempts ||
!isRetryableStartupFailure
) {
throw error;
}
options.logger.warn(
{ err: error, attempt, maxAttempts: this.maxAttempts },
"Codex app-server SQLite startup failed; retrying with a fresh process",
);
}
}
throw new Error("Codex app-server startup attempts exhausted");
} finally {
clearTimeout(queueTimer);
options.signal?.removeEventListener("abort", onAbort);
release();
}
}
private async startAttempt<TClient extends InitializableAppServerClient>(
options: StartupOptions<TClient>,
): Promise<StartedAppServer<TClient>> {
const attemptAbort = new AbortController();
const onAbort = () => attemptAbort.abort(options.signal?.reason);
options.signal?.addEventListener("abort", onAbort, { once: true });
const attemptTimer = setTimeout(
() => attemptAbort.abort(new Error("Timed out initializing Codex app-server")),
this.attemptTimeoutMs,
);
let child: ChildProcessWithoutNullStreams | undefined;
let client: TClient | undefined;
try {
attemptAbort.signal.throwIfAborted();
// Production hands in a synchronous spawn closure. Resolve the binary
// before entering the lane, then create the child while holding it.
const spawnPromise = Promise.resolve(options.spawn());
try {
child = await waitForSignal(spawnPromise, attemptAbort.signal);
} catch (error) {
if (attemptAbort.signal.aborted) {
// A future asynchronous spawner may finish after cancellation.
void spawnPromise
.then((lateChild) => disposeUnownedChild(lateChild))
.catch((cleanupError) =>
options.logger.warn({ err: cleanupError }, "Failed to clean up late Codex spawn"),
);
}
throw error;
}
attemptAbort.signal.throwIfAborted();
client = options.createClient(child);
const initialized = await waitForSignal(
client.request("initialize", options.initializeParams, this.attemptTimeoutMs),
attemptAbort.signal,
);
attemptAbort.signal.throwIfAborted();
client.notify("initialized", {});
return { client, initialized };
} catch (error) {
try {
if (client) {
await client.dispose();
} else if (child) {
await disposeUnownedChild(child);
}
} catch (cleanupError) {
options.logger.warn(
{ err: cleanupError, startupError: error },
"Failed to clean up Codex app-server after startup failure",
);
throw new Error("Codex app-server startup cleanup failed", { cause: cleanupError });
}
throw error;
} finally {
clearTimeout(attemptTimer);
options.signal?.removeEventListener("abort", onAbort);
}
}
}
export const codexAppServerStartup = new CodexAppServerStartupCoordinator();
@@ -5,9 +5,57 @@ import {
createCodexAppServerChildProcess,
createFakeCodexAppServer,
} from "./test-utils/fake-app-server.js";
import { CodexAppServerClient } from "./app-server-transport.js";
import { CodexAppServerClient, CodexAppServerExitError } from "./app-server-transport.js";
describe("Codex app-server transport", () => {
test("classifies startup diagnostics delivered after the child exits", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const request = client.request("initialize", {});
const result = expect(request).rejects.toMatchObject({
name: "CodexAppServerExitError",
reason: "sqlite_state_initialization",
stderr: "failed to initialize sqlite state runtime",
});
child.exitCode = 1;
child.emit("exit", 1, null);
child.stderr.write("failed to initialize sqlite state runtime");
child.emit("close", 1, null);
try {
await result;
} finally {
child.stdout.end();
child.stderr.end();
child.stdin.end();
}
});
test.each([
{
stderr: "Error: failed to initialize sqlite state runtime under /isolated/.codex",
reason: "sqlite_state_initialization",
},
{ stderr: "database is locked while loading configuration", reason: "other" },
] as const)("classifies a failed startup from child stderr as $reason", async (entry) => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const request = client.request("initialize", {});
child.stderr.write(entry.stderr);
child.exitCode = 1;
child.emit("exit", 1, null);
await expect(request).rejects.toMatchObject({
name: "CodexAppServerExitError",
reason: entry.reason,
exitCode: 1,
exitSignal: null,
stderr: entry.stderr,
} satisfies Partial<CodexAppServerExitError>);
child.stdout.end();
child.stderr.end();
child.stdin.end();
});
test("ignores non-JSON stdout lines without dropping pending requests", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
@@ -64,6 +64,39 @@ export class CodexAppServerRpcError extends Error {
}
}
interface CodexAppServerExitDetails {
exitCode: number | null;
exitSignal: NodeJS.Signals | null;
stderr: string;
}
export class CodexAppServerExitError extends Error {
readonly reason: "sqlite_state_initialization" | "other";
readonly exitCode: number | null;
readonly exitSignal: NodeJS.Signals | null;
readonly stderr: string;
constructor(details: CodexAppServerExitDetails) {
const message =
details.exitCode === 0 && !details.exitSignal
? "Codex app-server exited"
: `Codex app-server exited with code ${details.exitCode ?? "null"} and signal ${details.exitSignal ?? "null"}`;
super(`${message}\n${details.stderr}`.trim());
this.name = "CodexAppServerExitError";
this.exitCode = details.exitCode;
this.exitSignal = details.exitSignal;
this.stderr = details.stderr;
// Codex reports this pre-initialize failure only on stderr, not as a
// JSON-RPC error. Classify it here so callers branch on a typed reason.
this.reason =
details.exitCode === 1 &&
!details.exitSignal &&
details.stderr.includes("failed to initialize sqlite state runtime")
? "sqlite_state_initialization"
: "other";
}
}
type RequestHandler = (params: unknown, requestId: number) => unknown;
type NotificationHandler = (method: string, params: unknown) => void;
type UnexpectedTerminationHandler = (error: Error) => void;
@@ -234,14 +267,24 @@ export class CodexAppServerClient {
this.handleUnexpectedTermination(err);
});
const reportExit = (code: number | null, signal: NodeJS.Signals | null) => {
this.handleUnexpectedTermination(
new CodexAppServerExitError({
exitCode: code,
exitSignal: signal,
stderr: this.stderrBuffer,
}),
);
};
child.on("exit", (code, signal) => {
const message =
code === 0 && !signal
? "Codex app-server exited"
: `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`;
const error = new Error(`${message}\n${this.stderrBuffer}`.trim());
this.handleUnexpectedTermination(error);
// Startup retries need complete diagnostics. Node drains the child's
// stdio before close, but exit can precede the final stderr data.
const initializing = Array.from(this.pending.values()).some(
(request) => request.method === "initialize",
);
if (!initializing) reportExit(code, signal);
});
child.on("close", reportExit);
}
setUnexpectedTerminationHandler(handler: UnexpectedTerminationHandler): void {
@@ -0,0 +1,187 @@
import { z } from "zod";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import { CodexAppServerRpcError } from "./app-server-transport.js";
type InventoryEvent = Extract<AgentStreamEvent, { type: "background_work" }>;
interface Client {
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
}
export class CodexBackgroundInventory {
private generation = 0;
private client: Client | null = null;
private thread: string | null = null;
private readonly threads = new Set<string>();
private origins = new Map<string, { threadId: string; processId: string }>();
private pending = false;
private dirty = false;
private unsupported = false;
private retrying = false;
private timer: ReturnType<typeof setTimeout> | null = null;
private current: InventoryEvent | null = null;
private readonly listeners = new Set<() => void>();
async stopItem(id: string): Promise<void> {
const client = this.client;
const origin = this.origins.get(id);
const generation = this.generation;
if (!client || !origin || !this.current?.items?.some((item) => item.id === id))
throw new Error("Background terminal is no longer available");
const result = z
.object({ terminated: z.boolean() })
.parse(
await client.request(
"thread/backgroundTerminals/terminate",
{ threadId: origin.threadId, processId: origin.processId },
10000,
),
);
if (generation !== this.generation) throw new Error("Background terminal connection changed");
if (!result.terminated) throw new Error("Background terminal was not terminated");
await this.waitUntilAbsent(id, generation);
}
private waitUntilAbsent(id: string, generation: number): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => finish(new Error("Background terminal confirmation timed out")),
15000,
);
const finish = (error?: Error) => {
clearTimeout(timer);
this.listeners.delete(check);
if (error) reject(error);
else resolve();
};
const check = () => {
if (generation !== this.generation)
finish(new Error("Background terminal connection changed"));
else if (this.current?.items && !this.current.items.some((item) => item.id === id))
finish();
};
this.listeners.add(check);
this.changed(this.thread);
});
}
private notify() {
for (const listener of this.listeners) listener();
}
constructor(private readonly emit: (event: InventoryEvent) => void) {}
snapshot() {
return this.current;
}
start({ client, thread }: { client: Client; thread: string | null }) {
this.stop();
this.client = client;
this.thread = thread;
this.unsupported = false;
this.changed(thread);
}
stop() {
this.generation++;
this.notify();
this.client = null;
this.thread = null;
this.threads.clear();
this.origins.clear();
this.pending = false;
this.dirty = false;
this.retrying = false;
if (this.timer) clearTimeout(this.timer);
this.timer = null;
if (this.current) this.emit({ type: "background_work", provider: "codex", items: null });
this.current = null;
}
changed(thread: string | null) {
this.thread = thread;
if (thread) this.threads.add(thread);
if (!this.client || !thread || this.unsupported) return;
this.dirty = true;
if (!this.retrying) this.refresh();
}
private refresh() {
if (this.pending || !this.client || !this.thread || this.unsupported) return;
if (this.timer) clearTimeout(this.timer);
this.timer = null;
this.pending = true;
this.dirty = false;
const generation = this.generation;
const client = this.client;
const threads = [...this.threads];
void (async () => {
let failed = false;
try {
const { items, origins, activeThreads } = await readInventories(client, threads);
if (generation !== this.generation || this.dirty) return;
this.origins = origins;
this.threads.clear();
for (const threadId of activeThreads) this.threads.add(threadId);
if (this.thread) this.threads.add(this.thread);
this.current = { type: "background_work", provider: "codex", items };
this.emit(this.current);
this.notify();
} catch (error) {
failed = true;
if (
generation === this.generation &&
error instanceof CodexAppServerRpcError &&
error.code === -32601
)
this.unsupported = true;
} finally {
if (generation === this.generation) {
this.pending = false;
if (this.dirty && !failed && !this.unsupported) this.refresh();
else if (!this.unsupported && (failed || this.current?.items?.length)) {
this.retrying = failed;
this.timer = setTimeout(() => {
this.timer = null;
this.retrying = false;
this.refresh();
}, 5000);
this.timer.unref();
}
}
}
})();
}
}
async function readInventories(client: Client, threads: string[]) {
const items: NonNullable<InventoryEvent["items"]> = [];
const origins = new Map<string, { threadId: string; processId: string }>();
const activeThreads = new Set<string>();
for (const threadId of threads) {
let cursor: string | null = null;
do {
const page = z
.object({
data: z.array(z.object({ processId: z.string(), command: z.string() })),
nextCursor: z.string().nullable(),
})
.parse(
await client.request(
"thread/backgroundTerminals/list",
{ threadId, ...(cursor ? { cursor } : {}) },
10_000,
),
);
for (const row of page.data) {
const id = JSON.stringify([threadId, row.processId]);
origins.set(id, { threadId, processId: row.processId });
activeThreads.add(threadId);
items.push({
id,
kind: "shell",
canStop: true,
description: null,
...(row.command ? { command: row.command } : {}),
});
}
cursor = page.nextCursor;
} while (cursor);
}
return { items, origins, activeThreads };
}
@@ -43,6 +43,13 @@ interface FakeTerminalInput {
processId: string;
text: string;
}
interface FakeFileChange {
threadId: string;
turnId: string;
callId: string;
path: string;
diff: string;
}
interface FakeLegacyPatch {
threadId: string;
callId: string;
@@ -88,6 +95,7 @@ export interface FakeCodexAppServer {
completesSubAgentActivity(params: FakeSubAgentActivity): void;
completesCompaction(params: { threadId: string; itemId: string }): void;
runsLegacyCommand(params: FakeLegacyCommand): void;
startsFileChange(params: FakeFileChange): void;
appliesLegacyPatch(params: FakeLegacyPatch): void;
completesCommand(params: FakeLegacyCommand): void;
completesSilentCommand(params: FakeSilentCommand): void;
@@ -138,6 +146,10 @@ export function createCodexAppServerChildProcess(): CodexAppServerChildProcess {
exitCode: null,
signalCode: null,
}) as CodexAppServerChildProcess;
// A real ChildProcess closes its stdio after exit, then emits close.
child.once("exit", (code, signal) => {
queueMicrotask(() => child.emit("close", code, signal));
});
child.kill = ((signal?: NodeJS.Signals | number) => {
queueMicrotask(() => child.emit("exit", null, signal ?? null));
return true;
@@ -167,6 +179,7 @@ export function createFakeCodexAppServer(
"skills/list": () => ({ data: [] }),
"thread/start": () => ({ thread: { id: "thread-1" } }),
"thread/loaded/list": () => ({ data: [] }),
"thread/backgroundTerminals/list": () => ({ data: [], nextCursor: null }),
"thread/resume": () => ({}),
"turn/start": () => ({}),
"thread/fork": (params) => ({
@@ -467,6 +480,20 @@ export function createFakeCodexAppServer(
success: true,
});
},
startsFileChange(params) {
writeNotification("item/started", {
threadId: params.threadId,
turnId: params.turnId,
item: {
type: "fileChange",
id: params.callId,
status: "inProgress",
changes: [
{ path: params.path, kind: { type: "update", move_path: null }, diff: params.diff },
],
},
});
},
appliesLegacyPatch(params) {
const changes = [
{
@@ -1,4 +1,3 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
@@ -27,15 +26,24 @@ async function openCodexThread(
const appServer = createFakeCodexAppServer(
fakeCodexThreadHistory({ "saved-thread": thread }, options),
);
const provider = new CodexAppServerAgentClient(createTestLogger());
const provider = new CodexAppServerAgentClient(
createTestLogger(),
{
command: { mode: "replace", argv: ["codex-must-not-run-in-this-test"] },
},
{
appServerProcess: {
resolvePrefix: async () => ({ command: "codex", args: [] }),
spawn: () => appServer.child,
},
},
);
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 };
}
@@ -528,6 +528,165 @@ describe("codex tool-call mapper", () => {
});
});
it.each([
["inProgress", "running"],
["completed", "completed"],
])("keeps both fileChange diffs when the patch is %s", (status, expectedStatus) => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-two-files",
status,
changes: [
{
path: "/tmp/repo/alpha.txt",
kind: { type: "update", move_path: null },
diff: "@@ -1 +1 @@\n-color = blue\n+color = green\n",
},
{
path: "/tmp/repo/beta.txt",
kind: { type: "update", move_path: null },
diff: "@@ -1 +1 @@\n-size = small\n+size = large\n",
},
],
},
{ cwd: "/tmp/repo" },
);
expect(item).toEqual({
type: "tool_call",
callId: "codex-two-files",
name: "apply_patch",
status: expectedStatus,
error: null,
detail: {
type: "edit",
filePath: "alpha.txt",
unifiedDiff: [
"diff --git a/alpha.txt b/alpha.txt",
"--- a/alpha.txt",
"+++ b/alpha.txt",
"@@ -1 +1 @@",
"-color = blue",
"+color = green",
"",
"diff --git a/beta.txt b/beta.txt",
"--- a/beta.txt",
"+++ b/beta.txt",
"@@ -1 +1 @@",
"-size = small",
"+size = large",
].join("\n"),
},
});
});
it("uses the move destination in generated multi-file diff headers", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-moved-file",
status: "completed",
changes: [
{
path: "/tmp/repo/old.txt",
kind: { type: "update", move_path: "/tmp/repo/new.txt" },
diff: "@@ -1 +1 @@\n-old\n+new\n",
},
{
path: "/tmp/repo/other.txt",
kind: { type: "update", move_path: null },
diff: "@@ -1 +1 @@\n-before\n+after\n",
},
],
},
{ cwd: "/tmp/repo" },
);
expect(item).toEqual({
type: "tool_call",
callId: "codex-moved-file",
name: "apply_patch",
status: "completed",
error: null,
detail: {
type: "edit",
filePath: "old.txt",
unifiedDiff: [
"diff --git a/old.txt b/new.txt",
"--- a/old.txt",
"+++ b/new.txt",
"@@ -1 +1 @@",
"-old",
"+new",
"",
"diff --git a/other.txt b/other.txt",
"--- a/other.txt",
"+++ b/other.txt",
"@@ -1 +1 @@",
"-before",
"+after",
].join("\n"),
},
});
});
it("keeps update, add, and delete changes in one fileChange diff", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-mixed-files",
status: "completed",
changes: [
{
path: "/tmp/repo/alpha.txt",
kind: { type: "update", move_path: null },
diff: "--- a/alpha.txt\n+++ b/alpha.txt\n@@ -1 +1 @@\n-blue\n+green\n",
},
{ path: "/tmp/repo/new.txt", kind: { type: "add" }, diff: "new\n\nvalue\n" },
{ path: "/tmp/repo/old.txt", kind: { type: "delete" }, diff: "old\n\nvalue\n" },
],
},
{ cwd: "/tmp/repo" },
);
expect(item).toEqual({
type: "tool_call",
callId: "codex-mixed-files",
name: "apply_patch",
status: "completed",
error: null,
detail: {
type: "edit",
filePath: "alpha.txt",
unifiedDiff: [
"diff --git a/alpha.txt b/alpha.txt",
"--- a/alpha.txt",
"+++ b/alpha.txt",
"@@ -1 +1 @@",
"-blue",
"+green",
"",
"diff --git a/new.txt b/new.txt",
"--- /dev/null",
"+++ b/new.txt",
"@@ -0,0 +1,3 @@",
"+new",
"+",
"+value",
"",
"diff --git a/old.txt b/old.txt",
"--- a/old.txt",
"+++ /dev/null",
"@@ -1,3 +0,0 @@",
"-old",
"-",
"-value",
].join("\n"),
},
});
});
it("maps fileChange object-style change payloads keyed by path", () => {
const item = expectMapped(
mapCodexToolCallFromThreadItem(
@@ -827,6 +986,44 @@ describe("codex tool-call mapper", () => {
});
});
it("maps multi-file path-only updates to unknown detail", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-multi-file-path-only",
status: "completed",
changes: [
{ path: "/tmp/repo/first.ts", kind: "modify" },
{ path: "/tmp/repo/second.ts", kind: { type: "update", move_path: null } },
],
},
{ cwd: "/tmp/repo" },
);
expect(item).toEqual({
type: "tool_call",
callId: "codex-multi-file-path-only",
name: "apply_patch",
status: "completed",
error: null,
detail: {
type: "unknown",
input: {
files: [
{ path: "first.ts", kind: "modify" },
{ path: "second.ts", kind: "update" },
],
},
output: {
files: [
{ path: "first.ts", kind: "modify" },
{ path: "second.ts", kind: "update" },
],
},
},
});
});
it("maps path-only apply_patch tool-call payloads to unknown detail instead of empty edit detail", () => {
const item = expectMapped(
mapCodexToolCallEnvelope({
@@ -1102,3 +1299,186 @@ describe("codex tool-call mapper", () => {
`);
});
});
describe("multi-file diff budget", () => {
it("retains a labeled diff for later files when the first diff exceeds the detail limit", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "large-multi-file",
status: "completed",
changes: [
{
path: "/repo/alpha.txt",
kind: "modify",
diff: "@@ -1 +1 @@\n-old\n+" + "a".repeat(14000),
},
{ path: "/repo/beta.txt", kind: "modify", diff: "@@ -1 +1 @@\n-small\n+large\n" },
],
},
{ cwd: "/repo" },
);
expect(item?.detail.type).toBe("edit");
if (item?.detail.type !== "edit") throw new Error("Expected edit detail");
expect(item.detail.unifiedDiff).toContain("diff --git a/alpha.txt b/alpha.txt");
expect(item.detail.unifiedDiff).toContain("diff --git a/beta.txt b/beta.txt");
expect(item.detail.unifiedDiff).toContain("+large");
expect(item.detail.unifiedDiff!.length).toBeLessThanOrEqual(12000);
});
});
describe("multi-file preview metadata", () => {
it("preserves a path-keyed move destination", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "moved-keyed",
status: "completed",
changes: {
"/repo/old.txt": {
type: "update",
move_path: "/repo/new.txt",
unified_diff: "@@ -1 +1 @@\n-old\n+new\n",
},
"/repo/other.txt": { type: "update", unified_diff: "@@ -1 +1 @@\n-before\n+after\n" },
},
},
{ cwd: "/repo" },
);
expect(item?.detail.type).toBe("edit");
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.detail.unifiedDiff).toContain("diff --git a/old.txt b/new.txt");
expect(item.detail.unifiedDiff).toContain("+++ b/new.txt");
});
it("names the file whose diff is shown when the first entry has no content", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "partial-content",
status: "completed",
changes: [
{ path: "/repo/first.txt", kind: "modify" },
{ path: "/repo/second.txt", kind: "modify", diff: "@@ -1 +1 @@\n-old\n+new\n" },
],
},
{ cwd: "/repo" },
);
expect(item?.detail).toMatchObject({ type: "edit", filePath: "second.txt" });
});
it("keeps whole labels and marks omitted files when many long paths exceed the preview", () => {
const paths = Array.from(
{ length: 50 },
(_, index) => `src/${String(index).padStart(2, "0")}-${"x".repeat(108)}.txt`,
);
const item = mapCodexToolCallFromThreadItem({
type: "fileChange",
id: "many-files",
status: "completed",
changes: paths.map((path) => ({ path, kind: "modify", diff: "@@ -1 +1 @@\n-old\n+new\n" })),
});
if (item?.detail.type !== "edit") throw new Error("Expected edit");
const diff = item.detail.unifiedDiff!;
const headers = diff.split("\n").filter((line) => line.startsWith("diff --git "));
expect(headers.length).toBeGreaterThan(0);
const expectedHeaders = paths.map((path) => `diff --git a/${path} b/${path}`);
expect(expectedHeaders).toEqual(expect.arrayContaining(headers));
expect(diff).toContain("-old\n+new");
expect(diff).toContain(`${paths.length - headers.length} more files omitted`);
expect(diff.length).toBeLessThanOrEqual(12000);
});
});
describe("large multi-file additions", () => {
it("bounds a generated addition without throwing before later files are mapped", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "large-addition",
status: "completed",
changes: [
{ path: "/repo/generated.txt", kind: { type: "add" }, diff: "x\n".repeat(150000) },
{ path: "/repo/second.txt", kind: { type: "add" }, diff: "second\n" },
],
},
{ cwd: "/repo" },
);
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.status).toBe("completed");
expect(item.detail.unifiedDiff).toContain("diff --git a/generated.txt b/generated.txt");
expect(item.detail.unifiedDiff).toContain("diff --git a/second.txt b/second.txt");
expect(item.detail.unifiedDiff).toContain("+second");
expect(item.detail.unifiedDiff!.length).toBeLessThanOrEqual(12000);
});
});
describe("move-only entries in multi-file previews", () => {
it("shows a move without manufacturing added file contents from provider metadata", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "move-only",
status: "completed",
changes: [
{
path: "/repo/old.txt",
kind: { type: "update", move_path: "/repo/new.txt" },
diff: "\n\nMoved to: /repo/new.txt",
},
{ path: "/repo/second.txt", kind: "modify", diff: "@@ -1 +1 @@\n-small\n+large\n" },
],
},
{ cwd: "/repo" },
);
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.detail.unifiedDiff).toContain("rename from old.txt\nrename to new.txt");
expect(item.detail.unifiedDiff).toContain("diff --git a/second.txt b/second.txt");
expect(item.detail.unifiedDiff).not.toContain("+Moved to:");
expect(item.detail.unifiedDiff).not.toContain("@@ -0,0");
});
});
describe("single-file move compatibility", () => {
it("retains the destination outside the multi-file preview path", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "single-move",
status: "completed",
changes: [
{
path: "/repo/old.txt",
kind: { type: "update", move_path: "/repo/new.txt" },
diff: "\n\nMoved to: /repo/new.txt",
},
],
},
{ cwd: "/repo" },
);
expect(JSON.stringify(item)).toContain("new.txt");
});
});
describe("empty additions in multi-file previews", () => {
it.each(["inProgress", "completed"])(
"keeps an empty created file when the patch is %s",
(status) => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "empty-addition",
status,
changes: [
{ path: "/repo/empty.txt", kind: { type: "add" }, diff: "" },
{ path: "/repo/second.txt", kind: "modify", diff: "@@ -1 +1 @@\n-old\n+new\n" },
],
},
{ cwd: "/repo" },
);
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.detail.unifiedDiff).toContain(
"diff --git a/empty.txt b/empty.txt\n--- /dev/null\n+++ b/empty.txt",
);
expect(item.detail.unifiedDiff).toContain("diff --git a/second.txt b/second.txt");
},
);
});
@@ -4,6 +4,7 @@ import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
import {
extractCodexShellOutput,
normalizeToolCallStatus,
TOOL_CALL_DIFF_MAX_CHARS,
truncateDiffText,
} from "../tool-call-mapper-utils.js";
import { deriveCodexToolDetail, normalizeCodexFilePath } from "./tool-call-detail-parser.js";
@@ -400,7 +401,7 @@ function contentToDeletionDiff(filePath: string, content: string): string {
output.push(`diff --git a/${filePath} b/${filePath}`);
output.push(`--- a/${filePath}`);
output.push(`+++ /dev/null`);
const nonEmpty = lines.filter((l) => l.length > 0 || lines.indexOf(l) < lines.length - 1);
const nonEmpty = lines.slice(0, lines.at(-1) === "" ? -1 : undefined);
if (nonEmpty.length > 0) {
output.push(`@@ -1,${nonEmpty.length} +0,0 @@`);
for (const line of nonEmpty) {
@@ -713,6 +714,7 @@ interface CodexFileChangeEntry {
path: string;
kind?: string;
diff?: string;
movePath?: string;
}
function parseFileChangePath(
@@ -740,6 +742,9 @@ function parseFileChangePath(
}
function parseFileChangeKind(entry: Record<string, unknown>): string | undefined {
if (isRecord(entry.kind) && typeof entry.kind.type === "string") {
return entry.kind.type;
}
return (
(typeof entry.kind === "string" && entry.kind) ||
(typeof entry.type === "string" && entry.type) ||
@@ -767,10 +772,14 @@ function toFileChangeEntry(
if (!path) {
return null;
}
const kind = isRecord(entry.kind) ? entry.kind : undefined;
const movePath = entry.move_path ?? kind?.move_path;
return {
path,
kind: parseFileChangeKind(entry),
diff: parseFileChangeDiff(entry),
movePath:
typeof movePath === "string" ? normalizeCodexFilePath(movePath, options?.cwd) : undefined,
};
}
@@ -839,6 +848,105 @@ function resolveFileChangeTextFields(file: CodexFileChangeEntry | undefined): {
return asEditTextFields(file.diff);
}
function fileChangeToUnifiedDiff(file: CodexFileChangeEntry): string {
// Codex formats a content-preserving move as a notice, not added file text.
const isMoveOnlyNotice = file.movePath && file.diff?.startsWith("\n\nMoved to: ");
const { unifiedDiff, newString } = isMoveOnlyNotice ? {} : resolveFileChangeTextFields(file);
if (!unifiedDiff && !newString) {
if (file.kind === "add") {
return [
`diff --git a/${file.path} b/${file.path}`,
"--- /dev/null",
`+++ b/${file.path}`,
].join("\n");
}
return file.movePath
? [
`diff --git a/${file.path} b/${file.movePath}`,
`rename from ${file.path}`,
`rename to ${file.movePath}`,
].join("\n")
: "";
}
if (unifiedDiff?.startsWith("diff --git")) {
return unifiedDiff.replace(/\r?\n$/, "");
}
const destination = file.movePath ?? file.path;
const header = `diff --git a/${file.path} b/${destination}`;
if (unifiedDiff?.startsWith("--- ")) {
return `${header}\n${unifiedDiff.replace(/\r?\n$/, "")}`;
}
const left = file.kind === "add" ? "/dev/null" : `a/${file.path}`;
const right = file.kind === "delete" ? "/dev/null" : `b/${destination}`;
const lines = [header, `--- ${left}`, `+++ ${right}`];
if (unifiedDiff) {
lines.push(unifiedDiff.replace(/\r?\n$/, ""));
} else if (newString) {
const contentLines = newString.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
lines.push(`@@ -0,0 +1,${contentLines.length} @@`);
for (const line of contentLines) lines.push(`+${line}`);
}
return lines.join("\n");
}
function splitFileDiffPreview(diff: string): { header: string; body: string } {
const hunk = diff.indexOf("\n@@");
const bodyStart = hunk < 0 ? -1 : diff.indexOf("\n", hunk + 1);
return bodyStart < 0
? { header: diff, body: "" }
: { header: diff.slice(0, bodyStart + 1), body: diff.slice(bodyStart + 1) };
}
export function fileChangesToPreview(files: CodexFileChangeEntry[]): {
filePath: string | undefined;
unifiedDiff: string;
} {
const diffs = files
.map((file) => ({ filePath: file.path, diff: fileChangeToUnifiedDiff(file) }))
.filter((file) => file.diff.length > 0);
const filePath = diffs[0]?.filePath ?? files[0]?.path;
const combined = diffs.map((file) => file.diff).join("\n\n");
if (combined.length <= TOOL_CALL_DIFF_MAX_CHARS) return { filePath, unifiedDiff: combined };
const marker = "\n...[truncated]";
const omittedNotice = (count: number) =>
count > 0 ? `\n\n...[${count} more files omitted]` : "";
const previews = diffs.map((file) => splitFileDiffPreview(file.diff));
const visible: typeof previews = [];
let minimumSize = 0;
for (const preview of previews) {
// Keep whole labels and a useful body excerpt, or explicitly omit the file.
const required =
preview.header.length + Math.max(marker.length, Math.min(80, preview.body.length));
const nextSize = minimumSize + required + (visible.length > 0 ? 2 : 0);
if (
nextSize + omittedNotice(previews.length - visible.length - 1).length >
TOOL_CALL_DIFF_MAX_CHARS
)
break;
visible.push(preview);
minimumSize = nextSize;
}
const omitted = omittedNotice(previews.length - visible.length);
const headersSize = visible.reduce((size, preview) => size + preview.header.length, 0);
const bodyBudget = Math.floor(
(TOOL_CALL_DIFF_MAX_CHARS -
headersSize -
Math.max(0, visible.length - 1) * 2 -
omitted.length) /
Math.max(1, visible.length),
);
const unifiedDiff =
visible
.map(
({ header, body }) =>
header +
(body.length <= bodyBudget ? body : body.slice(0, bodyBudget - marker.length) + marker),
)
.join("\n\n") + omitted;
return { filePath, unifiedDiff };
}
function mapFileChangeItem(
item: z.infer<typeof CodexFileChangeItemSchema>,
options?: CodexMapperOptions,
@@ -873,17 +981,17 @@ function mapFileChangeItem(
const error = item.error ?? null;
const status = normalizeToolCallStatus(item.status, error, output);
const firstFile = files[0];
const firstTextFields = resolveFileChangeTextFields(firstFile);
const hasFirstTextFields = Object.keys(firstTextFields).length > 0;
const preview = files.length > 1 ? fileChangesToPreview(files) : undefined;
const filePath = preview?.filePath ?? firstFile?.path;
const textFields = preview
? { unifiedDiff: preview.unifiedDiff }
: resolveFileChangeTextFields(firstFile);
const hasTextFields = Boolean(textFields.unifiedDiff || textFields.newString);
const input = toNullableObject({
...inputBase,
...(firstFile?.path && hasFirstTextFields ? { path: firstFile.path } : {}),
...(hasFirstTextFields && firstTextFields.unifiedDiff
? { patch: firstTextFields.unifiedDiff }
: {}),
...(hasFirstTextFields && firstTextFields.newString
? { content: firstTextFields.newString }
: {}),
...(filePath && hasTextFields ? { path: filePath } : {}),
...(hasTextFields && textFields.unifiedDiff ? { patch: textFields.unifiedDiff } : {}),
...(hasTextFields && textFields.newString ? { content: textFields.newString } : {}),
});
return {
@@ -950,6 +1058,7 @@ function mapCollabAgentToolCallItem(
type: "sub_agent",
subAgentType: "Sub-agent",
...(item.prompt ? { description: item.prompt } : {}),
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
};
@@ -1006,6 +1115,7 @@ function mapSubAgentActivityItem(
type: "sub_agent",
subAgentType: nativeName,
description,
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
},
@@ -5,6 +5,7 @@ import path from "node:path";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { AgentManager } from "../agent-manager.js";
import type { AgentSession, AgentStreamEvent } from "../agent-sdk-types.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
@@ -12,6 +13,7 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
test.each([
{ ending: "fail", terminalEvent: "turn_failed" },
{ ending: "cancel", terminalEvent: "turn_canceled" },
{ ending: "end_turn", terminalEvent: "turn_completed" },
] as const)(
"answers a permission request left open by a $terminalEvent turn with cancelled",
async ({ ending, terminalEvent }) => {
@@ -25,6 +27,16 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
await waitForEvent(events, terminalEvent);
expect(hasEvent(events, "permission_requested")).toBe(true);
expect(session.getPendingPermissions()).toEqual([]);
const requested = permissionRequests(events)[0]!;
expect(resolvedEvents(events)).toEqual([
{
type: "permission_resolved",
provider: "acp",
requestId: requested.request.id,
resolution: { behavior: "deny", message: "Permission request canceled" },
turnId: requested.turnId,
},
]);
const replyStart = events.length;
await session.startTurn("turn two");
@@ -39,6 +51,84 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
});
},
);
test.each(["completed", "failed"] as const)(
"cancels only the matching permission when its tool becomes %s",
async (status) => {
await withFakeACPAgent(status, async (command, cwd) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.createSession({ provider: "acp", cwd });
try {
const events = collectEvents(session);
await session.startTurn("turn one");
await waitForToolUpdate(events);
expect(pendingToolIds(session)).toEqual(["tool-2"]);
expect(resolvedEvents(events)).toEqual([
expect.objectContaining({ requestId: permissionRequests(events)[0]!.request.id }),
]);
} finally {
await session.close();
}
});
},
);
test("keeps a permission raised outside a turn answerable after another turn completes", async () => {
await withFakeACPAgent("outside_turn", async (command, cwd) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.createSession({ provider: "acp", cwd });
try {
const outside = session.getPendingPermissions();
expect(pendingToolIds(session)).toEqual(["tool-outside"]);
const events = collectEvents(session);
await session.startTurn("turn one");
await waitForEvent(events, "turn_completed");
expect(session.getPendingPermissions()).toEqual(outside);
await session.respondToPermission(outside[0]!.id, { behavior: "allow" });
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
}
});
});
test.each(["fail", "cancel"] as const)(
"settles an outside-turn permission that the manager clears when a turn ends with %s",
async (ending) => {
await withFakeACPAgent(`outside_${ending}`, async (command, cwd) => {
const logger = createTestLogger();
const client = new GenericACPAgentClient({
logger,
command,
waitForInitialCommands: false,
});
const manager = new AgentManager({ clients: { acp: client }, logger });
const agent = await manager.createAgent({ provider: "acp", cwd }, undefined, {
workspaceId: undefined,
});
try {
await waitForManagedPermission(manager, agent.id);
if (ending === "fail") {
await expect(manager.runAgent(agent.id, "turn one")).rejects.toThrow(
"turn failed with a permission open",
);
} else {
await expect(manager.runAgent(agent.id, "turn one")).resolves.toMatchObject({
canceled: true,
});
}
expect(manager.getPendingPermissions(agent.id)).toEqual([]);
const result = await manager.runAgent(agent.id, "turn two");
expect(result.finalText).toBe(
'perm-outside answered: {"outcome":{"outcome":"cancelled"}}',
);
} finally {
await manager.closeAgent(agent.id);
await manager.flush();
}
});
},
);
});
function collectEvents(session: AgentSession): AgentStreamEvent[] {
@@ -47,6 +137,10 @@ function collectEvents(session: AgentSession): AgentStreamEvent[] {
return events;
}
async function waitForManagedPermission(manager: AgentManager, agentId: string): Promise<void> {
await expect.poll(() => manager.getPendingPermissions(agentId).length).toBe(1);
}
function hasEvent(
events: AgentStreamEvent[],
type: AgentStreamEvent["type"],
@@ -77,8 +171,32 @@ function assistantText(events: AgentStreamEvent[]): string {
.join("");
}
function permissionRequests(events: AgentStreamEvent[]) {
return events.filter((event) => event.type === "permission_requested");
}
function resolvedEvents(events: AgentStreamEvent[]) {
return events.filter((event) => event.type === "permission_resolved");
}
function pendingToolIds(session: AgentSession) {
return session.getPendingPermissions().map((request) => request.metadata?.toolCallId);
}
async function waitForToolUpdate(events: AgentStreamEvent[]): Promise<void> {
await expect.poll(() => assistantText(events)).toBe("tool terminal");
}
async function withFakeACPAgent(
ending: "fail" | "cancel",
ending:
| "fail"
| "cancel"
| "end_turn"
| "completed"
| "failed"
| "outside_turn"
| "outside_fail"
| "outside_cancel",
run: (command: [string, ...string[]], cwd: string) => Promise<void>,
): Promise<void> {
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-permission-turn-failure-"));
@@ -91,8 +209,7 @@ async function withFakeACPAgent(
}
}
// Turn one asks for permission, then fails or is cancelled while the request is
// still open.
// Turn one ends while its permission request is still open.
// Later turns report whether that request was ever answered.
const fakeACPAgentScript = `
const readline = require("node:readline");
@@ -101,15 +218,34 @@ const ending = process.argv[2];
const rl = readline.createInterface({ input: process.stdin });
let promptCount = 0;
let permissionAnswer = null;
let outsidePermissionAnswer = null;
function write(message) {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
}
function ask(sessionId, requestId, toolCallId) {
const toolCall = { toolCallId, title: "echo hello", kind: "execute", status: "pending" };
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call", ...toolCall } } });
write({
id: requestId,
method: "session/request_permission",
params: {
sessionId,
toolCall,
options: [
{ optionId: "allow", name: "Allow once", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" },
],
},
});
}
rl.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === undefined && message.id === "perm-1") {
permissionAnswer = message.result ?? { error: message.error };
if (message.method === undefined) {
if (message.id === "perm-1") permissionAnswer = message.result ?? { error: message.error };
if (message.id === "perm-outside") outsidePermissionAnswer = message.result ?? { error: message.error };
return;
}
if (message.method === "initialize") {
@@ -117,6 +253,7 @@ rl.on("line", (line) => {
return;
}
if (message.method === "session/new") {
if (ending.startsWith("outside_")) ask("session-1", "perm-outside", "tool-outside");
write({ id: message.id, result: { sessionId: "session-1" } });
return;
}
@@ -124,35 +261,32 @@ rl.on("line", (line) => {
const sessionId = message.params.sessionId;
promptCount += 1;
if (promptCount === 1) {
const toolCall = { toolCallId: "tool-1", title: "echo hello", kind: "execute", status: "pending" };
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call", ...toolCall } } });
write({
id: "perm-1",
method: "session/request_permission",
params: {
sessionId,
toolCall,
options: [
{ optionId: "allow", name: "Allow once", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" },
],
},
});
ask(sessionId, "perm-1", "tool-1");
if (ending === "completed" || ending === "failed") {
ask(sessionId, "perm-2", "tool-2");
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call_update", toolCallId: "tool-1", status: ending } } });
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "tool terminal" } } } });
return;
}
setTimeout(() => {
if (ending === "cancel") {
if (ending === "cancel" || ending === "outside_cancel") {
write({ id: message.id, result: { stopReason: "cancelled" } });
} else if (ending === "end_turn" || ending === "outside_turn") {
write({ id: message.id, result: { stopReason: "end_turn" } });
} else {
write({ id: message.id, error: { code: -32603, message: "turn failed with a permission open" } });
}
}, 50);
return;
}
const state = permissionAnswer === null ? "unanswered" : "answered: " + JSON.stringify(permissionAnswer);
const answer = ending.startsWith("outside_") ? outsidePermissionAnswer : permissionAnswer;
const requestId = ending.startsWith("outside_") ? "perm-outside" : "perm-1";
const state = answer === null ? "unanswered" : "answered: " + JSON.stringify(answer);
write({
method: "session/update",
params: {
sessionId,
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "perm-1 " + state } },
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: requestId + " " + state } },
},
});
write({ id: message.id, result: { stopReason: "end_turn" } });
@@ -69,7 +69,7 @@ import {
type OmpRuntimeOptions,
} from "./provider-config.js";
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js";
import { getUserMessageText } from "./message-history.js";
import { mapOmpSystemNoticeToNotification } from "./system-notice.js";
@@ -130,7 +130,6 @@ export interface OmpAgentClientOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
runtime?: OmpRuntime;
subagentCardScheduler?: OmpSubagentCardScheduler;
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
@@ -205,7 +204,6 @@ interface OmpAgentSessionOptions {
initialState: OmpSessionState;
currentModeId?: string | null;
logger: Logger;
subagentCardScheduler?: OmpSubagentCardScheduler;
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
@@ -773,9 +771,7 @@ export class OmpAgentSession implements AgentSession {
this.logger.debug({ err: error }, "OMP context usage poll failed");
},
});
this.subagentCardTracker = new OmpSubagentCardTracker({
scheduler: options.subagentCardScheduler,
});
this.subagentCardTracker = new OmpSubagentCardTracker();
this.lastKnownThinkingOptionId =
normalizeOmpThinkingOption(options.config.thinkingOptionId) ??
this.state.thinkingLevel ??
@@ -1306,10 +1302,10 @@ export class OmpAgentSession implements AgentSession {
return null;
}
async setModel(modelId: string | null): Promise<void> {
async setModel(modelId: string | null): Promise<{ model: string | null }> {
const parsedReference = parseModelReference(modelId);
if (!parsedReference) {
return;
return { model: modelToId(this.state.model) };
}
if (!parsedReference.provider) {
throw new Error(`OMP model id must include a provider: ${modelId}`);
@@ -1318,6 +1314,7 @@ export class OmpAgentSession implements AgentSession {
const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id);
this.config.model = `${model.provider}/${model.id}`;
await this.refreshState();
return { model: modelToId(this.state.model) };
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
@@ -2155,8 +2152,7 @@ export class OmpAgentSession implements AgentSession {
return mapOmpToolDetail(toolCall, result, {
toolCallId,
bridgedTool: this.hostTools?.bridge?.tool(toolCall.toolName),
mapSubagentDetail: (detail) =>
this.subagentCardTracker.detailFor(toolCallId, detail) ?? detail,
mapSubagentDetail: (detail) => this.subagentCardTracker.detailFor(toolCallId, detail),
});
}
@@ -2246,7 +2242,6 @@ export class OmpAgentClient implements AgentClient {
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly subagentCardScheduler?: OmpSubagentCardScheduler;
private readonly providerIdleScheduler?: OmpProviderIdleScheduler;
private readonly noTurnScheduler?: OmpNoTurnScheduler;
private readonly usagePollScheduler?: OmpUsagePollScheduler;
@@ -2265,7 +2260,6 @@ export class OmpAgentClient implements AgentClient {
);
this.logger = options.logger;
this.runtimeSettings = runtimeSettings;
this.subagentCardScheduler = options.subagentCardScheduler;
this.providerIdleScheduler = options.providerIdleScheduler;
this.noTurnScheduler = options.noTurnScheduler;
this.usagePollScheduler = options.usagePollScheduler;
@@ -2349,7 +2343,6 @@ export class OmpAgentClient implements AgentClient {
initialState,
currentModeId: launchMode.modeId,
logger: this.logger,
subagentCardScheduler: this.subagentCardScheduler,
providerIdleScheduler: this.providerIdleScheduler,
noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler,
@@ -2421,7 +2414,6 @@ export class OmpAgentClient implements AgentClient {
initialState,
currentModeId: launchMode.modeId,
logger: this.logger,
subagentCardScheduler: this.subagentCardScheduler,
providerIdleScheduler: this.providerIdleScheduler,
noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler,
@@ -637,7 +637,7 @@ describe("OMP history mapper", () => {
subAgentType: "explore",
description: "Inspect files",
childSessionId: "/tmp/omp-task/Explore.jsonl",
log: "done\ntranscript: /tmp/omp-task/Explore.jsonl",
log: "",
},
error: null,
},
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
const PARENT_TOOL_CALL_ID = "task-1";
@@ -59,184 +59,84 @@ const PROGRESS: OmpSubagentProgressPayload[] = [
},
];
class ManualScheduler implements OmpSubagentCardScheduler {
private currentMs = 0;
private nextId = 1;
private readonly timers = new Map<number, { dueMs: number; callback: () => void }>();
now(): number {
return this.currentMs;
}
setTimeout(callback: () => void, delayMs: number) {
const id = this.nextId;
this.nextId += 1;
this.timers.set(id, { dueMs: this.currentMs + delayMs, callback });
return { token: id };
}
clearTimeout(timer: { token: unknown }): void {
if (typeof timer.token === "number") {
this.timers.delete(timer.token);
}
}
advance(ms: number): void {
this.currentMs += ms;
const dueTimers = [...this.timers.entries()]
.filter(([, timer]) => timer.dueMs <= this.currentMs)
.sort((left, right) => left[1].dueMs - right[1].dueMs);
for (const [id, timer] of dueTimers) {
if (this.timers.delete(id)) {
timer.callback();
}
}
}
}
describe("OmpSubagentCardTracker", () => {
test("folds lifecycle and progress into one throttled sub-agent detail", () => {
const scheduler = new ManualScheduler();
test("keeps the card a launch marker while the child runs", () => {
const emitted: string[] = [];
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
},
});
tracker.handleLifecycle(LIFECYCLE);
const emit = (toolCallId: string) => {
emitted.push(toolCallId);
return true;
};
const tracker = new OmpSubagentCardTracker();
tracker.handleLifecycle(LIFECYCLE, emit);
for (const payload of PROGRESS) {
tracker.handleProgress(payload);
tracker.handleProgress(payload, emit);
}
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
const detailBeforeTrailing = tracker.detailFor(PARENT_TOOL_CALL_ID, {
type: "sub_agent",
subAgentType: "task",
description: "Task arg description wins",
log: "",
});
expect(detailBeforeTrailing).toEqual({
type: "sub_agent",
subAgentType: "task",
description: "Task arg description wins",
childSessionId: SESSION_FILE,
log: [
"EchoSubagent started",
"[bash] echo subagent-hi",
"[yield]",
"EchoSubagent completed",
].join("\n"),
});
scheduler.advance(500);
expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]);
});
test("aggregates batch progress streams into one index-prefixed log", () => {
const scheduler = new ManualScheduler();
const emitted: string[] = [];
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
},
});
tracker.handleLifecycle({
id: "Explore",
agent: "task",
description: "Inspect files",
status: "started",
sessionFile: "/tmp/one.jsonl",
parentToolCallId: "task.batch-1",
index: 0,
});
scheduler.advance(600);
tracker.handleProgress({
index: 5,
agent: "task",
task: "Run tests",
parentToolCallId: "task.batch-1",
progress: {
id: "Test",
status: "running",
description: "Run tests",
recentTools: [{ tool: "bash", args: "npm test", endMs: 10 }],
},
sessionFile: "/tmp/two.jsonl",
});
expect(emitted).toEqual(["task.batch-1", "task.batch-1"]);
expect(
tracker.detailFor("task.batch-1", {
tracker.detailFor(PARENT_TOOL_CALL_ID, {
type: "sub_agent",
subAgentType: "batch",
actions: [],
subAgentType: "task",
description: "Task arg description wins",
log: "",
}),
).toEqual({
type: "sub_agent",
subAgentType: "batch",
description: "Inspect files",
childSessionId: "/tmp/one.jsonl",
log: "[1/6] Explore started\n[6/6] [bash] npm test",
actions: [],
subAgentType: "task",
description: "Task arg description wins",
childSessionId: SESSION_FILE,
log: "",
});
});
test("keeps dirty trailing state available for final detail and cancels it on cleanup", () => {
const scheduler = new ManualScheduler();
test("re-emits the card when a later event names the child", () => {
const emitted: string[] = [];
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
const emit = (toolCallId: string) => {
emitted.push(toolCallId);
return true;
};
const tracker = new OmpSubagentCardTracker();
tracker.handleLifecycle({ ...LIFECYCLE, description: undefined, sessionFile: undefined }, emit);
tracker.handleProgress(PROGRESS[0], emit);
expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]);
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
type: "sub_agent",
childSessionId: SESSION_FILE,
log: "",
});
tracker.delete(PARENT_TOOL_CALL_ID);
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
type: "sub_agent",
log: "",
});
});
test("leaves the card alone when a later child of the same task starts", () => {
const emitted: string[] = [];
const emit = (toolCallId: string) => {
emitted.push(toolCallId);
return true;
};
const tracker = new OmpSubagentCardTracker();
tracker.handleLifecycle(LIFECYCLE, emit);
tracker.handleLifecycle(
{
...LIFECYCLE,
id: "Second",
index: 1,
description: "Second child",
sessionFile: "/s.jsonl",
},
emit,
);
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
type: "sub_agent",
description: "Run echo in subagent",
childSessionId: SESSION_FILE,
log: "",
});
tracker.handleLifecycle({
id: "Explore",
agent: "task",
description: "Inspect files",
status: "started",
parentToolCallId: "task-1",
index: 0,
});
scheduler.advance(100);
tracker.handleProgress({
index: 0,
agent: "task",
task: "Inspect files",
parentToolCallId: "task-1",
progress: {
id: "Explore",
status: "running",
recentOutput: ["found target file"],
},
});
expect(emitted).toEqual(["task-1"]);
expect(
tracker.detailFor("task-1", {
type: "sub_agent",
log: "",
}).log,
).toBe("Explore started\nfound target file");
tracker.delete("task-1");
scheduler.advance(500);
expect(emitted).toEqual(["task-1"]);
expect(
tracker.detailFor("task-1", {
type: "sub_agent",
log: "static",
}).log,
).toBe("static");
});
});
@@ -1,361 +1,106 @@
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
export interface OmpSubagentCardTimer {
readonly token: unknown;
}
export interface OmpSubagentCardScheduler {
now(): number;
setTimeout(callback: () => void, delayMs: number): OmpSubagentCardTimer;
clearTimeout(timer: OmpSubagentCardTimer): void;
}
export interface OmpSubagentCardTrackerOptions {
scheduler?: OmpSubagentCardScheduler;
emitToolCall?: (toolCallId: string) => boolean;
}
interface OmpSubagentLogLine {
key: string;
text: string;
}
interface OmpSubagentCardItem {
interface OmpSubagentCardChild {
index: number;
description?: string;
agent?: string;
status?: "pending" | "running" | "completed" | "failed" | "aborted";
childSessionId?: string;
lines: OmpSubagentLogLine[];
lineKeys: Set<string>;
}
interface OmpSubagentCardState {
items: Map<number, OmpSubagentCardItem>;
emitToolCall: (toolCallId: string) => boolean;
lastEmitMs: number | null;
trailingTimer: OmpSubagentCardTimer | null;
dirty: boolean;
}
const MAX_LOG_LINES_PER_ITEM = 200;
const MAX_LOG_LINE_CHARS = 240;
const THROTTLE_INTERVAL_MS = 500;
export function createOmpSubagentCardScheduler(): OmpSubagentCardScheduler {
return {
now: () => Date.now(),
setTimeout: (callback, delayMs) => ({ token: setTimeout(callback, delayMs) }),
clearTimeout: (timer) => {
clearTimeout(timer.token as ReturnType<typeof setTimeout>);
},
};
}
/**
* Fills a task card's description and child session from OMP's sub-agent events. The card is a
* launch marker that names the task's first child; the children's activity belongs to their own
* timelines.
*/
export class OmpSubagentCardTracker {
private readonly scheduler: OmpSubagentCardScheduler;
private readonly emitToolCall: (toolCallId: string) => boolean;
private readonly states = new Map<string, OmpSubagentCardState>();
constructor(options: OmpSubagentCardTrackerOptions) {
this.scheduler = options.scheduler ?? createOmpSubagentCardScheduler();
this.emitToolCall = options.emitToolCall ?? (() => false);
}
private readonly firstChildren = new Map<string, OmpSubagentCardChild>();
handleLifecycle(
payload: OmpSubagentLifecyclePayload,
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
emitToolCall: (toolCallId: string) => boolean,
): void {
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
if (!parentToolCallId) {
return;
}
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
const item = this.upsertItem(parentToolCallId, {
index: payload.index,
agent: payload.agent,
description: payload.description,
status: payload.status === "started" ? "running" : payload.status,
childSessionId: payload.sessionFile,
});
this.appendLine(
item,
`lifecycle:${payload.status}:${payload.id}`,
`${payload.id} ${payload.status}`,
this.update(
payload.parentToolCallId,
{
index: payload.index,
description: payload.description,
childSessionId: payload.sessionFile,
},
emitToolCall,
);
this.requestEmit(parentToolCallId);
}
handleProgress(
payload: OmpSubagentProgressPayload,
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
emitToolCall: (toolCallId: string) => boolean,
): void {
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
if (!parentToolCallId) {
return;
}
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
const item = this.upsertItem(parentToolCallId, {
index: payload.index,
agent: payload.agent,
description: payload.progress.description,
status: payload.progress.status,
childSessionId: payload.sessionFile,
});
const currentToolLine = summarizeTool(readRecord(payload.progress.currentTool));
if (currentToolLine) {
this.appendLine(item, `current:${currentToolLine.key}`, currentToolLine.text);
}
for (const tool of readRecordArray(payload.progress.recentTools)) {
const line = summarizeTool(tool);
if (line) {
this.appendLine(item, `tool:${line.key}`, line.text);
}
}
for (const output of readOutputLines(payload.progress.recentOutput)) {
this.appendLine(item, `output:${output}`, output);
}
if (payload.progress.status !== "running") {
this.appendLine(
item,
`status:${payload.progress.status}:${payload.progress.id}`,
`${payload.progress.id} ${payload.progress.status}`,
);
}
this.requestEmit(parentToolCallId);
this.update(
payload.parentToolCallId,
{
index: payload.index,
description: payload.progress.description,
childSessionId: payload.sessionFile,
},
emitToolCall,
);
}
detailFor(toolCallId: string, baseDetail: ToolCallDetail): ToolCallDetail {
if (baseDetail.type !== "sub_agent") {
return baseDetail;
}
const state = this.states.get(toolCallId);
if (!state) {
const first = this.firstChildren.get(toolCallId);
if (!first) {
return baseDetail;
}
const items = [...state.items.values()].sort((left, right) => left.index - right.index);
const firstItem = items[0];
if (!firstItem) {
return baseDetail;
}
const detail: ToolCallDetail = {
type: "sub_agent",
...(baseDetail.subAgentType ? { subAgentType: baseDetail.subAgentType } : {}),
...((baseDetail.description ?? firstItem.description)
? { description: baseDetail.description ?? firstItem.description }
: {}),
...((firstItem.childSessionId ?? baseDetail.childSessionId)
? { childSessionId: firstItem.childSessionId ?? baseDetail.childSessionId }
: {}),
log: buildLog(items, baseDetail.log),
const description = baseDetail.description ?? first.description;
const childSessionId = first.childSessionId ?? baseDetail.childSessionId;
return {
...baseDetail,
...(description ? { description } : {}),
...(childSessionId ? { childSessionId } : {}),
};
return baseDetail.actions ? { ...detail, actions: baseDetail.actions } : detail;
}
flush(toolCallId: string): void {
const state = this.states.get(toolCallId);
if (!state || !state.dirty) {
return;
}
this.emitNow(toolCallId, state);
}
delete(toolCallId: string): void {
const state = this.states.get(toolCallId);
if (state?.trailingTimer) {
this.scheduler.clearTimeout(state.trailingTimer);
}
this.states.delete(toolCallId);
this.firstChildren.delete(toolCallId);
}
clear(): void {
for (const toolCallId of this.states.keys()) {
this.delete(toolCallId);
}
this.firstChildren.clear();
}
private upsertItem(
parentToolCallId: string,
input: {
index: number;
agent?: string;
status?: "pending" | "running" | "completed" | "failed" | "aborted";
description?: string;
childSessionId?: string;
},
): OmpSubagentCardItem {
const state = this.stateFor(parentToolCallId);
const existing = state.items.get(input.index);
if (existing) {
existing.agent = readTrimmedString(input.agent) ?? existing.agent;
existing.status = input.status ?? existing.status;
existing.description = readTrimmedString(input.description) ?? existing.description;
existing.childSessionId = readTrimmedString(input.childSessionId) ?? existing.childSessionId;
return existing;
private update(
parentToolCallId: string | undefined,
input: { index: number; description?: string; childSessionId?: string },
emitToolCall: (toolCallId: string) => boolean,
): void {
const toolCallId = readTrimmedString(parentToolCallId);
if (!toolCallId) {
return;
}
const item: OmpSubagentCardItem = {
const first = this.firstChildren.get(toolCallId);
if (first && input.index > first.index) {
return;
}
const existing = first?.index === input.index ? first : undefined;
const next: OmpSubagentCardChild = {
index: input.index,
lines: [],
lineKeys: new Set<string>(),
...(readTrimmedString(input.agent) ? { agent: readTrimmedString(input.agent) } : {}),
...(input.status ? { status: input.status } : {}),
...(readTrimmedString(input.description)
? { description: readTrimmedString(input.description) }
: {}),
...(readTrimmedString(input.childSessionId)
? { childSessionId: readTrimmedString(input.childSessionId) }
: {}),
description: readTrimmedString(input.description) ?? existing?.description,
childSessionId: readTrimmedString(input.childSessionId) ?? existing?.childSessionId,
};
state.items.set(input.index, item);
return item;
}
private stateFor(parentToolCallId: string): OmpSubagentCardState {
const existing = this.states.get(parentToolCallId);
if (existing) {
return existing;
}
const state: OmpSubagentCardState = {
items: new Map(),
emitToolCall: this.emitToolCall,
lastEmitMs: null,
trailingTimer: null,
dirty: false,
};
this.states.set(parentToolCallId, state);
return state;
}
private appendLine(item: OmpSubagentCardItem, key: string, text: string): void {
const normalizedText = normalizeLine(text);
if (!normalizedText || item.lineKeys.has(key)) {
if (
existing &&
existing.description === next.description &&
existing.childSessionId === next.childSessionId
) {
return;
}
item.lines.push({ key, text: normalizedText });
item.lineKeys.add(key);
while (item.lines.length > MAX_LOG_LINES_PER_ITEM) {
const removed = item.lines.shift();
if (removed) {
item.lineKeys.delete(removed.key);
}
}
this.firstChildren.set(toolCallId, next);
emitToolCall(toolCallId);
}
private requestEmit(toolCallId: string): void {
const state = this.stateFor(toolCallId);
const now = this.scheduler.now();
const elapsedMs = state.lastEmitMs === null ? THROTTLE_INTERVAL_MS : now - state.lastEmitMs;
if (elapsedMs >= THROTTLE_INTERVAL_MS) {
this.emitNow(toolCallId, state);
return;
}
state.dirty = true;
if (state.trailingTimer) {
return;
}
state.trailingTimer = this.scheduler.setTimeout(() => {
state.trailingTimer = null;
if (state.dirty) {
this.emitNow(toolCallId, state);
}
}, THROTTLE_INTERVAL_MS - elapsedMs);
}
private emitNow(toolCallId: string, state: OmpSubagentCardState): void {
if (state.trailingTimer) {
this.scheduler.clearTimeout(state.trailingTimer);
state.trailingTimer = null;
}
state.dirty = false;
state.lastEmitMs = this.scheduler.now();
state.emitToolCall(toolCallId);
}
}
function buildLog(items: OmpSubagentCardItem[], fallback: string): string {
const itemCount = Math.max(...items.map((item) => item.index)) + 1;
const lines = items.flatMap((item) => {
const prefix = itemCount > 1 ? `[${item.index + 1}/${itemCount}] ` : "";
return item.lines.map((line) => `${prefix}${line.text}`);
});
return lines.length > 0 ? lines.join("\n") : fallback;
}
function summarizeTool(tool: Record<string, unknown> | null): OmpSubagentLogLine | null {
if (!tool) {
return null;
}
const toolName = readTrimmedString(tool.tool) ?? readTrimmedString(tool.name);
if (!toolName) {
return null;
}
const args = readTrimmedString(tool.args) ?? summarizeUnknown(tool.input);
const endMs = typeof tool.endMs === "number" ? `:${tool.endMs}` : "";
return {
key: `${toolName}:${args ?? ""}${endMs}`,
text: args ? `[${toolName}] ${args}` : `[${toolName}]`,
};
}
function readOutputLines(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item) => {
const text =
readTrimmedString(item) ??
(isRecord(item)
? (readTrimmedString(item.text) ??
readTrimmedString(item.output) ??
readTrimmedString(item.content))
: undefined);
return text ? [text] : [];
});
}
function readRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
}
function readRecordArray(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.filter(isRecord) : [];
}
function summarizeUnknown(value: unknown): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value === "string") {
return readTrimmedString(value);
}
const json = JSON.stringify(value);
return typeof json === "string" ? normalizeLine(json) : undefined;
}
function normalizeLine(value: string): string | undefined {
const normalized = value.trim().replace(/\s+/g, " ");
if (!normalized) {
return undefined;
}
if (normalized.length <= MAX_LOG_LINE_CHARS) {
return normalized;
}
return `${normalized.slice(0, MAX_LOG_LINE_CHARS)}...`;
}
function readTrimmedString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -85,7 +85,7 @@ describe("OMP tool call mapper", () => {
expect(mapOmpToolDetail(parseToolArgs("todo", { op: "view" }), null)).toBeNull();
});
test("uses task result text and transcript path as the best static replay detail", () => {
test("takes the child session from the task transcript path", () => {
expect(
mapOmpToolDetail(
parseToolArgs("task", {
@@ -106,7 +106,7 @@ describe("OMP tool call mapper", () => {
subAgentType: "explore",
description: "Inspect the target files",
childSessionId: "/tmp/omp-task-static/Explore.jsonl",
log: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl",
log: "",
});
});
@@ -179,7 +179,6 @@ const OPAQUE_TEXT_TOOLS = new Set([
function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail {
const argRecord = isRecord(args) ? args : {};
const firstTask = Array.isArray(argRecord.tasks) ? argRecord.tasks.find(isRecord) : undefined;
const resultText = extractTextFromToolResult(result);
const childSessionId = readChildSessionId(result);
return {
type: "sub_agent",
@@ -200,7 +199,8 @@ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail
argRecord.assignment,
),
...(childSessionId ? { childSessionId } : {}),
log: resultText?.trim() ?? "",
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
};
}
@@ -349,7 +349,11 @@ type OpenCodeAgentConfig = Omit<AgentSessionConfig, "providerOptions"> & {
providerOptions: OpenCodeProviderOptions;
};
const OPENCODE_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]);
const OPENCODE_SESSION_ENV_KEYS = new Set([
"PASEO_AGENT_ID",
"PASEO_AGENT_CWD",
"PASEO_WORKSPACE_ID",
]);
function requiresDedicatedOpenCodeServer(
config: OpenCodeAgentConfig,
@@ -4846,15 +4850,6 @@ class OpenCodeAgentSession implements AgentSession {
...item,
status: "failed",
error,
detail:
item.detail.type === "sub_agent"
? {
...item.detail,
log: [item.detail.log, error.message]
.filter((entry) => entry.trim().length > 0)
.join("\n"),
}
: item.detail,
},
},
turnId,
@@ -80,8 +80,12 @@ describe("OpenCode bridge adapter", () => {
await rm(paseoHome, { recursive: true, force: true });
});
const runtime = new TestOpenCodeHarness();
runtime.enqueueClient(new TestOpenCodeClient());
runtime.enqueueClient(new TestOpenCodeClient());
const envClient = new TestOpenCodeClient();
const mcpClient = new TestOpenCodeClient();
envClient.sessionCreateResponse = { data: { id: "ses_env" } };
mcpClient.sessionCreateResponse = { data: { id: "ses_mcp" } };
runtime.enqueueClient(envClient);
runtime.enqueueClient(mcpClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
@@ -102,6 +106,12 @@ describe("OpenCode bridge adapter", () => {
);
expect(runtime.acquisitions.map(({ kind }) => kind)).toEqual(["dedicated", "dedicated"]);
await expect(readBridgeContext(bridge, "ses_env")).resolves.toEqual({
env: { PASEO_AGENT_ID: "one", CUSTOM_TOKEN: "secret" },
});
await expect(readBridgeContext(bridge, "ses_mcp")).resolves.toEqual({
env: { PASEO_AGENT_ID: "two" },
});
await customEnv.close();
await customMcp.close();
});
@@ -94,7 +94,7 @@ test("bridge preserves tool failure messages and classifies invalid requests as
const { tools } = await manifest.json();
expect(
tools.find((tool: { name: string }) => tool.name === "create_agent").inputSchema.properties,
).not.toHaveProperty("background");
).toHaveProperty("background");
expect(
tools.find((tool: { name: string }) => tool.name === "update_schedule").inputSchema
.properties,
@@ -20,6 +20,7 @@ import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../../tool
import { serializePaseoToolInputParameters } from "../../tools/paseo-tool-serialization.js";
import {
OpenCodeBridge,
decorateOpenCodeV2Env,
loadOpenCodeBridgePluginArtifact,
materializeOpenCodeV2Plugin,
} from "./bridge.js";
@@ -137,33 +138,28 @@ describe("OpenCodeBridge", () => {
};
expect(ajv.compile(createDefinition.inputSchema)(createInput)).toBe(true);
const v2Config = z
.object({ plugins: z.array(z.object({ package: z.string() })) })
.parse(JSON.parse((await bridge.decorateV2ServerEnv({})).OPENCODE_CONFIG_CONTENT));
const v2Module: {
default: { setup(context: V2TestPluginContext): Promise<() => Promise<void>> };
} = await import(
pathToFileURL(path.join(fileURLToPath(v2Config.plugins[0]!.package), "server.js")).href
);
const tools = new Map<string, V2TestTool>();
const dispose = await v2Module.default.setup({
options: plugin,
tool: {
transform: async (transform) => {
transform({
add: (tool) => {
tools.set(tool.name, tool);
},
});
return { dispose: async () => undefined };
const dispose = await setupBoundV2Plugin(
bridge,
{
tool: {
transform: async (transform) => {
transform({
add: (tool) => {
tools.set(tool.name, tool);
},
});
return { dispose: async () => undefined };
},
},
session: {
context: async () => [],
get: async () => ({ parentID: "bound" }),
hook: async () => ({ dispose: async () => undefined }),
},
},
session: {
context: async () => [],
get: async () => ({ parentID: "bound" }),
hook: async () => ({ dispose: async () => undefined }),
},
});
["bound"],
);
try {
const created = await tools
.get("paseo_create_agent")!
@@ -185,13 +181,18 @@ describe("OpenCodeBridge", () => {
expect(createDefinition.inputSchema).toMatchObject({
properties: { notifyOnFinish: { default: true } },
});
expect(createDefinition.inputSchema.properties).not.toHaveProperty("background");
expect(createDefinition.inputSchema.properties).toHaveProperty("background");
expect(sendDefinition.inputSchema).toMatchObject({
properties: { notifyOnFinish: { default: true } },
});
await expect(
catalog.executeTool("create_agent", { ...createInput, background: true }),
).rejects.toThrow("background");
await catalog.executeTool("create_agent", {
...createInput,
title: "Background child",
background: true,
});
expect(
agentManager.listAgents().find((agent) => agent.config.title === "Background child"),
).toMatchObject({ background: true });
const v1Module = await import(plugin.pluginUrl);
const hooks = await v1Module.default({}, plugin);
@@ -402,45 +403,35 @@ describe("OpenCodeBridge", () => {
const release = bridge.bindSession({ sessionId: "parent", env: {}, tools: catalog });
const releaseDisabled = bridge.bindSession({ sessionId: "disabled", env: {} });
try {
const env = await bridge.decorateV2ServerEnv({});
const config = z
.object({
plugins: z.array(
z.object({
package: z.string(),
options: z.object({ baseUrl: z.string(), token: z.string() }),
}),
),
})
.parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT));
expect(await bridge.decorateV2ServerEnv(env)).toEqual(env);
const plugin = config.plugins[0]!;
const pluginUrl = await bridge.materializeV2Plugin();
const env = decorateOpenCodeV2Env({}, pluginUrl);
expect(decorateOpenCodeV2Env(env, pluginUrl)).toEqual(env);
const tools = new Map<string, V2TestTool>();
let filter!: (input: V2TestContext) => Promise<void>;
const module: {
default: { setup(context: V2TestPluginContext): Promise<() => Promise<void>> };
} = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href);
const dispose = await module.default.setup({
options: plugin.options,
tool: {
transform: async (transform) => {
transform({
add: (tool) => {
tools.set(tool.name, tool);
},
});
return { dispose: async () => undefined };
const dispose = await setupBoundV2Plugin(
bridge,
{
tool: {
transform: async (transform) => {
transform({
add: (tool) => {
tools.set(tool.name, tool);
},
});
return { dispose: async () => undefined };
},
},
session: {
context: async () => [],
get: async ({ sessionID }) => ({ parentID: sessionID === "child" ? "parent" : "" }),
hook: async (_name, callback) => {
filter = callback;
return { dispose: async () => undefined };
},
},
},
session: {
context: async () => [],
get: async () => ({ parentID: "parent" }),
hook: async (_name, callback) => {
filter = callback;
return { dispose: async () => undefined };
},
},
});
["parent", "disabled"],
);
const allowed: V2TestContext = {
sessionID: "child",
tools: { paseo_echo_context: {}, native: {} },
@@ -453,6 +444,12 @@ describe("OpenCodeBridge", () => {
};
await filter(disabled);
expect(Object.keys(disabled.tools)).toEqual(["native"]);
const unrelated: V2TestContext = {
sessionID: "unrelated",
tools: { paseo_echo_context: {}, native: {} },
};
await filter(unrelated);
expect(Object.keys(unrelated.tools)).toEqual(["native"]);
await expect(
tools.get("paseo_echo_context")!.execute({ value: "child result" }, { sessionID: "child" }),
).resolves.toMatchObject({ content: [{ type: "text", text: "child result" }] });
@@ -486,35 +483,33 @@ describe("OpenCodeBridge", () => {
await bridge.start();
const release = bridge.bindSession({ sessionId: "caller", env: {}, tools: catalog });
try {
const env = await bridge.decorateV2ServerEnv({});
const { plugins } = JSON.parse(env.OPENCODE_CONFIG_CONTENT);
const plugin = plugins[0];
const module = await import(
pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href
);
const options = bridge.sharedV2Connection();
const tools = new Map<string, V2TestTool>();
const dispose = await module.default.setup({
options: plugin.options,
tool: {
transform: async (transform: (editor: { add(tool: V2TestTool): void }) => void) => {
transform({ add: (tool) => tools.set(tool.name, tool) });
return { dispose: async () => undefined };
const dispose = await setupBoundV2Plugin(
bridge,
{
tool: {
transform: async (transform: (editor: { add(tool: V2TestTool): void }) => void) => {
transform({ add: (tool) => tools.set(tool.name, tool) });
return { dispose: async () => undefined };
},
},
session: {
context: async () => [],
get: async () => ({ parentID: "" }),
hook: async () => ({ dispose: async () => undefined }),
},
},
session: {
context: async () => [],
get: async () => ({ parentID: "" }),
hook: async () => ({ dispose: async () => undefined }),
},
} satisfies V2TestPluginContext);
["caller"],
);
try {
const input = { provider: "codex", title: "Invalid provider", initialPrompt: "Hello" };
const response = await fetch(
`${plugin.options.baseUrl}/_internal/opencode/sessions/caller/tools/create_agent`,
`${options.baseUrl}/_internal/opencode/sessions/caller/tools/create_agent`,
{
method: "POST",
headers: {
Authorization: `Bearer ${plugin.options.token}`,
Authorization: `Bearer ${options.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(input),
@@ -572,35 +567,24 @@ describe("OpenCodeBridge", () => {
await bridge.start();
const release = bridge.bindSession({ sessionId: "session", env: {}, tools: catalog });
try {
const env = await bridge.decorateV2ServerEnv({});
const plugin = z
.object({
plugins: z.array(
z.object({
package: z.string(),
options: z.object({ baseUrl: z.string(), token: z.string() }),
}),
),
})
.parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)).plugins[0]!;
const pluginTools = new Map<string, V2TestTool>();
const module: {
default: { setup(context: V2TestPluginContext): Promise<() => Promise<void>> };
} = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href);
const dispose = await module.default.setup({
options: plugin.options,
tool: {
transform: async (transform) => {
transform({ add: (tool) => pluginTools.set(tool.name, tool) });
return { dispose: async () => undefined };
const dispose = await setupBoundV2Plugin(
bridge,
{
tool: {
transform: async (transform) => {
transform({ add: (tool) => pluginTools.set(tool.name, tool) });
return { dispose: async () => undefined };
},
},
session: {
context: async () => [],
get: async () => ({ parentID: undefined }),
hook: async () => ({ dispose: async () => undefined }),
},
},
session: {
context: async () => [],
get: async () => ({ parentID: undefined }),
hook: async () => ({ dispose: async () => undefined }),
},
});
["session"],
);
const result = await pluginTools
.get("paseo_browser_screenshot")!
.execute({}, { sessionID: "session" });
@@ -642,7 +626,8 @@ describe("OpenCodeBridge", () => {
const tools = new Map<string, V2TestTool>();
let hook!: (input: V2TestContext) => Promise<void>;
const dispose = await module.default.setup({
options: { baseUrl: "", token: "" },
options: { mode: "structured-only" },
location: { directory: root },
tool: {
transform: async (transform) => {
transform({
@@ -657,7 +642,7 @@ describe("OpenCodeBridge", () => {
context: async () => history,
get: async () => ({ parentID: "parent" }),
hook: async (_name, callback) => {
hook = callback;
hook ??= callback;
return { dispose: async () => undefined };
},
},
@@ -701,9 +686,13 @@ describe("OpenCodeBridge", () => {
name.startsWith("paseo-v2-"),
),
).toEqual([]);
const env = await bridge.decorateV2ServerEnv({
OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: ["user-plugin"] }),
});
const pluginUrl = await bridge.materializeV2Plugin();
const env = decorateOpenCodeV2Env(
{
OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: ["user-plugin"] }),
},
pluginUrl,
);
const config = z
.object({ plugins: z.tuple([z.literal("user-plugin"), z.object({ package: z.string() })]) })
.parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT));
@@ -711,6 +700,9 @@ describe("OpenCodeBridge", () => {
expect((await stat(directory)).isDirectory()).toBe(true);
const entry = await readFile(path.join(directory, "server.js"), "utf8");
expect(entry).toContain('id: "paseo"');
expect(await readFile(path.join(directory, "state.mjs"), "utf8")).toBe(
await readFile(new URL("./v2/state.mjs", import.meta.url), "utf8"),
);
const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"));
expect(manifest).toMatchObject({ type: "module", exports: { "./server": "./server.js" } });
} finally {
@@ -856,7 +848,8 @@ interface V2TestContext {
system?: Array<{ type: "text"; text: string }>;
}
interface V2TestPluginContext {
options: { baseUrl: string; token: string };
options: Record<string, string>;
location?: { directory: string };
tool: {
transform(
callback: (editor: { add(tool: V2TestTool): void }) => void,
@@ -864,10 +857,33 @@ interface V2TestPluginContext {
};
session: {
context(input: { sessionID: string }): Promise<SessionMessageInfo[]>;
get(input: { sessionID: string }): Promise<{ parentID: string }>;
get(input: { sessionID: string }): Promise<{ parentID: string | undefined }>;
hook(
name: string,
callback: (input: V2TestContext) => Promise<void>,
): Promise<{ dispose(): Promise<void> }>;
};
}
async function setupBoundV2Plugin(
bridge: OpenCodeBridge,
context: Pick<V2TestPluginContext, "tool" | "session">,
sessionIds: string[],
): Promise<() => Promise<void>> {
const directory = fileURLToPath(await bridge.materializeV2Plugin());
const module = await import(pathToFileURL(path.join(directory, "server.js")).href);
let bind!: (input: { sessionID: string; baseUrl: string; token: string }) => Promise<unknown>;
const dispose: () => Promise<void> = await module.default.setup({
...context,
options: {},
location: { directory },
rpc: {
register: async (_definition: unknown, handlers: { bind: typeof bind }) => {
bind = handlers.bind;
return { dispose: async () => undefined };
},
},
});
for (const sessionID of sessionIds) await bind({ sessionID, ...bridge.sharedV2Connection() });
return dispose;
}
@@ -30,6 +30,7 @@ class OpenCodeBridgeClientError extends Error {
interface OpenCodeBridgeOptions {
paseoHome: string;
logger: Logger;
port?: number;
}
interface OpenCodeSessionBinding {
@@ -54,6 +55,7 @@ interface OpenCodeConfig {
export class OpenCodeBridge {
private readonly paseoHome: string;
private readonly logger: Logger;
private readonly port: number;
private readonly token = randomBytes(32).toString("hex");
private readonly sessions = new Map<string, OpenCodeSessionBinding>();
private server: Server | null = null;
@@ -65,6 +67,7 @@ export class OpenCodeBridge {
constructor(options: OpenCodeBridgeOptions) {
this.paseoHome = options.paseoHome;
this.logger = options.logger.child({ module: "agent", component: "opencode-bridge" });
this.port = options.port ?? 0;
}
async start(): Promise<void> {
@@ -75,7 +78,7 @@ export class OpenCodeBridge {
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.listen(this.port, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
@@ -94,6 +97,8 @@ export class OpenCodeBridge {
}
bindSession(input: BindOpenCodeSessionInput): () => void {
if (this.sessions.has(input.sessionId))
throw new Error(`OpenCode session ${input.sessionId} is already bound to a Paseo agent`);
const binding: OpenCodeSessionBinding = {
env: { ...input.env },
...(input.tools ? { tools: input.tools } : {}),
@@ -127,12 +132,13 @@ export class OpenCodeBridge {
};
}
async decorateV2ServerEnv(env: Record<string, string>): Promise<Record<string, string>> {
async materializeV2Plugin(): Promise<string> {
this.v2PluginUrl ??= await this.materializePlugin(2);
return decorateOpenCodeV2Env(env, this.v2PluginUrl, {
baseUrl: this.requireBaseUrl(),
token: this.token,
});
return this.v2PluginUrl;
}
sharedV2Connection(baseUrl = this.requireBaseUrl()): OpenCodePluginOptions {
return { baseUrl, token: this.token };
}
async close(): Promise<void> {
@@ -282,6 +288,8 @@ export async function materializeOpenCodeV2Plugin(paseoHome: string): Promise<st
// V2 ignores configured file paths; it loads a package directory's server entry point.
const directory = path.join(paseoHome, "runtime", "opencode", `paseo-v2-${digest}`);
await writeFileAtomic(path.join(directory, "server.js"), artifact);
const state = await readFile(new URL("./v2/state.mjs", import.meta.url));
await writeFileAtomic(path.join(directory, "state.mjs"), state);
await writeFileAtomic(
path.join(directory, "package.json"),
JSON.stringify({
@@ -66,6 +66,7 @@ test("retries a rejected version probe after the configured binary changes", asy
const script = join(root, "version.cjs");
const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, script] },
env: { XDG_STATE_HOME: root },
});
try {
await writeFile(script, 'console.log("opencode v2.0.3")');
@@ -95,6 +96,7 @@ test.each([
const script = join(root, "version.cjs");
const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, script] },
env: { XDG_STATE_HOME: root },
});
try {
await writeFile(script, initialSource);
@@ -193,6 +195,7 @@ test.each([
logger,
{
command: { mode: "replace", argv: [process.execPath, script] },
env: { XDG_STATE_HOME: root },
},
{ serverManager: new TrackedServerManager({ logger }) },
);
@@ -209,3 +212,38 @@ test.each([
},
10000,
);
test("selects v2 without probing the binary when an external server is configured", async () => {
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, "-e", "process.exit(1)"] },
env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" },
});
try {
const features = await client.listFeatures({ provider: "opencode", cwd: "/tmp/project" });
expect(features.map((feature) => feature.label)).toEqual(["Auto-accept"]);
} finally {
await client.shutdown();
}
});
test("keeps native Paseo tools available when an external server is configured", async () => {
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const { OpenCodeBridge } = await import("./bridge.js");
const bridge = new OpenCodeBridge({ paseoHome: "/tmp", logger: createTestLogger() });
const external = new OpenCodeRuntimeClient(
createTestLogger(),
{ env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" } },
{ bridge },
);
const local = new OpenCodeRuntimeClient(createTestLogger(), undefined, { bridge });
try {
expect(external.capabilities.supportsNativePaseoTools).toBe(true);
expect(local.capabilities.supportsNativePaseoTools).toBe(true);
} finally {
await external.shutdown();
await local.shutdown();
}
});
@@ -19,7 +19,9 @@ import {
import { execCommand } from "../../../../utils/spawn.js";
import { OpenCodeAgentClient } from "../opencode-agent.js";
import type { OpenCodeV2AgentClient } from "./v2/agent.js";
import { resolveExternalOpenCodeServer } from "./v2/external-server.js";
import { withOpenCodeRuntimeNotice } from "./runtime-notice.js";
import { selectOpenCodeService } from "./v2/service-selection.js";
// Keep the minimum aligned with the SDK and binary exercised by CI.
const MINIMUM_V2: readonly [number, number] = [0, 10];
@@ -67,8 +69,25 @@ export class OpenCodeRuntimeClient implements AgentClient {
this.resolveCreateConfig = this.legacy.resolveCreateConfig;
this.isCreateConfigUnattended = this.legacy.isCreateConfigUnattended;
}
private selectService() {
return selectOpenCodeService({
explicit: resolveExternalOpenCodeServer({ ...globalThis.process.env, ...this.settings?.env }),
env: this.settings?.env,
});
}
private client(): Promise<OpenCodeAgentClient | OpenCodeV2AgentClient> {
this.selected ??= (async () => {
const selection = await this.selectService();
if (selection.kind !== "owned") {
const { OpenCodeV2AgentClient } = await import("./v2/agent.js");
return new OpenCodeV2AgentClient({
logger: this.logger,
settings: this.settings,
managedProcesses: this.options.managedProcesses,
bridge: this.options.bridge,
selection,
});
}
let output: string;
try {
const launch = await resolveProviderLaunch({
@@ -97,6 +116,7 @@ export class OpenCodeRuntimeClient implements AgentClient {
settings: this.settings,
managedProcesses: this.options.managedProcesses,
bridge: this.options.bridge,
selection,
});
})().catch((error: unknown) => {
// A failed probe must not poison refresh after the user updates the binary.
@@ -106,9 +126,14 @@ export class OpenCodeRuntimeClient implements AgentClient {
return this.selected;
}
async isAvailable() {
const selection = await this.selectService();
if (selection.kind !== "owned") return true;
return this.legacy.isAvailable();
}
async getDiagnostic() {
const selection = await this.selectService();
if (selection.kind !== "owned")
return { diagnostic: `OpenCode server: ${selection.server.url}` };
return this.legacy.getDiagnostic();
}
async createSession(
@@ -91,6 +91,7 @@ export class V2Harness {
readonly api: V2Api = {
server: { info: unexpected },
shell: {
remove: unexpected,
list: async () => ({ location: { directory: this.info.location.directory }, data: [] }),
},
plugin: {
@@ -172,6 +173,9 @@ export class V2Harness {
event: { subscribe: (options) => this.events(options?.signal) },
};
readonly connection: V2Connection = {
bindSession: async () => {},
unbindSession: async () => {},
refreshLocation: async () => {},
client: this.api,
release: async () => {
this.releases += 1;
@@ -153,21 +153,6 @@ const OpencodeGrepOutputSchema = z
])
.nullable();
function formatLogEntry(value: unknown): string | undefined {
const outputText = readOutputText(value);
if (outputText) {
return outputText;
}
if (value === null || value === undefined) {
return undefined;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function extractOpenCodeTaskSessionId(value: unknown): string | undefined {
const text = readOutputText(value);
if (text) {
@@ -195,7 +180,6 @@ function extractOpenCodeTaskSessionId(value: unknown): string | undefined {
function deriveOpencodeTaskDetail(
input: unknown,
output: unknown,
error: unknown,
metadata: Record<string, unknown> | undefined,
): ToolCallDetail | null {
if (!isRecord(input)) {
@@ -208,7 +192,6 @@ function deriveOpencodeTaskDetail(
return null;
}
const log = [formatLogEntry(output), formatLogEntry(error)].filter((entry) => entry).join("\n");
const childSessionId =
normalizeSubAgentText(metadata?.sessionId) ?? extractOpenCodeTaskSessionId(output);
return {
@@ -216,7 +199,8 @@ function deriveOpencodeTaskDetail(
...(subAgentType ? { subAgentType } : {}),
...(description ? { description } : {}),
...(childSessionId ? { childSessionId } : {}),
log,
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
};
}
@@ -373,11 +357,10 @@ export function deriveOpencodeToolDetail(
toolName: string,
input: unknown,
output: unknown,
error: unknown = null,
metadata?: Record<string, unknown>,
): ToolCallDetail {
if (toolName.trim().toLowerCase() === "task") {
const taskDetail = deriveOpencodeTaskDetail(input, output, error, metadata);
const taskDetail = deriveOpencodeTaskDetail(input, output, metadata);
if (taskDetail) {
return taskDetail;
}
@@ -460,7 +460,7 @@ describe("opencode tool-call mapper", () => {
});
});
it("maps completed task calls with final output log to sub_agent detail", () => {
it("maps completed task calls to sub_agent detail", () => {
const item = expectMapped(
mapOpencodeToolCall({
toolName: "task",
@@ -480,7 +480,7 @@ describe("opencode tool-call mapper", () => {
type: "sub_agent",
subAgentType: "explore",
description: "Explore agent-tools codebase",
log: "Found the CLI entrypoint and provider registry.",
log: "",
actions: [],
});
});
@@ -505,12 +505,12 @@ describe("opencode tool-call mapper", () => {
subAgentType: "explore",
description: "Explore current directory",
childSessionId: "ses_2268db431ffe299vL1bbot8R7Z",
log: '<task id="ses_2268db431ffe299vL1bbot8R7Z">done</task>',
log: "",
actions: [],
});
});
it("maps aborted task calls with preserved error log to sub_agent detail", () => {
it("maps aborted task calls to sub_agent detail", () => {
const item = expectMapped(
mapOpencodeToolCall({
toolName: "task",
@@ -531,7 +531,7 @@ describe("opencode tool-call mapper", () => {
type: "sub_agent",
subAgentType: "explore",
description: "Explore agent-tools codebase",
log: "Tool execution aborted",
log: "",
actions: [],
});
});

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