mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
Merge remote-tracking branch 'origin/main' into diagnose/3589-contributor-6b87ee9f
This commit is contained in:
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
+131
-12
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
+8
-2
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
+23
-25
@@ -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(
|
||||
|
||||
+120
-1
@@ -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);
|
||||
|
||||
+6
-7
@@ -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
+99
-44
@@ -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: [],
|
||||
},
|
||||
|
||||
+156
-22
@@ -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
Reference in New Issue
Block a user