mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 17:34:51 +00:00
Merge main into fix/usage-screen-provider-logins
This commit is contained in:
@@ -1,17 +1,10 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { existsSync } from "node:fs";
|
||||
import { delimiter, resolve } from "node:path";
|
||||
import type { ProcessEnvRecord } from "../server/paseo-env.js";
|
||||
import { execCommand } from "../utils/spawn.js";
|
||||
import which from "which";
|
||||
import { windowsExecutableResolution } from "./windows.js";
|
||||
|
||||
type Which = (
|
||||
command: string,
|
||||
options: { all: true; path?: string; pathExt?: string },
|
||||
) => Promise<string[]>;
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const which = require("which") as Which;
|
||||
const PROBE_TIMEOUT_MS = 2000;
|
||||
|
||||
function hasPathSeparator(value: string): boolean {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// `which` ships no types. Imported statically (not through createRequire) so the terminal
|
||||
// worker bundle includes it: the worker runs from a copy that has only node-pty beside it.
|
||||
// Listed in the tsconfig includes, since nothing imports a declaration file.
|
||||
declare module "which" {
|
||||
function which(
|
||||
command: string,
|
||||
options: { all: true; path?: string; pathExt?: string },
|
||||
): Promise<string[]>;
|
||||
export default which;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { buildAgentForkContextAttachment, curateAgentActivity } from "./activity-curator.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
|
||||
import { formatAgentMessage, projectAgentMessage } from "./agent-messages/index.js";
|
||||
import { formatAgentMessage, projectAgentMessages } from "./agent-messages/index.js";
|
||||
|
||||
function toolCallItem(params: {
|
||||
callId: string;
|
||||
@@ -81,10 +81,10 @@ describe("curateAgentActivity", () => {
|
||||
|
||||
it("renders delivered agent messages with their event, sender, and body", () => {
|
||||
const delivered = (source: Parameters<typeof formatAgentMessage>[0]["source"], text: string) =>
|
||||
projectAgentMessage({
|
||||
projectAgentMessages({
|
||||
type: "user_message",
|
||||
text: formatAgentMessage({ id: `msg-${text.length}`, source, text }),
|
||||
})!;
|
||||
})[0]!;
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
delivered(
|
||||
{ kind: "agent-message", agentId: "agt_lead", title: "CI lead" },
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
|
||||
import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization";
|
||||
import { buildAgentMessageDisplay } from "@getpaseo/protocol/paseo-tool-presentation/index";
|
||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import { projectTimelineRows, selectItemsByProjectedLimit } from "./timeline-projection.js";
|
||||
|
||||
const DEFAULT_MAX_ITEMS = 0;
|
||||
const MAX_TOOL_INPUT_CHARS = 400;
|
||||
@@ -210,6 +210,24 @@ export function curateAgentActivity(
|
||||
: "No activity to display.";
|
||||
}
|
||||
|
||||
export function summarizeAgentActivity(timeline: AgentTimelineItem[], limit?: number) {
|
||||
const selection = selectItemsByProjectedLimit({
|
||||
items: timeline,
|
||||
direction: "tail",
|
||||
limit: limit ?? 0,
|
||||
});
|
||||
const { totalProjected, shownProjected } = selection;
|
||||
const noun = totalProjected === 1 ? "activity" : "activities";
|
||||
const countHeader =
|
||||
limit && shownProjected < totalProjected
|
||||
? `Showing ${shownProjected} of ${totalProjected} ${noun} (limited to ${limit})`
|
||||
: `Showing all ${totalProjected} ${noun}`;
|
||||
return {
|
||||
updateCount: timeline.length,
|
||||
content: `${countHeader}\n\n${curateAgentActivity(selection.items)}`,
|
||||
};
|
||||
}
|
||||
|
||||
interface ForkCursorBoundary {
|
||||
timelineEpoch: string;
|
||||
cursor: { epoch: string; seq: number };
|
||||
|
||||
@@ -135,6 +135,7 @@ export async function ensureAgentLoaded(
|
||||
labels: record.labels,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
env: record.launchEnv,
|
||||
});
|
||||
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
|
||||
import {
|
||||
prepareAgentMessage,
|
||||
formatAgentMessage,
|
||||
formatSystemNotificationPrompt,
|
||||
} from "./agent-messages/index.js";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { expect, test, vi, onTestFinished } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -879,6 +880,66 @@ test("retries provider history hydration after a stream failure", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("notification batches share the run they start or join", async () => {
|
||||
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-notification-turns-"));
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new (class extends TestAgentClient {
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return session;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const batch = (prefix: string) =>
|
||||
["one", "two"]
|
||||
.map((id) =>
|
||||
formatAgentMessage({
|
||||
id: `${prefix}-${id}`,
|
||||
source: { kind: "agent-notification", agentId: "worker", event: "finished" },
|
||||
text: "Worker finished",
|
||||
}),
|
||||
)
|
||||
.join("\n\n");
|
||||
const consume = async (prompt: string, clientMessageId: string) => {
|
||||
for await (const _event of manager.streamAgent(agent.id, prompt, { clientMessageId })) {
|
||||
}
|
||||
};
|
||||
try {
|
||||
const first = consume("human", "human");
|
||||
await manager.waitForAgentRunStart(agent.id);
|
||||
await expect(
|
||||
manager.steerAgentRun(agent.id, batch("joined"), { clientMessageId: "joined-one" }),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-1" });
|
||||
await first;
|
||||
const second = consume(batch("idle"), "idle-one");
|
||||
await manager.waitForAgentRunStart(agent.id);
|
||||
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-2" });
|
||||
await second;
|
||||
expect(
|
||||
manager.fetchTimeline(agent.id, { limit: 0 }).rows.map((row) => ({
|
||||
turnId: row.turnId,
|
||||
id: row.item.type === "tool_call" ? row.item.callId : row.item.type,
|
||||
})),
|
||||
).toEqual([
|
||||
{ turnId: "active-turn-1", id: "user_message" },
|
||||
{ turnId: "active-turn-1", id: "paseo-agent-message:joined-one" },
|
||||
{ turnId: "active-turn-1", id: "paseo-agent-message:joined-two" },
|
||||
{ turnId: "active-turn-2", id: "paseo-agent-message:idle-one" },
|
||||
{ turnId: "active-turn-2", id: "paseo-agent-message:idle-two" },
|
||||
]);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("unavailable steer interrupts once and starts one replacement turn", async () => {
|
||||
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
|
||||
session.steerResult = "unavailable";
|
||||
@@ -895,6 +956,131 @@ test("unavailable steer interrupts once and starts one replacement turn", async
|
||||
}
|
||||
});
|
||||
|
||||
test("steers a send that arrives while the previous turn is still starting", async () => {
|
||||
const startHeld = deferred<void>();
|
||||
class SlowStartSteeringSession extends SteeringTestSession {
|
||||
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
await startHeld.promise;
|
||||
return await super.startTurn(prompt);
|
||||
}
|
||||
}
|
||||
const session = new SlowStartSteeringSession({
|
||||
provider: "codex",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-starting-"));
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new (class extends TestAgentClient {
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return session;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
agentId = agent.id;
|
||||
const steerOptions = (clientMessageId: string) => ({
|
||||
replaceRunning: true,
|
||||
activeTurnBehavior: "steer" as const,
|
||||
runOptions: { clientMessageId },
|
||||
});
|
||||
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
|
||||
const second = startAgentRun(
|
||||
manager,
|
||||
agent.id,
|
||||
"SECOND",
|
||||
logger,
|
||||
steerOptions("second-client"),
|
||||
);
|
||||
|
||||
startHeld.resolve();
|
||||
await expect(second).resolves.toEqual({ disposition: "steered" });
|
||||
|
||||
expect(session.startPrompts).toEqual(["FIRST"]);
|
||||
expect(session.interruptCount).toBe(0);
|
||||
expect(
|
||||
manager
|
||||
.fetchTimeline(agent.id, { limit: 0 })
|
||||
.rows.filter((row) => row.item.type === "user_message")
|
||||
.map((row) => ({
|
||||
text: row.item.type === "user_message" ? row.item.text : null,
|
||||
turnId: row.turnId,
|
||||
})),
|
||||
).toEqual([
|
||||
{ text: "FIRST", turnId: "active-turn-1" },
|
||||
{ text: "SECOND", turnId: "active-turn-1" },
|
||||
]);
|
||||
} finally {
|
||||
startHeld.resolve();
|
||||
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("starts its own turn when the previous turn fails to start under a steer send", async () => {
|
||||
const startHeld = deferred<void>();
|
||||
class FailingFirstStartSession extends SteeringTestSession {
|
||||
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
if (prompt === "FIRST") {
|
||||
this.startPrompts.push(prompt);
|
||||
await startHeld.promise;
|
||||
throw new Error("first start failed");
|
||||
}
|
||||
return await super.startTurn(prompt);
|
||||
}
|
||||
}
|
||||
const session = new FailingFirstStartSession({
|
||||
provider: "codex",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-failed-start-"));
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new (class extends TestAgentClient {
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return session;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
agentId = agent.id;
|
||||
const steerOptions = (clientMessageId: string) => ({
|
||||
replaceRunning: true,
|
||||
activeTurnBehavior: "steer" as const,
|
||||
runOptions: { clientMessageId },
|
||||
});
|
||||
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
|
||||
const second = startAgentRun(
|
||||
manager,
|
||||
agent.id,
|
||||
"SECOND",
|
||||
logger,
|
||||
steerOptions("second-client"),
|
||||
);
|
||||
|
||||
startHeld.resolve();
|
||||
await expect(second).resolves.toEqual({ disposition: "turn_started" });
|
||||
await vi.waitFor(() => expect(session.startPrompts).toEqual(["FIRST", "SECOND"]));
|
||||
expect(session.interruptCount).toBe(0);
|
||||
} finally {
|
||||
startHeld.resolve();
|
||||
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("orders an accepted steer before output emitted while acknowledgement is pending", async () => {
|
||||
const entered = deferred<void>();
|
||||
const release = deferred<void>();
|
||||
@@ -2868,6 +3054,92 @@ test("createAgent passes daemon launch env through the provider launch context",
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ provider: "codex", hasHandle: true },
|
||||
{ provider: "opencode", hasHandle: true },
|
||||
{ provider: "codex", hasHandle: false },
|
||||
{ provider: "opencode", hasHandle: false },
|
||||
])(
|
||||
"$provider preserves explicit launch env across reloads and storage-backed resumes (handle: $hasHandle)",
|
||||
async ({ provider, hasHandle }) => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-launch-env-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
class LaunchEnvClient extends TestAgentClient {
|
||||
lastLaunchContext: AgentLaunchContext | undefined;
|
||||
|
||||
override async createSession(
|
||||
config: AgentSessionConfig,
|
||||
context?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.lastLaunchContext = context;
|
||||
const session = await super.createSession(config);
|
||||
session.describePersistence = () => ({ provider, sessionId: session.id });
|
||||
return session;
|
||||
}
|
||||
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
context?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.lastLaunchContext = context;
|
||||
const session = await super.resumeSession(handle, config, context);
|
||||
session.describePersistence = () => ({ provider, sessionId: handle.sessionId });
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
const client = new LaunchEnvClient(provider);
|
||||
let storage = new AgentStorage(storagePath, logger);
|
||||
let manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
|
||||
const env = { XDG_DATA_HOME: join(workdir, "agent-data"), CUSTOM_EMPTY: "" };
|
||||
const expectedEnv = { ...env };
|
||||
const agent = await manager.createAgent({ provider, cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
env,
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
env.XDG_DATA_HOME = "caller-mutated";
|
||||
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
|
||||
if (!hasHandle) {
|
||||
const record = await storage.get(agent.id);
|
||||
if (!record) throw new Error("Created agent was not persisted");
|
||||
await storage.upsert({ ...record, persistence: null });
|
||||
}
|
||||
|
||||
// Fresh storage and manager instances must read the environment from disk.
|
||||
for (let restart = 0; restart < 2; restart += 1) {
|
||||
storage = new AgentStorage(storagePath, logger);
|
||||
manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
|
||||
const resumed = await ensureAgentLoaded(agent.id, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
expect(client.lastLaunchContext).toEqual({
|
||||
agentId: agent.id,
|
||||
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
|
||||
});
|
||||
await manager.reloadAgentSession(agent.id);
|
||||
expect(client.lastLaunchContext).toEqual({
|
||||
agentId: agent.id,
|
||||
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
|
||||
});
|
||||
expect(toAgentPayload(resumed)).not.toHaveProperty("launchEnv");
|
||||
expect(toAgentPayload(resumed).persistence?.metadata).not.toHaveProperty("launchEnv");
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("createAgent passes persistSession to provider create options", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
@@ -7746,7 +8018,7 @@ test("workspace ownership does not turn an ordinary agent into a disposable help
|
||||
expect(manager.listAgents().map((agent) => agent.id)).toEqual([agentId]);
|
||||
expect(await storage.get(agentId)).not.toBeNull();
|
||||
// Workspace ownership preserves ordinary agent lifecycle hooks.
|
||||
expect(beforeHookCalls).toContain("agent.create");
|
||||
expect(beforeHookCalls).toContain("agent.create.before");
|
||||
});
|
||||
|
||||
test("global subscribers receive internal agent events only when they opt in", async () => {
|
||||
@@ -7787,6 +8059,60 @@ test("global subscribers receive internal agent events only when they opt in", a
|
||||
unsubscribeOptedIn();
|
||||
});
|
||||
|
||||
test("agent removal notifications respect scoped subscriptions and internal visibility", () => {
|
||||
const manager = new AgentManager({ clients: {}, logger });
|
||||
const removedId = "00000000-0000-4000-8000-000000000901";
|
||||
const internalId = "00000000-0000-4000-8000-000000000902";
|
||||
const otherId = "00000000-0000-4000-8000-000000000903";
|
||||
const publicEvents: AgentManagerEvent[] = [];
|
||||
const allEvents: AgentManagerEvent[] = [];
|
||||
const scopedEvents: AgentManagerEvent[] = [];
|
||||
const internalEvents: AgentManagerEvent[] = [];
|
||||
const unrelatedEvents: AgentManagerEvent[] = [];
|
||||
const releases = [
|
||||
manager.subscribe((event) => publicEvents.push(event), { replayState: false }),
|
||||
manager.subscribe((event) => allEvents.push(event), {
|
||||
replayState: false,
|
||||
includeInternal: true,
|
||||
}),
|
||||
manager.subscribe((event) => scopedEvents.push(event), {
|
||||
replayState: false,
|
||||
agentId: removedId,
|
||||
}),
|
||||
manager.subscribe((event) => internalEvents.push(event), {
|
||||
replayState: false,
|
||||
agentId: internalId,
|
||||
}),
|
||||
manager.subscribe((event) => unrelatedEvents.push(event), {
|
||||
replayState: false,
|
||||
agentId: otherId,
|
||||
}),
|
||||
];
|
||||
try {
|
||||
manager.notifyAgentRemoved({ id: removedId, workspaceId: "workspace" });
|
||||
manager.notifyAgentRemoved({ id: internalId, workspaceId: "workspace", internal: true });
|
||||
const publicRemoval = {
|
||||
type: "agent_removed",
|
||||
agentId: removedId,
|
||||
workspaceId: "workspace",
|
||||
internal: false,
|
||||
};
|
||||
const internalRemoval = {
|
||||
type: "agent_removed",
|
||||
agentId: internalId,
|
||||
workspaceId: "workspace",
|
||||
internal: true,
|
||||
};
|
||||
expect(publicEvents).toEqual([publicRemoval]);
|
||||
expect(allEvents).toEqual([publicRemoval, internalRemoval]);
|
||||
expect(scopedEvents).toEqual([publicRemoval]);
|
||||
expect(internalEvents).toEqual([internalRemoval]);
|
||||
expect(unrelatedEvents).toEqual([]);
|
||||
} finally {
|
||||
for (const release of releases) release();
|
||||
}
|
||||
});
|
||||
|
||||
test("getAgent returns internal agents by ID", async () => {
|
||||
const internalAgentId = "00000000-0000-4000-8000-000000000107";
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
@@ -10007,6 +10333,47 @@ test("closeAgent persists one final closed snapshot", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("idle runtime cleanup honors a provider veto and resumes the saved conversation", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-cleanup-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
let safe = false;
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const session = await super.createSession(config);
|
||||
session.canStopRuntime = async () => safe;
|
||||
return session;
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
const collect = runtimeCleanup(manager);
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const now = new Date(Date.now() + 61 * 60_000);
|
||||
expect(await collect({ now })).toEqual({ collected: [], failures: [] });
|
||||
safe = true;
|
||||
expect(await collect({ now })).toEqual({ collected: [], failures: [] });
|
||||
const later = new Date(now.getTime() + 61 * 60_000);
|
||||
expect((await collect({ now: later })).collected).toEqual([
|
||||
{ agentId: agent.id, provider: "codex", sessionId: agent.persistence?.sessionId },
|
||||
]);
|
||||
expect(manager.getAgent(agent.id)).toBeNull();
|
||||
const resumed = await ensureAgentLoaded(agent.id, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
expect(resumed.id).toBe(agent.id);
|
||||
expect(resumed.persistence).toEqual(agent.persistence);
|
||||
await manager.runAgent(agent.id, "Continue the saved conversation");
|
||||
} finally {
|
||||
await manager.closeAgent(manager.listAgents()[0]?.id ?? "missing").catch(() => undefined);
|
||||
await storage.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("idle agents remain resident until an explicit lifecycle action closes them", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-residency-"));
|
||||
let closeCount = 0;
|
||||
@@ -10951,12 +11318,12 @@ test("tryRunOutOfBand gives providers the command another agent sent, not its en
|
||||
|
||||
try {
|
||||
const fromAgent = prepareAgentMessage("/handled", { kind: "agent-message", agentId: "sender" });
|
||||
expect(manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true);
|
||||
expect(await manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true);
|
||||
expect(seenPrompts).toEqual(["/handled"]);
|
||||
|
||||
// A human who pastes an envelope gets it quoted, and it stays a plain prompt.
|
||||
const pasted = prepareAgentMessage(String(fromAgent.prompt));
|
||||
expect(manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false);
|
||||
expect(await manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false);
|
||||
expect(seenPrompts.at(-1)).toBe(pasted.prompt);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
@@ -11299,7 +11666,7 @@ test("listImportableSessions searches every provider result before global rankin
|
||||
]);
|
||||
});
|
||||
|
||||
test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => {
|
||||
test("system-injected prompts appear as trigger tools in the live timeline", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
@@ -11328,13 +11695,16 @@ test("user_message events wrapping a paseo-system envelope are not added to the
|
||||
await manager.runAgent(snapshot.id, { text: "do something" });
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
expect(timeline).toContainEqual(
|
||||
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
|
||||
);
|
||||
const userMessages = timeline.filter((item) => item.type === "user_message");
|
||||
|
||||
expect(userMessages).toHaveLength(1);
|
||||
expect(userMessages[0].text).toBe("plain user message");
|
||||
});
|
||||
|
||||
test("user_message events wrapping a paseo-system envelope are not restored during history replay", async () => {
|
||||
test("system-injected prompts appear as trigger tools during history replay", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-history-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
@@ -11369,6 +11739,9 @@ test("user_message events wrapping a paseo-system envelope are not restored duri
|
||||
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true });
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
expect(timeline).toContainEqual(
|
||||
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
|
||||
);
|
||||
const userMessages = timeline.filter((item) => item.type === "user_message");
|
||||
|
||||
expect(userMessages).toHaveLength(1);
|
||||
@@ -11892,3 +12265,367 @@ test.each([false, true])(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{
|
||||
event: {
|
||||
type: "turn_completed",
|
||||
provider: "codex",
|
||||
turnId: "hook-turn",
|
||||
} satisfies AgentStreamEvent,
|
||||
outcome: { kind: "completed" },
|
||||
},
|
||||
{
|
||||
event: {
|
||||
type: "turn_failed",
|
||||
provider: "codex",
|
||||
turnId: "hook-turn",
|
||||
error: "Failed",
|
||||
code: "test",
|
||||
} satisfies AgentStreamEvent,
|
||||
outcome: { kind: "failed", error: { message: "Failed", code: "test" } },
|
||||
},
|
||||
{
|
||||
event: {
|
||||
type: "turn_canceled",
|
||||
provider: "codex",
|
||||
turnId: "hook-turn",
|
||||
reason: "Stopped",
|
||||
} satisfies AgentStreamEvent,
|
||||
outcome: { kind: "canceled", reason: "Stopped" },
|
||||
},
|
||||
])(
|
||||
"lifecycle hooks receive no conversation for $event.type and stream dispatch never reads timeline items",
|
||||
async ({ event: terminalEvent, outcome }) => {
|
||||
class CountingTimelineStore extends InMemoryAgentTimelineStore {
|
||||
itemReads = 0;
|
||||
override getItems(agentId: string): AgentTimelineItem[] {
|
||||
this.itemReads += 1;
|
||||
return super.getItems(agentId);
|
||||
}
|
||||
}
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-hook-payload-"));
|
||||
const client = new SessionRecordingAgentClient();
|
||||
const timelineStore = new CountingTimelineStore();
|
||||
const events: Array<{ name: string; event: unknown }> = [];
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
||||
logger,
|
||||
timelineStore,
|
||||
pluginLifecycle: {
|
||||
before: async (_name, request) => request,
|
||||
emit: (name, event) => {
|
||||
events.push({ name, event });
|
||||
},
|
||||
},
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await manager.closeAgent(manager.listAgents()[0]!.id);
|
||||
await manager.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
await manager.appendTimelineItem(agent.id, {
|
||||
type: "assistant_message",
|
||||
text: "A long conversation".repeat(10_000),
|
||||
});
|
||||
expect(manager.getTimeline(agent.id)).toHaveLength(1);
|
||||
const readsBeforeDispatch = timelineStore.itemReads;
|
||||
const session = client.sessions[0]!;
|
||||
session.pushEvent({ type: "turn_started", provider: "codex", turnId: "hook-turn" });
|
||||
await expect
|
||||
.poll(() => events.filter(({ name }) => name === "agent.turn.before").length)
|
||||
.toBe(1);
|
||||
expect(timelineStore.itemReads).toBe(readsBeforeDispatch);
|
||||
session.pushEvent(terminalEvent);
|
||||
await expect
|
||||
.poll(() => events.filter(({ name }) => name === "agent.turn.after"))
|
||||
.toEqual([
|
||||
{
|
||||
name: "agent.turn.after",
|
||||
event: {
|
||||
agent: {
|
||||
id: agent.id,
|
||||
workspaceId: null,
|
||||
parentAgentId: null,
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: null,
|
||||
},
|
||||
turnId: "hook-turn",
|
||||
outcome,
|
||||
timeline: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(timelineStore.itemReads).toBe(readsBeforeDispatch);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["live", "history", "accepted"] as const)(
|
||||
"batched agent entries retain their identities through %s ingestion",
|
||||
async (ingestion) => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-batch-provenance-"));
|
||||
const entries = [
|
||||
{
|
||||
id: "report",
|
||||
source: { kind: "agent-message" as const, agentId: "worker", serverId: "remote-host" },
|
||||
text: "Result <ready>",
|
||||
},
|
||||
{
|
||||
id: "finish",
|
||||
source: {
|
||||
kind: "agent-notification" as const,
|
||||
agentId: "worker",
|
||||
serverId: "remote-host",
|
||||
event: "finished" as const,
|
||||
},
|
||||
text: "Worker finished",
|
||||
},
|
||||
];
|
||||
const prompt = entries.map(formatAgentMessage).join("\n\n");
|
||||
const item: AgentTimelineItem = {
|
||||
type: "user_message",
|
||||
text: prompt,
|
||||
clientMessageId: "report",
|
||||
messageId: "provider-batch",
|
||||
};
|
||||
const codex = fakeCodexEmitting(
|
||||
ingestion === "history" ? { historyItems: [item] } : { turnItems: [item] },
|
||||
);
|
||||
const manager = new AgentManager({ clients: { codex }, logger });
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
try {
|
||||
if (ingestion === "history")
|
||||
await manager.hydrateTimelineFromProvider(agent.id, { force: true });
|
||||
else if (ingestion === "accepted") {
|
||||
for await (const _event of manager.streamAgent(agent.id, prompt, {
|
||||
clientMessageId: "report",
|
||||
})) {
|
||||
}
|
||||
} else await manager.runAgent(agent.id, { text: "start" });
|
||||
expect(
|
||||
manager
|
||||
.getTimeline(agent.id)
|
||||
.filter((row) => row.type === "tool_call")
|
||||
.map((row) => ({ callId: row.callId, agentMessage: row.agentMessage })),
|
||||
).toEqual([
|
||||
{
|
||||
callId: "paseo-agent-message:report",
|
||||
agentMessage: {
|
||||
event: "message",
|
||||
sender: { id: "worker", serverId: "remote-host" },
|
||||
text: "Result <ready>",
|
||||
},
|
||||
},
|
||||
{
|
||||
callId: "paseo-agent-message:finish",
|
||||
agentMessage: {
|
||||
event: "finished",
|
||||
sender: { id: "worker", serverId: "remote-host" },
|
||||
text: "Worker finished",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(manager.getTimeline(agent.id).filter((row) => row.type === "user_message")).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(manager.getAgent(agent.id)?.lastUserMessageAt).toBeNull();
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("reads the last durable assistant response without loading a stored-only child", async () => {
|
||||
const store = new RecordingTimelineStore();
|
||||
await store.appendCommitted("stored-child", {
|
||||
type: "assistant_message",
|
||||
text: "Partial work saved before restart",
|
||||
});
|
||||
const manager = new AgentManager({ clients: {}, durableTimelineStore: store, logger });
|
||||
expect(manager.getAgent("stored-child")).toBeNull();
|
||||
expect(await manager.getLastAssistantMessage("stored-child")).toBe(
|
||||
"Partial work saved before restart",
|
||||
);
|
||||
expect(manager.getAgent("stored-child")).toBeNull();
|
||||
});
|
||||
|
||||
function runtimeCleanup(manager: AgentManager) {
|
||||
let now = Date.now();
|
||||
let protectedCaller = (_agentId: string) => false;
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled: true,
|
||||
runtimes: () => manager.getRuntimeCleanupCandidates(),
|
||||
logger,
|
||||
now: () => now,
|
||||
isProtected: (agentId) => protectedCaller(agentId),
|
||||
schedule: () => () => {},
|
||||
});
|
||||
onTestFinished(() => cleanup.close());
|
||||
return (options: { now: Date; isProtected?: (agentId: string) => boolean }) => {
|
||||
now = options.now.getTime();
|
||||
protectedCaller = options.isProtected ?? (() => false);
|
||||
return cleanup.sweep();
|
||||
};
|
||||
}
|
||||
|
||||
function idleCleanupHarness() {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-protection-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const commands: AgentPromptInput[] = [];
|
||||
const prompts: AgentPromptInput[] = [];
|
||||
const recordPrompts = (session: AgentSession) => {
|
||||
session.tryHandleOutOfBand = (prompt) =>
|
||||
prompt === "/maintenance"
|
||||
? {
|
||||
run: async () => {
|
||||
commands.push(prompt);
|
||||
},
|
||||
}
|
||||
: null;
|
||||
const start = session.startTurn.bind(session);
|
||||
session.startTurn = (prompt, options) => {
|
||||
prompts.push(prompt);
|
||||
return start(prompt, options);
|
||||
};
|
||||
return session;
|
||||
};
|
||||
const sessions: AgentSession[] = [];
|
||||
const probes: Array<(() => Promise<boolean>) | undefined> = [];
|
||||
const closeEntered = deferred<void>();
|
||||
let closeGate: Promise<void> | undefined;
|
||||
let closes = 0;
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
const session = await super.resumeSession(handle, config);
|
||||
session.describePersistence = () => handle;
|
||||
return recordPrompts(session);
|
||||
}
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const index = sessions.length;
|
||||
const session: AgentSession = await super.createSession(config);
|
||||
session.canStopRuntime = async () => probes[index]?.() ?? false;
|
||||
session.close = async () => {
|
||||
closes++;
|
||||
closeEntered.resolve();
|
||||
await closeGate;
|
||||
};
|
||||
sessions.push(session);
|
||||
return recordPrompts(session);
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
onTestFinished(async () => {
|
||||
closeGate = undefined;
|
||||
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
|
||||
await storage.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
return {
|
||||
commands,
|
||||
sweep: runtimeCleanup(manager),
|
||||
manager,
|
||||
storage,
|
||||
sessions,
|
||||
probes,
|
||||
prompts,
|
||||
closeEntered,
|
||||
get closes() {
|
||||
return closes;
|
||||
},
|
||||
holdClose: (gate: Promise<void>) => {
|
||||
closeGate = gate;
|
||||
},
|
||||
create: (labels?: Record<string, string>) =>
|
||||
manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
labels,
|
||||
}),
|
||||
later: (minutes: number) => new Date(Date.now() + minutes * 60_000),
|
||||
};
|
||||
}
|
||||
|
||||
test("idle cleanup rechecks new prompt activity after an asynchronous safety probe", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
const agent = await h.create();
|
||||
const entered = deferred<void>();
|
||||
const probe = deferred<boolean>();
|
||||
h.probes[0] = () => {
|
||||
entered.resolve();
|
||||
return probe.promise;
|
||||
};
|
||||
const sweep = h.sweep({ now: h.later(61) });
|
||||
await entered.promise;
|
||||
await h.manager.runAgent(agent.id, "arrived during safety query");
|
||||
probe.resolve(true);
|
||||
expect((await sweep).collected).toEqual([]);
|
||||
expect(h.closes).toBe(0);
|
||||
});
|
||||
|
||||
test("idle cleanup retains a parent whose idle managed child owns background work", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
const parent = await h.create();
|
||||
await h.create({ [PARENT_AGENT_ID_LABEL]: parent.id });
|
||||
h.probes[0] = async () => true;
|
||||
h.probes[1] = async () => false;
|
||||
expect((await h.sweep({ now: h.later(61) })).collected).toEqual([]);
|
||||
expect(h.closes).toBe(0);
|
||||
});
|
||||
|
||||
test("idle cleanup rechecks queued deliveries admitted during the safety probe", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
await h.create();
|
||||
let queued = false;
|
||||
h.probes[0] = async () => {
|
||||
queued = true;
|
||||
return true;
|
||||
};
|
||||
expect((await h.sweep({ now: h.later(61), isProtected: () => queued })).collected).toEqual([]);
|
||||
expect(h.closes).toBe(0);
|
||||
});
|
||||
|
||||
test("a prompt racing idle cleanup waits for close and resumes exactly once", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
const agent = await h.create();
|
||||
h.probes[0] = async () => true;
|
||||
const gate = deferred<void>();
|
||||
h.holdClose(gate.promise);
|
||||
const sweep = h.sweep({ now: h.later(61) });
|
||||
await h.closeEntered.promise;
|
||||
const run = h.manager.runAgent(agent.id, "arrived while closing");
|
||||
gate.resolve();
|
||||
expect((await sweep).collected).toHaveLength(1);
|
||||
const result = await run;
|
||||
expect(result.sessionId).toBe(agent.persistence?.sessionId);
|
||||
expect(h.closes).toBe(1);
|
||||
expect(h.prompts).toEqual(["arrived while closing"]);
|
||||
});
|
||||
|
||||
test("an out-of-band command racing idle cleanup waits for the resumed runtime", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
const agent = await h.create();
|
||||
h.probes[0] = async () => true;
|
||||
const gate = deferred<void>();
|
||||
h.holdClose(gate.promise);
|
||||
const sweep = h.sweep({ now: h.later(61) });
|
||||
await h.closeEntered.promise;
|
||||
const command = startAgentRun(h.manager, agent.id, "/maintenance", logger);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const deliveredWhileClosing = [...h.commands];
|
||||
gate.resolve();
|
||||
await sweep;
|
||||
expect(deliveredWhileClosing).toEqual([]);
|
||||
expect(await command).toEqual({ disposition: "out_of_band" });
|
||||
expect(h.commands).toEqual(["/maintenance"]);
|
||||
expect(h.prompts).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js";
|
||||
import type { AgentToolResult } from "@getpaseo/plugin/server";
|
||||
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
|
||||
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
|
||||
@@ -79,9 +83,13 @@ import {
|
||||
type PendingForegroundRun,
|
||||
} from "./agent-run-state.js";
|
||||
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
||||
import { parseAgentMessage, projectAgentMessage } from "./agent-messages/index.js";
|
||||
import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js";
|
||||
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
|
||||
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
||||
import {
|
||||
PASEO_MCP_SERVER_NAME,
|
||||
stripInternalPaseoMcpServer,
|
||||
withRuntimePaseoMcpServer,
|
||||
} from "./runtime-mcp-config.js";
|
||||
import { resolveCreateAgentTitles } from "./create-agent-title.js";
|
||||
import type { PaseoToolCatalogFactory } from "./tools/types.js";
|
||||
import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js";
|
||||
@@ -228,6 +236,7 @@ export type {
|
||||
|
||||
export type AgentManagerEvent =
|
||||
| { type: "agent_state"; agent: ManagedAgent }
|
||||
| { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean }
|
||||
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
|
||||
| { type: "timeline_replacement"; agentId: string; epoch: string }
|
||||
| {
|
||||
@@ -326,10 +335,12 @@ export interface AgentManagerOptions {
|
||||
registry?: AgentStorage;
|
||||
onAgentAttention?: AgentAttentionCallback;
|
||||
onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
|
||||
timelineStore?: InMemoryAgentTimelineStore;
|
||||
durableTimelineStore?: AgentTimelineStore;
|
||||
terminalManager?: TerminalManager | null;
|
||||
mcpBaseUrl?: string;
|
||||
mcpAuthToken?: string;
|
||||
pluginAgentTools?: PluginAgentToolsRuntime;
|
||||
paseoToolsEnabled?: boolean;
|
||||
paseoToolCatalogFactory?: PaseoToolCatalogFactory;
|
||||
resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined;
|
||||
@@ -411,6 +422,8 @@ interface ManagedAgentBase {
|
||||
owner?: AgentOwner;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
config: AgentSessionConfig;
|
||||
/** Explicit launch overrides, before per-opening hooks and daemon-owned variables. */
|
||||
launchEnv?: Record<string, string>;
|
||||
runtimeInfo?: AgentRuntimeInfo;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -553,18 +566,20 @@ interface AgentMetadataPatch {
|
||||
|
||||
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
||||
|
||||
function attachPersistenceCwd(
|
||||
function normalizePersistenceForConfig(
|
||||
handle: AgentPersistenceHandle | null,
|
||||
cwd: string,
|
||||
config: AgentSessionConfig,
|
||||
): AgentPersistenceHandle | null {
|
||||
if (!handle) {
|
||||
return null;
|
||||
}
|
||||
const { mcpServers: _launchMcpServers, ...metadata } = handle.metadata ?? {};
|
||||
return {
|
||||
...handle,
|
||||
metadata: {
|
||||
...handle.metadata,
|
||||
cwd,
|
||||
...metadata,
|
||||
cwd: config.cwd,
|
||||
...(config.mcpServers ? { mcpServers: config.mcpServers } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -657,13 +672,13 @@ function buildExplicitTimelineSeedForRegister(
|
||||
function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): AgentTimelineRow[] {
|
||||
const rows: AgentTimelineRow[] = [];
|
||||
for (const entry of entries) {
|
||||
const item = projectAgentMessage(entry.item);
|
||||
if (!item) continue;
|
||||
rows.push({
|
||||
seq: rows.length + 1,
|
||||
timestamp: entry.timestamp ?? new Date().toISOString(),
|
||||
item: limitAgentTimelineItemContent(item),
|
||||
});
|
||||
for (const item of projectAgentMessages(entry.item)) {
|
||||
rows.push({
|
||||
seq: rows.length + 1,
|
||||
timestamp: entry.timestamp ?? new Date().toISOString(),
|
||||
item: limitAgentTimelineItemContent(item),
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -741,7 +756,7 @@ export class AgentManager {
|
||||
private readonly providerEnabled = new Map<AgentProvider, boolean>();
|
||||
private readonly providerDefinitions = new Map<AgentProvider, ProviderEnabledFlag>();
|
||||
private readonly agents = new Map<string, LiveManagedAgent>();
|
||||
private readonly timelineStore = new InMemoryAgentTimelineStore();
|
||||
private readonly timelineStore: InMemoryAgentTimelineStore;
|
||||
private readonly providerSubagents = new ProviderSubagentStore();
|
||||
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
|
||||
private readonly sessionEventTails = new Map<string, Promise<void>>();
|
||||
@@ -757,10 +772,16 @@ export class AgentManager {
|
||||
private readonly agentRegistrationTasks = new Set<Promise<void>>();
|
||||
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
|
||||
private readonly reloadedSessionCloses = new WeakMap<AgentSession, Promise<void>>();
|
||||
private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>();
|
||||
private readonly collectingRuntimes = new Set<string>();
|
||||
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
|
||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||
private mcpBaseUrl: string | null;
|
||||
private readonly mcpAuthToken: string | null;
|
||||
private sessionOpenGeneration = 0;
|
||||
private readonly sessionOpenGenerations = new Map<string, number>();
|
||||
private readonly attachedPluginTools = new Map<string, AttachedAgentTool[]>();
|
||||
private readonly pluginAgentTools: PluginAgentToolsRuntime | undefined;
|
||||
private paseoToolsEnabled = true;
|
||||
private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null;
|
||||
private readonly paseoToolPolicies = new Map<string, ProviderPaseoToolsPolicy | undefined>();
|
||||
@@ -778,7 +799,9 @@ export class AgentManager {
|
||||
|
||||
constructor(options: AgentManagerOptions) {
|
||||
this.pluginLifecycle = options.pluginLifecycle;
|
||||
this.idFactory = options?.idFactory ?? (() => randomUUID());
|
||||
this.pluginAgentTools = options.pluginAgentTools;
|
||||
this.timelineStore = options.timelineStore ?? new InMemoryAgentTimelineStore();
|
||||
this.idFactory = options.idFactory ?? (() => randomUUID());
|
||||
this.registry = options?.registry;
|
||||
this.durableTimelineStore = options?.durableTimelineStore;
|
||||
this.onAgentAttention = options?.onAgentAttention;
|
||||
@@ -881,6 +904,62 @@ export class AgentManager {
|
||||
this.paseoToolCatalogFactory = factory;
|
||||
}
|
||||
|
||||
invalidatePluginSessions(): void {
|
||||
this.sessionOpenGeneration++;
|
||||
}
|
||||
|
||||
async reloadStalePluginSession(agentId: string): Promise<void> {
|
||||
if (!this.needsPluginSessionReload(agentId)) return;
|
||||
await this.trackAgentRegistrationOperation(
|
||||
this.runLifecycleMutation(agentId, async () => {
|
||||
if (!this.needsPluginSessionReload(agentId)) return;
|
||||
await this.reloadAgentSessionInternal(agentId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private needsPluginSessionReload(agentId: string): boolean {
|
||||
const agent = this.getAgent(agentId);
|
||||
return (
|
||||
agent !== null &&
|
||||
agent.lifecycle !== "closed" &&
|
||||
this.sessionOpenGenerations.get(agentId) !== this.sessionOpenGeneration &&
|
||||
!this.hasInFlightRun(agentId)
|
||||
);
|
||||
}
|
||||
|
||||
getPluginTools(agentId: string): readonly AttachedAgentTool[] {
|
||||
return (this.attachedPluginTools.get(agentId) ?? []).filter((tool) =>
|
||||
this.pluginAgentTools?.isToolPluginAvailable(tool.pluginId, tool.pluginInstanceId),
|
||||
);
|
||||
}
|
||||
|
||||
async executePluginTool(
|
||||
tool: AttachedAgentTool,
|
||||
agentId: string,
|
||||
input: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentToolResult> {
|
||||
const agent = this.getAgent(agentId);
|
||||
if (!agent || agent.lifecycle === "closed") throw new Error(`Agent is not live: ${agentId}`);
|
||||
if (!this.pluginAgentTools) throw new Error("Plugin tools are unavailable");
|
||||
const record = await this.registry?.get(agentId);
|
||||
return this.pluginAgentTools.callTool(
|
||||
tool.pluginId,
|
||||
tool.pluginInstanceId,
|
||||
describeHookAgent({ ...agent, title: record?.title ?? agent.config.title }),
|
||||
tool.name,
|
||||
input,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
private detachPluginTools(agentId: string): void {
|
||||
this.attachedPluginTools.delete(agentId);
|
||||
this.sessionOpenGenerations.delete(agentId);
|
||||
this.pluginAgentTools?.detachTools(agentId);
|
||||
}
|
||||
|
||||
getPaseoToolPolicy(agentId: string): ProviderPaseoToolsPolicy | undefined {
|
||||
return this.paseoToolPolicies.get(agentId);
|
||||
}
|
||||
@@ -1282,7 +1361,7 @@ export class AgentManager {
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||
if (this.pluginLifecycle && !config.internal) {
|
||||
const request = await this.pluginLifecycle.before("agent.create", {
|
||||
const request = await this.pluginLifecycle.before("agent.create.before", {
|
||||
config,
|
||||
env: options.env,
|
||||
});
|
||||
@@ -1306,21 +1385,28 @@ export class AgentManager {
|
||||
storedConfig.cwd,
|
||||
paseoToolPolicy,
|
||||
options?.env,
|
||||
{ reason: "create", purpose: "interactive", workspaceId: options.workspaceId ?? null },
|
||||
{
|
||||
reason: "create",
|
||||
purpose: "interactive",
|
||||
workspaceId: options.workspaceId ?? null,
|
||||
title: options.initialTitle ?? storedConfig.title ?? null,
|
||||
labels: options.labels ?? {},
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
const createOptions = this.buildCreateSessionOptions(options);
|
||||
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
|
||||
await this.requireExternalMcpSupport(session, storedConfig);
|
||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
labels: options.labels,
|
||||
initialTitle: options.initialTitle,
|
||||
workspaceId: options.workspaceId,
|
||||
owner: options.owner,
|
||||
launchEnv: options.env ? { ...options.env } : undefined,
|
||||
historyPrimed: true,
|
||||
});
|
||||
if (!agent.internal) {
|
||||
this.pluginLifecycle?.emit("agent.created", {
|
||||
this.pluginLifecycle?.emit("agent.create.after", {
|
||||
agent: describeHookAgent({ ...agent, title: agent.config.title }),
|
||||
});
|
||||
}
|
||||
@@ -1405,10 +1491,11 @@ export class AgentManager {
|
||||
: resumeOptions;
|
||||
const purpose = currentResumeOptions?.purpose ?? "interactive";
|
||||
|
||||
const launchEnv = record?.launchEnv;
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
mergedConfig,
|
||||
resolvedAgentId,
|
||||
{ purpose },
|
||||
{ purpose, env: launchEnv },
|
||||
);
|
||||
const client = this.requireClient(handle.provider);
|
||||
const available = await client.isAvailable();
|
||||
@@ -1423,11 +1510,13 @@ export class AgentManager {
|
||||
client,
|
||||
storedConfig.cwd,
|
||||
paseoToolPolicy,
|
||||
undefined,
|
||||
launchEnv,
|
||||
{
|
||||
reason: "resume",
|
||||
purpose,
|
||||
workspaceId: options?.workspaceId ?? null,
|
||||
title: record?.title ?? mergedConfig.title ?? null,
|
||||
labels: record?.labels ?? options?.labels ?? {},
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
@@ -1437,9 +1526,10 @@ export class AgentManager {
|
||||
launchContext,
|
||||
currentResumeOptions,
|
||||
);
|
||||
await this.requireExternalMcpSupport(session, storedConfig);
|
||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
...options,
|
||||
launchEnv,
|
||||
persistence: handle,
|
||||
restoring: true,
|
||||
});
|
||||
@@ -1485,7 +1575,12 @@ export class AgentManager {
|
||||
storedConfig.cwd,
|
||||
paseoToolPolicy,
|
||||
undefined,
|
||||
{ reason: "import", purpose: "interactive", workspaceId: input.workspaceId },
|
||||
{
|
||||
reason: "import",
|
||||
purpose: "interactive",
|
||||
workspaceId: input.workspaceId,
|
||||
labels: input.labels ?? {},
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
const imported = await client.importSession(
|
||||
@@ -1495,6 +1590,7 @@ export class AgentManager {
|
||||
},
|
||||
{ config: providerLaunchConfig, storedConfig, launchContext },
|
||||
);
|
||||
await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext);
|
||||
let handedToRegistration = false;
|
||||
try {
|
||||
const importedConfig = await this.normalizeConfig(
|
||||
@@ -1571,6 +1667,7 @@ export class AgentManager {
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
refreshConfig,
|
||||
agentId,
|
||||
{ env: existing.launchEnv },
|
||||
);
|
||||
const hadPreviousPaseoToolPolicy = this.paseoToolPolicies.has(agentId);
|
||||
const previousPaseoToolPolicy = this.paseoToolPolicies.get(agentId);
|
||||
@@ -1579,15 +1676,21 @@ export class AgentManager {
|
||||
client,
|
||||
storedConfig.cwd,
|
||||
paseoToolPolicy,
|
||||
undefined,
|
||||
{ reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId },
|
||||
existing.launchEnv,
|
||||
{
|
||||
reason: "refresh",
|
||||
purpose: "interactive",
|
||||
workspaceId: existing.workspaceId,
|
||||
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
|
||||
labels: existing.labels,
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
if (
|
||||
Object.keys(storedConfig.mcpServers ?? {}).length > 0 &&
|
||||
Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 &&
|
||||
existing.session.capabilities.supportsMcpServers !== true
|
||||
) {
|
||||
throw new Error(`Provider '${provider}' does not support MCP servers`);
|
||||
throw this.unsupportedMcpError(providerLaunchConfig, launchContext);
|
||||
}
|
||||
|
||||
let session: AgentSession | undefined;
|
||||
@@ -1606,7 +1709,7 @@ export class AgentManager {
|
||||
session = handle
|
||||
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
|
||||
: await client.createSession(providerLaunchConfig, launchContext);
|
||||
await this.requireExternalMcpSupport(session, storedConfig);
|
||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
|
||||
if (rehydrateFromDisk) {
|
||||
@@ -1624,6 +1727,7 @@ export class AgentManager {
|
||||
labels: existing.labels,
|
||||
workspaceId: existing.workspaceId,
|
||||
owner: existing.owner,
|
||||
launchEnv: existing.launchEnv,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
lastUserMessageAt: existing.lastUserMessageAt,
|
||||
@@ -1644,6 +1748,7 @@ export class AgentManager {
|
||||
throw error;
|
||||
} finally {
|
||||
if (!handedToRegistration) {
|
||||
this.detachPluginTools(agentId);
|
||||
if (hadPreviousPaseoToolPolicy) {
|
||||
this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy);
|
||||
} else {
|
||||
@@ -1745,6 +1850,64 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
/** Capture runtime capabilities without handing lifecycle ownership to the collector. */
|
||||
getRuntimeCleanupCandidates(): RuntimeCleanupCandidate[] {
|
||||
if (!this.registry) return [];
|
||||
const candidates: RuntimeCleanupCandidate[] = [];
|
||||
for (const agent of this.agents.values()) {
|
||||
const updatedAt = agent.updatedAt;
|
||||
let identity = this.runtimeCleanupIdentities.get(agent.session);
|
||||
if (!identity) {
|
||||
identity = Object.freeze({});
|
||||
this.runtimeCleanupIdentities.set(agent.session, identity);
|
||||
}
|
||||
const isEligible = () =>
|
||||
this.agents.get(agent.id) === agent &&
|
||||
agent.updatedAt === updatedAt &&
|
||||
this.runtimeHasNoPendingWork(agent);
|
||||
candidates.push({
|
||||
identity,
|
||||
agentId: agent.id,
|
||||
parentAgentId: getParentAgentIdFromLabels(agent.labels) ?? undefined,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? null,
|
||||
lastActivityAt: updatedAt.getTime(),
|
||||
isEligible,
|
||||
canStopRuntime: agent.session.canStopRuntime?.bind(agent.session),
|
||||
closeIfEligible: async (revalidate) => {
|
||||
if (!agent.persistence?.sessionId) return false;
|
||||
// Block prompt admission before entering the close lane. A skipped close also
|
||||
// releases this barrier, so the waiting prompt keeps the existing runtime.
|
||||
this.collectingRuntimes.add(agent.id);
|
||||
try {
|
||||
return await this.runLifecycleMutation(agent.id, () =>
|
||||
this.closeAgentRuntime(agent.id, () => isEligible() && revalidate()),
|
||||
);
|
||||
} finally {
|
||||
this.collectingRuntimes.delete(agent.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private runtimeHasNoPendingWork(agent: LiveManagedAgent): boolean {
|
||||
return (
|
||||
agent.lifecycle === "idle" &&
|
||||
!agent.internal &&
|
||||
agent.activeForegroundTurnId === null &&
|
||||
!this.runs.hasRun(agent.id) &&
|
||||
!agent.pendingReplacement &&
|
||||
agent.pendingPermissions.size === 0 &&
|
||||
agent.inFlightPermissionResponses.size === 0 &&
|
||||
(this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) &&
|
||||
!this.foregroundMutationTails.has(agent.id) &&
|
||||
!this.sessionEventTails.has(agent.id) &&
|
||||
!this.providerSubagents.list(agent.id).some((child) => child.status === "running")
|
||||
);
|
||||
}
|
||||
|
||||
closeAgent(agentId: string): Promise<void> {
|
||||
const existing = this.inFlightAgentCloses.get(agentId);
|
||||
if (existing) {
|
||||
@@ -1765,7 +1928,7 @@ export class AgentManager {
|
||||
return close;
|
||||
}
|
||||
|
||||
private async closeAgentRuntime(agentId: string): Promise<void> {
|
||||
private async closeAgentRuntime(agentId: string, canClose?: () => boolean): Promise<boolean> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -1780,6 +1943,7 @@ export class AgentManager {
|
||||
"agent.manager.close.start",
|
||||
);
|
||||
await this.drainSessionEvents(agentId);
|
||||
if (canClose && !canClose()) return false;
|
||||
// Retain ownership until shutdown succeeds. A failed close may still own a
|
||||
// native writer, so publishing a resumable closed snapshot would orphan it.
|
||||
await agent.session.close();
|
||||
@@ -1792,6 +1956,7 @@ export class AgentManager {
|
||||
} catch (error) {
|
||||
persistError = error;
|
||||
}
|
||||
this.detachPluginTools(agentId);
|
||||
this.emitClosedAgent(closedAgent, { persist: false });
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -1805,6 +1970,7 @@ export class AgentManager {
|
||||
if (persistError !== undefined) {
|
||||
throw persistError;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private cancelRunningProviderSubagents(parentAgentId: string): void {
|
||||
@@ -1956,7 +2122,7 @@ export class AgentManager {
|
||||
const archivedRecord = buildArchivedAgentRecord(record, options);
|
||||
await this.requireRegistry().upsert(archivedRecord);
|
||||
if (!record.archivedAt && !record.internal) {
|
||||
this.pluginLifecycle?.emit("agent.archived", {
|
||||
this.pluginLifecycle?.emit("agent.archive.after", {
|
||||
agent: describeHookAgent(archivedRecord),
|
||||
archivedAt: archivedRecord.archivedAt,
|
||||
});
|
||||
@@ -2457,7 +2623,19 @@ export class AgentManager {
|
||||
* emitted by the handler flow through dispatchStream so they persist and
|
||||
* broadcast like normal timeline events.
|
||||
*/
|
||||
tryRunOutOfBand(agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions): boolean {
|
||||
async tryRunOutOfBand(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<boolean> {
|
||||
if (this.collectingRuntimes.has(agentId) || !this.agents.has(agentId)) {
|
||||
await this.waitForAgentClose(agentId);
|
||||
await ensureAgentLoaded(agentId, {
|
||||
agentManager: this,
|
||||
agentStorage: this.requireRegistry(),
|
||||
logger: this.logger,
|
||||
});
|
||||
}
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
// Providers parse slash commands from the raw text, so a command another agent sent
|
||||
// must reach them without its sender envelope.
|
||||
@@ -2468,6 +2646,7 @@ export class AgentManager {
|
||||
if (!handler) {
|
||||
return false;
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
if (options?.clientMessageId) {
|
||||
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted");
|
||||
this.emitState(agent);
|
||||
@@ -2487,7 +2666,7 @@ export class AgentManager {
|
||||
}
|
||||
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
|
||||
};
|
||||
void (async () => {
|
||||
void this.runForegroundMutation(agentId, async () => {
|
||||
try {
|
||||
await handler.run({ emit: dispatch });
|
||||
} catch (error) {
|
||||
@@ -2498,7 +2677,7 @@ export class AgentManager {
|
||||
item: { type: "assistant_message", text: `[Error] ${text}` },
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2556,7 +2735,7 @@ export class AgentManager {
|
||||
throw error;
|
||||
}
|
||||
if (isStaleProviderSessionError(error)) {
|
||||
pendingRun.start = { status: "failed", error: error.message };
|
||||
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: error.message });
|
||||
agent.pendingReplacement = false;
|
||||
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
|
||||
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
||||
@@ -2564,29 +2743,49 @@ export class AgentManager {
|
||||
}
|
||||
agent.pendingReplacement = false;
|
||||
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
|
||||
pendingRun.start = { status: "failed", error: errorMsg };
|
||||
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
|
||||
if (options?.clientMessageId) {
|
||||
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
|
||||
messageId: options.clientMessageId,
|
||||
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: errorMsg });
|
||||
try {
|
||||
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
|
||||
if (options?.clientMessageId) {
|
||||
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
|
||||
messageId: options.clientMessageId,
|
||||
});
|
||||
}
|
||||
await this.handleStreamEvent(agent, {
|
||||
type: "turn_failed",
|
||||
provider: agent.provider,
|
||||
error: errorMsg,
|
||||
});
|
||||
this.finalizeForegroundTurn(agent);
|
||||
} finally {
|
||||
// A steer waiting on this start is released only once the run is cleared.
|
||||
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
||||
}
|
||||
await this.handleStreamEvent(agent, {
|
||||
type: "turn_failed",
|
||||
provider: agent.provider,
|
||||
error: errorMsg,
|
||||
});
|
||||
this.finalizeForegroundTurn(agent);
|
||||
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async *streamAfterRuntimeCollection(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
await this.waitForAgentClose(agentId);
|
||||
await ensureAgentLoaded(agentId, {
|
||||
agentManager: this,
|
||||
agentStorage: this.requireRegistry(),
|
||||
logger: this.logger,
|
||||
});
|
||||
yield* this.streamAgent(agentId, prompt, options);
|
||||
}
|
||||
|
||||
streamAgent(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
if (this.collectingRuntimes.has(agentId) || (!this.agents.has(agentId) && this.registry))
|
||||
return this.streamAfterRuntimeCollection(agentId, prompt, options);
|
||||
const existingAgent = this.requireSessionAgent(agentId);
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -2638,7 +2837,7 @@ export class AgentManager {
|
||||
agent.pendingReplacement = false;
|
||||
}
|
||||
const turnStartedAt = new Date();
|
||||
pendingRun.start = { status: "started", turnId };
|
||||
this.runs.settleForegroundStart(pendingRun, { status: "started", turnId });
|
||||
agent.activeForegroundTurnId = turnId;
|
||||
this.openActiveTurn(agent, turnId, turnStartedAt);
|
||||
agent.lifecycle = "running";
|
||||
@@ -2741,7 +2940,10 @@ export class AgentManager {
|
||||
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
|
||||
: null);
|
||||
if (persistenceHandle) {
|
||||
mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd);
|
||||
mutableAgent.persistence = normalizePersistenceForConfig(
|
||||
persistenceHandle,
|
||||
mutableAgent.config,
|
||||
);
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -2844,6 +3046,9 @@ export class AgentManager {
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentSteerOptions,
|
||||
): Promise<ActiveTurnSteerDispatchResult> {
|
||||
// A turn that is still starting has no id to steer yet. Replacing it would drop its
|
||||
// prompt, so wait until it is accepted (or fails) and steer the turn it became.
|
||||
await this.runs.waitForForegroundStart(agentId);
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
|
||||
if (!expectedTurnId) {
|
||||
@@ -3315,16 +3520,23 @@ export class AgentManager {
|
||||
await this.deleteCommittedTimeline(agentId);
|
||||
}
|
||||
|
||||
notifyAgentRemoved(agent: Pick<StoredAgentRecord, "id" | "workspaceId" | "internal">): void {
|
||||
this.dispatch({
|
||||
type: "agent_removed",
|
||||
agentId: agent.id,
|
||||
workspaceId: agent.workspaceId,
|
||||
internal: agent.internal === true,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCommittedTimeline(agentId: string): Promise<void> {
|
||||
await this.durableTimelineStore?.deleteAgent(agentId);
|
||||
}
|
||||
|
||||
async getLastAssistantMessage(agentId: string): Promise<string | null> {
|
||||
const agent = this.agents.get(agentId);
|
||||
if (!agent) {
|
||||
return null;
|
||||
if (!this.timelineStore.has(agentId)) {
|
||||
return (await this.durableTimelineStore?.getLastAssistantMessage(agentId)) ?? null;
|
||||
}
|
||||
|
||||
return await this.getLastAssistantMessageFromStores(agentId);
|
||||
}
|
||||
|
||||
@@ -3575,6 +3787,7 @@ export class AgentManager {
|
||||
publishWhenReady?: boolean;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
launchEnv?: Record<string, string>;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
let registered = false;
|
||||
@@ -3654,6 +3867,8 @@ export class AgentManager {
|
||||
this.assertAgentRegistrationActive(managed);
|
||||
this.emitState(managed, { persist: false });
|
||||
this.subscribeToSession(managed);
|
||||
await this.drainSessionEvents(managed.id);
|
||||
this.assertAgentRegistrationActive(managed);
|
||||
return { ...managed };
|
||||
} catch (error) {
|
||||
if (!registered) {
|
||||
@@ -3685,16 +3900,23 @@ export class AgentManager {
|
||||
|
||||
private async requireExternalMcpSupport(
|
||||
session: AgentSession,
|
||||
storedConfig: AgentSessionConfig,
|
||||
launchConfig: AgentSessionConfig,
|
||||
launchContext: AgentLaunchContext,
|
||||
): Promise<void> {
|
||||
if (
|
||||
Object.keys(storedConfig.mcpServers ?? {}).length === 0 ||
|
||||
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 ||
|
||||
session.capabilities.supportsMcpServers === true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.closeUnregisteredSession(session);
|
||||
throw new Error(`Provider '${storedConfig.provider}' does not support MCP servers`);
|
||||
throw this.unsupportedMcpError(launchConfig, launchContext);
|
||||
}
|
||||
|
||||
private unsupportedMcpError(config: AgentSessionConfig, context: AgentLaunchContext): Error {
|
||||
const owners = [...new Set(Object.values(context.mcpServerOwners ?? {}))];
|
||||
const source = owners.length ? ` (plugin ${owners.join(", ")})` : "";
|
||||
return new Error(`Provider '${config.provider}' does not support MCP servers${source}`);
|
||||
}
|
||||
|
||||
private async initializeAgentTimelineForRegister(params: {
|
||||
@@ -3751,6 +3973,7 @@ export class AgentManager {
|
||||
persistence?: AgentPersistenceHandle;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
launchEnv?: Record<string, string>;
|
||||
}
|
||||
| undefined;
|
||||
}): ActiveManagedAgent {
|
||||
@@ -3761,6 +3984,7 @@ export class AgentManager {
|
||||
cwd: config.cwd,
|
||||
workspaceId: options?.workspaceId,
|
||||
owner: options?.owner,
|
||||
launchEnv: options?.launchEnv,
|
||||
session,
|
||||
capabilities: session.capabilities,
|
||||
config,
|
||||
@@ -3780,9 +4004,9 @@ export class AgentManager {
|
||||
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
|
||||
finalizedForegroundTurnIds: new Set<string>(),
|
||||
unsubscribeSession: null,
|
||||
persistence: attachPersistenceCwd(
|
||||
persistence: normalizePersistenceForConfig(
|
||||
options?.persistence ?? session.describePersistence(),
|
||||
config.cwd,
|
||||
config,
|
||||
),
|
||||
historyPrimed: options?.historyPrimed ?? durableTimelineHasRows,
|
||||
lastUserMessageAt: options?.lastUserMessageAt ?? null,
|
||||
@@ -3845,6 +4069,7 @@ export class AgentManager {
|
||||
private discardRetainedAgentState(agentId: string): void {
|
||||
this.timelineStore.delete(agentId);
|
||||
this.paseoToolPolicies.delete(agentId);
|
||||
this.detachPluginTools(agentId);
|
||||
for (const event of this.providerSubagents.deleteParent(agentId)) {
|
||||
this.dispatch({ type: "provider_subagent", event });
|
||||
}
|
||||
@@ -3853,7 +4078,7 @@ export class AgentManager {
|
||||
private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void {
|
||||
this.emitState(agent, options);
|
||||
if (!agent.internal) {
|
||||
this.pluginLifecycle?.emit("agent.closed", {
|
||||
this.pluginLifecycle?.emit("agent.close.after", {
|
||||
agent: describeHookAgent({ ...agent, title: agent.config.title }),
|
||||
});
|
||||
}
|
||||
@@ -4085,9 +4310,9 @@ export class AgentManager {
|
||||
newInfo.modeId !== agent.runtimeInfo?.modeId;
|
||||
agent.runtimeInfo = newInfo;
|
||||
if (!agent.persistence && newInfo.sessionId) {
|
||||
agent.persistence = attachPersistenceCwd(
|
||||
agent.persistence = normalizePersistenceForConfig(
|
||||
{ provider: agent.provider, sessionId: newInfo.sessionId },
|
||||
agent.cwd,
|
||||
agent.config,
|
||||
);
|
||||
}
|
||||
// Emit state if runtimeInfo changed so clients get the updated model
|
||||
@@ -4132,9 +4357,9 @@ export class AgentManager {
|
||||
for await (const rawEvent of agent.session.streamHistory()) {
|
||||
const event = limitAgentStreamEventContent(rawEvent);
|
||||
if (event.type === "timeline") {
|
||||
const item = projectAgentMessage(event.item);
|
||||
if (!item) continue;
|
||||
historyEvents.push({ ...event, item });
|
||||
for (const item of projectAgentMessages(event.item)) {
|
||||
historyEvents.push({ ...event, item });
|
||||
}
|
||||
} else if (event.type === "provider_subagent") {
|
||||
providerSubagentEvents.push(event);
|
||||
}
|
||||
@@ -4199,9 +4424,9 @@ export class AgentManager {
|
||||
if (event.type !== "timeline") {
|
||||
continue;
|
||||
}
|
||||
const item = projectAgentMessage(event.item);
|
||||
if (!item) continue;
|
||||
historyEvents.push({ ...event, item });
|
||||
for (const item of projectAgentMessages(event.item)) {
|
||||
historyEvents.push({ ...event, item });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error, agentId: agent.id }, "Failed to hydrate provider history");
|
||||
@@ -4445,9 +4670,9 @@ export class AgentManager {
|
||||
case "model_changed":
|
||||
agent.runtimeInfo = event.runtimeInfo;
|
||||
if (!agent.persistence && event.runtimeInfo.sessionId) {
|
||||
agent.persistence = attachPersistenceCwd(
|
||||
agent.persistence = normalizePersistenceForConfig(
|
||||
{ provider: agent.provider, sessionId: event.runtimeInfo.sessionId },
|
||||
agent.cwd,
|
||||
agent.config,
|
||||
);
|
||||
}
|
||||
this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId);
|
||||
@@ -4521,7 +4746,7 @@ export class AgentManager {
|
||||
private refreshSessionPersistence(agent: ActiveManagedAgent): void {
|
||||
const handle = agent.session.describePersistence();
|
||||
if (handle) {
|
||||
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
|
||||
agent.persistence = normalizePersistenceForConfig(handle, agent.config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4533,42 +4758,32 @@ export class AgentManager {
|
||||
}): Promise<void> {
|
||||
const { agent, event, options, flags } = params;
|
||||
|
||||
const item = projectAgentMessage(event.item);
|
||||
if (!item) {
|
||||
flags.shouldDispatchEvent = false;
|
||||
flags.shouldNotifyWaiters = false;
|
||||
return;
|
||||
}
|
||||
|
||||
event.item = item;
|
||||
if (
|
||||
event.item.type === "user_message" &&
|
||||
event.item.clientMessageId &&
|
||||
this.reconcileSubmittedPromptEcho(agent, event.item, event.turnId)
|
||||
) {
|
||||
flags.shouldDispatchEvent = false;
|
||||
flags.shouldNotifyWaiters = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.fromHistory) {
|
||||
this.recordTimeline(
|
||||
agent.id,
|
||||
event.item,
|
||||
event.timestamp ? { timestamp: event.timestamp } : undefined,
|
||||
);
|
||||
flags.shouldDispatchEvent = false;
|
||||
flags.shouldNotifyWaiters = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.recordAndDispatchTimelineItem(agent.id, event.item, event.provider, event.turnId);
|
||||
if (event.item.type === "user_message") {
|
||||
agent.lastUserMessageAt = new Date();
|
||||
this.emitState(agent);
|
||||
}
|
||||
flags.shouldDispatchEvent = false;
|
||||
flags.shouldNotifyWaiters = true;
|
||||
flags.shouldNotifyWaiters = false;
|
||||
for (const item of projectAgentMessages(event.item)) {
|
||||
if (
|
||||
item.type === "user_message" &&
|
||||
item.clientMessageId &&
|
||||
this.reconcileSubmittedPromptEcho(agent, item, event.turnId)
|
||||
)
|
||||
continue;
|
||||
|
||||
if (options?.fromHistory) {
|
||||
this.recordTimeline(
|
||||
agent.id,
|
||||
item,
|
||||
event.timestamp ? { timestamp: event.timestamp } : undefined,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.recordAndDispatchTimelineItem(agent.id, item, event.provider, event.turnId);
|
||||
if (item.type === "user_message") {
|
||||
agent.lastUserMessageAt = new Date();
|
||||
this.emitState(agent);
|
||||
}
|
||||
flags.shouldNotifyWaiters = true;
|
||||
}
|
||||
}
|
||||
|
||||
private onStreamTurnCompleted(params: {
|
||||
@@ -4814,16 +5029,17 @@ export class AgentManager {
|
||||
outcome: "accepted" | "rejected",
|
||||
options?: { messageId?: string; providerMessageId?: string; turnId?: string },
|
||||
): void {
|
||||
const item = projectAgentMessage({
|
||||
const items = projectAgentMessages({
|
||||
type: "user_message",
|
||||
text: submittedPromptText(prompt),
|
||||
clientMessageId,
|
||||
...(options?.messageId ? { messageId: options.messageId } : {}),
|
||||
});
|
||||
if (!item) return;
|
||||
// Human attempts stay in history on rejection; delivery notifications require acceptance.
|
||||
if (outcome === "rejected" && item.type !== "user_message") return;
|
||||
this.recordSubmittedPromptItem(agent, item, options);
|
||||
for (const item of items) {
|
||||
// Human attempts stay in history on rejection; delivery notifications require acceptance.
|
||||
if (outcome === "rejected" && item.type !== "user_message") continue;
|
||||
this.recordSubmittedPromptItem(agent, item, options);
|
||||
}
|
||||
}
|
||||
|
||||
private recordSubmittedPromptItem(
|
||||
@@ -5162,7 +5378,6 @@ export class AgentManager {
|
||||
this.pluginLifecycle,
|
||||
describeHookAgent({ ...agent, title: agent.config.title }),
|
||||
event,
|
||||
this.timelineStore.getItems(agentId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5171,7 +5386,7 @@ export class AgentManager {
|
||||
for (const subscriber of this.subscribers) {
|
||||
if (
|
||||
subscriber.agentId &&
|
||||
event.type === "agent_stream" &&
|
||||
(event.type === "agent_stream" || event.type === "agent_removed") &&
|
||||
subscriber.agentId !== event.agentId
|
||||
) {
|
||||
continue;
|
||||
@@ -5207,6 +5422,7 @@ export class AgentManager {
|
||||
|
||||
private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean {
|
||||
if (event.type === "agent_state") return event.agent.internal === true;
|
||||
if (event.type === "agent_removed") return event.internal;
|
||||
if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true;
|
||||
if (event.type !== "provider_subagent") return false;
|
||||
const parentAgentId =
|
||||
@@ -5341,10 +5557,17 @@ export class AgentManager {
|
||||
reason: PluginSessionOpenRequest["reason"];
|
||||
purpose: PluginSessionOpenRequest["purpose"];
|
||||
workspaceId?: string | null;
|
||||
title?: string | null;
|
||||
labels?: Record<string, string>;
|
||||
},
|
||||
): Promise<AgentLaunchContext> {
|
||||
// A plugin changing during this open must still make the resulting session stale.
|
||||
const generation = this.sessionOpenGeneration;
|
||||
this.detachPluginTools(agentId);
|
||||
let mcpServers: AgentSessionConfig["mcpServers"];
|
||||
let mcpServerOwners: Record<string, string> | undefined;
|
||||
if (this.pluginLifecycle) {
|
||||
const request: PluginSessionOpenRequest = {
|
||||
const request = {
|
||||
agentId,
|
||||
provider: client.provider,
|
||||
cwd,
|
||||
@@ -5352,29 +5575,49 @@ export class AgentManager {
|
||||
reason: opening?.reason ?? "resume",
|
||||
purpose: opening?.purpose ?? "interactive",
|
||||
env: { ...env },
|
||||
title: opening?.title ?? null,
|
||||
labels: opening?.labels ?? {},
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
};
|
||||
const transformed = await this.pluginLifecycle.before("agent.session_open", request);
|
||||
const transformed = await this.pluginLifecycle.before("agent.open.before", request);
|
||||
env = transformed.env;
|
||||
if (request.purpose === "interactive") {
|
||||
this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]);
|
||||
mcpServers = transformed.mcpServers;
|
||||
mcpServerOwners = transformed.mcpServerOwners;
|
||||
}
|
||||
}
|
||||
const context: AgentLaunchContext = {
|
||||
agentId,
|
||||
mcpServers,
|
||||
mcpServerOwners,
|
||||
env: {
|
||||
...env,
|
||||
PASEO_AGENT_ID: agentId,
|
||||
PASEO_AGENT_CWD: cwd,
|
||||
},
|
||||
};
|
||||
if (
|
||||
const useNativeTools =
|
||||
this.paseoToolsEnabled &&
|
||||
isPaseoToolPolicyEnabled(paseoToolPolicy) &&
|
||||
client.capabilities.supportsNativePaseoTools &&
|
||||
this.paseoToolCatalogFactory
|
||||
client.capabilities.supportsNativePaseoTools;
|
||||
if (
|
||||
this.paseoToolCatalogFactory &&
|
||||
(useNativeTools || this.getPluginTools(agentId).length > 0)
|
||||
) {
|
||||
context.paseoTools = await this.paseoToolCatalogFactory({
|
||||
callerAgentId: agentId,
|
||||
paseoToolPolicy,
|
||||
});
|
||||
try {
|
||||
const catalog = await this.paseoToolCatalogFactory({
|
||||
callerAgentId: agentId,
|
||||
paseoToolPolicy,
|
||||
});
|
||||
if (useNativeTools) context.paseoTools = catalog;
|
||||
} catch (error) {
|
||||
this.detachPluginTools(agentId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
this.sessionOpenGenerations.set(agentId, generation);
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -5382,7 +5625,26 @@ export class AgentManager {
|
||||
launchConfig: AgentSessionConfig,
|
||||
launchContext: AgentLaunchContext,
|
||||
): AgentSessionConfig {
|
||||
return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig;
|
||||
const savedServerNames = new Set(
|
||||
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}),
|
||||
);
|
||||
for (const name of Object.keys(launchContext.mcpServers ?? {})) {
|
||||
const owner = launchContext.mcpServerOwners?.[name];
|
||||
if (name === PASEO_MCP_SERVER_NAME || savedServerNames.has(name)) {
|
||||
const existingOwner = savedServerNames.has(name)
|
||||
? "agent saved configuration"
|
||||
: "Paseo internal MCP server";
|
||||
throw new Error(`MCP server '${name}' from plugin ${owner} collides with ${existingOwner}`);
|
||||
}
|
||||
}
|
||||
const merged =
|
||||
Object.keys(launchContext.mcpServers ?? {}).length > 0
|
||||
? {
|
||||
...launchConfig,
|
||||
mcpServers: { ...launchConfig.mcpServers, ...launchContext.mcpServers },
|
||||
}
|
||||
: launchConfig;
|
||||
return launchContext.paseoTools ? stripInternalPaseoMcpServer(merged) : merged;
|
||||
}
|
||||
|
||||
private async requireAvailableClient(options: { provider: AgentProvider }): Promise<AgentClient> {
|
||||
|
||||
@@ -787,7 +787,7 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("create_agent with worktree is async and boots terminals only after setup success", async () => {
|
||||
test("workspace creation owns async setup and terminal bootstrap before agent attachment", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-worktree-repo-"));
|
||||
@@ -843,21 +843,30 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const result = await withTimeout({
|
||||
const workspaceResult = await withTimeout({
|
||||
promise: client.callTool({
|
||||
name: "create_agent",
|
||||
name: "create_workspace",
|
||||
args: {
|
||||
cwd: repoRoot,
|
||||
title: "MCP worktree setup terminals",
|
||||
provider: "claude/claude-test-model",
|
||||
mode: "bypassPermissions",
|
||||
initialPrompt: "say done and stop",
|
||||
worktreeName: "mcp-worktree-setup-test",
|
||||
isolation: "worktree",
|
||||
path: repoRoot,
|
||||
worktreeSlug: "mcp-worktree-setup-test",
|
||||
baseBranch: "main",
|
||||
},
|
||||
}),
|
||||
timeoutMs: 2500,
|
||||
label: "create_agent should not block on setup",
|
||||
label: "create_workspace should not block on setup",
|
||||
});
|
||||
const workspace = getStructuredContent(workspaceResult);
|
||||
if (typeof workspace?.workspaceId !== "string") throw new Error("Expected created workspace");
|
||||
const result = await client.callTool({
|
||||
name: "create_agent",
|
||||
args: {
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "MCP worktree setup terminals",
|
||||
provider: "claude/claude-test-model",
|
||||
settings: { modeId: "bypassPermissions" },
|
||||
initialPrompt: "say done and stop",
|
||||
},
|
||||
});
|
||||
|
||||
const payload = getStructuredContent(result);
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { createAgentMessageDelivery, parseAgentMessages } from "./index.js";
|
||||
import type { AgentPromptInput } from "../agent-sdk-types.js";
|
||||
|
||||
function createClock() {
|
||||
let now = 0;
|
||||
const jobs = new Map<() => void, number>();
|
||||
return {
|
||||
schedule(delayMs: number, callback: () => void) {
|
||||
jobs.set(callback, now + delayMs);
|
||||
return () => {
|
||||
jobs.delete(callback);
|
||||
};
|
||||
},
|
||||
advance(ms: number) {
|
||||
now += ms;
|
||||
for (const [callback, deadline] of jobs) {
|
||||
if (deadline > now) continue;
|
||||
jobs.delete(callback);
|
||||
callback();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("a queued child report and completion reach the recipient in one steer after two quiet seconds", async () => {
|
||||
const clock = createClock();
|
||||
const sent: Array<{ recipientId: string; prompt: AgentPromptInput }> = [];
|
||||
const delivery = createAgentMessageDelivery({
|
||||
schedule: clock.schedule,
|
||||
dispatch: async (batch) => {
|
||||
sent.push(batch);
|
||||
return "steered";
|
||||
},
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const receipt = delivery.enqueue({
|
||||
recipientId: "parent",
|
||||
messageId: "report",
|
||||
prompt: "Results <ready>",
|
||||
source: { kind: "agent-message", agentId: "worker" },
|
||||
});
|
||||
expect(receipt).toMatchObject({ status: "queued", messageId: "report" });
|
||||
expect(sent).toEqual([]);
|
||||
clock.advance(1000);
|
||||
delivery.enqueue({
|
||||
recipientId: "parent",
|
||||
messageId: "finish",
|
||||
prompt: "Worker finished",
|
||||
source: { kind: "agent-notification", agentId: "worker", event: "finished" },
|
||||
});
|
||||
clock.advance(1999);
|
||||
expect(sent).toEqual([]);
|
||||
clock.advance(1);
|
||||
await delivery.close();
|
||||
expect(
|
||||
sent.map((batch) => ({
|
||||
recipientId: batch.recipientId,
|
||||
messages: parseAgentMessages(String(batch.prompt)),
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
recipientId: "parent",
|
||||
messages: [
|
||||
{
|
||||
id: "report",
|
||||
source: { kind: "agent-message", agentId: "worker" },
|
||||
text: "Results <ready>",
|
||||
},
|
||||
{
|
||||
id: "finish",
|
||||
source: { kind: "agent-notification", agentId: "worker", event: "finished" },
|
||||
text: "Worker finished",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("recipient timers are independent, later messages form another batch, and close flushes pending work", async () => {
|
||||
const clock = createClock();
|
||||
const sent: string[] = [];
|
||||
const delivery = createAgentMessageDelivery({
|
||||
schedule: clock.schedule,
|
||||
dispatch: async (request) => {
|
||||
sent.push(`${request.recipientId}:${request.messageIds.join(",")}`);
|
||||
return "steered";
|
||||
},
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const source = { kind: "agent-message" as const, agentId: "worker" };
|
||||
const a = delivery.enqueue({ recipientId: "a", messageId: "first", source, prompt: "one" });
|
||||
clock.advance(1000);
|
||||
const b = delivery.enqueue({ recipientId: "b", messageId: "independent", source, prompt: "two" });
|
||||
clock.advance(1000);
|
||||
await a.dispatched;
|
||||
expect(sent).toEqual(["a:first"]);
|
||||
delivery.enqueue({ recipientId: "a", messageId: "later", source, prompt: "three" });
|
||||
clock.advance(1000);
|
||||
await b.dispatched;
|
||||
expect(sent).toEqual(["a:first", "b:independent"]);
|
||||
await delivery.close();
|
||||
expect(sent).toEqual(["a:first", "b:independent", "a:later"]);
|
||||
expect(() => delivery.enqueue({ recipientId: "a", source, prompt: "closed" })).toThrow("closed");
|
||||
});
|
||||
|
||||
test("explicit interrupt and conflicting session modes retain ordered dispatch boundaries", async () => {
|
||||
const sent: Array<{
|
||||
activeTurnBehavior: string;
|
||||
sessionMode?: string;
|
||||
messageIds: readonly string[];
|
||||
}> = [];
|
||||
const delivery = createAgentMessageDelivery({
|
||||
dispatch: async (request) => {
|
||||
sent.push(request);
|
||||
request.onDispatch("steered");
|
||||
return "steered";
|
||||
},
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const source = { kind: "agent-message" as const, agentId: "worker" };
|
||||
delivery.enqueue({
|
||||
recipientId: "parent",
|
||||
source,
|
||||
prompt: "interrupt",
|
||||
messageId: "interrupt",
|
||||
activeTurnBehavior: "interrupt",
|
||||
});
|
||||
delivery.enqueue({
|
||||
recipientId: "parent",
|
||||
source,
|
||||
prompt: "review",
|
||||
messageId: "review",
|
||||
sessionMode: "plan",
|
||||
});
|
||||
delivery.enqueue({
|
||||
recipientId: "parent",
|
||||
source,
|
||||
prompt: "execute",
|
||||
messageId: "execute",
|
||||
sessionMode: "full-access",
|
||||
});
|
||||
delivery.enqueue({
|
||||
recipientId: "parent",
|
||||
source: { kind: "agent-notification", agentId: "worker", event: "finished" },
|
||||
prompt: "done",
|
||||
messageId: "finished",
|
||||
});
|
||||
await delivery.close();
|
||||
expect(
|
||||
sent.map(({ activeTurnBehavior, sessionMode, messageIds }) => ({
|
||||
activeTurnBehavior,
|
||||
sessionMode,
|
||||
messageIds,
|
||||
})),
|
||||
).toEqual([
|
||||
{ activeTurnBehavior: "interrupt", sessionMode: undefined, messageIds: ["interrupt"] },
|
||||
{ activeTurnBehavior: "steer", sessionMode: "plan", messageIds: ["review"] },
|
||||
{
|
||||
activeTurnBehavior: "steer",
|
||||
sessionMode: "full-access",
|
||||
messageIds: ["execute", "finished"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("dispatch failures reject waits before and after flush and do not poison later messages", async () => {
|
||||
const clock = createClock();
|
||||
const failed = new Error("provider rejected");
|
||||
const errors: unknown[] = [];
|
||||
const delivery = createAgentMessageDelivery({
|
||||
schedule: clock.schedule,
|
||||
dispatch: async (request) => {
|
||||
if (request.messageIds.includes("bad")) throw failed;
|
||||
return "steered";
|
||||
},
|
||||
onError: (error) => {
|
||||
errors.push(error);
|
||||
},
|
||||
});
|
||||
const message = {
|
||||
recipientId: "parent",
|
||||
source: { kind: "agent-message" as const, agentId: "worker" },
|
||||
prompt: "report",
|
||||
};
|
||||
const bad = delivery.enqueue({ ...message, messageId: "bad" });
|
||||
const before = expect(
|
||||
delivery.waitForDispatch("parent", new AbortController().signal),
|
||||
).rejects.toBe(failed);
|
||||
clock.advance(2000);
|
||||
const after = expect(
|
||||
delivery.waitForDispatch("parent", new AbortController().signal),
|
||||
).rejects.toBe(failed);
|
||||
await Promise.all([before, after, expect(bad.dispatched).rejects.toBe(failed)]);
|
||||
const next = delivery.enqueue({ ...message, messageId: "next" });
|
||||
await delivery.close();
|
||||
await expect(next.dispatched).resolves.toBe("steered");
|
||||
expect(errors).toEqual([failed]);
|
||||
});
|
||||
|
||||
test("canceling a completion wait leaves queued delivery intact", async () => {
|
||||
const clock = createClock();
|
||||
const delivery = createAgentMessageDelivery({
|
||||
schedule: clock.schedule,
|
||||
dispatch: async () => "steered",
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const receipt = delivery.enqueue({
|
||||
recipientId: "parent",
|
||||
source: { kind: "agent-message", agentId: "worker" },
|
||||
prompt: "report",
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const wait = delivery.waitForDispatch("parent", controller.signal);
|
||||
controller.abort();
|
||||
await expect(wait).rejects.toMatchObject({ name: "AbortError" });
|
||||
await delivery.close();
|
||||
await expect(receipt.dispatched).resolves.toBe("steered");
|
||||
});
|
||||
|
||||
test("batches retain each sender's attachment context and image order", async () => {
|
||||
let prompt: AgentPromptInput = "";
|
||||
const delivery = createAgentMessageDelivery({
|
||||
dispatch: async (request) => {
|
||||
prompt = request.prompt;
|
||||
return "steered";
|
||||
},
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const firstImage = { type: "image" as const, data: "Zmlyc3Q=", mimeType: "image/png" };
|
||||
const secondImage = { type: "image" as const, data: "c2Vjb25k", mimeType: "image/png" };
|
||||
delivery.enqueue({
|
||||
recipientId: "parent",
|
||||
messageId: "first",
|
||||
source: { kind: "agent-message", agentId: "one" },
|
||||
prompt: [{ type: "text", text: "First" }, firstImage],
|
||||
});
|
||||
delivery.enqueue({
|
||||
recipientId: "parent",
|
||||
messageId: "second",
|
||||
source: { kind: "agent-message", agentId: "two" },
|
||||
prompt: [
|
||||
{ type: "text", text: "Second" },
|
||||
{
|
||||
type: "github_issue",
|
||||
mimeType: "application/github-issue",
|
||||
number: 42,
|
||||
title: "Context",
|
||||
url: "https://example.com/42",
|
||||
body: "Attached",
|
||||
},
|
||||
secondImage,
|
||||
],
|
||||
});
|
||||
await delivery.close();
|
||||
expect(prompt).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: '<paseo-system version="1" kind="agent-message" source-agent-id="one" message-id="first">\nFirst\n</paseo-system>',
|
||||
},
|
||||
firstImage,
|
||||
{
|
||||
type: "text",
|
||||
text: '<paseo-system version="1" kind="agent-message" source-agent-id="two" message-id="second">\nSecond\n\nGitHub Issue #42: Context\nhttps://example.com/42\n\nAttached\n</paseo-system>',
|
||||
},
|
||||
secondImage,
|
||||
]);
|
||||
expect(
|
||||
parseAgentMessages(
|
||||
(prompt as Exclude<AgentPromptInput, string>)
|
||||
.flatMap((block) => (block.type === "text" ? [block.text] : []))
|
||||
.join("\n"),
|
||||
)?.map((message) => message.source?.agentId),
|
||||
).toEqual(["one", "two"]);
|
||||
});
|
||||
|
||||
test("runtime safety sees queued messages until provider dispatch settles", async () => {
|
||||
const clock = createClock();
|
||||
let release!: () => void;
|
||||
const dispatched = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const delivery = createAgentMessageDelivery({
|
||||
schedule: clock.schedule,
|
||||
dispatch: async () => {
|
||||
await dispatched;
|
||||
return "steered";
|
||||
},
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
expect(delivery.hasPending("parent")).toBe(false);
|
||||
delivery.enqueue({
|
||||
recipientId: "parent",
|
||||
prompt: "finished",
|
||||
source: { kind: "agent-message", agentId: "child" },
|
||||
});
|
||||
expect(delivery.hasPending("parent")).toBe(true);
|
||||
clock.advance(2000);
|
||||
expect(delivery.hasPending("parent")).toBe(true);
|
||||
release();
|
||||
await delivery.close();
|
||||
expect(delivery.hasPending("parent")).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { PromptDispatchDisposition } from "../agent-prompt.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AgentPromptInput, AgentPromptContentBlock } from "../agent-sdk-types.js";
|
||||
import { prepareAgentMessage, type AgentPromptSource } from "./envelope.js";
|
||||
|
||||
interface Message {
|
||||
recipientId: string;
|
||||
messageId?: string;
|
||||
source: AgentPromptSource;
|
||||
prompt: AgentPromptInput;
|
||||
sessionMode?: string;
|
||||
unarchive?: boolean;
|
||||
clearPendingPermissions?: boolean;
|
||||
activeTurnBehavior?: "steer" | "interrupt";
|
||||
beforeDispatch?: () => void;
|
||||
onDispatch?: (disposition: PromptDispatchDisposition) => void;
|
||||
}
|
||||
interface Receipt {
|
||||
status: "queued";
|
||||
messageId: string;
|
||||
dispatched: Promise<PromptDispatchDisposition>;
|
||||
}
|
||||
interface Admitted extends Message {
|
||||
receipt: Receipt;
|
||||
resolve(disposition: PromptDispatchDisposition): void;
|
||||
reject(error: unknown): void;
|
||||
}
|
||||
interface DispatchRequest {
|
||||
recipientId: string;
|
||||
messageIds: readonly string[];
|
||||
prompt: AgentPromptInput;
|
||||
activeTurnBehavior: "steer" | "interrupt";
|
||||
sessionMode?: string;
|
||||
clearPendingPermissions: boolean;
|
||||
unarchive: boolean;
|
||||
onDispatch(disposition: PromptDispatchDisposition): void;
|
||||
}
|
||||
interface Dependencies {
|
||||
dispatch(request: DispatchRequest): Promise<PromptDispatchDisposition>;
|
||||
onError(error: unknown, request: DispatchRequest): void;
|
||||
/** Return a cancellation function. Production uses the daemon's event loop. */
|
||||
schedule?: (delayMs: number, callback: () => void) => () => void;
|
||||
}
|
||||
export interface AgentMessageDelivery {
|
||||
enqueue(message: Message): Receipt;
|
||||
/** Queued messages and provider dispatches still owned by this recipient. */
|
||||
hasPending(recipientId: string): boolean;
|
||||
/** Stop admission, flush pending messages, and await provider dispatch acknowledgments. */
|
||||
close(): Promise<void>;
|
||||
/** Existing completion waits include agent messages admitted before the wait. */
|
||||
waitForDispatch(recipientId: string, signal: AbortSignal): Promise<void>;
|
||||
}
|
||||
|
||||
/** Recipient-owned agent delivery. Human prompts never enter this module. */
|
||||
export function createAgentMessageDelivery(dependencies: Dependencies): AgentMessageDelivery {
|
||||
const schedule =
|
||||
dependencies.schedule ??
|
||||
((delayMs, callback) => {
|
||||
const timer = setTimeout(callback, delayMs);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
const pending = new Map<string, { messages: Admitted[]; cancel(): void }>();
|
||||
const dispatching = new Map<string, Promise<void>>();
|
||||
let closed = false;
|
||||
let closing: Promise<void> | undefined;
|
||||
|
||||
function dispatch(recipientId: string, messages: Admitted[]): void {
|
||||
const prompts = messages.map(
|
||||
(message) =>
|
||||
prepareAgentMessage(message.prompt, message.source, message.receipt.messageId).prompt,
|
||||
);
|
||||
const prompt: AgentPromptInput = prompts.every((entry) => typeof entry === "string")
|
||||
? prompts.join("\n\n")
|
||||
: prompts.flatMap((entry): AgentPromptContentBlock[] =>
|
||||
typeof entry === "string" ? [{ type: "text", text: entry }] : entry,
|
||||
);
|
||||
const request: DispatchRequest = {
|
||||
recipientId,
|
||||
messageIds: messages.map((message) => message.receipt.messageId),
|
||||
prompt,
|
||||
activeTurnBehavior: messages[0]!.activeTurnBehavior ?? "steer",
|
||||
sessionMode: messages.find((message) => message.sessionMode)?.sessionMode,
|
||||
clearPendingPermissions: messages.some((message) => message.clearPendingPermissions),
|
||||
unarchive: messages.some((message) => message.unarchive !== false),
|
||||
onDispatch: (disposition) => {
|
||||
for (const message of messages) {
|
||||
try {
|
||||
message.onDispatch?.(disposition);
|
||||
} catch (error) {
|
||||
dependencies.onError(error, request);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
const previous = dispatching.get(recipientId) ?? Promise.resolve();
|
||||
const dispatched = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
try {
|
||||
for (const message of messages) message.beforeDispatch?.();
|
||||
const disposition = await dependencies.dispatch(request);
|
||||
for (const message of messages) message.resolve(disposition);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
for (const message of messages) message.reject(error);
|
||||
dependencies.onError(error, request);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
dispatching.set(recipientId, dispatched);
|
||||
void dispatched
|
||||
.finally(() => {
|
||||
if (dispatching.get(recipientId) === dispatched) dispatching.delete(recipientId);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
function flush(recipientId: string): void {
|
||||
const entry = pending.get(recipientId);
|
||||
if (!entry) return;
|
||||
pending.delete(recipientId);
|
||||
entry.cancel();
|
||||
let group: Admitted[] = [];
|
||||
let mode: string | undefined;
|
||||
for (const message of entry.messages) {
|
||||
// Explicitly conflicting send options require separate, ordered dispatches.
|
||||
if (
|
||||
group.length &&
|
||||
((message.activeTurnBehavior ?? "steer") !== (group[0]!.activeTurnBehavior ?? "steer") ||
|
||||
(mode && message.sessionMode && mode !== message.sessionMode))
|
||||
) {
|
||||
dispatch(recipientId, group);
|
||||
group = [];
|
||||
mode = undefined;
|
||||
}
|
||||
group.push(message);
|
||||
mode ??= message.sessionMode;
|
||||
}
|
||||
if (group.length) dispatch(recipientId, group);
|
||||
}
|
||||
|
||||
return {
|
||||
hasPending: (recipientId) => pending.has(recipientId) || dispatching.has(recipientId),
|
||||
enqueue(message) {
|
||||
if (closed) throw new Error("Agent message delivery is closed");
|
||||
let resolve!: Admitted["resolve"];
|
||||
let reject!: Admitted["reject"];
|
||||
const dispatched = new Promise<PromptDispatchDisposition>((accepted, failed) => {
|
||||
resolve = accepted;
|
||||
reject = failed;
|
||||
});
|
||||
void dispatched.catch(() => undefined);
|
||||
const receipt: Receipt = {
|
||||
status: "queued",
|
||||
messageId: message.messageId ?? randomUUID(),
|
||||
dispatched,
|
||||
};
|
||||
const entry = pending.get(message.recipientId) ?? { messages: [], cancel: () => {} };
|
||||
entry.cancel();
|
||||
entry.messages.push({ ...message, receipt, resolve, reject });
|
||||
entry.cancel = schedule(2000, () => flush(message.recipientId));
|
||||
pending.set(message.recipientId, entry);
|
||||
return receipt;
|
||||
},
|
||||
async waitForDispatch(recipientId, signal) {
|
||||
signal.throwIfAborted();
|
||||
const admitted =
|
||||
pending.get(recipientId)?.messages.map((message) => message.receipt.dispatched) ?? [];
|
||||
let aborted!: () => void;
|
||||
const cancellation = new Promise<never>((_resolve, reject) => {
|
||||
aborted = () => reject(new DOMException("Wait aborted", "AbortError"));
|
||||
signal.addEventListener("abort", aborted, { once: true });
|
||||
});
|
||||
try {
|
||||
await Promise.race([
|
||||
Promise.all([dispatching.get(recipientId), ...admitted]),
|
||||
cancellation,
|
||||
]);
|
||||
} finally {
|
||||
signal.removeEventListener("abort", aborted);
|
||||
}
|
||||
},
|
||||
close() {
|
||||
if (closing) return closing;
|
||||
closed = true;
|
||||
for (const recipientId of pending.keys()) flush(recipientId);
|
||||
// Each failure is reported and rejects its receipts; shutdown still drains other recipients.
|
||||
closing = Promise.allSettled(dispatching.values()).then(() => undefined);
|
||||
return closing;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
AgentPromptContentBlock,
|
||||
AgentPromptInput,
|
||||
AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
const agentIdSchema = z.string().min(1);
|
||||
const sourceSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("agent-message"),
|
||||
agentId: agentIdSchema,
|
||||
serverId: z.string().min(1).optional(),
|
||||
title: z.string().min(1).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("agent-notification"),
|
||||
agentId: agentIdSchema,
|
||||
serverId: z.string().min(1).optional(),
|
||||
title: z.string().min(1).optional(),
|
||||
event: z.enum(["finished", "errored", "permission-required", "closed"]),
|
||||
}),
|
||||
]);
|
||||
export type AgentPromptSource = z.infer<typeof sourceSchema>;
|
||||
|
||||
interface AgentMessage {
|
||||
id: string;
|
||||
source: AgentPromptSource | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (character) => {
|
||||
switch (character) {
|
||||
case "&":
|
||||
return "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case '"':
|
||||
return """;
|
||||
default:
|
||||
return "'";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function unescapeXml(value: string): string | null {
|
||||
if (/[<>]|&(?!amp;|lt;|gt;|quot;|apos;)/.test(value)) return null;
|
||||
return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, entity: string) => {
|
||||
switch (entity) {
|
||||
case "amp":
|
||||
return "&";
|
||||
case "lt":
|
||||
return "<";
|
||||
case "gt":
|
||||
return ">";
|
||||
case "quot":
|
||||
return '"';
|
||||
default:
|
||||
return "'";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function formatAgentMessage(message: AgentMessage): string {
|
||||
const { source } = message;
|
||||
const attributes = source
|
||||
? `kind="${source.kind}" source-agent-id="${escapeXml(source.agentId)}"`
|
||||
: 'kind="user-message"';
|
||||
const server = source?.serverId ? ` source-server-id="${escapeXml(source.serverId)}"` : "";
|
||||
const title = source?.title ? ` source-agent-title="${escapeXml(source.title)}"` : "";
|
||||
const event = source?.kind === "agent-notification" ? ` event="${source.event}"` : "";
|
||||
return `<paseo-system version="1" ${attributes} message-id="${escapeXml(message.id)}"${server}${title}${event}>\n${escapeXml(message.text)}\n</paseo-system>`;
|
||||
}
|
||||
|
||||
export function parseAgentMessage(text: string): AgentMessage | null {
|
||||
const envelope = /^<paseo-system((?:\s+[a-z-]+="[^"<>]*")+)>\n([\s\S]*)\n<\/paseo-system>$/.exec(
|
||||
text,
|
||||
);
|
||||
if (!envelope) return null;
|
||||
const attributes = new Map<string, string>();
|
||||
for (const match of envelope[1].matchAll(/\s+([a-z-]+)="([^"<>]*)"/g)) {
|
||||
const value = unescapeXml(match[2]);
|
||||
if (value === null || attributes.has(match[1])) return null;
|
||||
attributes.set(match[1], value);
|
||||
}
|
||||
if (attributes.get("version") !== "1") return null;
|
||||
const id = attributes.get("message-id");
|
||||
const body = unescapeXml(envelope[2]);
|
||||
if (!id || body === null) return null;
|
||||
if (attributes.get("kind") === "user-message") {
|
||||
if (attributes.has("source-agent-id") || attributes.has("event")) return null;
|
||||
return { id, source: null, text: body };
|
||||
}
|
||||
const source = sourceSchema.safeParse({
|
||||
kind: attributes.get("kind"),
|
||||
agentId: attributes.get("source-agent-id"),
|
||||
serverId: attributes.get("source-server-id"),
|
||||
event: attributes.get("event"),
|
||||
title: attributes.get("source-agent-title"),
|
||||
});
|
||||
if (!source.success) return null;
|
||||
return { id, source: source.data, text: body };
|
||||
}
|
||||
|
||||
/** Accept only a complete sequence of envelopes; prose around them stays user text. */
|
||||
export function parseAgentMessages(text: string): AgentMessage[] | null {
|
||||
// Bodies are XML escaped, so a literal closing tag unambiguously ends an entry.
|
||||
const parts = text.split(/(?<=<\/paseo-system>)\n+(?=<paseo-system )/);
|
||||
const messages = parts.map(parseAgentMessage);
|
||||
return messages.every((message) => message !== null) ? messages : null;
|
||||
}
|
||||
|
||||
function renderMessageText(block: AgentPromptContentBlock): string[] {
|
||||
if (block.type === "image") return [];
|
||||
if (block.type === "text") return [block.text];
|
||||
return [renderPromptAttachmentAsText(block)];
|
||||
}
|
||||
|
||||
export function prepareAgentMessage(
|
||||
prompt: AgentPromptInput,
|
||||
source?: AgentPromptSource,
|
||||
id?: string,
|
||||
): { prompt: AgentPromptInput; messageId?: string } {
|
||||
if (!source) {
|
||||
const submittedText =
|
||||
typeof prompt === "string"
|
||||
? prompt
|
||||
: prompt
|
||||
.flatMap((block) =>
|
||||
block.type === "text" && !("mimeType" in block) ? [block.text] : [],
|
||||
)
|
||||
.join("\n")
|
||||
.trim();
|
||||
// Quote reserved envelopes only when they arrived through the human send path.
|
||||
// This provenance must survive provider history, not only the accepted local row.
|
||||
if (!parseAgentMessages(submittedText) && !isSystemInjectedEnvelope(submittedText)) {
|
||||
return { prompt, messageId: id };
|
||||
}
|
||||
}
|
||||
const messageId = id ?? randomUUID();
|
||||
const text = typeof prompt === "string" ? prompt : prompt.flatMap(renderMessageText).join("\n\n");
|
||||
// Render textual attachments before wrapping, so provider replay still contains one
|
||||
// complete envelope instead of an envelope followed by provider-rendered context.
|
||||
const envelope = formatAgentMessage({ id: messageId, source: source ?? null, text });
|
||||
return {
|
||||
messageId,
|
||||
prompt:
|
||||
typeof prompt === "string"
|
||||
? envelope
|
||||
: [{ type: "text", text: envelope }, ...prompt.filter((block) => block.type === "image")],
|
||||
};
|
||||
}
|
||||
|
||||
/** The same projection owns provider echoes, acceptance, import, and replay. */
|
||||
export function projectAgentMessages(item: AgentTimelineItem): AgentTimelineItem[] {
|
||||
if (item.type === "user_message") {
|
||||
const messages = parseAgentMessages(item.text);
|
||||
if (messages) {
|
||||
return messages.flatMap((message) => {
|
||||
const projected = projectAgentMessage({ ...item, text: formatAgentMessage(message) });
|
||||
return projected ? [projected] : [];
|
||||
});
|
||||
}
|
||||
}
|
||||
const projected = projectAgentMessage(item);
|
||||
return projected ? [projected] : [];
|
||||
}
|
||||
|
||||
function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null {
|
||||
if (item.type !== "user_message") return item;
|
||||
const message = parseAgentMessage(item.text);
|
||||
if (!message) {
|
||||
// Schedule bodies include their run ID, keeping identity stable across acceptance
|
||||
// and provider replay even when the provider assigns its own message ID.
|
||||
if (!isSystemInjectedEnvelope(item.text)) return item;
|
||||
const text = item.text.slice("<paseo-system>\n".length, -"\n</paseo-system>".length);
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: `paseo-system:${createHash("sha256").update(item.text).digest("hex")}`,
|
||||
name: "heartbeat",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", text, icon: "sparkles" },
|
||||
};
|
||||
}
|
||||
const { source } = message;
|
||||
if (!source) {
|
||||
return { ...item, text: message.text, clientMessageId: message.id };
|
||||
}
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: `paseo-agent-message:${message.id}`,
|
||||
agentMessage: {
|
||||
event: source.kind === "agent-message" ? "message" : source.event,
|
||||
sender: {
|
||||
id: source.agentId,
|
||||
...(source.serverId ? { serverId: source.serverId } : {}),
|
||||
...(source.title ? { title: source.title } : {}),
|
||||
},
|
||||
text: message.text,
|
||||
},
|
||||
// COMPAT(agentMessageToolEnvelope): added in v0.11.1; remove after 2027-04-08
|
||||
// once clients can accept a dedicated timeline variant. Older clients still
|
||||
// parse this existing shape and can expand the delivered body.
|
||||
name: "agent_message",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", text: message.text, icon: "bot" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a body in <paseo-system>…</paseo-system> so the receiving agent
|
||||
* recognizes the prompt as system-injected context — not a user turn.
|
||||
* Used by schedule fires; agent messages use the versioned envelope.
|
||||
*/
|
||||
export function formatSystemNotificationPrompt(reason: string): string {
|
||||
return `<paseo-system>\n${reason}\n</paseo-system>`;
|
||||
}
|
||||
|
||||
const SYSTEM_ENVELOPE_PATTERN = /^<paseo-system>\n[\s\S]*\n<\/paseo-system>$/;
|
||||
|
||||
export function isSystemInjectedEnvelope(text: string): boolean {
|
||||
return SYSTEM_ENVELOPE_PATTERN.test(text);
|
||||
}
|
||||
@@ -1,191 +1,11 @@
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
AgentPromptContentBlock,
|
||||
AgentPromptInput,
|
||||
AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
const agentIdSchema = z.string().min(1);
|
||||
const sourceSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("agent-message"),
|
||||
agentId: agentIdSchema,
|
||||
title: z.string().min(1).optional(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("agent-notification"),
|
||||
agentId: agentIdSchema,
|
||||
title: z.string().min(1).optional(),
|
||||
event: z.enum(["finished", "errored", "permission-required", "closed"]),
|
||||
}),
|
||||
]);
|
||||
export type AgentPromptSource = z.infer<typeof sourceSchema>;
|
||||
|
||||
interface AgentMessage {
|
||||
id: string;
|
||||
source: AgentPromptSource | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (character) => {
|
||||
switch (character) {
|
||||
case "&":
|
||||
return "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case '"':
|
||||
return """;
|
||||
default:
|
||||
return "'";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function unescapeXml(value: string): string | null {
|
||||
if (/[<>]|&(?!amp;|lt;|gt;|quot;|apos;)/.test(value)) return null;
|
||||
return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, entity: string) => {
|
||||
switch (entity) {
|
||||
case "amp":
|
||||
return "&";
|
||||
case "lt":
|
||||
return "<";
|
||||
case "gt":
|
||||
return ">";
|
||||
case "quot":
|
||||
return '"';
|
||||
default:
|
||||
return "'";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function formatAgentMessage(message: AgentMessage): string {
|
||||
const { source } = message;
|
||||
const attributes = source
|
||||
? `kind="${source.kind}" source-agent-id="${escapeXml(source.agentId)}"`
|
||||
: 'kind="user-message"';
|
||||
const title = source?.title ? ` source-agent-title="${escapeXml(source.title)}"` : "";
|
||||
const event = source?.kind === "agent-notification" ? ` event="${source.event}"` : "";
|
||||
return `<paseo-system version="1" ${attributes} message-id="${escapeXml(message.id)}"${title}${event}>\n${escapeXml(message.text)}\n</paseo-system>`;
|
||||
}
|
||||
|
||||
export function parseAgentMessage(text: string): AgentMessage | null {
|
||||
const envelope = /^<paseo-system((?:\s+[a-z-]+="[^"<>]*")+)>\n([\s\S]*)\n<\/paseo-system>$/.exec(
|
||||
text,
|
||||
);
|
||||
if (!envelope) return null;
|
||||
const attributes = new Map<string, string>();
|
||||
for (const match of envelope[1].matchAll(/\s+([a-z-]+)="([^"<>]*)"/g)) {
|
||||
const value = unescapeXml(match[2]);
|
||||
if (value === null || attributes.has(match[1])) return null;
|
||||
attributes.set(match[1], value);
|
||||
}
|
||||
if (attributes.get("version") !== "1") return null;
|
||||
const id = attributes.get("message-id");
|
||||
const body = unescapeXml(envelope[2]);
|
||||
if (!id || body === null) return null;
|
||||
if (attributes.get("kind") === "user-message") {
|
||||
if (attributes.has("source-agent-id") || attributes.has("event")) return null;
|
||||
return { id, source: null, text: body };
|
||||
}
|
||||
const source = sourceSchema.safeParse({
|
||||
kind: attributes.get("kind"),
|
||||
agentId: attributes.get("source-agent-id"),
|
||||
event: attributes.get("event"),
|
||||
title: attributes.get("source-agent-title"),
|
||||
});
|
||||
if (!source.success) return null;
|
||||
return { id, source: source.data, text: body };
|
||||
}
|
||||
|
||||
function renderMessageText(block: AgentPromptContentBlock): string[] {
|
||||
if (block.type === "image") return [];
|
||||
if (block.type === "text") return [block.text];
|
||||
return [renderPromptAttachmentAsText(block)];
|
||||
}
|
||||
|
||||
export function prepareAgentMessage(
|
||||
prompt: AgentPromptInput,
|
||||
source?: AgentPromptSource,
|
||||
id?: string,
|
||||
): { prompt: AgentPromptInput; messageId?: string } {
|
||||
if (!source) {
|
||||
const submittedText =
|
||||
typeof prompt === "string"
|
||||
? prompt
|
||||
: prompt
|
||||
.flatMap((block) =>
|
||||
block.type === "text" && !("mimeType" in block) ? [block.text] : [],
|
||||
)
|
||||
.join("\n")
|
||||
.trim();
|
||||
// Quote reserved envelopes only when they arrived through the human send path.
|
||||
// This provenance must survive provider history, not only the accepted local row.
|
||||
if (!parseAgentMessage(submittedText) && !isSystemInjectedEnvelope(submittedText)) {
|
||||
return { prompt, messageId: id };
|
||||
}
|
||||
}
|
||||
const messageId = id ?? randomUUID();
|
||||
const text = typeof prompt === "string" ? prompt : prompt.flatMap(renderMessageText).join("\n\n");
|
||||
// Render textual attachments before wrapping, so provider replay still contains one
|
||||
// complete envelope instead of an envelope followed by provider-rendered context.
|
||||
const envelope = formatAgentMessage({ id: messageId, source: source ?? null, text });
|
||||
return {
|
||||
messageId,
|
||||
prompt:
|
||||
typeof prompt === "string"
|
||||
? envelope
|
||||
: [{ type: "text", text: envelope }, ...prompt.filter((block) => block.type === "image")],
|
||||
};
|
||||
}
|
||||
|
||||
/** The same projection owns provider echoes, acceptance, import, and replay. */
|
||||
export function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null {
|
||||
if (item.type !== "user_message") return item;
|
||||
const message = parseAgentMessage(item.text);
|
||||
if (!message) {
|
||||
// COMPAT(legacySystemEnvelope): added in v0.11.1; remove after 2027-04-08 once
|
||||
// legacy notification history is retired. Schedules still use this envelope.
|
||||
return isSystemInjectedEnvelope(item.text) ? null : item;
|
||||
}
|
||||
const { source } = message;
|
||||
if (!source) {
|
||||
return { ...item, text: message.text, clientMessageId: message.id };
|
||||
}
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: `paseo-agent-message:${message.id}`,
|
||||
agentMessage: {
|
||||
event: source.kind === "agent-message" ? "message" : source.event,
|
||||
sender: { id: source.agentId, ...(source.title ? { title: source.title } : {}) },
|
||||
text: message.text,
|
||||
},
|
||||
// COMPAT(agentMessageToolEnvelope): added in v0.11.1; remove after 2027-04-08
|
||||
// once clients can accept a dedicated timeline variant. Older clients still
|
||||
// parse this existing shape and can expand the delivered body.
|
||||
name: "agent_message",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", text: message.text, icon: "bot" },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a body in <paseo-system>…</paseo-system> so the receiving agent
|
||||
* recognizes the prompt as system-injected context — not a user turn.
|
||||
* Used by schedule fires; agent messages use the versioned envelope.
|
||||
*/
|
||||
export function formatSystemNotificationPrompt(reason: string): string {
|
||||
return `<paseo-system>\n${reason}\n</paseo-system>`;
|
||||
}
|
||||
|
||||
const SYSTEM_ENVELOPE_PATTERN = /^<paseo-system>\n[\s\S]*\n<\/paseo-system>$/;
|
||||
|
||||
export function isSystemInjectedEnvelope(text: string): boolean {
|
||||
return SYSTEM_ENVELOPE_PATTERN.test(text);
|
||||
}
|
||||
export {
|
||||
formatAgentMessage,
|
||||
parseAgentMessage,
|
||||
parseAgentMessages,
|
||||
prepareAgentMessage,
|
||||
projectAgentMessages,
|
||||
formatSystemNotificationPrompt,
|
||||
isSystemInjectedEnvelope,
|
||||
type AgentPromptSource,
|
||||
} from "./envelope.js";
|
||||
export { createAgentMessageDelivery, type AgentMessageDelivery } from "./delivery.js";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isSameOrDescendantPath } from "../path-utils.js";
|
||||
import type {
|
||||
AgentListItemPayload,
|
||||
AgentSnapshotPayload,
|
||||
@@ -30,6 +31,13 @@ interface ProjectionOptions {
|
||||
title?: string | null;
|
||||
createdAt?: string;
|
||||
internal?: boolean;
|
||||
/**
|
||||
* Archive timestamp, supplied by a caller holding the stored record. A live
|
||||
* managed agent cannot know this: an archived agent is resumed back into
|
||||
* memory to serve a history read, so being in the manager is not evidence
|
||||
* that it is active. Storage stays the single owner.
|
||||
*/
|
||||
archivedAt?: string | null;
|
||||
}
|
||||
|
||||
interface RecentProviderSessionProjectionOptions {
|
||||
@@ -87,6 +95,7 @@ export function toStoredAgentRecord(
|
||||
lastStatus: agent.lifecycle,
|
||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||
config: config ?? null,
|
||||
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
|
||||
runtimeInfo,
|
||||
features: normalizeFeatures(agent.features),
|
||||
persistence,
|
||||
@@ -139,6 +148,7 @@ export function toAgentPayload(
|
||||
persistence: projectPersistenceHandleForWire(agent.persistence),
|
||||
title: options?.title ?? null,
|
||||
labels: agent.labels,
|
||||
archivedAt: options?.archivedAt ?? null,
|
||||
...(agent.internal ? { internal: true } : {}),
|
||||
};
|
||||
|
||||
@@ -278,6 +288,66 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt
|
||||
};
|
||||
}
|
||||
|
||||
function parseTimestamp(value: string | null | undefined): number {
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function resolveAgentListActivityTime(agent: AgentListItemPayload): number {
|
||||
return Math.max(
|
||||
parseTimestamp(agent.updatedAt),
|
||||
parseTimestamp(agent.lastUserMessageAt),
|
||||
parseTimestamp(agent.attentionTimestamp),
|
||||
parseTimestamp(agent.archivedAt),
|
||||
parseTimestamp(agent.createdAt),
|
||||
);
|
||||
}
|
||||
|
||||
function compareAgentListItems(a: AgentListItemPayload, b: AgentListItemPayload): number {
|
||||
const attentionDelta =
|
||||
Number(b.requiresAttention ?? false) - Number(a.requiresAttention ?? false);
|
||||
if (attentionDelta !== 0) {
|
||||
return attentionDelta;
|
||||
}
|
||||
|
||||
const statusOrder = {
|
||||
running: 0,
|
||||
initializing: 1,
|
||||
idle: 2,
|
||||
error: 3,
|
||||
closed: 4,
|
||||
} as Record<string, number>;
|
||||
const statusDelta = (statusOrder[a.status] ?? 999) - (statusOrder[b.status] ?? 999);
|
||||
if (statusDelta !== 0) {
|
||||
return statusDelta;
|
||||
}
|
||||
|
||||
return resolveAgentListActivityTime(b) - resolveAgentListActivityTime(a);
|
||||
}
|
||||
|
||||
export function selectAgentListItems(
|
||||
agents: readonly AgentSnapshotPayload[],
|
||||
options: {
|
||||
cwd?: string;
|
||||
statuses?: AgentSnapshotPayload["status"][];
|
||||
sinceHours?: number;
|
||||
limit?: number;
|
||||
} = {},
|
||||
): AgentListItemPayload[] {
|
||||
const statusFilter = options.statuses?.length ? new Set(options.statuses) : null;
|
||||
const sinceMs = Date.now() - (options.sinceHours ?? 48) * 60 * 60 * 1000;
|
||||
return agents
|
||||
.map(toAgentListItemPayload)
|
||||
.filter((agent) => !options.cwd || isSameOrDescendantPath(options.cwd, agent.cwd))
|
||||
.filter((agent) => !statusFilter || statusFilter.has(agent.status))
|
||||
.filter((agent) => !agent.archivedAt || resolveAgentListActivityTime(agent) >= sinceMs)
|
||||
.sort(compareAgentListItems)
|
||||
.slice(0, options.limit ?? 50);
|
||||
}
|
||||
|
||||
export function toRecentProviderSessionDescriptorPayload(
|
||||
session: ImportableProviderSession & { provider: string },
|
||||
options: RecentProviderSessionProjectionOptions,
|
||||
|
||||
@@ -4,10 +4,9 @@ import {
|
||||
parseAgentMessage,
|
||||
formatAgentMessage,
|
||||
prepareAgentMessage,
|
||||
projectAgentMessage,
|
||||
projectAgentMessages,
|
||||
} from "./agent-messages/index.js";
|
||||
import { expect, it, test, vi } from "vitest";
|
||||
import pino, { type Logger } from "pino";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -15,9 +14,7 @@ import { join } from "node:path";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { setupFinishNotification, waitForAgentRunStartWithTimeout } from "./agent-prompt.js";
|
||||
import type { AgentManagerEvent, ManagedAgent } from "./agent-manager.js";
|
||||
import { waitForAgentRunStartWithTimeout } from "./agent-prompt.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentRunResult,
|
||||
@@ -25,543 +22,12 @@ import type {
|
||||
AgentStreamEvent,
|
||||
} from "./agent-sdk-types.js";
|
||||
|
||||
interface CapturedLogger {
|
||||
logger: Logger;
|
||||
records: Array<Record<string, unknown>>;
|
||||
nextRecord: Promise<void>;
|
||||
}
|
||||
|
||||
function createCapturedLogger(): CapturedLogger {
|
||||
const records: Array<Record<string, unknown>> = [];
|
||||
let resolveNextRecord!: () => void;
|
||||
const nextRecord = new Promise<void>((resolve) => {
|
||||
resolveNextRecord = resolve;
|
||||
});
|
||||
const logger = pino(
|
||||
{ level: "error" },
|
||||
{
|
||||
write(line: string) {
|
||||
records.push(JSON.parse(line) as Record<string, unknown>);
|
||||
resolveNextRecord();
|
||||
},
|
||||
},
|
||||
);
|
||||
return { logger, records, nextRecord };
|
||||
}
|
||||
|
||||
interface FinishNotificationScenarioOptions {
|
||||
childLastAssistantMessage?: string | null;
|
||||
childParentAgentId?: string | null;
|
||||
requireParentOwnership?: boolean;
|
||||
parentPromptError?: Error;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
interface FinishNotificationScenario {
|
||||
startWatchingChild(): void;
|
||||
requestChildPermission(requestId?: string): void;
|
||||
resolveChildPermission(requestId?: string): void;
|
||||
resolveChildPermissionFromState(requestId?: string): void;
|
||||
resolveChildPermissionWhileIdle(requestId?: string): void;
|
||||
finishChild(): void;
|
||||
finishChildAndReadParentPrompt(): Promise<string>;
|
||||
closeChildAndReadParentPrompt(): Promise<string>;
|
||||
parentPrompts(): string[];
|
||||
steerAttemptCount(): number;
|
||||
wasParentPrompted(): boolean;
|
||||
}
|
||||
|
||||
function createFinishNotificationScenario(
|
||||
options?: FinishNotificationScenarioOptions,
|
||||
): FinishNotificationScenario {
|
||||
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
|
||||
let resolveParentPrompt: ((prompt: string) => void) | null = null;
|
||||
let parentPrompted = false;
|
||||
let steerAttemptCount = 0;
|
||||
const parentPrompts: string[] = [];
|
||||
|
||||
const childAgent: ManagedAgent = Object.create(null);
|
||||
Reflect.set(childAgent, "id", "child-agent");
|
||||
Reflect.set(childAgent, "lifecycle", "idle");
|
||||
Reflect.set(childAgent, "config", { title: "Child Agent" });
|
||||
Reflect.set(childAgent, "pendingPermissions", new Map());
|
||||
|
||||
const callerAgent: ManagedAgent = Object.create(null);
|
||||
Reflect.set(callerAgent, "id", "caller-agent");
|
||||
Reflect.set(callerAgent, "lifecycle", "idle");
|
||||
Reflect.set(callerAgent, "config", { title: "Caller Agent" });
|
||||
|
||||
const agentManager = new AgentManager({ clients: {}, logger: createTestLogger() });
|
||||
Reflect.set(agentManager, "getAgent", (agentId: string) => {
|
||||
if (agentId === "child-agent") {
|
||||
return childAgent;
|
||||
}
|
||||
if (agentId === "caller-agent") {
|
||||
return callerAgent;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
Reflect.set(agentManager, "subscribe", (callback: (event: AgentManagerEvent) => void) => {
|
||||
subscriber = callback;
|
||||
return () => {
|
||||
subscriber = null;
|
||||
};
|
||||
});
|
||||
Reflect.set(agentManager, "getLastAssistantMessage", async () => {
|
||||
return options?.childLastAssistantMessage ?? null;
|
||||
});
|
||||
Reflect.set(agentManager, "tryRunOutOfBand", () => false);
|
||||
Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError));
|
||||
Reflect.set(agentManager, "steerOrReplaceActiveTurn", async () => {
|
||||
steerAttemptCount += 1;
|
||||
return { status: "inactive" };
|
||||
});
|
||||
Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => {
|
||||
parentPrompted = true;
|
||||
parentPrompts.push(prompt);
|
||||
resolveParentPrompt?.(prompt);
|
||||
return (async function* noop() {})();
|
||||
});
|
||||
Reflect.set(agentManager, "replaceAgentRun", async (_agentId: string, prompt: string) => {
|
||||
resolveParentPrompt?.(prompt);
|
||||
throw options?.parentPromptError;
|
||||
});
|
||||
|
||||
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
|
||||
Reflect.set(agentStorage, "get", async (agentId: string) => {
|
||||
if (agentId === "child-agent") {
|
||||
const parentAgentId =
|
||||
options?.childParentAgentId === undefined ? "caller-agent" : options.childParentAgentId;
|
||||
return {
|
||||
title: "Child Agent",
|
||||
labels: parentAgentId ? { "paseo.parent-agent-id": parentAgentId } : {},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return {
|
||||
startWatchingChild() {
|
||||
setupFinishNotification({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
childAgentId: "child-agent",
|
||||
callerAgentId: "caller-agent",
|
||||
requireParentOwnership: options?.requireParentOwnership,
|
||||
logger: options?.logger ?? createTestLogger(),
|
||||
});
|
||||
},
|
||||
requestChildPermission(requestId = "permission-1") {
|
||||
childAgent.lifecycle = "running";
|
||||
childAgent.pendingPermissions.set(requestId, {
|
||||
id: requestId,
|
||||
provider: "claude",
|
||||
kind: "tool",
|
||||
name: "Run command",
|
||||
description: "Write the QA sentinel",
|
||||
input: {
|
||||
file_path: "/tmp/permission-qa.txt",
|
||||
content: "PASEO_PERMISSION_NOTIFY_QA_OK\n",
|
||||
},
|
||||
});
|
||||
subscriber?.({
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
subscriber?.({
|
||||
type: "agent_stream",
|
||||
agentId: "child-agent",
|
||||
event: {
|
||||
type: "permission_requested",
|
||||
provider: "codex",
|
||||
request: childAgent.pendingPermissions.get(requestId)!,
|
||||
},
|
||||
});
|
||||
},
|
||||
resolveChildPermission(requestId = "permission-1") {
|
||||
childAgent.pendingPermissions.delete(requestId);
|
||||
subscriber?.({
|
||||
type: "agent_stream",
|
||||
agentId: "child-agent",
|
||||
event: {
|
||||
type: "permission_resolved",
|
||||
provider: "codex",
|
||||
requestId,
|
||||
resolution: { behavior: "allow" },
|
||||
},
|
||||
});
|
||||
},
|
||||
resolveChildPermissionFromState(requestId = "permission-1") {
|
||||
childAgent.pendingPermissions.delete(requestId);
|
||||
subscriber?.({ type: "agent_state", agent: childAgent });
|
||||
},
|
||||
resolveChildPermissionWhileIdle(requestId = "permission-1") {
|
||||
childAgent.pendingPermissions.delete(requestId);
|
||||
childAgent.lifecycle = "idle";
|
||||
subscriber?.({ type: "agent_state", agent: childAgent });
|
||||
subscriber?.({
|
||||
type: "agent_stream",
|
||||
agentId: "child-agent",
|
||||
event: {
|
||||
type: "permission_resolved",
|
||||
provider: "codex",
|
||||
requestId,
|
||||
resolution: { behavior: "allow" },
|
||||
},
|
||||
});
|
||||
},
|
||||
finishChild() {
|
||||
childAgent.lifecycle = "running";
|
||||
subscriber?.({
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
|
||||
childAgent.lifecycle = "idle";
|
||||
subscriber?.({
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
subscriber?.({
|
||||
type: "agent_stream",
|
||||
agentId: "child-agent",
|
||||
event: { type: "turn_completed", provider: "codex" },
|
||||
});
|
||||
},
|
||||
async finishChildAndReadParentPrompt() {
|
||||
const parentPrompt = new Promise<string>((resolve) => {
|
||||
resolveParentPrompt = resolve;
|
||||
});
|
||||
this.finishChild();
|
||||
|
||||
return parentPrompt;
|
||||
},
|
||||
async closeChildAndReadParentPrompt() {
|
||||
const parentPrompt = new Promise<string>((resolve) => {
|
||||
resolveParentPrompt = resolve;
|
||||
});
|
||||
|
||||
childAgent.lifecycle = "running";
|
||||
subscriber?.({
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
|
||||
childAgent.lifecycle = "closed";
|
||||
subscriber?.({
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
|
||||
return parentPrompt;
|
||||
},
|
||||
parentPrompts() {
|
||||
return parentPrompts;
|
||||
},
|
||||
steerAttemptCount() {
|
||||
return steerAttemptCount;
|
||||
},
|
||||
wasParentPrompted() {
|
||||
return parentPrompted;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("isSystemInjectedEnvelope matches the envelope formatSystemNotificationPrompt produces", () => {
|
||||
expect(isSystemInjectedEnvelope(formatSystemNotificationPrompt("child finished"))).toBe(true);
|
||||
expect(isSystemInjectedEnvelope("hello world")).toBe(false);
|
||||
});
|
||||
|
||||
test("finish notifications tell the parent the child's last assistant message", async () => {
|
||||
const scenario = createFinishNotificationScenario({
|
||||
childLastAssistantMessage: "Implemented the cleanup and all checks pass.",
|
||||
});
|
||||
|
||||
scenario.startWatchingChild();
|
||||
const parentPrompt = await scenario.finishChildAndReadParentPrompt();
|
||||
|
||||
expect(parseAgentMessage(parentPrompt)).toEqual({
|
||||
id: expect.any(String),
|
||||
source: {
|
||||
kind: "agent-notification",
|
||||
agentId: "child-agent",
|
||||
title: "Child Agent",
|
||||
event: "finished",
|
||||
},
|
||||
text: "Agent child-agent (Child Agent) finished.\n\n<agent-response>\nImplemented the cleanup and all checks pass.\n</agent-response>",
|
||||
});
|
||||
expect(scenario.steerAttemptCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("finish notifications truncate oversized child responses", async () => {
|
||||
const included = "x".repeat(4000);
|
||||
const omitted = "TAIL-MARKER".repeat(50);
|
||||
const scenario = createFinishNotificationScenario({
|
||||
childLastAssistantMessage: included + omitted,
|
||||
});
|
||||
|
||||
scenario.startWatchingChild();
|
||||
const parentPrompt = await scenario.finishChildAndReadParentPrompt();
|
||||
|
||||
expect(parentPrompt).toContain(included);
|
||||
expect(parentPrompt).toContain(
|
||||
`[truncated ${omitted.length} chars; use get_agent_activity for the full response]`,
|
||||
);
|
||||
expect(parentPrompt).not.toContain("TAIL-MARKER");
|
||||
});
|
||||
|
||||
test("closing a watched child notifies the caller", async () => {
|
||||
const scenario = createFinishNotificationScenario();
|
||||
|
||||
scenario.startWatchingChild();
|
||||
const parentPrompt = await scenario.closeChildAndReadParentPrompt();
|
||||
|
||||
expect(parseAgentMessage(parentPrompt)).toEqual({
|
||||
id: expect.any(String),
|
||||
source: {
|
||||
kind: "agent-notification",
|
||||
agentId: "child-agent",
|
||||
title: "Child Agent",
|
||||
event: "closed",
|
||||
},
|
||||
text: "Agent child-agent (Child Agent) was closed.",
|
||||
});
|
||||
});
|
||||
|
||||
test("finish notifications survive permission responses", async () => {
|
||||
const scenario = createFinishNotificationScenario();
|
||||
|
||||
scenario.startWatchingChild();
|
||||
scenario.requestChildPermission();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(scenario.parentPrompts()).toHaveLength(1);
|
||||
});
|
||||
expect(scenario.parentPrompts()[0]).toContain("needs permission.");
|
||||
const permissionPayload = parseAgentMessage(scenario.parentPrompts()[0])!.text.match(
|
||||
/<permission-request>\n([\s\S]+?)\n<\/permission-request>/,
|
||||
)?.[1];
|
||||
expect(permissionPayload).toBeDefined();
|
||||
expect(JSON.parse(permissionPayload!)).toEqual({
|
||||
agentId: "child-agent",
|
||||
requestId: "permission-1",
|
||||
request: {
|
||||
id: "permission-1",
|
||||
provider: "claude",
|
||||
kind: "tool",
|
||||
name: "Run command",
|
||||
description: "Write the QA sentinel",
|
||||
input: {
|
||||
file_path: "/tmp/permission-qa.txt",
|
||||
content: "PASEO_PERMISSION_NOTIFY_QA_OK\n",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
scenario.resolveChildPermission();
|
||||
scenario.finishChild();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(scenario.parentPrompts()).toHaveLength(2);
|
||||
});
|
||||
expect(scenario.parentPrompts()[1]).toContain("finished.");
|
||||
});
|
||||
|
||||
test("an idle permission resolution waits for the resumed run to finish", async () => {
|
||||
const scenario = createFinishNotificationScenario();
|
||||
|
||||
scenario.startWatchingChild();
|
||||
scenario.requestChildPermission();
|
||||
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1));
|
||||
|
||||
scenario.resolveChildPermissionWhileIdle();
|
||||
scenario.requestChildPermission("permission-2");
|
||||
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2));
|
||||
expect(scenario.parentPrompts().every((prompt) => prompt.includes("needs permission."))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
scenario.resolveChildPermission("permission-2");
|
||||
scenario.finishChild();
|
||||
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3));
|
||||
expect(scenario.parentPrompts()[2]).toContain("finished.");
|
||||
});
|
||||
|
||||
test("finish notifications report every concurrently pending permission", async () => {
|
||||
const scenario = createFinishNotificationScenario();
|
||||
|
||||
scenario.startWatchingChild();
|
||||
scenario.requestChildPermission("permission-1");
|
||||
scenario.requestChildPermission("permission-2");
|
||||
|
||||
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2));
|
||||
expect(
|
||||
scenario.parentPrompts().map((prompt) => {
|
||||
const payload = parseAgentMessage(prompt)!.text.match(
|
||||
/<permission-request>\n([\s\S]+?)\n<\/permission-request>/,
|
||||
)?.[1];
|
||||
return JSON.parse(payload!).requestId;
|
||||
}),
|
||||
).toEqual(["permission-1", "permission-2"]);
|
||||
|
||||
scenario.resolveChildPermission("permission-1");
|
||||
scenario.resolveChildPermission("permission-2");
|
||||
scenario.finishChild();
|
||||
|
||||
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3));
|
||||
expect(scenario.parentPrompts()[2]).toContain("finished.");
|
||||
});
|
||||
|
||||
test("finish notifications survive repeated permission cycles", async () => {
|
||||
const scenario = createFinishNotificationScenario();
|
||||
|
||||
scenario.startWatchingChild();
|
||||
scenario.requestChildPermission();
|
||||
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1));
|
||||
scenario.resolveChildPermissionFromState();
|
||||
|
||||
scenario.requestChildPermission();
|
||||
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2));
|
||||
scenario.resolveChildPermission();
|
||||
scenario.finishChild();
|
||||
|
||||
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3));
|
||||
expect(
|
||||
scenario.parentPrompts().map((prompt) => prompt.match(/(needs permission|finished)\./)?.[1]),
|
||||
).toEqual(["needs permission", "needs permission", "finished"]);
|
||||
});
|
||||
|
||||
test("detaching a child ends its parent-owned finish notification", async () => {
|
||||
const scenario = createFinishNotificationScenario({
|
||||
childParentAgentId: null,
|
||||
requireParentOwnership: true,
|
||||
});
|
||||
scenario.startWatchingChild();
|
||||
scenario.finishChild();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(scenario.wasParentPrompted()).toBe(false);
|
||||
});
|
||||
|
||||
test("follow-up finish notifications do not require a parent relationship", async () => {
|
||||
const scenario = createFinishNotificationScenario({ childParentAgentId: "another-agent" });
|
||||
|
||||
scenario.startWatchingChild();
|
||||
const parentPrompt = await scenario.finishChildAndReadParentPrompt();
|
||||
|
||||
expect(parentPrompt).toContain("Agent child-agent (Child Agent) finished.");
|
||||
});
|
||||
|
||||
test("finish notifications log a rejected parent prompt without an unhandled rejection", async () => {
|
||||
const captured = createCapturedLogger();
|
||||
const scenario = createFinishNotificationScenario({
|
||||
parentPromptError: new Error("parent provider rejected replacement"),
|
||||
logger: captured.logger,
|
||||
});
|
||||
|
||||
scenario.startWatchingChild();
|
||||
await scenario.finishChildAndReadParentPrompt();
|
||||
await captured.nextRecord;
|
||||
|
||||
expect(captured.records).toEqual([
|
||||
expect.objectContaining({
|
||||
msg: "Failed to notify caller agent",
|
||||
childAgentId: "child-agent",
|
||||
callerAgentId: "caller-agent",
|
||||
reason: "finished",
|
||||
err: expect.objectContaining({ message: "parent provider rejected replacement" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not notify archived callers", async () => {
|
||||
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
|
||||
|
||||
const childAgent: ManagedAgent = Object.create(null);
|
||||
Reflect.set(childAgent, "id", "child-agent");
|
||||
Reflect.set(childAgent, "lifecycle", "idle");
|
||||
Reflect.set(childAgent, "config", { title: "Child Agent" });
|
||||
Reflect.set(childAgent, "pendingPermissions", new Map());
|
||||
|
||||
const callerAgent: ManagedAgent = Object.create(null);
|
||||
Reflect.set(callerAgent, "id", "caller-agent");
|
||||
Reflect.set(callerAgent, "lifecycle", "idle");
|
||||
Reflect.set(callerAgent, "config", { title: "Caller Agent" });
|
||||
|
||||
const streamAgentSpy = vi.fn(() => (async function* noop() {})());
|
||||
const replaceAgentRunSpy = vi.fn(() => (async function* noop() {})());
|
||||
|
||||
const agentManager = new AgentManager({ clients: {}, logger: createTestLogger() });
|
||||
Reflect.set(
|
||||
agentManager,
|
||||
"getAgent",
|
||||
vi.fn((agentId: string) => {
|
||||
if (agentId === "child-agent") {
|
||||
return childAgent;
|
||||
}
|
||||
if (agentId === "caller-agent") {
|
||||
return callerAgent;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
Reflect.set(
|
||||
agentManager,
|
||||
"subscribe",
|
||||
vi.fn((callback: (event: AgentManagerEvent) => void) => {
|
||||
subscriber = callback;
|
||||
return () => {
|
||||
subscriber = null;
|
||||
};
|
||||
}),
|
||||
);
|
||||
Reflect.set(agentManager, "hasInFlightRun", vi.fn().mockReturnValue(false));
|
||||
Reflect.set(agentManager, "streamAgent", streamAgentSpy);
|
||||
Reflect.set(agentManager, "replaceAgentRun", replaceAgentRunSpy);
|
||||
|
||||
const agentStorageGetSpy = vi.fn(async (agentId: string) =>
|
||||
agentId === "caller-agent" ? { archivedAt: "2024-01-01" } : null,
|
||||
);
|
||||
const agentStorage: AgentStorage = Object.create(AgentStorage.prototype);
|
||||
Reflect.set(agentStorage, "get", agentStorageGetSpy);
|
||||
|
||||
setupFinishNotification({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
childAgentId: "child-agent",
|
||||
callerAgentId: "caller-agent",
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(subscriber).not.toBeNull();
|
||||
|
||||
childAgent.lifecycle = "running";
|
||||
subscriber?.({
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
|
||||
childAgent.lifecycle = "idle";
|
||||
subscriber?.({
|
||||
type: "agent_state",
|
||||
agent: childAgent,
|
||||
});
|
||||
subscriber?.({
|
||||
type: "agent_stream",
|
||||
agentId: "child-agent",
|
||||
event: { type: "turn_completed", provider: "codex" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(agentStorageGetSpy).toHaveBeenCalledWith("caller-agent");
|
||||
});
|
||||
|
||||
expect(streamAgentSpy).not.toHaveBeenCalled();
|
||||
expect(replaceAgentRunSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Deliberately independent literals rather than the production constants these tests
|
||||
// guard: deriving the boundaries from AGENT_RUN_START_TIMEOUT_MS would keep the tests
|
||||
// green if that constant were shortened back under a provider's startup budget.
|
||||
// Independent of the production constant so shortening the budget breaks this test.
|
||||
const EXPECTED_RUN_START_BUDGET_MS = 60_000;
|
||||
// The slowest provider startup budget the run-start wait has to sit outside of today
|
||||
// (OpenCode's OPENCODE_SERVER_STARTUP_TIMEOUT_MS).
|
||||
@@ -808,6 +274,36 @@ test("agent envelopes round-trip opaque sender IDs and XML-sensitive messages",
|
||||
expect(parseAgentMessage(encoded.replace('version="1"', 'version="1" version="1"'))).toBeNull();
|
||||
});
|
||||
|
||||
test("a delivered batch projects each message and completion separately in order", () => {
|
||||
const entries = [
|
||||
{
|
||||
id: "report",
|
||||
source: { kind: "agent-message" as const, agentId: "remote::worker", title: "Worker" },
|
||||
text: "Result <ready>",
|
||||
},
|
||||
{
|
||||
id: "finished",
|
||||
source: {
|
||||
kind: "agent-notification" as const,
|
||||
agentId: "remote::worker",
|
||||
event: "finished" as const,
|
||||
},
|
||||
text: "Finished & ready",
|
||||
},
|
||||
];
|
||||
const text = entries.map(formatAgentMessage).join("\n\n");
|
||||
expect(projectAgentMessages({ type: "user_message", text })).toEqual(
|
||||
entries.flatMap((entry) =>
|
||||
projectAgentMessages({ type: "user_message", text: formatAgentMessage(entry) }),
|
||||
),
|
||||
);
|
||||
// Human submissions containing the same envelopes must survive replay as human text.
|
||||
const pasted = prepareAgentMessage(text, undefined, "human-batch");
|
||||
expect(projectAgentMessages({ type: "user_message", text: String(pasted.prompt) })).toEqual([
|
||||
{ type: "user_message", text, clientMessageId: "human-batch" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("agent envelope includes rendered attachment context and preserves images", () => {
|
||||
const image = { type: "image" as const, data: "aW1hZ2U=", mimeType: "image/png" };
|
||||
const attachment = {
|
||||
@@ -849,11 +345,11 @@ test("a human pasting an agent envelope stays a human message through provider r
|
||||
});
|
||||
const delivery = prepareAgentMessage(pasted, undefined, "human-submission");
|
||||
expect(
|
||||
projectAgentMessage({
|
||||
projectAgentMessages({
|
||||
type: "user_message",
|
||||
text: String(delivery.prompt),
|
||||
messageId: "provider-echo",
|
||||
}),
|
||||
})[0],
|
||||
).toEqual({
|
||||
type: "user_message",
|
||||
text: pasted,
|
||||
@@ -861,3 +357,21 @@ test("a human pasting an agent envelope stays a human message through provider r
|
||||
clientMessageId: "human-submission",
|
||||
});
|
||||
});
|
||||
|
||||
test("schedule prompts remain visible as completed tool calls on replay", () => {
|
||||
const text = formatSystemNotificationPrompt("Heartbeat: check progress");
|
||||
expect(projectAgentMessages({ type: "user_message", messageId: "fire-1", text })).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool_call",
|
||||
status: "completed",
|
||||
detail: { type: "plain_text", text: "Heartbeat: check progress", icon: "sparkles" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("schedule trigger identity survives the provider assigning a message ID", () => {
|
||||
const text = formatSystemNotificationPrompt("Schedule fired (id=heartbeat, run=run-1).");
|
||||
expect(projectAgentMessages({ type: "user_message", text })).toEqual(
|
||||
projectAgentMessages({ type: "user_message", text, messageId: "provider-message" }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { prepareAgentMessage, type AgentPromptSource } from "./agent-messages/index.js";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPromptInput,
|
||||
AgentRunOptions,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
|
||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type AgentUnarchiveController = Pick<AgentManager, "notifyAgentState" | "unarchiveSnapshot">;
|
||||
@@ -20,6 +15,7 @@ export type AgentRunController = Pick<
|
||||
| "getAgent"
|
||||
| "tryRunOutOfBand"
|
||||
| "hasInFlightRun"
|
||||
| "reloadStalePluginSession"
|
||||
| "replaceAgentRun"
|
||||
| "steerOrReplaceActiveTurn"
|
||||
| "streamAgent"
|
||||
@@ -115,11 +111,12 @@ export async function startAgentRun(
|
||||
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
|
||||
// in-flight turn — replaceAgentRun would interrupt the running turn. The
|
||||
// intercept lives at this layer so it covers every prompt entrypoint.
|
||||
if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
|
||||
if (await agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
|
||||
options?.onDispatch?.("out_of_band");
|
||||
return { disposition: "out_of_band" };
|
||||
}
|
||||
try {
|
||||
await agentManager.reloadStalePluginSession(agentId);
|
||||
return await startAgentRunInner(agentManager, agentId, prompt, logger, options);
|
||||
} catch (error) {
|
||||
if (!isStaleProviderSessionError(error)) throw error;
|
||||
@@ -292,7 +289,7 @@ async function resolvePromptSource(
|
||||
manager: Pick<AgentManager, "getAgent">,
|
||||
storage: AgentStorage,
|
||||
): Promise<AgentPromptSource | undefined> {
|
||||
if (!source) return undefined;
|
||||
if (!source || source.title) return source;
|
||||
const title = (
|
||||
manager.getAgent(source.agentId)?.config.title ?? (await storage.get(source.agentId))?.title
|
||||
)?.trim();
|
||||
@@ -312,6 +309,21 @@ async function resolvePromptSource(
|
||||
*/
|
||||
export async function sendPromptToAgent(
|
||||
params: SendPromptToAgentParams,
|
||||
): Promise<{ disposition: PromptDispatchDisposition }> {
|
||||
const source = await resolvePromptSource(params.source, params.agentManager, params.agentStorage);
|
||||
const delivery = prepareAgentMessage(params.prompt, source, params.messageId);
|
||||
return sendPreparedPromptToAgent({
|
||||
...params,
|
||||
prompt: delivery.prompt,
|
||||
runOptions: delivery.messageId
|
||||
? { ...params.runOptions, clientMessageId: delivery.messageId }
|
||||
: params.runOptions,
|
||||
});
|
||||
}
|
||||
|
||||
/** Dispatch daemon-prepared context without reclassifying it as human input. */
|
||||
export async function sendPreparedPromptToAgent(
|
||||
params: Omit<SendPromptToAgentParams, "source" | "messageId">,
|
||||
): Promise<{ disposition: PromptDispatchDisposition }> {
|
||||
const unarchive = params.unarchive ?? true;
|
||||
|
||||
@@ -345,18 +357,12 @@ export async function sendPromptToAgent(
|
||||
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
|
||||
}
|
||||
|
||||
const source = await resolvePromptSource(params.source, params.agentManager, params.agentStorage);
|
||||
const delivery = prepareAgentMessage(params.prompt, source, params.messageId);
|
||||
const runOptions = delivery.messageId
|
||||
? { ...params.runOptions, clientMessageId: delivery.messageId }
|
||||
: params.runOptions;
|
||||
|
||||
return await startAgentRun(params.agentManager, params.agentId, delivery.prompt, params.logger, {
|
||||
return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, {
|
||||
replaceRunning: true,
|
||||
onDispatch: params.onDispatch,
|
||||
activeTurnBehavior: params.activeTurnBehavior,
|
||||
clearPendingPermissions: params.clearPendingPermissions,
|
||||
runOptions,
|
||||
runOptions: params.runOptions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -395,315 +401,3 @@ export async function startCreatedAgentInitialPrompt(
|
||||
}
|
||||
return refreshedSnapshot;
|
||||
}
|
||||
|
||||
export interface SetupFinishNotificationParams {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
childAgentId: string;
|
||||
callerAgentId: string;
|
||||
requireParentOwnership?: boolean;
|
||||
/** Retain pre-dispatch outcomes only when dispatch keeps the existing turn. */
|
||||
dispatchResult?: Promise<PromptDispatchDisposition | false>;
|
||||
/** A replacement must observe its new start, not the displaced running snapshot. */
|
||||
waitForTurnStart?: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
type FinishNotificationReason =
|
||||
| "finished"
|
||||
| "errored"
|
||||
| "needs permission"
|
||||
| "was closed"
|
||||
| "was canceled";
|
||||
|
||||
const finishNotificationEvents = {
|
||||
finished: "finished",
|
||||
errored: "errored",
|
||||
"needs permission": "permission-required",
|
||||
"was closed": "closed",
|
||||
// Use the existing failure event so older clients still parse agent-message metadata.
|
||||
"was canceled": "errored",
|
||||
} as const;
|
||||
|
||||
const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000;
|
||||
|
||||
interface FinishNotificationBodyInput {
|
||||
childAgentId: string;
|
||||
title: string;
|
||||
reason: FinishNotificationReason;
|
||||
lastAssistantMessage: string | null;
|
||||
permissionRequest?: AgentPermissionRequest;
|
||||
}
|
||||
|
||||
function formatFinishNotificationBody(params: FinishNotificationBodyInput): string {
|
||||
const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`;
|
||||
const sections = [statusLine];
|
||||
if (params.reason === "needs permission" && params.permissionRequest) {
|
||||
sections.push(
|
||||
"Respond with `respond_to_permission` using the `agentId` and `requestId` below.",
|
||||
`<permission-request>\n${JSON.stringify(
|
||||
{
|
||||
agentId: params.childAgentId,
|
||||
requestId: params.permissionRequest.id,
|
||||
request: params.permissionRequest,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n</permission-request>`,
|
||||
);
|
||||
}
|
||||
let lastAssistantMessage = params.lastAssistantMessage?.trim();
|
||||
if (lastAssistantMessage) {
|
||||
if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) {
|
||||
const omitted = lastAssistantMessage.length - FINISH_NOTIFICATION_MESSAGE_LIMIT;
|
||||
lastAssistantMessage = `${lastAssistantMessage.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`;
|
||||
}
|
||||
sections.push(`<agent-response>\n${lastAssistantMessage}\n</agent-response>`);
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
interface NotifySafelyOptions {
|
||||
terminal?: boolean;
|
||||
permissionRequest?: AgentPermissionRequest;
|
||||
}
|
||||
|
||||
// Pending follow-ups coexist until acknowledgement. A successful dispatch replaces
|
||||
// every earlier observation for the caller/child pair; a failure removes only itself.
|
||||
interface ArmedFinishNotification {
|
||||
stop(): void;
|
||||
supersedeWhen(accepted: Promise<boolean>): void;
|
||||
}
|
||||
|
||||
const armedFinishNotifications = new WeakMap<
|
||||
AgentManager,
|
||||
Map<string, Set<ArmedFinishNotification>>
|
||||
>();
|
||||
|
||||
export function setupFinishNotification(params: SetupFinishNotificationParams): () => void {
|
||||
const {
|
||||
agentManager,
|
||||
agentStorage,
|
||||
childAgentId,
|
||||
callerAgentId,
|
||||
requireParentOwnership = false,
|
||||
waitForTurnStart = false,
|
||||
logger,
|
||||
} = params;
|
||||
let hasSeenRunning = false;
|
||||
let hasSeenTurn = false;
|
||||
let stopped = false;
|
||||
const notifiedPermissionRequestIds = new Set<string>();
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let notificationQueue = Promise.resolve();
|
||||
|
||||
const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map();
|
||||
armedFinishNotifications.set(agentManager, armedByManager);
|
||||
const armedKey = JSON.stringify([childAgentId, callerAgentId]);
|
||||
const registrations = armedByManager.get(armedKey) ?? new Set<ArmedFinishNotification>();
|
||||
armedByManager.set(armedKey, registrations);
|
||||
const previous = [...registrations];
|
||||
let superseded = Promise.resolve(false);
|
||||
let dispatchPending = Boolean(params.dispatchResult);
|
||||
const registration: ArmedFinishNotification = {
|
||||
stop,
|
||||
supersedeWhen(accepted) {
|
||||
superseded = superseded.then(
|
||||
async (alreadySuperseded) => alreadySuperseded || (await accepted),
|
||||
);
|
||||
},
|
||||
};
|
||||
registrations.add(registration);
|
||||
if (params.dispatchResult) {
|
||||
const dispatchAccepted = params.dispatchResult.then((result) => result !== false);
|
||||
for (const observer of previous) observer.supersedeWhen(dispatchAccepted);
|
||||
void params.dispatchResult.then((accepted) => {
|
||||
dispatchPending = false;
|
||||
if (accepted) {
|
||||
for (const observer of previous) observer.stop();
|
||||
if (stopped || accepted === "turn_started") stop();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
} else {
|
||||
for (const observer of previous) {
|
||||
observer.supersedeWhen(Promise.resolve(true));
|
||||
observer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
stopped = true;
|
||||
unsubscribe?.();
|
||||
// Pending observations remain visible to overlapping sends until acknowledgement.
|
||||
if (!dispatchPending) {
|
||||
registrations.delete(registration);
|
||||
if (registrations.size === 0 && armedByManager.get(armedKey) === registrations) {
|
||||
armedByManager.delete(armedKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function notify(
|
||||
reason: FinishNotificationReason,
|
||||
permissionRequest?: AgentPermissionRequest,
|
||||
): Promise<void> {
|
||||
if (params.dispatchResult) {
|
||||
const result = await params.dispatchResult;
|
||||
if (result === false || result === "turn_started") return;
|
||||
}
|
||||
if (await superseded) return;
|
||||
const callerRecord = await agentStorage.get(callerAgentId);
|
||||
if (callerRecord?.archivedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = await agentStorage.get(childAgentId);
|
||||
if (requireParentOwnership && getParentAgentIdFromLabels(record?.labels) !== callerAgentId) {
|
||||
return;
|
||||
}
|
||||
const title = record?.title ?? childAgentId;
|
||||
const lastAssistantMessage = await agentManager.getLastAssistantMessage(childAgentId);
|
||||
const body = formatFinishNotificationBody({
|
||||
childAgentId,
|
||||
title,
|
||||
reason,
|
||||
lastAssistantMessage,
|
||||
permissionRequest,
|
||||
});
|
||||
|
||||
await sendPromptToAgent({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
agentId: callerAgentId,
|
||||
prompt: body,
|
||||
source: {
|
||||
kind: "agent-notification",
|
||||
agentId: childAgentId,
|
||||
event: finishNotificationEvents[reason],
|
||||
},
|
||||
activeTurnBehavior: "steer",
|
||||
unarchive: false,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
function notifySafely(reason: FinishNotificationReason, options: NotifySafelyOptions = {}): void {
|
||||
if (stopped) return;
|
||||
if (options.terminal ?? true) stop();
|
||||
notificationQueue = notificationQueue
|
||||
.then(() => notify(reason, options.permissionRequest))
|
||||
.catch((error) => {
|
||||
logger.error(
|
||||
{ err: error, childAgentId, callerAgentId, reason },
|
||||
"Failed to notify caller agent",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function observeState(agent: ManagedAgent): void {
|
||||
for (const requestId of notifiedPermissionRequestIds) {
|
||||
if (!agent.pendingPermissions.has(requestId)) {
|
||||
notifiedPermissionRequestIds.delete(requestId);
|
||||
}
|
||||
}
|
||||
if (agent.lifecycle === "running") {
|
||||
if (waitForTurnStart && !hasSeenTurn) return;
|
||||
hasSeenTurn = true;
|
||||
if (agent.pendingPermissions.size === 0) {
|
||||
hasSeenRunning = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Prior terminal state is not this task's outcome. A failure before turn start
|
||||
// is reported by the dispatch acknowledgement instead of a finish callback.
|
||||
if (agent.lifecycle === "error" && hasSeenTurn) {
|
||||
notifySafely("errored");
|
||||
return;
|
||||
}
|
||||
// Idle is published before the terminal stream event for both completion
|
||||
// and cancellation. Let that event identify the outcome instead.
|
||||
if (agent.lifecycle === "closed") {
|
||||
notifySafely("was closed");
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
unsubscribe = agentManager.subscribe(
|
||||
(event) => {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "agent_state") {
|
||||
observeState(event.agent);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "timeline_replacement") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.event.type === "turn_started") {
|
||||
hasSeenTurn = true;
|
||||
hasSeenRunning = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.event.type === "turn_completed" || event.event.type === "turn_canceled") {
|
||||
// Replacing a turn keeps the child running through the displaced turn's
|
||||
// cancellation. The task settles only when the child becomes idle.
|
||||
if (agentManager.getAgent(childAgentId)?.lifecycle !== "idle") return;
|
||||
if (event.event.type === "turn_completed" && hasSeenRunning) {
|
||||
notifySafely("finished");
|
||||
} else if (event.event.type === "turn_canceled" && hasSeenTurn) {
|
||||
notifySafely("was canceled");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.event.type === "permission_requested") {
|
||||
// A permission pause is an intermediate checkpoint. Forget the run
|
||||
// observed before it so an idle state during follow-up startup cannot
|
||||
// masquerade as the final completion.
|
||||
hasSeenRunning = false;
|
||||
if (!notifiedPermissionRequestIds.has(event.event.request.id)) {
|
||||
notifiedPermissionRequestIds.add(event.event.request.id);
|
||||
notifySafely("needs permission", {
|
||||
terminal: false,
|
||||
permissionRequest: event.event.request,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.event.type === "permission_resolved") {
|
||||
notifiedPermissionRequestIds.delete(event.event.requestId);
|
||||
const childAgent = agentManager.getAgent(childAgentId);
|
||||
if (childAgent?.pendingPermissions.size === 0) {
|
||||
hasSeenRunning = childAgent.lifecycle === "running";
|
||||
}
|
||||
}
|
||||
},
|
||||
{ agentId: childAgentId, replayState: false },
|
||||
);
|
||||
|
||||
// Check if the child is already running (catches the case where
|
||||
// the lifecycle flipped before our subscribe call was processed).
|
||||
// Do NOT treat an immediate "idle" as "finished" — the agent may
|
||||
// not have started yet (streamAgent sets a pending run before
|
||||
// transitioning to "running").
|
||||
const childSnapshot = agentManager.getAgent(childAgentId);
|
||||
if (!childSnapshot || childSnapshot.lifecycle === "closed") {
|
||||
stop();
|
||||
return stop;
|
||||
}
|
||||
if (childSnapshot.lifecycle === "running" && !waitForTurnStart) {
|
||||
hasSeenTurn = true;
|
||||
hasSeenRunning = true;
|
||||
}
|
||||
return stop;
|
||||
}
|
||||
|
||||
@@ -254,6 +254,57 @@ describe("generateStructuredAgentResponseWithFallback", () => {
|
||||
expect(manager.checkedProviders).toEqual(["claude", "codex"]);
|
||||
});
|
||||
|
||||
it("logs the provider, model and error of every failed attempt", async () => {
|
||||
const warnings: Array<{ obj: object; msg?: string }> = [];
|
||||
const runnerError = new Error("connection refused");
|
||||
const manager = createManager([
|
||||
{ provider: "local-llm", available: true, error: null },
|
||||
{ provider: "opencode", available: false, error: "not installed" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
generateStructuredAgentResponseWithFallback({
|
||||
manager,
|
||||
cwd: "/tmp/project",
|
||||
prompt: "Return JSON",
|
||||
schema,
|
||||
schemaName: "BranchName",
|
||||
providers: [
|
||||
{ provider: "local-llm", model: "qwen-local" },
|
||||
{ provider: "opencode", model: "minimax-m3" },
|
||||
],
|
||||
logger: {
|
||||
info: () => undefined,
|
||||
warn: (obj, msg) => warnings.push({ obj, msg }),
|
||||
},
|
||||
runner: async () => {
|
||||
throw runnerError;
|
||||
},
|
||||
}),
|
||||
).rejects.toBeInstanceOf(StructuredAgentFallbackError);
|
||||
|
||||
expect(warnings).toEqual([
|
||||
{
|
||||
obj: {
|
||||
err: runnerError,
|
||||
provider: "local-llm",
|
||||
model: "qwen-local",
|
||||
schemaName: "BranchName",
|
||||
},
|
||||
msg: "Structured generation: provider failed",
|
||||
},
|
||||
{
|
||||
obj: {
|
||||
provider: "opencode",
|
||||
model: "minimax-m3",
|
||||
schemaName: "BranchName",
|
||||
reason: "not installed",
|
||||
},
|
||||
msg: "Structured generation: skipping unavailable provider",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws a fallback error when all providers are unavailable or fail", async () => {
|
||||
const manager = createManager([
|
||||
{ provider: "claude", available: false, error: "missing auth" },
|
||||
|
||||
@@ -475,7 +475,7 @@ export async function generateStructuredAgentResponseWithFallback<T>(
|
||||
});
|
||||
logger?.warn(
|
||||
{ err: error, provider: candidate.provider, model: candidate.model, schemaName },
|
||||
"Structured generation: provider failed, trying next",
|
||||
"Structured generation: provider failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { AgentRunState } from "./agent-run-state.js";
|
||||
|
||||
function track(promise: Promise<void>): { readonly resolved: boolean } {
|
||||
const state = { resolved: false };
|
||||
void (async () => {
|
||||
await promise;
|
||||
state.resolved = true;
|
||||
})();
|
||||
return state;
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
describe("waitForForegroundStart", () => {
|
||||
test("resolves once the starting turn has an id", async () => {
|
||||
const runs = new AgentRunState();
|
||||
const run = runs.createPendingRun("agent");
|
||||
const wait = track(runs.waitForForegroundStart("agent"));
|
||||
|
||||
await flushMicrotasks();
|
||||
expect(wait.resolved).toBe(false);
|
||||
|
||||
runs.settleForegroundStart(run, { status: "started", turnId: "turn-1" });
|
||||
await flushMicrotasks();
|
||||
expect(wait.resolved).toBe(true);
|
||||
});
|
||||
|
||||
test("waits for a failed start's run to be cleared", async () => {
|
||||
const runs = new AgentRunState();
|
||||
const run = runs.createPendingRun("agent");
|
||||
const wait = track(runs.waitForForegroundStart("agent"));
|
||||
|
||||
runs.settleForegroundStart(run, { status: "failed", error: "start failed" });
|
||||
await flushMicrotasks();
|
||||
expect(wait.resolved).toBe(false);
|
||||
expect(runs.hasPendingRun("agent")).toBe(true);
|
||||
|
||||
runs.settleForegroundRun("agent", run.token);
|
||||
await flushMicrotasks();
|
||||
expect(wait.resolved).toBe(true);
|
||||
});
|
||||
|
||||
test("resolves at once when no turn is starting", async () => {
|
||||
const runs = new AgentRunState();
|
||||
await expect(runs.waitForForegroundStart("agent")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -10,14 +10,18 @@ export interface ForegroundTurnWaiter {
|
||||
resolveSettled: () => void;
|
||||
}
|
||||
|
||||
type ForegroundRunStart =
|
||||
| { status: "pending" }
|
||||
| { status: "started"; turnId: string }
|
||||
| { status: "failed"; error: string };
|
||||
|
||||
export interface PendingForegroundRun {
|
||||
token: string;
|
||||
kind: "foreground";
|
||||
stagedEvents: AgentStreamEvent[];
|
||||
start:
|
||||
| { status: "pending" }
|
||||
| { status: "started"; turnId: string }
|
||||
| { status: "failed"; error: string };
|
||||
start: ForegroundRunStart;
|
||||
turnStartedPromise: Promise<void>;
|
||||
resolveTurnStarted: () => void;
|
||||
settled: boolean;
|
||||
settledPromise: Promise<void>;
|
||||
resolveSettled: () => void;
|
||||
@@ -54,6 +58,28 @@ export class AgentRunState {
|
||||
return run?.kind === "foreground" ? run : null;
|
||||
}
|
||||
|
||||
settleForegroundStart(
|
||||
run: PendingForegroundRun,
|
||||
start: Exclude<ForegroundRunStart, { status: "pending" }>,
|
||||
): void {
|
||||
run.start = start;
|
||||
if (start.status === "started") {
|
||||
run.resolveTurnStarted();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once the agent's foreground run has a turn id or has ended. A failed start
|
||||
* resolves only once its run is cleared, so callers never see a failed run still tracked.
|
||||
*/
|
||||
async waitForForegroundStart(agentId: string): Promise<void> {
|
||||
const run = this.getPendingRun(agentId);
|
||||
if (run?.start.status !== "pending") {
|
||||
return;
|
||||
}
|
||||
await Promise.race([run.turnStartedPromise, run.settledPromise]);
|
||||
}
|
||||
|
||||
hasPendingRun(agentId: string): boolean {
|
||||
return this.getPendingRun(agentId) !== null;
|
||||
}
|
||||
@@ -277,10 +303,16 @@ export class ForegroundTurnStream {
|
||||
}
|
||||
|
||||
function createPendingForegroundRun(): PendingForegroundRun {
|
||||
let resolveTurnStarted!: () => void;
|
||||
const turnStartedPromise = new Promise<void>((resolvePromise) => {
|
||||
resolveTurnStarted = resolvePromise;
|
||||
});
|
||||
return {
|
||||
...createTrackedRunState(),
|
||||
kind: "foreground",
|
||||
start: { status: "pending" },
|
||||
turnStartedPromise,
|
||||
resolveTurnStarted,
|
||||
stagedEvents: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -616,6 +616,9 @@ export interface AgentSessionConfig {
|
||||
}
|
||||
|
||||
export interface AgentLaunchContext {
|
||||
/** Launch-only plugin MCP servers and their owners; never persisted. */
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
mcpServerOwners?: Record<string, string>;
|
||||
agentId?: string;
|
||||
env?: Record<string, string>;
|
||||
/**
|
||||
@@ -704,6 +707,10 @@ export interface AgentSession {
|
||||
* still uncertain.
|
||||
*/
|
||||
interrupt(): Promise<void>;
|
||||
/** True only when releasing this runtime loses no background work or pending replies.
|
||||
* An optional additional veto: missing methods use daemon baseline eligibility;
|
||||
* implemented checks that reject or return false keep the runtime resident. */
|
||||
canStopRuntime?(): Promise<boolean>;
|
||||
/** Release live runtime resources without archiving or deleting the durable native session. */
|
||||
close(): Promise<void>;
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
|
||||
@@ -54,8 +54,10 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
title: z.string().nullable().optional(),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
lastStatus: AgentStatusSchema.default("closed"),
|
||||
resumeOnStartup: z.boolean().optional(),
|
||||
lastModeId: z.string().nullable().optional(),
|
||||
config: SERIALIZABLE_CONFIG_SCHEMA,
|
||||
launchEnv: z.record(z.string(), z.string()).optional(),
|
||||
runtimeInfo: z
|
||||
.object({
|
||||
provider: z.string(),
|
||||
@@ -259,6 +261,8 @@ export class AgentStorage {
|
||||
if (existing && existing.archivedAt !== undefined) {
|
||||
record.archivedAt = existing.archivedAt;
|
||||
}
|
||||
record.resumeOnStartup =
|
||||
existing?.resumeOnStartup && agent.lifecycle !== "idle" && agent.lifecycle !== "error";
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,29 @@ import { describe, expect, test } from "vitest";
|
||||
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
||||
|
||||
describe("agent timeline content", () => {
|
||||
test.each([false, true])(
|
||||
"preserves the exact UTF-16 prefix for oversized shell output (failed=%s)",
|
||||
(failed) => {
|
||||
const text = "x".repeat(64 * 1024 - 1) + "🙂";
|
||||
const item = limitAgentTimelineItemContent({
|
||||
type: "tool_call",
|
||||
callId: "large-shell",
|
||||
name: "Bash",
|
||||
status: failed ? "failed" : "completed",
|
||||
detail: { type: "shell", command: "echo large", output: text },
|
||||
error: failed ? { content: text } : null,
|
||||
});
|
||||
expect(item).toEqual({
|
||||
type: "tool_call",
|
||||
callId: "large-shell",
|
||||
name: "Bash",
|
||||
status: failed ? "failed" : "completed",
|
||||
detail: { type: "shell", command: "echo large", output: text.slice(0, 64 * 1024) },
|
||||
error: failed ? { content: text.slice(0, 64 * 1024) } : null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("marks truncated plain-text tool content within the content budget", () => {
|
||||
const oversizedInput = "x".repeat(64 * 1024 + 1);
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ export function assertPluginTimelineDataSize(data: JsonValue): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** A sliced string can keep the entire provider output alive. Copy only the retained UTF-16
|
||||
* units, including an unmatched surrogate at the existing character limit. */
|
||||
function retainTextPrefix(text: string, length: number): string {
|
||||
return Buffer.from(text.slice(0, length), "utf16le").toString("utf16le");
|
||||
}
|
||||
|
||||
function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
|
||||
if (
|
||||
item.type !== "tool_call" ||
|
||||
@@ -28,7 +34,7 @@ function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
|
||||
...item,
|
||||
error: {
|
||||
...item.error,
|
||||
content: item.error.content.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
content: retainTextPrefix(item.error.content, TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -47,7 +53,8 @@ function limitPlainText(item: AgentTimelineItem): AgentTimelineItem {
|
||||
...item,
|
||||
detail: {
|
||||
...item.detail,
|
||||
text: item.detail.text.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice,
|
||||
text:
|
||||
retainTextPrefix(item.detail.text, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -69,7 +76,7 @@ export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTim
|
||||
...item,
|
||||
detail: {
|
||||
...item.detail,
|
||||
output: item.detail.output.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
output: retainTextPrefix(item.detail.output, TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface AgentTimelineCursor {
|
||||
export type AgentTimelineFetchDirection = "tail" | "before" | "after";
|
||||
|
||||
export interface AgentTimelineFetchOptions {
|
||||
wholeTurns?: boolean;
|
||||
direction?: AgentTimelineFetchDirection;
|
||||
cursor?: AgentTimelineCursor;
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,45 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
|
||||
describe("InMemoryAgentTimelineStore", () => {
|
||||
it("resets stale forward cursors to complete available turns", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("agent", { epoch: "current" });
|
||||
for (let index = 0; index < 60; index++)
|
||||
store.append(
|
||||
"agent",
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: `call-${index}`,
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
},
|
||||
{ turnId: "run" },
|
||||
);
|
||||
const page = store.fetch("agent", {
|
||||
direction: "after",
|
||||
cursor: { epoch: "old", seq: 10 },
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(page.rows).toHaveLength(60);
|
||||
expect(page).toMatchObject({
|
||||
reset: true,
|
||||
staleCursor: true,
|
||||
startSeq: 1,
|
||||
endSeq: 60,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps an overshooting before cursor into the bounded tail window", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("agent-1", {
|
||||
@@ -177,7 +215,10 @@ describe("projected sequence ownership", () => {
|
||||
store.append("a", { type: "assistant_message", text: "Answer" });
|
||||
store.append("a", { ...tool, status: "completed" });
|
||||
store.initialize("b", { rows: store.getRows("a") });
|
||||
expect(store.getLastItem("b")).toMatchObject({ type: "tool_call", status: "completed" });
|
||||
expect(store.getLastAssistantMessage("b")).toBe("Answer");
|
||||
expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4);
|
||||
expect(store.getLastItem("b")).toEqual({ type: "user_message", text: "next" });
|
||||
expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]);
|
||||
});
|
||||
it("returns the last projected assistant message without joining different messages", () => {
|
||||
@@ -211,3 +252,178 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
|
||||
expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]);
|
||||
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
|
||||
});
|
||||
|
||||
async function measureTimelineTail(wholeTurns: boolean) {
|
||||
const fixture = fileURLToPath(
|
||||
new URL("./test-utils/timeline-responsiveness.ts", import.meta.url),
|
||||
);
|
||||
const { stdout, stderr } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
["--expose-gc", "--import", "tsx", fixture, String(wholeTurns)],
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
process.stderr.write(stderr);
|
||||
return JSON.parse(stdout) as {
|
||||
result: ReturnType<InMemoryAgentTimelineStore["fetch"]>;
|
||||
blockingMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
it("excludes unrelated test-runner CPU from the timeline fetch budget", async () => {
|
||||
let unrelatedWork: ReturnType<typeof setImmediate>;
|
||||
const burnCpu = () => {
|
||||
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
|
||||
process,
|
||||
"threadCpuUsage",
|
||||
);
|
||||
const start = threadCpuUsage();
|
||||
let elapsed: NodeJS.CpuUsage;
|
||||
do {
|
||||
elapsed = threadCpuUsage(start);
|
||||
} while (elapsed.user + elapsed.system < 80_000);
|
||||
unrelatedWork = setImmediate(burnCpu);
|
||||
};
|
||||
unrelatedWork = setImmediate(burnCpu);
|
||||
try {
|
||||
const { result: tail, blockingMs } = await measureTimelineTail(false);
|
||||
expect(tail.rows).toHaveLength(200);
|
||||
expect(tail.startSeq).toBe(99_801);
|
||||
expect(tail.endSeq).toBe(100_000);
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
} finally {
|
||||
clearImmediate(unrelatedWork);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
|
||||
async (wholeTurns) => {
|
||||
const { result: tail, blockingMs } = await measureTimelineTail(wholeTurns);
|
||||
expect(tail.rows).toHaveLength(200);
|
||||
expect(tail.startSeq).toBe(99_801);
|
||||
expect(tail.endSeq).toBe(100_000);
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
},
|
||||
);
|
||||
|
||||
it("matches the previous reprojecting selector on seeded randomized timelines", () => {
|
||||
let seed = 0x76aabbcc;
|
||||
const random = (bound: number) => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||
return Math.floor((seed / 0x1_0000_0000) * bound);
|
||||
};
|
||||
for (let scenario = 0; scenario < 100; scenario++) {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("a", { epoch: "e" });
|
||||
for (let seq = 1; seq <= 100; seq++) {
|
||||
const identity = String(random(8));
|
||||
const text = String(seq);
|
||||
const items: AgentTimelineItem[] = [
|
||||
{ type: "user_message", text },
|
||||
{ type: "assistant_message", text },
|
||||
{ type: "assistant_message", text, messageId: identity },
|
||||
{ type: "reasoning", text },
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: identity,
|
||||
name: "shell",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "plain_text", label: text },
|
||||
},
|
||||
{
|
||||
type: "plugin",
|
||||
id: identity,
|
||||
pluginId: "test",
|
||||
kind: "state",
|
||||
version: 1,
|
||||
data: { text },
|
||||
},
|
||||
];
|
||||
store.append("a", items[random(items.length)], { turnId: String(random(3)) });
|
||||
}
|
||||
// Include rehydration of already-projected rows, with non-monotonic seqEnd.
|
||||
store.initialize("b", { rows: store.getRows("a"), epoch: "e" });
|
||||
for (const agentId of ["a", "b"]) {
|
||||
assertSelectionMatchesReference(store, agentId);
|
||||
store.append(agentId, { type: "assistant_message", text: "later" });
|
||||
assertSelectionMatchesReference(store, agentId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function assertSelectionMatchesReference(store: InMemoryAgentTimelineStore, agentId: string): void {
|
||||
for (const direction of ["tail", "before", "after"] as const) {
|
||||
for (const limit of [0, 1, 7, 200]) {
|
||||
for (const cursorSeq of [0, 1, 25, 50, 99, 100, 150]) {
|
||||
const actual = store.fetch(agentId, {
|
||||
direction,
|
||||
limit,
|
||||
cursor: { epoch: "e", seq: cursorSeq },
|
||||
});
|
||||
const expected = selectReferenceProjectedTimelinePage({
|
||||
rows: store.getRows(agentId),
|
||||
bounds: actual.window,
|
||||
direction,
|
||||
limit,
|
||||
cursorSeq,
|
||||
});
|
||||
expect({
|
||||
...expected,
|
||||
entries: undefined,
|
||||
rows: expected.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)),
|
||||
}).toEqual({
|
||||
entries: undefined,
|
||||
rows: actual.rows,
|
||||
startSeq: actual.startSeq,
|
||||
endSeq: actual.endSeq,
|
||||
hasOlder: actual.hasOlder,
|
||||
hasNewer: actual.hasNewer,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it("continues a scroll across appends and lifecycle collapse without losing source coverage", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("a", { epoch: "e" });
|
||||
const tool: AgentTimelineItem = {
|
||||
type: "tool_call",
|
||||
callId: "t",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "plain_text", label: "work" },
|
||||
};
|
||||
store.append("a", tool);
|
||||
for (let seq = 2; seq <= 5; seq++) store.append("a", { type: "user_message", text: String(seq) });
|
||||
const first = store.fetch("a", { direction: "after", cursor: { epoch: "e", seq: 0 }, limit: 2 });
|
||||
expect(first.endSeq).toBe(2);
|
||||
store.append("a", { ...tool, status: "completed" });
|
||||
store.append("a", { type: "assistant_message", text: "Done" });
|
||||
expect(first.rows[0].item).toMatchObject({ status: "running" });
|
||||
const second = store.fetch("a", {
|
||||
direction: "after",
|
||||
cursor: { epoch: "e", seq: first.endSeq! },
|
||||
limit: 2,
|
||||
});
|
||||
expect(second.rows.map((row) => row.seqStart)).toEqual([3, 4]);
|
||||
expect(second.endSeq).toBe(4);
|
||||
const third = store.fetch("a", {
|
||||
direction: "after",
|
||||
cursor: { epoch: "e", seq: second.endSeq! },
|
||||
limit: 2,
|
||||
});
|
||||
expect(third.rows.map((row) => row.seqStart)).toEqual([1, 5]);
|
||||
expect(third.rows[0].item).toMatchObject({ status: "completed" });
|
||||
expect(third.endSeq).toBe(6);
|
||||
const last = store.fetch("a", {
|
||||
direction: "after",
|
||||
cursor: { epoch: "e", seq: third.endSeq! },
|
||||
limit: 2,
|
||||
});
|
||||
expect(last.rows.map((row) => row.item)).toEqual([{ type: "assistant_message", text: "Done" }]);
|
||||
expect(last.endSeq).toBe(7);
|
||||
expect(last.hasNewer).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
TimelineProjection,
|
||||
selectProjectedTimelinePage,
|
||||
type ProjectedTimelineRow,
|
||||
} from "./timeline-projection.js";
|
||||
import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentTimelineFetchOptions,
|
||||
@@ -43,8 +39,7 @@ export class InMemoryAgentTimelineStore {
|
||||
? options.rows.map(cloneRow)
|
||||
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
|
||||
const nextSeq = rows.reduce((next, row) => Math.max(next, row.seq + 1), options?.nextSeq ?? 1);
|
||||
const projection = new TimelineProjection();
|
||||
for (const row of rows) projection.append(row);
|
||||
const projection = new TimelineProjection(rows);
|
||||
this.states.set(agentId, {
|
||||
epoch: options?.epoch ?? randomUUID(),
|
||||
projection,
|
||||
@@ -97,22 +92,22 @@ export class InMemoryAgentTimelineStore {
|
||||
const state = this.requireState(agentId);
|
||||
const direction = options?.direction ?? "tail";
|
||||
const cursor = options?.cursor;
|
||||
const rows = state.projection.getRows();
|
||||
const hasRows = state.minSeq !== 0;
|
||||
const window = { minSeq: state.minSeq, maxSeq: state.nextSeq - 1, nextSeq: state.nextSeq };
|
||||
const staleCursor = cursor !== undefined && cursor.epoch !== state.epoch;
|
||||
const gap =
|
||||
!staleCursor &&
|
||||
direction === "after" &&
|
||||
cursor !== undefined &&
|
||||
rows.length > 0 &&
|
||||
hasRows &&
|
||||
cursor.seq < state.minSeq - 1;
|
||||
const reset = staleCursor || gap;
|
||||
const page = selectProjectedTimelinePage({
|
||||
rows,
|
||||
const page = state.projection.selectPage({
|
||||
bounds: window,
|
||||
direction: reset ? "tail" : direction,
|
||||
cursorSeq: cursor?.seq,
|
||||
limit: options?.limit ?? DEFAULT_TIMELINE_FETCH_LIMIT,
|
||||
wholeTurns: options?.wholeTurns,
|
||||
});
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
@@ -150,14 +145,11 @@ export class InMemoryAgentTimelineStore {
|
||||
|
||||
getLastItem(agentId: string): AgentTimelineItem | null {
|
||||
const state = this.requireState(agentId);
|
||||
return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null;
|
||||
return state.projection.getLastItem(state.nextSeq - 1);
|
||||
}
|
||||
|
||||
getLastAssistantMessage(agentId: string): string | null {
|
||||
const row = this.requireState(agentId)
|
||||
.projection.getRows()
|
||||
.findLast((candidate) => candidate.item.type === "assistant_message");
|
||||
return row?.item.type === "assistant_message" ? row.item.text : null;
|
||||
return this.requireState(agentId).projection.getLastAssistantMessage();
|
||||
}
|
||||
|
||||
private requireState(agentId: string): AgentTimelineState {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AgentListItemPayloadSchema,
|
||||
AgentSnapshotPayloadSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
|
||||
import { ClaudeAgentClient } from "./providers/claude/agent.js";
|
||||
|
||||
// Opt in to the same workflow with the installed providers instead of test clients.
|
||||
const real = process.env.PASEO_ARCHIVE_REAL === "1";
|
||||
const StatusSchema = z.object({ snapshot: AgentSnapshotPayloadSchema });
|
||||
const ListSchema = z.object({ agents: z.array(AgentListItemPayloadSchema) });
|
||||
|
||||
test.each(["codex", "claude"] as const)(
|
||||
"archived %s history remains archived across MCP discovery",
|
||||
async (provider) => {
|
||||
const cwd = await mkdtemp(path.join(os.tmpdir(), "archive-discovery-"));
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: real
|
||||
? {
|
||||
[provider]:
|
||||
provider === "codex"
|
||||
? new CodexAppServerAgentClient(logger)
|
||||
: new ClaudeAgentClient({ logger }),
|
||||
}
|
||||
: createTestAgentClients(),
|
||||
pluginsEnabled: false,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const mcp = new Client({ name: "archive-discovery-test", version: "1.0.0" });
|
||||
const call = async (name: string, args: Record<string, unknown>) => {
|
||||
const result = await mcp.callTool({ name, arguments: args });
|
||||
if (result.isError) throw new Error(JSON.stringify(result.content));
|
||||
return result.structuredContent;
|
||||
};
|
||||
const list = async (includeArchived: boolean) =>
|
||||
ListSchema.parse(await call("list_agents", { cwd, includeArchived })).agents;
|
||||
const status = async (agentId: string) =>
|
||||
StatusSchema.parse(await call("get_agent_status", { agentId })).snapshot;
|
||||
try {
|
||||
await client.connect();
|
||||
await mcp.connect(
|
||||
new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${daemon.port}/mcp/agents`)),
|
||||
);
|
||||
const realModel = provider === "codex" ? "gpt-5.6-sol" : "haiku";
|
||||
const agent = await client.createAgent({
|
||||
config: {
|
||||
provider,
|
||||
cwd,
|
||||
model: real ? realModel : undefined,
|
||||
modeId: provider === "codex" ? "full-access" : "bypassPermissions",
|
||||
},
|
||||
});
|
||||
await daemon.daemon.agentManager.runAgent(
|
||||
agent.id,
|
||||
"Reply with ARCHIVE_OK. Do not use tools.",
|
||||
);
|
||||
expect((await list(false)).map((item) => item.id)).toContain(agent.id);
|
||||
await call("archive_agent", { agentId: agent.id });
|
||||
const archivedAt = (await status(agent.id)).archivedAt;
|
||||
expect(archivedAt).toEqual(expect.any(String));
|
||||
expect((await list(false)).map((item) => item.id)).not.toContain(agent.id);
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
|
||||
expect(timeline.entries.length).toBeGreaterThan(0);
|
||||
expect.soft((await status(agent.id)).archivedAt).toBe(archivedAt);
|
||||
expect.soft((await list(false)).map((item) => item.id)).not.toContain(agent.id);
|
||||
expect
|
||||
.soft((await list(true)).find((item) => item.id === agent.id)?.archivedAt)
|
||||
.toBe(archivedAt);
|
||||
const ordinary = await client.fetchAgents({ filter: { includeArchived: false } });
|
||||
expect.soft(ordinary.entries.map((entry) => entry.agent.id)).not.toContain(agent.id);
|
||||
const inclusive = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
expect
|
||||
.soft(inclusive.entries.find((entry) => entry.agent.id === agent.id)?.agent.archivedAt)
|
||||
.toBe(archivedAt);
|
||||
|
||||
await client.refreshAgent(agent.id);
|
||||
expect((await status(agent.id)).archivedAt).toBeNull();
|
||||
expect((await list(false)).map((item) => item.id)).toContain(agent.id);
|
||||
} finally {
|
||||
await mcp.close();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CreatePaseoWorktreeInput } from "../paseo-worktree-service.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type pino from "pino";
|
||||
|
||||
@@ -7,11 +8,13 @@ import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-se
|
||||
import type {
|
||||
CreatePaseoWorktreeWorkflowFn,
|
||||
CreatePaseoWorktreeWorkflowResult,
|
||||
CreatePaseoWorktreeSetupContinuationInput,
|
||||
NormalizedGitOptions,
|
||||
} from "../worktree-session.js";
|
||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||
import type { FirstAgentContext, SessionOutboundMessage } from "../messages.js";
|
||||
import type {
|
||||
FirstAgentContext,
|
||||
CreateAgentWorktreeTarget,
|
||||
SessionOutboundMessage,
|
||||
} from "../messages.js";
|
||||
import type { AgentManager, AgentSubscriber, SubscribeOptions } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
|
||||
@@ -57,33 +60,39 @@ export class CreateAgentLifecycleDispatch {
|
||||
|
||||
async createWorktreeForRequest(input: {
|
||||
cwd: string;
|
||||
target: NormalizedGitOptions | null;
|
||||
target: CreateAgentWorktreeTarget | undefined;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
}): Promise<CreatePaseoWorktreeWorkflowResult | null> {
|
||||
const target = input.target;
|
||||
if (!target?.createWorktree) return null;
|
||||
if (!target) return null;
|
||||
const placement: CreatePaseoWorktreeInput = { cwd: input.cwd };
|
||||
switch (target.mode) {
|
||||
case "branch-off":
|
||||
placement.action = "branch-off";
|
||||
placement.worktreeSlug = target.newBranch;
|
||||
placement.refName = target.base;
|
||||
break;
|
||||
case "checkout-branch":
|
||||
placement.action = "checkout";
|
||||
placement.refName = target.branch;
|
||||
break;
|
||||
case "checkout-pr":
|
||||
placement.githubPrNumber = target.prNumber;
|
||||
break;
|
||||
}
|
||||
const base = target.mode === "branch-off" ? target.base : undefined;
|
||||
return this.dependencies.createPaseoWorktreeWorkflow(
|
||||
{
|
||||
cwd: input.cwd,
|
||||
...placement,
|
||||
background: input.background,
|
||||
callerWorkspaceId: input.callerWorkspaceId,
|
||||
firstAgentContext: input.firstAgentContext,
|
||||
worktreeSlug: target.worktreeSlug,
|
||||
refName: target.refName,
|
||||
action: target.action,
|
||||
checkoutSource: target.checkoutSource,
|
||||
githubPrNumber: target.githubPrNumber,
|
||||
runSetup: false,
|
||||
paseoHome: this.dependencies.paseoHome,
|
||||
worktreesRoot: this.dependencies.worktreesRoot,
|
||||
},
|
||||
{
|
||||
...(target.baseBranch ? { resolveDefaultBranch: async () => target.baseBranch! } : {}),
|
||||
setupContinuation: input.setupContinuation,
|
||||
},
|
||||
base ? { resolveDefaultBranch: async () => base } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { AgentStorage } from "../agent-storage.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||
import { createAgentCommand } from "./create.js";
|
||||
import type { ManagedAgent } from "../agent-manager.js";
|
||||
import type { AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
|
||||
const logger = createTestLogger();
|
||||
|
||||
@@ -53,7 +52,6 @@ function fakeWorktreeCreator(args: { repoRoot: string; createdWorkspaceId: strin
|
||||
workspace: { workspaceId: args.createdWorkspaceId, cwd: workspaceCwd },
|
||||
repoRoot: args.repoRoot,
|
||||
created: true,
|
||||
setupContinuation: { kind: "agent" as const, startAfterAgentCreate: () => {} },
|
||||
}) as unknown as CreatePaseoWorktreeWorkflowResult;
|
||||
}
|
||||
|
||||
@@ -71,6 +69,7 @@ test("session create forwards clientMessageId to the initial prompt run options"
|
||||
getAgent: vi.fn(() => snapshot),
|
||||
tryRunOutOfBand: vi.fn(() => false),
|
||||
hasInFlightRun: vi.fn(() => false),
|
||||
reloadStalePluginSession: async () => {},
|
||||
streamAgent,
|
||||
waitForAgentRunStart: vi.fn(async () => undefined),
|
||||
} as unknown as Parameters<typeof createAgentCommand>[0]["agentManager"],
|
||||
@@ -88,7 +87,6 @@ test("session create forwards clientMessageId to the initial prompt run options"
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
});
|
||||
|
||||
expect(streamAgent).toHaveBeenCalledWith("agent-1", "hello from create", {
|
||||
@@ -125,7 +123,6 @@ test("session create validates the requested mode against the provider's modes",
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
}),
|
||||
).rejects.toThrow("Invalid mode 'plan'");
|
||||
|
||||
@@ -169,7 +166,6 @@ test("session create applies the resolved mode from the provider create config",
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
});
|
||||
|
||||
expect(createAgent).toHaveBeenCalledWith(
|
||||
@@ -247,7 +243,6 @@ test("session create stamps the requested workspaceId when no worktree setup run
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -258,41 +253,6 @@ test("session create stamps the requested workspaceId when no worktree setup run
|
||||
}
|
||||
});
|
||||
|
||||
test("session create stamps the new worktree's workspaceId when a setup continuation runs", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const agentManager = createRealAgentManager(storage);
|
||||
|
||||
try {
|
||||
const { snapshot } = await createAgentCommand(
|
||||
{
|
||||
agentManager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
||||
},
|
||||
{
|
||||
kind: "session",
|
||||
config: { provider: "codex", cwd: workdir },
|
||||
workspaceId: "ws-source",
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({
|
||||
sessionConfig: config,
|
||||
setupContinuation: { kind: "agent", startAfterAgentCreate: () => {} },
|
||||
createdWorkspaceId: "ws-new-worktree",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const stored = await storage.get(snapshot.id);
|
||||
expect(stored?.workspaceId).toBe("ws-new-worktree");
|
||||
} finally {
|
||||
await removeRealAgentManagerWorkdir({ agentManager, storage, workdir });
|
||||
}
|
||||
});
|
||||
|
||||
test("mcp create stamps the new worktree's workspaceId, not the parent's", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "create-agent-test-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
@@ -309,7 +269,6 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -416,7 +375,6 @@ test("session create keeps the prompt title after the initial prompt settles", a
|
||||
labels: {},
|
||||
provisionalTitle: title,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -454,7 +412,6 @@ test("session create keeps an explicit title after the initial prompt settles",
|
||||
labels: {},
|
||||
provisionalTitle: title,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config) => ({ sessionConfig: config }),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -492,7 +449,6 @@ test("session create with an internal config skips provider session persistence"
|
||||
labels: {},
|
||||
provisionalTitle: null,
|
||||
firstAgentContext: { attachments: [] },
|
||||
buildSessionConfig: async (config: AgentSessionConfig) => ({ sessionConfig: config }),
|
||||
};
|
||||
|
||||
await createAgentCommand(dependencies, {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { FinishSubscriptions } from "../finish-subscriptions/index.js";
|
||||
import type { AgentPromptSource } from "../agent-messages/index.js";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
@@ -6,37 +7,24 @@ import type { CreatePaseoWorktreeInput } from "../../paseo-worktree-service.js";
|
||||
import { expandUserPath, resolvePathFromBase } from "../../path-utils.js";
|
||||
import { toWorktreeRequestError } from "../../worktree-errors.js";
|
||||
import type {
|
||||
AgentWorktreeSetupContinuation,
|
||||
CreatePaseoWorktreeSetupContinuationInput,
|
||||
CreatePaseoWorktreeWorkflowFn,
|
||||
CreatePaseoWorktreeWorkflowResult,
|
||||
} from "../../worktree-session.js";
|
||||
import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js";
|
||||
import type { AgentAttachment, FirstAgentContext } from "../../messages.js";
|
||||
import type { AgentManager, CreateAgentOptions, ManagedAgent } from "../agent-manager.js";
|
||||
import type { AgentPromptInput, AgentRunOptions, AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
import type { AgentStorage } from "../agent-storage.js";
|
||||
import type { AgentOwner } from "../agent-owner.js";
|
||||
import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
|
||||
import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js";
|
||||
import { startCreatedAgentInitialPrompt } from "../agent-prompt.js";
|
||||
import { resolveCreateAgentTitles } from "../create-agent-title.js";
|
||||
import { buildAgentPrompt } from "../prompt-attachments.js";
|
||||
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
|
||||
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
} from "../timeline-append.js";
|
||||
import { resolveCreateAgentIntent } from "./intent.js";
|
||||
|
||||
export interface CreateAgentSessionWorktreeResult {
|
||||
sessionConfig: AgentSessionConfig;
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
// Set when this build created a fresh worktree workspace. The agent must be
|
||||
// stamped with it so workspaceId-scoped archive can find the agent later.
|
||||
createdWorkspaceId?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentCommandDependencies {
|
||||
finishSubscriptions?: FinishSubscriptions;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
logger: Logger;
|
||||
@@ -61,24 +49,16 @@ export interface CreateAgentFromSessionInput {
|
||||
agentId?: string;
|
||||
config: AgentSessionConfig;
|
||||
workspaceId: string;
|
||||
worktreeName?: string;
|
||||
initialPrompt?: string;
|
||||
source?: AgentPromptSource;
|
||||
clientMessageId?: string;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
attachments?: AgentAttachment[];
|
||||
git?: GitSetupOptions;
|
||||
labels: Record<string, string>;
|
||||
env?: Record<string, string>;
|
||||
provisionalTitle: string | null;
|
||||
firstAgentContext: FirstAgentContext;
|
||||
buildSessionConfig: (
|
||||
config: AgentSessionConfig,
|
||||
gitOptions?: GitSetupOptions,
|
||||
legacyWorktreeName?: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
) => Promise<CreateAgentSessionWorktreeResult>;
|
||||
}
|
||||
|
||||
export interface CreateAgentFromMcpInput {
|
||||
@@ -111,6 +91,7 @@ export interface CreateAgentFromMcpInput {
|
||||
allowCustomCwd?: boolean;
|
||||
childAgentDefaultLabels?: Record<string, string>;
|
||||
} | null;
|
||||
|
||||
worktree?: {
|
||||
worktreeName?: string;
|
||||
branchName?: string;
|
||||
@@ -167,7 +148,6 @@ interface ResolvedCreateAgent {
|
||||
prompt?: AgentPromptInput;
|
||||
source?: AgentPromptSource;
|
||||
runOptions?: AgentRunOptions;
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
promptFailure: CreateAgentPromptFailureMode;
|
||||
promptLogger?: Logger;
|
||||
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
|
||||
@@ -196,29 +176,29 @@ export async function createAgentCommand(
|
||||
if (input.kind === "mcp") {
|
||||
input.onCreated?.({ agentId: snapshot.id, createdWorktree: resolved.createdWorktree ?? null });
|
||||
}
|
||||
const stopNotification =
|
||||
const notification =
|
||||
input.kind === "mcp" &&
|
||||
input.notifyOnFinish &&
|
||||
input.callerAgentId &&
|
||||
(resolved.prompt !== undefined || resolved.setupContinuation !== undefined)
|
||||
? setupFinishNotification({
|
||||
agentManager: dependencies.agentManager,
|
||||
agentStorage: dependencies.agentStorage,
|
||||
childAgentId: snapshot.id,
|
||||
callerAgentId: input.callerAgentId,
|
||||
requireParentOwnership: true,
|
||||
logger: dependencies.logger,
|
||||
})
|
||||
resolved.prompt !== undefined
|
||||
? await requireFinishSubscriptions(dependencies).register(
|
||||
{
|
||||
childAgentId: snapshot.id,
|
||||
callerAgentId: input.callerAgentId,
|
||||
requireParentOwnership: !(input.detached ?? false),
|
||||
},
|
||||
true,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
resolved.setupContinuation?.startAfterAgentCreate({
|
||||
agentId: snapshot.id,
|
||||
});
|
||||
if (resolved.prompt !== undefined) {
|
||||
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch((error) => {
|
||||
stopNotification?.();
|
||||
throw error;
|
||||
});
|
||||
const sendResult = await sendInitialPrompt(dependencies, resolved, snapshot).catch(
|
||||
async (error) => {
|
||||
await notification?.reject();
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
notification?.accept();
|
||||
initialPromptStarted = sendResult.started;
|
||||
liveSnapshot = sendResult.liveSnapshot;
|
||||
initialPromptError = sendResult.error ?? null;
|
||||
@@ -238,29 +218,7 @@ async function resolveSessionCreateAgent(
|
||||
input: CreateAgentFromSessionInput,
|
||||
): Promise<ResolvedCreateAgent> {
|
||||
const trimmedPrompt = input.initialPrompt?.trim();
|
||||
const {
|
||||
sessionConfig: builtSessionConfig,
|
||||
setupContinuation,
|
||||
createdWorkspaceId,
|
||||
} = await input.buildSessionConfig(
|
||||
input.config,
|
||||
input.git,
|
||||
input.worktreeName,
|
||||
input.firstAgentContext,
|
||||
);
|
||||
// Validate the requested mode against the provider's modes for the resolved
|
||||
// cwd. The app remembers mode preferences globally, so a saved mode can be
|
||||
// stale for a workspace whose provider config no longer defines it — reject
|
||||
// it here instead of letting the provider fail mid-turn.
|
||||
//
|
||||
// This runs after buildSessionConfig, which may already have created a
|
||||
// worktree and/or workspace record — cwd (required to resolve modes) is
|
||||
// only known once that step completes. If validation throws, any
|
||||
// worktree/workspace buildSessionConfig created is the caller's
|
||||
// responsibility to clean up (session.ts's handleCreateAgentRequest does
|
||||
// this for the worktree path via cleanupCreatedWorktreeAfterFailedAgentCreate;
|
||||
// this is a pre-existing gap for directory-only workspace creates, not
|
||||
// introduced by this validation).
|
||||
const builtSessionConfig = input.config;
|
||||
const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({
|
||||
cwd: builtSessionConfig.cwd,
|
||||
provider: builtSessionConfig.provider,
|
||||
@@ -284,7 +242,7 @@ async function resolveSessionCreateAgent(
|
||||
...(clientMessageId ? { clientMessageId } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const workspaceId = setupContinuation ? createdWorkspaceId : input.workspaceId;
|
||||
const workspaceId = input.workspaceId;
|
||||
|
||||
return {
|
||||
config: sessionConfig,
|
||||
@@ -296,15 +254,11 @@ async function resolveSessionCreateAgent(
|
||||
// Paseo never persists an internal agent, so nothing could resume its
|
||||
// provider session either. Same as the daemon's own ephemeral helpers.
|
||||
...(sessionConfig.internal ? { persistSession: false } : {}),
|
||||
// A legacy git/worktreeName worktree creates a fresh workspace, so the
|
||||
// agent belongs to that workspace, not the source one. createdWorkspaceId
|
||||
// is the freshly created worktree's workspace.
|
||||
workspaceId: requireResolvedWorkspaceId(workspaceId),
|
||||
},
|
||||
prompt: hasPromptContent ? prompt : undefined,
|
||||
source: input.source,
|
||||
runOptions,
|
||||
setupContinuation,
|
||||
// Registration commits creation. First-turn failures belong to the created agent.
|
||||
promptFailure: "return-error",
|
||||
promptLogger: dependencies.logger.child({
|
||||
@@ -323,18 +277,16 @@ async function resolveMcpCreateAgent(
|
||||
? requireParentAgent(dependencies.agentManager, input.callerAgentId)
|
||||
: null;
|
||||
const cwd = resolveMcpInitialCwd(input, parentAgent);
|
||||
const { resolvedCwd, setupContinuation, createdWorkspaceId, createdWorktree } =
|
||||
await resolveMcpCwd({
|
||||
dependencies,
|
||||
cwd,
|
||||
worktree: input.worktree,
|
||||
initialPrompt: input.initialPrompt ?? "",
|
||||
callerWorkspaceId: parentAgent?.workspaceId,
|
||||
});
|
||||
const { resolvedCwd, createdWorkspaceId, createdWorktree } = await resolveMcpCwd({
|
||||
dependencies,
|
||||
cwd,
|
||||
worktree: input.worktree,
|
||||
initialPrompt: input.initialPrompt ?? "",
|
||||
callerWorkspaceId: parentAgent?.workspaceId,
|
||||
});
|
||||
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
|
||||
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
explicitWorkspaceId: setupContinuation ? createdWorkspaceId : input.workspaceId,
|
||||
explicitWorkspaceId: createdWorkspaceId ?? input.workspaceId,
|
||||
caller: parentAgent
|
||||
? { id: parentAgent.id, cwd: parentAgent.cwd, workspaceId: parentAgent.workspaceId }
|
||||
: null,
|
||||
@@ -383,7 +335,6 @@ async function resolveMcpCreateAgent(
|
||||
source: input.callerAgentId
|
||||
? { kind: "agent-message", agentId: input.callerAgentId }
|
||||
: undefined,
|
||||
setupContinuation,
|
||||
createdWorktree,
|
||||
promptFailure: input.promptFailure ?? "log",
|
||||
};
|
||||
@@ -535,6 +486,28 @@ function resolveChildAgentCwd(params: {
|
||||
return resolvePathFromBase(params.parentCwd, requestedCwd);
|
||||
}
|
||||
|
||||
interface CreateMcpWorktreeOptions {
|
||||
input: CreatePaseoWorktreeInput;
|
||||
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
}
|
||||
|
||||
async function createMcpWorktree(
|
||||
options: CreateMcpWorktreeOptions,
|
||||
): Promise<CreatePaseoWorktreeWorkflowResult> {
|
||||
try {
|
||||
if (!options.createPaseoWorktree) {
|
||||
throw new Error("Paseo worktree service is not configured");
|
||||
}
|
||||
return await options.createPaseoWorktree(
|
||||
options.input,
|
||||
options.resolveDefaultBranch ? { resolveDefaultBranch: options.resolveDefaultBranch } : {},
|
||||
);
|
||||
} catch (error) {
|
||||
throw toWorktreeRequestError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveMcpCwd(params: {
|
||||
dependencies: CreateAgentCommandDependencies;
|
||||
cwd: string;
|
||||
@@ -543,7 +516,6 @@ async function resolveMcpCwd(params: {
|
||||
worktree: CreateAgentFromMcpInput["worktree"];
|
||||
}): Promise<{
|
||||
resolvedCwd: string;
|
||||
setupContinuation?: AgentWorktreeSetupContinuation;
|
||||
createdWorkspaceId?: string;
|
||||
createdWorktree?: CreatePaseoWorktreeWorkflowResult;
|
||||
}> {
|
||||
@@ -577,59 +549,23 @@ async function resolveMcpCwd(params: {
|
||||
action: worktree.action,
|
||||
githubPrNumber: worktree.githubPrNumber,
|
||||
firstAgentContext: { prompt: params.initialPrompt },
|
||||
runSetup: false,
|
||||
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: dependencies.worktreesRoot,
|
||||
},
|
||||
createPaseoWorktree: dependencies.createPaseoWorktree,
|
||||
resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined,
|
||||
setupContinuation: {
|
||||
kind: "agent",
|
||||
terminalManager: dependencies.terminalManager ?? null,
|
||||
appendTimelineItem: ({ agentId, item }) =>
|
||||
appendTimelineItemIfAgentKnown({
|
||||
agentManager: dependencies.agentManager,
|
||||
agentId,
|
||||
item,
|
||||
}),
|
||||
emitLiveTimelineItem: ({ agentId, item }) =>
|
||||
emitLiveTimelineItemIfAgentKnown({
|
||||
agentManager: dependencies.agentManager,
|
||||
agentId,
|
||||
item,
|
||||
}),
|
||||
logger: dependencies.logger,
|
||||
},
|
||||
});
|
||||
return {
|
||||
resolvedCwd: createdWorktree.workspace.cwd,
|
||||
setupContinuation: createdWorktree.setupContinuation,
|
||||
createdWorkspaceId: createdWorktree.workspace.workspaceId,
|
||||
createdWorktree,
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateMcpWorktreeOptions {
|
||||
input: CreatePaseoWorktreeInput;
|
||||
createPaseoWorktree: CreatePaseoWorktreeWorkflowFn | undefined;
|
||||
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
|
||||
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
|
||||
}
|
||||
|
||||
async function createMcpWorktree(
|
||||
options: CreateMcpWorktreeOptions,
|
||||
): Promise<CreatePaseoWorktreeWorkflowResult> {
|
||||
try {
|
||||
if (!options.createPaseoWorktree) {
|
||||
throw new Error("Paseo worktree service is not configured");
|
||||
}
|
||||
return await options.createPaseoWorktree(options.input, {
|
||||
...(options.resolveDefaultBranch
|
||||
? { resolveDefaultBranch: options.resolveDefaultBranch }
|
||||
: {}),
|
||||
...(options.setupContinuation ? { setupContinuation: options.setupContinuation } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
throw toWorktreeRequestError(error);
|
||||
}
|
||||
function requireFinishSubscriptions(
|
||||
dependencies: CreateAgentCommandDependencies,
|
||||
): FinishSubscriptions {
|
||||
if (!dependencies.finishSubscriptions) throw new Error("Finish subscriptions are not configured");
|
||||
return dependencies.finishSubscriptions;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
export class AgentDirectory {
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
manager: AgentManager;
|
||||
storage: AgentStorage;
|
||||
manager: Pick<AgentManager, "listAgents">;
|
||||
storage: Pick<AgentStorage, "list">;
|
||||
workspaces: Pick<WorkspaceRegistry, "get" | "list">;
|
||||
projectLive(agent: ManagedAgent): Promise<AgentSnapshotPayload>;
|
||||
projectStored(record: StoredAgentRecord): AgentSnapshotPayload;
|
||||
@@ -17,6 +17,26 @@ export class AgentDirectory {
|
||||
},
|
||||
) {}
|
||||
|
||||
/** Human prompt recency includes archived/unavailable agents without projecting their UI payloads. */
|
||||
async workspaceLastUserMessageTimes(): Promise<Map<string, string>> {
|
||||
const live = this.deps.manager.listAgents();
|
||||
const liveIds = new Set(live.map((agent) => agent.id));
|
||||
const times = new Map<string, string>();
|
||||
const record = (workspaceId: string | undefined, at: string | null | undefined) => {
|
||||
if (!workspaceId || !at) return;
|
||||
const previous = times.get(workspaceId);
|
||||
if (!previous || at > previous) times.set(workspaceId, at);
|
||||
};
|
||||
for (const agent of await this.deps.storage.list()) {
|
||||
if (!agent.internal && !liveIds.has(agent.id))
|
||||
record(agent.workspaceId, agent.lastUserMessageAt);
|
||||
}
|
||||
for (const agent of live) {
|
||||
if (!agent.internal) record(agent.workspaceId, agent.lastUserMessageAt?.toISOString());
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
async includes(
|
||||
agent: Pick<AgentSnapshotPayload, "workspaceId" | "internal">,
|
||||
includeBackground = false,
|
||||
@@ -60,6 +80,8 @@ export class AgentDirectory {
|
||||
const active = await Promise.all(
|
||||
live.filter(matches).map((agent) => this.deps.projectLive(agent)),
|
||||
);
|
||||
return [...active, ...stored].filter((agent) => this.deps.isProviderVisible(agent.provider));
|
||||
return [...active, ...stored]
|
||||
.filter(matches)
|
||||
.filter((agent) => this.deps.isProviderVisible(agent.provider));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import { createIdleRuntimeCleanup } from "../idle-runtime-cleanup/index.js";
|
||||
import { HostObservationError } from "../host-operations/index.js";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentMessageDelivery } from "../agent-messages/index.js";
|
||||
import { buildStoredAgentPayload } from "../agent-projections.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import {
|
||||
createFinishSubscriptions,
|
||||
createJsonFinishSubscriptionStore,
|
||||
type FinishObservation,
|
||||
type FinishSubscriptions,
|
||||
} from "./index.js";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
class Observation implements FinishObservation {
|
||||
waits: Array<{ signal: AbortSignal; result: ReturnType<typeof deferred<WaitForFinishResult>> }> =
|
||||
[];
|
||||
permission = deferred<void>();
|
||||
opening: Promise<void> = Promise.resolve();
|
||||
openedSignals: AbortSignal[] = [];
|
||||
async open(_record: unknown, signal: AbortSignal) {
|
||||
this.openedSignals.push(signal);
|
||||
await this.opening;
|
||||
return { next: () => this.wait(signal), close: async () => {} };
|
||||
}
|
||||
async wait(signal: AbortSignal) {
|
||||
signal.throwIfAborted();
|
||||
const result = deferred<WaitForFinishResult>();
|
||||
signal.addEventListener("abort", () => result.reject(new Error("Stopped")), { once: true });
|
||||
this.waits.push({ signal, result });
|
||||
return result.promise;
|
||||
}
|
||||
afterPermission() {
|
||||
return this.permission.promise;
|
||||
}
|
||||
}
|
||||
class Delivery implements AgentMessageDelivery {
|
||||
prompts: string[] = [];
|
||||
acknowledgments: Array<ReturnType<typeof deferred<"steered">>> = [];
|
||||
enqueue(message: Parameters<AgentMessageDelivery["enqueue"]>[0]) {
|
||||
this.prompts.push(String(message.prompt));
|
||||
const acknowledgment = deferred<"steered">();
|
||||
this.acknowledgments.push(acknowledgment);
|
||||
return {
|
||||
status: "queued" as const,
|
||||
messageId: "notification",
|
||||
dispatched: acknowledgment.promise,
|
||||
};
|
||||
}
|
||||
async close() {}
|
||||
async waitForDispatch() {}
|
||||
}
|
||||
const homes: string[] = [];
|
||||
const services: FinishSubscriptions[] = [];
|
||||
afterEach(async () => {
|
||||
for (const service of services.splice(0)) await service.close();
|
||||
await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true })));
|
||||
});
|
||||
async function scenario(directory?: string, removalFailures = 0, retryDelayMs = 1) {
|
||||
const home = directory ?? (await mkdtemp(join(tmpdir(), "finish-recovery-")));
|
||||
if (!directory) homes.push(home);
|
||||
const durableStore = createJsonFinishSubscriptionStore(home);
|
||||
const store = {
|
||||
...durableStore,
|
||||
async remove(id: string) {
|
||||
if (removalFailures > 0) {
|
||||
removalFailures--;
|
||||
throw new Error("Subscription removal unavailable");
|
||||
}
|
||||
await durableStore.remove(id);
|
||||
},
|
||||
};
|
||||
const observation = new Observation();
|
||||
const delivery = new Delivery();
|
||||
let active = true;
|
||||
const service = createFinishSubscriptions({
|
||||
store,
|
||||
messages: () => delivery,
|
||||
open: observation.open.bind(observation),
|
||||
callerIsActive: async () => active,
|
||||
serverId: "origin",
|
||||
logger: createTestLogger(),
|
||||
retryDelayMs,
|
||||
});
|
||||
services.push(service);
|
||||
await service.start();
|
||||
return {
|
||||
home,
|
||||
store,
|
||||
observation,
|
||||
delivery,
|
||||
service,
|
||||
archive: () => {
|
||||
active = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
const intent = { callerAgentId: "parent", childAgentId: "child" };
|
||||
function outcome(status: "idle" | "running" | "closed" | "error" = "closed"): WaitForFinishResult {
|
||||
return {
|
||||
status: status === "error" ? "error" : "idle",
|
||||
final: buildStoredAgentPayload(
|
||||
{
|
||||
id: "child",
|
||||
provider: "codex",
|
||||
cwd: "/tmp",
|
||||
title: "Child",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
labels: { "paseo.parent-agent-id": "parent" },
|
||||
lastStatus: status,
|
||||
},
|
||||
["codex"],
|
||||
),
|
||||
lastMessage: "Partial work saved",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
async function until(check: () => boolean) {
|
||||
await expect.poll(check).toBe(true);
|
||||
}
|
||||
|
||||
test("retains intent until parent dispatch acknowledgment, then restart sends nothing", async () => {
|
||||
const first = await scenario();
|
||||
await first.service.register(intent);
|
||||
await until(() => first.observation.waits.length === 1);
|
||||
first.observation.waits[0]!.result.resolve(outcome());
|
||||
await until(() => first.delivery.prompts.length === 1);
|
||||
expect(first.delivery.prompts[0]).toContain("was closed");
|
||||
expect(first.delivery.prompts[0]).toContain("Partial work saved");
|
||||
expect(await first.store.list()).toHaveLength(1);
|
||||
first.delivery.acknowledgments[0]!.resolve("steered");
|
||||
await expect.poll(async () => (await first.store.list()).length).toBe(0);
|
||||
await first.service.close();
|
||||
const restarted = await scenario(first.home);
|
||||
expect(restarted.observation.waits).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("restart coalesces predecessor and queued candidate into one settled notification", async () => {
|
||||
const first = await scenario();
|
||||
await first.service.register(intent);
|
||||
await first.service.register(intent, true);
|
||||
expect(await first.store.list()).toHaveLength(2);
|
||||
await first.service.close();
|
||||
const restarted = await scenario(first.home);
|
||||
await until(() => restarted.observation.waits.length === 1);
|
||||
restarted.observation.waits[0]!.result.resolve(outcome());
|
||||
await until(() => restarted.delivery.prompts.length === 1);
|
||||
restarted.delivery.acknowledgments[0]!.resolve("steered");
|
||||
await expect.poll(async () => (await restarted.store.list()).length).toBe(0);
|
||||
expect(restarted.delivery.prompts).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("rejected queued follow-up preserves the predecessor and its buffered finish", async () => {
|
||||
const state = await scenario();
|
||||
await state.service.register(intent);
|
||||
await until(() => state.observation.waits.length === 1);
|
||||
const pending = await state.service.register(intent, true);
|
||||
state.observation.waits[0]!.result.resolve(outcome("idle"));
|
||||
await Promise.resolve();
|
||||
expect(state.delivery.prompts).toHaveLength(0);
|
||||
await pending.reject();
|
||||
await until(() => state.delivery.prompts.length === 1);
|
||||
expect(await state.store.list()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("accepted replacement ignores old idle and stale acknowledgment cannot delete new intent", async () => {
|
||||
const state = await scenario();
|
||||
await state.service.register(intent);
|
||||
await until(() => state.observation.waits.length === 1);
|
||||
state.observation.waits[0]!.result.resolve(outcome("idle"));
|
||||
await until(() => state.delivery.prompts.length === 1);
|
||||
const next = await state.service.register(intent, true);
|
||||
next.accept();
|
||||
await until(() => state.observation.waits.length === 2);
|
||||
state.delivery.acknowledgments[0]!.resolve("steered");
|
||||
await expect.poll(async () => (await state.store.list()).length).toBe(1);
|
||||
state.observation.waits[1]!.result.resolve(outcome("idle"));
|
||||
await until(() => state.delivery.prompts.length === 2);
|
||||
});
|
||||
|
||||
test("shutdown before dispatch acknowledgment leaves intent for recovery", async () => {
|
||||
const state = await scenario();
|
||||
await state.service.register(intent);
|
||||
await until(() => state.observation.waits.length === 1);
|
||||
state.observation.waits[0]!.result.resolve(outcome());
|
||||
await until(() => state.delivery.prompts.length === 1);
|
||||
await state.service.close();
|
||||
state.delivery.acknowledgments[0]!.resolve("steered");
|
||||
const restarted = await scenario(state.home);
|
||||
await until(() => restarted.observation.waits.length === 1);
|
||||
restarted.observation.waits[0]!.result.resolve(outcome());
|
||||
await until(() => restarted.delivery.prompts.length === 1);
|
||||
});
|
||||
|
||||
test("remote recovery before three failed observations still reports child completion", async () => {
|
||||
const first = await scenario();
|
||||
await first.service.register({ ...intent, serverId: "remote" });
|
||||
await first.service.close();
|
||||
const state = await scenario(first.home);
|
||||
await until(() => state.observation.waits.length === 1);
|
||||
state.observation.waits[0]!.result.reject(new HostObservationError("unreachable"));
|
||||
await until(() => state.observation.waits.length === 2);
|
||||
expect(await state.store.list()).toHaveLength(1);
|
||||
expect(state.delivery.prompts).toHaveLength(0);
|
||||
state.observation.waits[1]!.result.resolve(outcome("idle"));
|
||||
await until(() => state.delivery.prompts.length === 1);
|
||||
expect(state.delivery.prompts[0]).toContain("Server: remote");
|
||||
});
|
||||
|
||||
test("archived parent suppresses delivery and removes obsolete intent", async () => {
|
||||
const state = await scenario();
|
||||
await state.service.register(intent);
|
||||
await until(() => state.observation.waits.length === 1);
|
||||
state.archive();
|
||||
state.observation.waits[0]!.result.resolve(outcome());
|
||||
await expect.poll(async () => (await state.store.list()).length).toBe(0);
|
||||
expect(state.delivery.prompts).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("permission checkpoint retains intent and completion reports after permission clears", async () => {
|
||||
const state = await scenario();
|
||||
await state.service.register(intent);
|
||||
await until(() => state.observation.waits.length === 1);
|
||||
const result = outcome("running");
|
||||
result.status = "permission";
|
||||
result.final!.pendingPermissions = [
|
||||
{
|
||||
id: "permission",
|
||||
name: "Write",
|
||||
input: { path: "file" },
|
||||
kind: "tool",
|
||||
} as (typeof result.final.pendingPermissions)[number],
|
||||
];
|
||||
state.observation.waits[0]!.result.resolve(result);
|
||||
await until(() => state.delivery.prompts.length === 1);
|
||||
expect(state.delivery.prompts[0]).toContain("respond_to_permission");
|
||||
expect(await state.store.list()).toHaveLength(1);
|
||||
state.delivery.acknowledgments[0]!.resolve("steered");
|
||||
state.observation.permission.resolve();
|
||||
await until(() => state.observation.waits.length === 2);
|
||||
state.observation.waits[1]!.result.resolve(outcome("idle"));
|
||||
await until(() => state.delivery.prompts.length === 2);
|
||||
});
|
||||
|
||||
test("coalesced unconditional follow-up survives detachment of a parent-owned creation", async () => {
|
||||
const first = await scenario();
|
||||
await first.service.register({ ...intent, requireParentOwnership: true });
|
||||
await first.service.register(intent, true);
|
||||
await first.service.close();
|
||||
const restarted = await scenario(first.home);
|
||||
await until(() => restarted.observation.waits.length === 1);
|
||||
const detached = outcome();
|
||||
detached.final!.labels = {};
|
||||
restarted.observation.waits[0]!.result.resolve(detached);
|
||||
await until(() => restarted.delivery.prompts.length === 1);
|
||||
});
|
||||
|
||||
test("detached child suppresses a parent-owned creation subscription", async () => {
|
||||
const state = await scenario();
|
||||
await state.service.register({ ...intent, requireParentOwnership: true });
|
||||
await until(() => state.observation.waits.length === 1);
|
||||
const detached = outcome();
|
||||
detached.final!.labels = {};
|
||||
state.observation.waits[0]!.result.resolve(detached);
|
||||
await expect.poll(async () => (await state.store.list()).length).toBe(0);
|
||||
expect(state.delivery.prompts).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("dispatch failure retains intent and retries the ordinary finish notification", async () => {
|
||||
const state = await scenario();
|
||||
await state.service.register(intent);
|
||||
await until(() => state.observation.waits.length === 1);
|
||||
state.observation.waits[0]!.result.resolve(outcome());
|
||||
await until(() => state.delivery.prompts.length === 1);
|
||||
state.delivery.acknowledgments[0]!.reject(new Error("Provider unavailable"));
|
||||
await until(() => state.delivery.prompts.length === 2);
|
||||
expect(await state.store.list()).toHaveLength(1);
|
||||
expect(state.observation.waits).toHaveLength(1);
|
||||
state.delivery.acknowledgments[1]!.resolve("steered");
|
||||
await expect.poll(async () => (await state.store.list()).length).toBe(0);
|
||||
});
|
||||
|
||||
test("error and missing-child notifications never claim successful completion", async () => {
|
||||
const state = await scenario();
|
||||
await state.service.register(intent);
|
||||
await until(() => state.observation.waits.length === 1);
|
||||
state.observation.waits[0]!.result.resolve(outcome("error"));
|
||||
await until(() => state.delivery.prompts.length === 1);
|
||||
expect(state.delivery.prompts[0]).toContain("errored");
|
||||
await state.service.register({ ...intent, childAgentId: "missing-child" });
|
||||
await until(() => state.observation.waits.length === 2);
|
||||
state.observation.waits[1]!.result.resolve({
|
||||
status: "error",
|
||||
final: null,
|
||||
error: "Agent not found: missing-child",
|
||||
lastMessage: null,
|
||||
});
|
||||
await until(() => state.delivery.prompts.length === 2);
|
||||
expect(state.delivery.prompts[1]).toContain("is unavailable");
|
||||
expect(state.delivery.prompts[1]).not.toContain("finished");
|
||||
});
|
||||
|
||||
test("finish notification limits a long last response and points to activity for the remainder", async () => {
|
||||
const state = await scenario();
|
||||
await state.service.register(intent);
|
||||
await until(() => state.observation.waits.length === 1);
|
||||
const result = outcome("idle");
|
||||
result.lastMessage = "x".repeat(4100);
|
||||
state.observation.waits[0]!.result.resolve(result);
|
||||
await until(() => state.delivery.prompts.length === 1);
|
||||
expect(state.delivery.prompts[0]).toContain("x".repeat(4000));
|
||||
expect(state.delivery.prompts[0]).not.toContain("x".repeat(4001));
|
||||
expect(state.delivery.prompts[0]).toContain("get_agent_activity");
|
||||
});
|
||||
|
||||
test("three failed remote observations consume intent only after unavailable-notification acknowledgment", async () => {
|
||||
const state = await scenario();
|
||||
await state.service.register({ ...intent, serverId: "remote" });
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await until(() => state.observation.waits.length === attempt + 1);
|
||||
expect(state.delivery.prompts).toEqual([]);
|
||||
state.observation.waits[attempt]!.result.reject(new HostObservationError("unreachable"));
|
||||
}
|
||||
await until(() => state.delivery.prompts.length === 1);
|
||||
expect(state.delivery.prompts[0]).toContain(
|
||||
"Could not observe child agent child on host remote after three failed attempts.",
|
||||
);
|
||||
expect(state.delivery.prompts[0]).toContain("Status of child agent child (child) is unknown.");
|
||||
expect(state.delivery.prompts[0]).not.toContain("finished.");
|
||||
expect(await state.store.list()).toHaveLength(1);
|
||||
expect(state.observation.waits).toHaveLength(3);
|
||||
state.delivery.acknowledgments[0]!.resolve("steered");
|
||||
await expect.poll(async () => (await state.store.list()).length).toBe(0);
|
||||
await state.service.close();
|
||||
const restarted = await scenario(state.home);
|
||||
expect(restarted.observation.waits).toEqual([]);
|
||||
expect(restarted.delivery.prompts).toEqual([]);
|
||||
});
|
||||
|
||||
test("connecting does not spend the remote failure budget and recovery before three failures still completes", async () => {
|
||||
const state = await scenario();
|
||||
await state.service.register({ ...intent, serverId: "remote" });
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await until(() => state.observation.waits.length === attempt + 1);
|
||||
expect(state.delivery.prompts).toEqual([]);
|
||||
state.observation.waits[attempt]!.result.reject(new HostObservationError("connecting"));
|
||||
}
|
||||
for (let attempt = 4; attempt < 6; attempt++) {
|
||||
await until(() => state.observation.waits.length === attempt + 1);
|
||||
expect(state.delivery.prompts).toEqual([]);
|
||||
state.observation.waits[attempt]!.result.reject(new HostObservationError("unreachable"));
|
||||
}
|
||||
await until(() => state.observation.waits.length === 7);
|
||||
expect(state.delivery.prompts).toEqual([]);
|
||||
state.observation.waits[6]!.result.resolve(outcome("idle"));
|
||||
await until(() => state.delivery.prompts.length === 1);
|
||||
expect(state.delivery.prompts[0]).toContain("finished.");
|
||||
});
|
||||
|
||||
test("removed-host notice retries parent dispatch without observing again or deleting unacknowledged intent", async () => {
|
||||
const state = await scenario();
|
||||
await state.service.register({ ...intent, serverId: "remote" });
|
||||
await until(() => state.observation.waits.length === 1);
|
||||
state.observation.waits[0]!.result.reject(new HostObservationError("removed"));
|
||||
await until(() => state.delivery.prompts.length === 1);
|
||||
expect(state.delivery.prompts[0]).toContain("Host remote was removed or is unavailable.");
|
||||
expect(state.delivery.prompts[0]).toContain("Status of child agent child (child) is unknown.");
|
||||
state.delivery.acknowledgments[0]!.reject(new Error("Parent provider unavailable"));
|
||||
await until(() => state.delivery.prompts.length === 2);
|
||||
expect(state.delivery.prompts[1]).toBe(state.delivery.prompts[0]);
|
||||
expect(state.observation.waits).toHaveLength(1);
|
||||
expect(await state.store.list()).toHaveLength(1);
|
||||
state.delivery.acknowledgments[1]!.resolve("steered");
|
||||
await expect.poll(async () => (await state.store.list()).length).toBe(0);
|
||||
});
|
||||
|
||||
test("failed removal after dispatch acknowledgment retries only storage, preserving the accepted outcome", async () => {
|
||||
const state = await scenario(undefined, 2, 1000);
|
||||
await state.service.register(intent);
|
||||
await until(() => state.observation.waits.length === 1);
|
||||
state.observation.waits[0]!.result.resolve(outcome("idle"));
|
||||
await until(() => state.delivery.prompts.length === 1);
|
||||
state.delivery.acknowledgments[0]!.resolve("steered");
|
||||
await expect.poll(async () => (await state.store.list()).length, { timeout: 10000 }).toBe(0);
|
||||
expect(state.delivery.prompts).toHaveLength(1);
|
||||
expect(state.delivery.prompts[0]).toContain("finished.");
|
||||
expect(state.observation.waits).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("shutdown during observation setup owns rejection of the already-canceled next result", async () => {
|
||||
const state = await scenario();
|
||||
const opening = deferred<void>();
|
||||
state.observation.opening = opening.promise;
|
||||
await state.service.register(intent);
|
||||
await until(() => state.observation.openedSignals.length === 1);
|
||||
const closing = state.service.close();
|
||||
await until(() => state.observation.openedSignals[0]!.aborted);
|
||||
opening.resolve();
|
||||
await closing;
|
||||
expect(await state.store.list()).toHaveLength(1);
|
||||
expect(state.delivery.prompts).toEqual([]);
|
||||
});
|
||||
|
||||
test("remote finish intent protects its caller through registration, observation and delivery", async () => {
|
||||
const s = await scenario();
|
||||
let time = 61 * 60_000;
|
||||
let collected = false;
|
||||
const runtime = {
|
||||
identity: {},
|
||||
agentId: "parent",
|
||||
parentAgentId: undefined,
|
||||
provider: "test",
|
||||
sessionId: "parent",
|
||||
lastActivityAt: 0,
|
||||
isEligible: () => !collected,
|
||||
closeIfEligible: async (revalidate: () => boolean) => {
|
||||
if (!revalidate()) return false;
|
||||
collected = true;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled: true,
|
||||
logger: createTestLogger(),
|
||||
now: () => time,
|
||||
runtimes: () => (collected ? [] : [runtime]),
|
||||
isProtected: (agentId) => s.service.hasPendingForCaller(agentId),
|
||||
schedule: () => () => {},
|
||||
});
|
||||
const savedPut = s.store.put.bind(s.store);
|
||||
const writing = deferred<void>();
|
||||
s.store.put = async (record) => {
|
||||
await writing.promise;
|
||||
await savedPut(record);
|
||||
};
|
||||
const registration = s.service.register({ ...intent, serverId: "remote" }, true);
|
||||
const protectedDuringRegistration = s.service.hasPendingForCaller?.("parent") ?? false;
|
||||
writing.resolve();
|
||||
const accepted = await registration;
|
||||
expect(protectedDuringRegistration).toBe(true);
|
||||
expect((await cleanup.sweep()).collected).toEqual([]);
|
||||
expect(s.service.hasPendingForCaller("parent")).toBe(true);
|
||||
accepted.accept();
|
||||
await until(() => s.observation.waits.length === 1);
|
||||
expect(s.service.hasPendingForCaller("parent")).toBe(true);
|
||||
s.observation.waits[0]!.result.resolve(outcome());
|
||||
await until(() => s.delivery.prompts.length === 1);
|
||||
expect(s.service.hasPendingForCaller("parent")).toBe(true);
|
||||
s.delivery.acknowledgments[0]!.resolve("steered");
|
||||
await until(() => !s.service.hasPendingForCaller("parent"));
|
||||
expect(await s.store.list()).toEqual([]);
|
||||
time = 62 * 60_000;
|
||||
expect((await cleanup.sweep()).collected).toEqual([]);
|
||||
time = 122 * 60_000;
|
||||
expect((await cleanup.sweep()).collected).toHaveLength(1);
|
||||
await cleanup.close();
|
||||
});
|
||||
|
||||
test("finish recovery protects callers before durable intent has loaded", async () => {
|
||||
const s = await scenario();
|
||||
await s.service.register({ ...intent, serverId: "remote" });
|
||||
await s.service.close();
|
||||
const loading = deferred<void>();
|
||||
const recovered = createFinishSubscriptions({
|
||||
store: {
|
||||
...s.store,
|
||||
list: async () => {
|
||||
await loading.promise;
|
||||
return s.store.list();
|
||||
},
|
||||
},
|
||||
messages: () => s.delivery,
|
||||
open: s.observation.open.bind(s.observation),
|
||||
callerIsActive: async () => true,
|
||||
serverId: "origin",
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
services.push(recovered);
|
||||
expect(recovered.hasPendingForCaller("parent")).toBe(true);
|
||||
const starting = recovered.start();
|
||||
expect(recovered.hasPendingForCaller("parent")).toBe(true);
|
||||
loading.resolve();
|
||||
await starting;
|
||||
expect(recovered.hasPendingForCaller("parent")).toBe(true);
|
||||
expect(recovered.hasPendingForCaller("unrelated")).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { setTimeout as pause } from "node:timers/promises";
|
||||
import { HostObservationError } from "../host-operations/index.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Logger } from "pino";
|
||||
import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { FinishObservationResult } from "./internal/observation-buffer.js";
|
||||
import type { AgentMessageDelivery } from "../agent-messages/index.js";
|
||||
import { getParentAddressFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import {
|
||||
formatFinishNotificationBody,
|
||||
finishNotificationEvents,
|
||||
type FinishNotificationReason,
|
||||
} from "./internal/notification.js";
|
||||
export { createJsonFinishSubscriptionStore } from "./internal/json-store.js";
|
||||
|
||||
export interface FinishSubscription {
|
||||
id: string;
|
||||
callerAgentId: string;
|
||||
childAgentId: string;
|
||||
serverId?: string;
|
||||
/** Creation subscriptions stop reporting after the child is detached. */
|
||||
requireParentOwnership?: boolean;
|
||||
}
|
||||
export interface FinishSubscriptionStore {
|
||||
list(): Promise<FinishSubscription[]>;
|
||||
put(subscription: FinishSubscription): Promise<void>;
|
||||
remove(id: string): Promise<void>;
|
||||
}
|
||||
export interface FinishObservationSession {
|
||||
next(): Promise<FinishObservationResult>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
export interface FinishObservation {
|
||||
open(subscription: FinishSubscription, signal: AbortSignal): Promise<FinishObservationSession>;
|
||||
}
|
||||
interface Dependencies extends FinishObservation {
|
||||
store: FinishSubscriptionStore;
|
||||
messages(): AgentMessageDelivery;
|
||||
callerIsActive(agentId: string): Promise<boolean>;
|
||||
serverId: string;
|
||||
logger: Logger;
|
||||
retryDelayMs?: number;
|
||||
}
|
||||
export interface FinishRegistration {
|
||||
/** The prompt was accepted. Replaces preceding registrations for this pair. */
|
||||
accept(): void;
|
||||
/** Rejected dispatch preserves preceding registrations. */
|
||||
reject(): Promise<void>;
|
||||
}
|
||||
export interface FinishSubscriptions {
|
||||
start(): Promise<void>;
|
||||
/** Pending notification intent, including registration, recovery and unacknowledged delivery. */
|
||||
hasPendingForCaller(agentId: string): boolean;
|
||||
register(
|
||||
input: Omit<FinishSubscription, "id">,
|
||||
pendingDispatch?: boolean,
|
||||
): Promise<FinishRegistration>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
interface Watch {
|
||||
records: FinishSubscription[];
|
||||
controller: AbortController;
|
||||
decision: Promise<boolean>;
|
||||
decide(accepted: boolean): void;
|
||||
pending: boolean;
|
||||
task?: Promise<void>;
|
||||
}
|
||||
function key(record: FinishSubscription): string {
|
||||
return JSON.stringify([record.callerAgentId, record.childAgentId, record.serverId]);
|
||||
}
|
||||
|
||||
/** Owns durable intent, live observation, supersession, and dispatch acknowledgment. */
|
||||
export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptions {
|
||||
const tasks = new Set<Promise<void>>();
|
||||
const watches = new Map<string, Set<Watch>>();
|
||||
let closed = false;
|
||||
let recovering = true;
|
||||
const registering = new Map<string, number>();
|
||||
let mutations = Promise.resolve();
|
||||
function serialize<T>(action: () => Promise<T>): Promise<T> {
|
||||
const result = mutations.then(action);
|
||||
mutations = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
function attach(records: FinishSubscription[], pending: boolean): Watch {
|
||||
let decide!: Watch["decide"];
|
||||
const decision = new Promise<boolean>((resolve) => {
|
||||
decide = resolve;
|
||||
});
|
||||
const watch: Watch = { records, controller: new AbortController(), pending, decision, decide };
|
||||
const group = watches.get(key(records[0]!)) ?? new Set<Watch>();
|
||||
group.add(watch);
|
||||
watches.set(key(records[0]!), group);
|
||||
if (!pending) decide(true);
|
||||
watch.task = run(watch, group).catch((error) =>
|
||||
deps.logger.error({ err: error }, "Finish subscription failed"),
|
||||
);
|
||||
tasks.add(watch.task);
|
||||
void watch.task.then(() => tasks.delete(watch.task!));
|
||||
return watch;
|
||||
}
|
||||
function stop(watch: Watch, group: Set<Watch>): void {
|
||||
watch.controller.abort();
|
||||
watch.decide(false);
|
||||
group.delete(watch);
|
||||
if (!group.size && watches.get(key(watch.records[0]!)) === group)
|
||||
watches.delete(key(watch.records[0]!));
|
||||
}
|
||||
async function remove(watch: Watch): Promise<void> {
|
||||
// Only IDs captured by this observer are eligible; newer registrations survive.
|
||||
for (const record of watch.records) await deps.store.remove(record.id);
|
||||
}
|
||||
async function delay(signal: AbortSignal, attempt: number): Promise<void> {
|
||||
try {
|
||||
await pause(
|
||||
Math.min((deps.retryDelayMs ?? 1000) * 2 ** Math.min(attempt, 3), 5000),
|
||||
undefined,
|
||||
{ signal },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!signal.aborted) throw error;
|
||||
}
|
||||
}
|
||||
async function abortable<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
let abort!: () => void;
|
||||
const cancellation = new Promise<never>((_resolve, reject) => {
|
||||
abort = () => reject(signal.reason);
|
||||
if (signal.aborted) abort();
|
||||
else signal.addEventListener("abort", abort, { once: true });
|
||||
});
|
||||
try {
|
||||
return await Promise.race([operation, cancellation]);
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
async function decisions(group: Set<Watch>): Promise<void> {
|
||||
// New candidates may arrive while an older dispatch decision is pending.
|
||||
while (true) {
|
||||
if (closed) return;
|
||||
const pending = [...group].filter((watch) => watch.pending);
|
||||
if (!pending.length) return;
|
||||
await Promise.all(pending.map((watch) => watch.decision));
|
||||
}
|
||||
}
|
||||
async function suppressed(
|
||||
record: FinishSubscription,
|
||||
result?: WaitForFinishResult,
|
||||
): Promise<boolean> {
|
||||
if (!(await deps.callerIsActive(record.callerAgentId))) return true;
|
||||
if (!record.requireParentOwnership || !result?.final) return false;
|
||||
const parent = getParentAddressFromLabels(
|
||||
result.final.labels,
|
||||
record.serverId ?? deps.serverId,
|
||||
);
|
||||
return parent?.agentId !== record.callerAgentId || parent.serverId !== deps.serverId;
|
||||
}
|
||||
function reasonFor(result: FinishObservationResult): FinishNotificationReason {
|
||||
if (result.reason === "host unreachable" || result.reason === "host removed")
|
||||
return result.reason;
|
||||
if (!result.final) return "is unavailable";
|
||||
if (result.final.status === "closed") return "was closed";
|
||||
if (result.status === "error") return "errored";
|
||||
if (result.status === "permission") return "needs permission";
|
||||
return result.reason ?? "finished";
|
||||
}
|
||||
async function deliver(
|
||||
record: FinishSubscription,
|
||||
result: FinishObservationResult,
|
||||
permissions: Set<string>,
|
||||
signal: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const requests = result.final?.pendingPermissions ?? [];
|
||||
const reason = reasonFor(result);
|
||||
for (const id of permissions)
|
||||
if (!requests.some((request) => request.id === id)) permissions.delete(id);
|
||||
const acknowledgments: Promise<unknown>[] = [];
|
||||
for (const request of reason === "needs permission" ? requests : [undefined]) {
|
||||
if (signal.aborted) return false;
|
||||
if (request && permissions.has(request.id)) continue;
|
||||
const title = result.final?.title ?? record.childAgentId;
|
||||
const receipt = deps.messages().enqueue({
|
||||
recipientId: record.callerAgentId,
|
||||
prompt: formatFinishNotificationBody({
|
||||
childAgentId: record.childAgentId,
|
||||
serverId: record.serverId ?? deps.serverId,
|
||||
title,
|
||||
reason,
|
||||
lastAssistantMessage: result.lastMessage,
|
||||
permissionRequest: request,
|
||||
}),
|
||||
source: {
|
||||
kind: "agent-notification",
|
||||
agentId: record.childAgentId,
|
||||
serverId: record.serverId ?? deps.serverId,
|
||||
title,
|
||||
event: finishNotificationEvents[reason],
|
||||
},
|
||||
activeTurnBehavior: "steer",
|
||||
unarchive: false,
|
||||
});
|
||||
acknowledgments.push(
|
||||
receipt.dispatched.then(() => {
|
||||
if (request) permissions.add(request.id);
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
}
|
||||
await abortable(Promise.all(acknowledgments), signal);
|
||||
if (signal.aborted) return false;
|
||||
if (reason !== "needs permission") return true;
|
||||
return false;
|
||||
}
|
||||
async function run(watch: Watch, group: Set<Watch>): Promise<void> {
|
||||
const signal = watch.controller.signal;
|
||||
if (!(await watch.decision) || signal.aborted) return;
|
||||
const record = {
|
||||
...watch.records[0]!,
|
||||
requireParentOwnership: watch.records.every(
|
||||
(intent) => intent.requireParentOwnership === true,
|
||||
),
|
||||
};
|
||||
const permissions = new Set<string>();
|
||||
let observation: FinishObservationSession | undefined;
|
||||
let pending: FinishObservationResult | undefined;
|
||||
let acknowledged = false;
|
||||
let remoteFailures = 0;
|
||||
let retries = 0;
|
||||
let warned = false;
|
||||
async function observeNext(): Promise<FinishObservationResult | undefined> {
|
||||
try {
|
||||
observation ??= await deps.open(record, signal);
|
||||
const result = await abortable(observation.next(), signal);
|
||||
remoteFailures = 0;
|
||||
return result;
|
||||
} catch (error) {
|
||||
await observation?.close();
|
||||
observation = undefined;
|
||||
signal.throwIfAborted();
|
||||
if (!(error instanceof HostObservationError) || !record.serverId) throw error;
|
||||
if (error.reason === "connecting") return undefined;
|
||||
if (error.reason === "unreachable" && ++remoteFailures < 3) return undefined;
|
||||
// Host visibility ends this subscription; it says nothing about child completion.
|
||||
return {
|
||||
status: "error",
|
||||
final: null,
|
||||
lastMessage: null,
|
||||
error: null,
|
||||
reason: error.reason === "removed" ? "host removed" : "host unreachable",
|
||||
};
|
||||
}
|
||||
}
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
if (await suppressed(record)) {
|
||||
await remove(watch);
|
||||
stop(watch, group);
|
||||
return;
|
||||
}
|
||||
if (!pending) pending = await observeNext();
|
||||
if (!pending) {
|
||||
await delay(signal, retries++);
|
||||
continue;
|
||||
}
|
||||
if (signal.aborted) return;
|
||||
await decisions(group);
|
||||
if (signal.aborted) return;
|
||||
if (await suppressed(record, pending)) {
|
||||
await remove(watch);
|
||||
stop(watch, group);
|
||||
return;
|
||||
}
|
||||
if (!acknowledged) acknowledged = await deliver(record, pending, permissions, signal);
|
||||
if (acknowledged) {
|
||||
// A removal failure retries only the commit, never the already accepted callback.
|
||||
await remove(watch);
|
||||
stop(watch, group);
|
||||
return;
|
||||
}
|
||||
pending = undefined;
|
||||
retries = 0;
|
||||
warned = false;
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
// Delivery and storage failures retain the captured outcome and durable intent.
|
||||
// They do not spend the remote-observation budget or change the child's outcome.
|
||||
if (!warned) {
|
||||
deps.logger.warn(
|
||||
{ err: error, subscription: record.id },
|
||||
"Retrying finish subscription",
|
||||
);
|
||||
warned = true;
|
||||
}
|
||||
await delay(signal, retries++);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await observation?.close();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasPendingForCaller(agentId) {
|
||||
if (recovering || registering.has(agentId)) return true;
|
||||
for (const group of watches.values()) {
|
||||
for (const watch of group) if (watch.records[0]!.callerAgentId === agentId) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
start: () =>
|
||||
serialize(async () => {
|
||||
const groups = new Map<string, FinishSubscription[]>();
|
||||
for (const record of await deps.store.list()) {
|
||||
const records = groups.get(key(record)) ?? [];
|
||||
records.push(record);
|
||||
groups.set(key(record), records);
|
||||
}
|
||||
for (const records of groups.values()) attach(records, false);
|
||||
recovering = false;
|
||||
}),
|
||||
register: (input, pendingDispatch = false) => {
|
||||
registering.set(input.callerAgentId, (registering.get(input.callerAgentId) ?? 0) + 1);
|
||||
return serialize(async () => {
|
||||
if (closed) throw new Error("Finish subscriptions are closed");
|
||||
const record: FinishSubscription = {
|
||||
...input,
|
||||
serverId: input.serverId === deps.serverId ? undefined : input.serverId,
|
||||
id: randomUUID(),
|
||||
};
|
||||
await deps.store.put(record);
|
||||
const group = watches.get(key(record)) ?? new Set<Watch>();
|
||||
const predecessors = [...group];
|
||||
const watch = attach([record], true);
|
||||
const actualGroup = watches.get(key(record))!;
|
||||
const registration: FinishRegistration = {
|
||||
accept() {
|
||||
if (!watch.pending || closed) return;
|
||||
watch.pending = false;
|
||||
for (const prior of predecessors) stop(prior, actualGroup);
|
||||
watch.decide(true);
|
||||
void serialize(async () => {
|
||||
for (const prior of predecessors) await remove(prior);
|
||||
}).catch((error) =>
|
||||
deps.logger.error({ err: error }, "Failed to remove superseded finish subscriptions"),
|
||||
);
|
||||
},
|
||||
reject: () =>
|
||||
serialize(async () => {
|
||||
await remove(watch);
|
||||
stop(watch, actualGroup);
|
||||
}),
|
||||
};
|
||||
if (!pendingDispatch) registration.accept();
|
||||
return registration;
|
||||
}).finally(() => {
|
||||
const remaining = registering.get(input.callerAgentId)! - 1;
|
||||
if (remaining) registering.set(input.callerAgentId, remaining);
|
||||
else registering.delete(input.callerAgentId);
|
||||
});
|
||||
},
|
||||
async close() {
|
||||
closed = true;
|
||||
await mutations;
|
||||
for (const group of watches.values())
|
||||
for (const watch of group) {
|
||||
watch.controller.abort();
|
||||
watch.decide(false);
|
||||
}
|
||||
await mutations;
|
||||
await Promise.all(tasks);
|
||||
await deps.store.list();
|
||||
// Dispatch may still be pending. The delivery owner drains it after observers stop.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { createFinishObservation } from "./internal/observation.js";
|
||||
@@ -0,0 +1,51 @@
|
||||
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { createJsonFinishSubscriptionStore } from "../index.js";
|
||||
|
||||
const homes: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(homes.splice(0).map((home) => rm(home, { recursive: true, force: true })));
|
||||
});
|
||||
async function temporaryHome() {
|
||||
const result = await mkdtemp(join(tmpdir(), "finish-subscriptions-"));
|
||||
homes.push(result);
|
||||
return result;
|
||||
}
|
||||
const subscription = {
|
||||
id: "subscription",
|
||||
callerAgentId: "parent",
|
||||
childAgentId: "child",
|
||||
serverId: "remote",
|
||||
};
|
||||
|
||||
test("acknowledged concurrent writes survive reopening and removal", async () => {
|
||||
const directory = await temporaryHome();
|
||||
const store = createJsonFinishSubscriptionStore(directory);
|
||||
await Promise.all([store.put(subscription), store.put({ ...subscription, id: "second" })]);
|
||||
const restarted = createJsonFinishSubscriptionStore(directory);
|
||||
expect(await restarted.list()).toEqual([subscription, { ...subscription, id: "second" }]);
|
||||
await restarted.remove(subscription.id);
|
||||
expect(await createJsonFinishSubscriptionStore(directory).list()).toEqual([
|
||||
{ ...subscription, id: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("write failure rejects registration and does not publish an unwritten record", async () => {
|
||||
const directory = await temporaryHome();
|
||||
const store = createJsonFinishSubscriptionStore(directory);
|
||||
expect(await store.list()).toEqual([]);
|
||||
await mkdir(join(directory, "finish-subscriptions.json"));
|
||||
await expect(store.put(subscription)).rejects.toThrow();
|
||||
expect(await store.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("invalid persisted records fail visibly", async () => {
|
||||
const directory = await temporaryHome();
|
||||
await writeFile(
|
||||
join(directory, "finish-subscriptions.json"),
|
||||
JSON.stringify([{ callerAgentId: 42 }]),
|
||||
);
|
||||
await expect(createJsonFinishSubscriptionStore(directory).list()).rejects.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { writeJsonFileAtomic } from "../../../atomic-file.js";
|
||||
import type { FinishSubscription, FinishSubscriptionStore } from "../index.js";
|
||||
|
||||
const recordsSchema = z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
callerAgentId: z.string().min(1),
|
||||
childAgentId: z.string().min(1),
|
||||
serverId: z.string().min(1).optional(),
|
||||
requireParentOwnership: z.boolean().optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
export function createJsonFinishSubscriptionStore(paseoHome: string): FinishSubscriptionStore {
|
||||
const file = join(paseoHome, "finish-subscriptions.json");
|
||||
let records: FinishSubscription[] | undefined;
|
||||
let pending = Promise.resolve();
|
||||
async function load(): Promise<FinishSubscription[]> {
|
||||
if (records) return records;
|
||||
try {
|
||||
records = recordsSchema.parse(JSON.parse(await readFile(file, "utf8")));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
records = [];
|
||||
}
|
||||
return records;
|
||||
}
|
||||
function serialized<T>(action: () => Promise<T>): Promise<T> {
|
||||
const result = pending.then(action);
|
||||
pending = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
async function mutate(
|
||||
update: (current: FinishSubscription[]) => FinishSubscription[],
|
||||
): Promise<void> {
|
||||
const next = update(await load());
|
||||
await writeJsonFileAtomic(file, next);
|
||||
records = next;
|
||||
}
|
||||
return {
|
||||
list: () => serialized(async () => (await load()).map((record) => Object.assign({}, record))),
|
||||
put: (subscription) =>
|
||||
serialized(() =>
|
||||
mutate((current) => [
|
||||
...current.filter((record) => record.id !== subscription.id),
|
||||
{ ...subscription },
|
||||
]),
|
||||
),
|
||||
remove: (id) =>
|
||||
serialized(() => mutate((current) => current.filter((record) => record.id !== id))),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { AgentPermissionRequest } from "../../agent-sdk-types.js";
|
||||
export type FinishNotificationReason =
|
||||
| "host unreachable"
|
||||
| "host removed"
|
||||
| "finished"
|
||||
| "errored"
|
||||
| "needs permission"
|
||||
| "is unavailable"
|
||||
| "was closed"
|
||||
| "was canceled";
|
||||
|
||||
export const finishNotificationEvents = {
|
||||
"host unreachable": "errored",
|
||||
"host removed": "errored",
|
||||
finished: "finished",
|
||||
errored: "errored",
|
||||
"needs permission": "permission-required",
|
||||
"was closed": "closed",
|
||||
"is unavailable": "closed",
|
||||
// Use the existing failure event so older clients still parse agent-message metadata.
|
||||
"was canceled": "errored",
|
||||
} as const;
|
||||
|
||||
const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000;
|
||||
|
||||
interface FinishNotificationBodyInput {
|
||||
childAgentId: string;
|
||||
serverId?: string;
|
||||
title: string;
|
||||
reason: FinishNotificationReason;
|
||||
lastAssistantMessage: string | null;
|
||||
permissionRequest?: AgentPermissionRequest;
|
||||
}
|
||||
|
||||
export function formatFinishNotificationBody(params: FinishNotificationBodyInput): string {
|
||||
if (params.reason === "host unreachable" || params.reason === "host removed") {
|
||||
const host =
|
||||
params.reason === "host removed"
|
||||
? `Host ${params.serverId} was removed or is unavailable.`
|
||||
: `Could not observe child agent ${params.childAgentId} on host ${params.serverId} after three failed attempts.`;
|
||||
return `${host}\n\nStatus of child agent ${params.childAgentId} (${params.title}) is unknown.\n\nServer: ${params.serverId}\n\nThis finish subscription has ended. Reassess the delegated work; reconnecting will not resume notifications.`;
|
||||
}
|
||||
const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`;
|
||||
const sections = [statusLine];
|
||||
if (params.serverId) sections.push(`Server: ${params.serverId}`);
|
||||
if (params.reason === "needs permission" && params.permissionRequest) {
|
||||
sections.push(
|
||||
"Respond with `respond_to_permission` using the `agentId` and `requestId` below.",
|
||||
`<permission-request>\n${JSON.stringify(
|
||||
{
|
||||
agentId: params.childAgentId,
|
||||
...(params.serverId ? { serverId: params.serverId } : {}),
|
||||
requestId: params.permissionRequest.id,
|
||||
request: params.permissionRequest,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n</permission-request>`,
|
||||
);
|
||||
}
|
||||
let lastAssistantMessage = params.lastAssistantMessage?.trim();
|
||||
if (lastAssistantMessage) {
|
||||
if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) {
|
||||
const omitted = lastAssistantMessage.length - FINISH_NOTIFICATION_MESSAGE_LIMIT;
|
||||
lastAssistantMessage = `${lastAssistantMessage.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`;
|
||||
}
|
||||
sections.push(`<agent-response>\n${lastAssistantMessage}\n</agent-response>`);
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
export type FinishObservationResult = WaitForFinishResult & {
|
||||
reason?: "was canceled" | "host unreachable" | "host removed";
|
||||
};
|
||||
|
||||
/** Coalesces duplicate checkpoints and retains one terminal result while delivery is pending. */
|
||||
export function createObservationBuffer(signal: AbortSignal) {
|
||||
const delivered = new Set<string>();
|
||||
const permissions = new Map<string, FinishObservationResult>();
|
||||
const resolvedBeforeInitial = new Set<string>();
|
||||
let initialPending = true;
|
||||
let terminal: FinishObservationResult | undefined;
|
||||
let failure: unknown;
|
||||
let wake: (() => void) | undefined;
|
||||
const abort = () => {
|
||||
failure = signal.reason;
|
||||
wake?.();
|
||||
};
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
function push(result: FinishObservationResult): void {
|
||||
if (result.status === "timeout") return;
|
||||
if (result.status === "permission") {
|
||||
for (const request of result.final?.pendingPermissions ?? [])
|
||||
if (!delivered.has(request.id)) permissions.set(request.id, result);
|
||||
} else terminal = result;
|
||||
wake?.();
|
||||
}
|
||||
return {
|
||||
pushInitial(result: FinishObservationResult) {
|
||||
initialPending = false;
|
||||
if (result.status === "permission" && result.final) {
|
||||
const pendingPermissions = result.final.pendingPermissions.filter(
|
||||
(request) => !resolvedBeforeInitial.has(request.id),
|
||||
);
|
||||
if (pendingPermissions.length)
|
||||
push({ ...result, final: { ...result.final, pendingPermissions } });
|
||||
} else if (
|
||||
!terminal &&
|
||||
result.final?.status !== "running" &&
|
||||
result.final?.status !== "initializing"
|
||||
)
|
||||
push(result);
|
||||
resolvedBeforeInitial.clear();
|
||||
},
|
||||
push,
|
||||
resolved(id: string) {
|
||||
if (initialPending) resolvedBeforeInitial.add(id);
|
||||
delivered.delete(id);
|
||||
permissions.delete(id);
|
||||
},
|
||||
fail(error: unknown) {
|
||||
failure = error;
|
||||
wake?.();
|
||||
},
|
||||
async next(): Promise<FinishObservationResult> {
|
||||
while (true) {
|
||||
signal.throwIfAborted();
|
||||
const first = permissions.entries().next().value;
|
||||
if (first) {
|
||||
for (const request of first[1].final?.pendingPermissions ?? []) {
|
||||
delivered.add(request.id);
|
||||
permissions.delete(request.id);
|
||||
}
|
||||
return first[1];
|
||||
}
|
||||
if (terminal) {
|
||||
const result = terminal;
|
||||
terminal = undefined;
|
||||
return result;
|
||||
}
|
||||
if (failure) throw failure;
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
wake = undefined;
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
signal.removeEventListener("abort", abort);
|
||||
},
|
||||
};
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { WaitForFinishResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import {
|
||||
HostObservationError,
|
||||
createHostObservationResolver,
|
||||
type ToolHosts,
|
||||
type HostObservation,
|
||||
} from "../../host-operations/index.js";
|
||||
import { buildStoredAgentPayload } from "../../agent-projections.js";
|
||||
import { openFinishObservation } from "./observation-stream.js";
|
||||
|
||||
class Host implements HostObservation {
|
||||
handler?: Parameters<HostObservation["observe"]>[1];
|
||||
released = false;
|
||||
failure?: (error: unknown) => void;
|
||||
baseline = Promise.withResolvers<WaitForFinishResult>();
|
||||
async wait() {
|
||||
return this.baseline.promise;
|
||||
}
|
||||
async observe(
|
||||
_id: string,
|
||||
handler: Parameters<HostObservation["observe"]>[1],
|
||||
_signal: AbortSignal,
|
||||
onError: (error: unknown) => void,
|
||||
) {
|
||||
this.handler = handler;
|
||||
this.failure = onError;
|
||||
return async () => {
|
||||
this.released = true;
|
||||
};
|
||||
}
|
||||
emit(status: "idle" | "running", event?: AgentStreamEvent) {
|
||||
this.handler!(
|
||||
buildStoredAgentPayload(
|
||||
{
|
||||
id: "child",
|
||||
cwd: "/tmp",
|
||||
provider: "codex",
|
||||
labels: {},
|
||||
lastStatus: status,
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
},
|
||||
["codex"],
|
||||
),
|
||||
event,
|
||||
"Last response",
|
||||
);
|
||||
}
|
||||
}
|
||||
function permission(id: string): AgentStreamEvent {
|
||||
return {
|
||||
type: "permission_requested",
|
||||
provider: "codex",
|
||||
request: { id, provider: "codex", name: "Write", kind: "tool", input: { path: id } },
|
||||
};
|
||||
}
|
||||
async function scenario() {
|
||||
const host = new Host();
|
||||
const controller = new AbortController();
|
||||
const observation = await openFinishObservation(
|
||||
{ use: async (_server, action) => action(host) },
|
||||
{ id: "watch", callerAgentId: "parent", childAgentId: "child" },
|
||||
controller.signal,
|
||||
);
|
||||
return { host, observation, controller };
|
||||
}
|
||||
|
||||
test("permission resolution followed by transient idle waits for resumed work to settle", async () => {
|
||||
const { host, observation } = await scenario();
|
||||
try {
|
||||
host.emit("running", permission("first"));
|
||||
expect((await observation.next()).status).toBe("permission");
|
||||
host.emit("idle", { type: "permission_resolved", provider: "codex", requestId: "first" });
|
||||
let settled = false;
|
||||
const next = observation.next().then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
host.emit("running", { type: "turn_started", provider: "codex" });
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
host.emit("idle", { type: "turn_completed", provider: "codex" });
|
||||
expect(await next).toMatchObject({ status: "idle", lastMessage: "Last response" });
|
||||
} finally {
|
||||
await observation.close();
|
||||
}
|
||||
expect(host.released).toBe(true);
|
||||
});
|
||||
|
||||
test("second checkpoint and terminal outcome remain buffered while the first callback is pending", async () => {
|
||||
const { host, observation } = await scenario();
|
||||
try {
|
||||
host.emit("running", permission("first"));
|
||||
expect((await observation.next()).status).toBe("permission");
|
||||
// The recipient has not acknowledged the first callback; no next() is in flight.
|
||||
host.emit("running", permission("second"));
|
||||
host.emit("idle", { type: "turn_completed", provider: "codex" });
|
||||
expect((await observation.next()).final?.pendingPermissions[0]?.id).toBe("second");
|
||||
expect((await observation.next()).status).toBe("idle");
|
||||
} finally {
|
||||
await observation.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("abort releases continuous observation even with a pending initial wait", async () => {
|
||||
const { host, observation, controller } = await scenario();
|
||||
const next = observation.next();
|
||||
controller.abort();
|
||||
await expect(next).rejects.toThrow();
|
||||
await observation.close();
|
||||
expect(host.released).toBe(true);
|
||||
});
|
||||
|
||||
test("late initial permission snapshot cannot resurrect an already resolved request", async () => {
|
||||
const { host, observation } = await scenario();
|
||||
try {
|
||||
const requestEvent = permission("resolved");
|
||||
host.emit("running", requestEvent);
|
||||
await observation.next();
|
||||
host.emit("idle", { type: "permission_resolved", provider: "codex", requestId: "resolved" });
|
||||
const final = buildStoredAgentPayload(
|
||||
{
|
||||
id: "child",
|
||||
cwd: "/tmp",
|
||||
provider: "codex",
|
||||
labels: {},
|
||||
lastStatus: "running",
|
||||
createdAt: "2026-01-01",
|
||||
updatedAt: "2026-01-01",
|
||||
},
|
||||
["codex"],
|
||||
);
|
||||
if (requestEvent.type !== "permission_requested") throw new Error("Expected permission");
|
||||
final.pendingPermissions = [requestEvent.request];
|
||||
host.baseline.resolve({ status: "permission", final, lastMessage: null, error: null });
|
||||
await host.baseline.promise;
|
||||
await Promise.resolve();
|
||||
host.emit("idle", { type: "turn_completed", provider: "codex" });
|
||||
expect((await observation.next()).status).toBe("idle");
|
||||
} finally {
|
||||
await observation.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("lookup failure uses its error channel and never fabricates a missing-child result", async () => {
|
||||
const { host, observation } = await scenario();
|
||||
try {
|
||||
host.failure!(new Error("Route disconnected"));
|
||||
await expect(observation.next()).rejects.toThrow("Route disconnected");
|
||||
} finally {
|
||||
await observation.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a terminal event observed after newer work starts waits for that running work", async () => {
|
||||
const { host, observation } = await scenario();
|
||||
try {
|
||||
host.emit("running", { type: "turn_completed", provider: "codex" });
|
||||
let settled = false;
|
||||
const next = observation.next().then((result) => {
|
||||
settled = true;
|
||||
return result;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(settled).toBe(false);
|
||||
host.emit("idle", { type: "turn_completed", provider: "codex" });
|
||||
expect((await next).status).toBe("idle");
|
||||
} finally {
|
||||
await observation.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ status: "connecting", reason: "connecting" },
|
||||
{ status: "offline", reason: "unreachable" },
|
||||
{ status: "disabled", reason: "unreachable" },
|
||||
{ status: "removed", reason: "removed" },
|
||||
] as const)(
|
||||
"remote observation classifies $status from the authoritative directory, ignoring arbitrary error codes",
|
||||
async ({ status, reason }) => {
|
||||
const row = {
|
||||
registrationId: "registration",
|
||||
serverId: "remote",
|
||||
label: null,
|
||||
source: { id: "owner", key: "remote" },
|
||||
metadata: {},
|
||||
enabled: status !== "disabled",
|
||||
status: status === "removed" ? ("offline" as const) : status,
|
||||
targetDescription: "Remote",
|
||||
lastOnlineAt: null,
|
||||
nextRetryAt: null,
|
||||
error: null,
|
||||
};
|
||||
const hosts: ToolHosts = {
|
||||
directory: async () => ({
|
||||
self: { serverId: "origin", label: "Origin" },
|
||||
hosts: status === "removed" ? [] : [row],
|
||||
}),
|
||||
async use() {
|
||||
throw Object.assign(new Error("Untrusted remote error"), { code: "host_unknown" });
|
||||
},
|
||||
};
|
||||
const local = new Host();
|
||||
const resolver = createHostObservationResolver({
|
||||
serverId: "origin",
|
||||
local,
|
||||
hosts: () => hosts,
|
||||
});
|
||||
const error = await resolver.use("remote", async () => {}).catch((failure: unknown) => failure);
|
||||
expect(error).toBeInstanceOf(HostObservationError);
|
||||
expect(error).toMatchObject({ reason });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import type { HostObservation } from "../../host-operations/index.js";
|
||||
import type { FinishSubscription, FinishObservationSession } from "../index.js";
|
||||
import { createObservationBuffer } from "./observation-buffer.js";
|
||||
|
||||
interface ObservationScope {
|
||||
use<T>(
|
||||
serverId: string | undefined,
|
||||
action: (host: HostObservation) => Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T>;
|
||||
}
|
||||
function isTerminal(snapshot: AgentSnapshotPayload, event?: AgentStreamEvent): boolean {
|
||||
if (snapshot.status === "closed" || snapshot.status === "error") return true;
|
||||
return (
|
||||
snapshot.status === "idle" &&
|
||||
(event?.type === "turn_completed" || event?.type === "turn_canceled")
|
||||
);
|
||||
}
|
||||
function observeState(
|
||||
buffer: ReturnType<typeof createObservationBuffer>,
|
||||
snapshot: AgentSnapshotPayload | null,
|
||||
event?: AgentStreamEvent,
|
||||
lastMessage: string | null = null,
|
||||
): void {
|
||||
if (!snapshot) {
|
||||
buffer.push({ status: "idle", final: null, lastMessage: null, error: null });
|
||||
return;
|
||||
}
|
||||
if (event?.type === "permission_resolved") {
|
||||
buffer.resolved(event.requestId);
|
||||
return;
|
||||
}
|
||||
if (event?.type === "turn_started") return;
|
||||
if (event?.type === "permission_requested") {
|
||||
buffer.push({
|
||||
status: "permission",
|
||||
final: { ...snapshot, pendingPermissions: [event.request] },
|
||||
lastMessage,
|
||||
error: null,
|
||||
});
|
||||
} else if (isTerminal(snapshot, event)) {
|
||||
buffer.push({
|
||||
status: snapshot.status === "error" ? "error" : "idle",
|
||||
final: snapshot,
|
||||
lastMessage,
|
||||
error: snapshot.lastError ?? null,
|
||||
...(event?.type === "turn_canceled" ? { reason: "was canceled" as const } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** One continuous source feeds a bounded buffer while the caller awaits dispatch. */
|
||||
export async function openFinishObservation(
|
||||
resolver: ObservationScope,
|
||||
record: FinishSubscription,
|
||||
signal: AbortSignal,
|
||||
): Promise<FinishObservationSession> {
|
||||
signal.throwIfAborted();
|
||||
const controller = new AbortController();
|
||||
const lifetime = AbortSignal.any([signal, controller.signal]);
|
||||
const buffer = createObservationBuffer(lifetime);
|
||||
let opened!: () => void;
|
||||
let failed!: (error: unknown) => void;
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
opened = resolve;
|
||||
failed = reject;
|
||||
});
|
||||
const route = resolver
|
||||
.use(
|
||||
record.serverId,
|
||||
async (host) => {
|
||||
const release = await host.observe(
|
||||
record.childAgentId,
|
||||
(snapshot, event, lastMessage) => observeState(buffer, snapshot, event, lastMessage),
|
||||
lifetime,
|
||||
(error) => buffer.fail(error),
|
||||
);
|
||||
opened();
|
||||
// The ordinary wait supplies the initial settled snapshot and covers queued admission.
|
||||
// Continuous observation buffers checkpoints and terminal events throughout delivery.
|
||||
void host
|
||||
.wait(record.childAgentId, lifetime)
|
||||
.then((result) => {
|
||||
buffer.pushInitial(result);
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => buffer.fail(error));
|
||||
try {
|
||||
if (!lifetime.aborted)
|
||||
await new Promise<void>((resolve) =>
|
||||
lifetime.addEventListener("abort", () => resolve(), { once: true }),
|
||||
);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
},
|
||||
lifetime,
|
||||
)
|
||||
.catch((error) => {
|
||||
failed(error);
|
||||
buffer.fail(error);
|
||||
});
|
||||
try {
|
||||
await ready;
|
||||
} catch (error) {
|
||||
controller.abort();
|
||||
buffer.dispose();
|
||||
await route;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
next: () => buffer.next(),
|
||||
async close() {
|
||||
controller.abort();
|
||||
buffer.dispose();
|
||||
await route;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Logger } from "pino";
|
||||
import type { AgentManager } from "../../agent-manager.js";
|
||||
import type { AgentStorage } from "../../agent-storage.js";
|
||||
import type { AgentMessageDelivery } from "../../agent-messages/index.js";
|
||||
import { buildStoredAgentPayload, toAgentPayload } from "../../agent-projections.js";
|
||||
import { serializeSnapshotWithMetadata } from "../../mcp-shared.js";
|
||||
import { createHostObservationResolver, type ToolHosts } from "../../host-operations/index.js";
|
||||
import { openFinishObservation } from "./observation-stream.js";
|
||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import type { FinishObservation } from "../index.js";
|
||||
|
||||
export function createFinishObservation(input: {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
/** A child chosen for restart recovery reports its state only after its resume was sent. */
|
||||
waitForResume(agentId: string, signal: AbortSignal): Promise<void>;
|
||||
messages(): AgentMessageDelivery;
|
||||
hosts(): ToolHosts;
|
||||
serverId: string;
|
||||
logger: Logger;
|
||||
}): FinishObservation {
|
||||
const { agentManager, agentStorage } = input;
|
||||
const resolver = createHostObservationResolver({
|
||||
serverId: input.serverId,
|
||||
hosts: input.hosts,
|
||||
local: {
|
||||
async wait(agentId, signal) {
|
||||
await input.waitForResume(agentId, signal);
|
||||
await input.messages().waitForDispatch(agentId, signal);
|
||||
signal.throwIfAborted();
|
||||
const live = agentManager.getAgent(agentId);
|
||||
if (!live) {
|
||||
const stored = await agentStorage.get(agentId);
|
||||
return {
|
||||
status: stored?.lastStatus === "error" ? "error" : "idle",
|
||||
final: stored ? buildStoredAgentPayload(stored, [stored.provider]) : null,
|
||||
lastMessage: await agentManager.getLastAssistantMessage(agentId),
|
||||
error: stored?.lastError ?? null,
|
||||
};
|
||||
}
|
||||
const result = await agentManager.waitForAgentEvent(agentId, {
|
||||
signal,
|
||||
waitForActive: true,
|
||||
});
|
||||
const current = agentManager.getAgent(agentId);
|
||||
const status = result.permission ? "permission" : result.status;
|
||||
return {
|
||||
status: status === "permission" || status === "error" ? status : "idle",
|
||||
final: current
|
||||
? await serializeSnapshotWithMetadata(agentStorage, current, input.logger)
|
||||
: null,
|
||||
lastMessage: result.lastMessage,
|
||||
error: current?.lastError ?? null,
|
||||
};
|
||||
},
|
||||
async observe(agentId, handler, signal, onError) {
|
||||
await input.waitForResume(agentId, signal);
|
||||
const stored = await agentStorage.get(agentId);
|
||||
let updates = Promise.resolve();
|
||||
const publish = (snapshot: AgentSnapshotPayload | null, event?: AgentStreamEvent) => {
|
||||
updates = updates
|
||||
.then(async () =>
|
||||
handler(snapshot, event, await agentManager.getLastAssistantMessage(agentId)),
|
||||
)
|
||||
.catch(onError);
|
||||
};
|
||||
signal.throwIfAborted();
|
||||
const unsubscribe = agentManager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state" && event.agent.lifecycle !== "idle")
|
||||
publish(toAgentPayload(event.agent, { title: stored?.title }));
|
||||
else if (
|
||||
event.type === "agent_stream" &&
|
||||
[
|
||||
"permission_requested",
|
||||
"permission_resolved",
|
||||
"turn_started",
|
||||
"turn_completed",
|
||||
"turn_canceled",
|
||||
"turn_failed",
|
||||
].includes(event.event.type)
|
||||
) {
|
||||
const current = agentManager.getAgent(agentId);
|
||||
if (current) publish(toAgentPayload(current, { title: stored?.title }), event.event);
|
||||
}
|
||||
},
|
||||
{ agentId, replayState: false },
|
||||
);
|
||||
const current = agentManager.getAgent(agentId);
|
||||
if (current)
|
||||
publish(toAgentPayload(current, { title: stored?.title }), {
|
||||
type: "turn_started",
|
||||
provider: current.provider,
|
||||
});
|
||||
else publish(stored ? buildStoredAgentPayload(stored, [stored.provider]) : null);
|
||||
const stop = () => unsubscribe();
|
||||
signal.addEventListener("abort", stop, { once: true });
|
||||
return async () => {
|
||||
signal.removeEventListener("abort", stop);
|
||||
unsubscribe();
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
return { open: (record, signal) => openFinishObservation(resolver, record, signal) };
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { parentAddressLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import type { ActiveTurnBehavior, AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
DaemonClient,
|
||||
DaemonConnectionError,
|
||||
type FetchWorkspacesEntry,
|
||||
type WaitForFinishResult,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types";
|
||||
import type { HostDirectory } from "@getpaseo/protocol/hosts";
|
||||
import { OWNER_PERMISSIONS } from "../../authorization/index.js";
|
||||
import type { ConnectedHosts } from "../../connected-hosts/index.js";
|
||||
import { resolveRequiredProviderModel } from "../mcp-shared.js";
|
||||
import { selectAgentListItems } from "../agent-projections.js";
|
||||
import { summarizeAgentActivity } from "../activity-curator.js";
|
||||
import { expandUserPath } from "../../path-utils.js";
|
||||
import type { PaseoToolExecutionContext, PaseoToolResult } from "../tools/types.js";
|
||||
|
||||
export class HostOperationRejectedError extends Error {
|
||||
readonly code: unknown;
|
||||
constructor(error: unknown) {
|
||||
super(error instanceof Error ? error.message : String(error), { cause: error });
|
||||
this.name = "HostOperationRejectedError";
|
||||
this.code = error instanceof Error && "code" in error ? error.code : undefined;
|
||||
}
|
||||
}
|
||||
function isAmbiguousPromptFailure(error: unknown): boolean {
|
||||
if (error instanceof DaemonConnectionError) return true;
|
||||
return error instanceof Error && error.message.startsWith("Timeout waiting for message");
|
||||
}
|
||||
export type HostOperation = (
|
||||
input: Record<string, unknown>,
|
||||
context: PaseoToolExecutionContext,
|
||||
) => Promise<PaseoToolResult>;
|
||||
export interface HostOperations {
|
||||
createAgent: HostOperation;
|
||||
createWorkspace: HostOperation;
|
||||
listWorkspaces: HostOperation;
|
||||
sendPrompt: HostOperation;
|
||||
getStatus: HostOperation;
|
||||
getActivity: HostOperation;
|
||||
listAgents: HostOperation;
|
||||
}
|
||||
/** Connection authority at the host boundary, independent of child lifecycle. */
|
||||
export class HostObservationError extends Error {
|
||||
constructor(
|
||||
readonly reason: "connecting" | "unreachable" | "removed",
|
||||
cause?: unknown,
|
||||
) {
|
||||
super(`Host observation ${reason}`, { cause });
|
||||
this.name = "HostObservationError";
|
||||
}
|
||||
}
|
||||
export interface HostObservation {
|
||||
wait(agentId: string, signal: AbortSignal): Promise<WaitForFinishResult>;
|
||||
observe(
|
||||
agentId: string,
|
||||
handler: (
|
||||
snapshot: AgentSnapshotPayload | null,
|
||||
event?: AgentStreamEvent,
|
||||
lastMessage?: string | null,
|
||||
) => void,
|
||||
signal: AbortSignal,
|
||||
onError: (error: unknown) => void,
|
||||
): Promise<() => Promise<void>>;
|
||||
}
|
||||
export interface ToolHosts {
|
||||
directory(): Promise<HostDirectory>;
|
||||
use<T>(
|
||||
serverId: string,
|
||||
action: (client: DaemonClient) => Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
/** Daemon MCP has the daemon's authority, bounded by the destination's admission of its link. */
|
||||
export function createToolHosts(hosts: ConnectedHosts): ToolHosts {
|
||||
return {
|
||||
directory: () => hosts.list(),
|
||||
async use(serverId, action, signal) {
|
||||
const lifetime = new AbortController();
|
||||
let routeFailure: unknown;
|
||||
const transportFactory: DaemonTransportFactory = () => {
|
||||
const opens = new Set<() => void>();
|
||||
const closes = new Set<(event?: unknown) => void>();
|
||||
const errors = new Set<(event: unknown) => void>();
|
||||
const messages = new Set<(data: unknown, binary: boolean) => void>();
|
||||
let closed = false;
|
||||
let route: Awaited<ReturnType<ConnectedHosts["openRoute"]>> | undefined;
|
||||
void hosts
|
||||
.openRoute({
|
||||
serverId,
|
||||
scopeId: randomUUID(),
|
||||
sourceId: "mcp",
|
||||
originSessionId: "mcp",
|
||||
permissions: OWNER_PERMISSIONS,
|
||||
signal: signal ? AbortSignal.any([lifetime.signal, signal]) : lifetime.signal,
|
||||
frame: (data) => {
|
||||
for (const listener of messages) listener(data, typeof data !== "string");
|
||||
},
|
||||
ended: (code, message) => {
|
||||
for (const listener of errors)
|
||||
listener(Object.assign(new Error(message), { code, serverId }));
|
||||
for (const listener of closes) listener();
|
||||
},
|
||||
})
|
||||
.then((opened) => {
|
||||
route = opened;
|
||||
if (closed) opened.close();
|
||||
else for (const listener of opens) listener();
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
routeFailure = error;
|
||||
for (const listener of errors) listener(error);
|
||||
for (const listener of closes) listener(error);
|
||||
});
|
||||
return {
|
||||
send: (data) => {
|
||||
if (!route) throw new Error("Host route is not open");
|
||||
route.send(data instanceof ArrayBuffer ? new Uint8Array(data) : data);
|
||||
},
|
||||
close: () => {
|
||||
closed = true;
|
||||
route?.close();
|
||||
},
|
||||
onOpen: (listener) => {
|
||||
opens.add(listener);
|
||||
return () => opens.delete(listener);
|
||||
},
|
||||
onClose: (listener) => {
|
||||
closes.add(listener);
|
||||
return () => closes.delete(listener);
|
||||
},
|
||||
onError: (listener) => {
|
||||
errors.add(listener);
|
||||
return () => errors.delete(listener);
|
||||
},
|
||||
onMessage: (listener) => {
|
||||
messages.add(listener);
|
||||
return () => messages.delete(listener);
|
||||
},
|
||||
};
|
||||
};
|
||||
const client = new DaemonClient({
|
||||
url: "ws://host-route/ws",
|
||||
clientId: randomUUID(),
|
||||
transportFactory,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
const stop = () => void client.close();
|
||||
signal?.addEventListener("abort", stop, { once: true });
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
await client.connect().catch((error) => {
|
||||
// The action has not run; no application request could have been transmitted.
|
||||
throw new HostOperationRejectedError(routeFailure ?? error);
|
||||
});
|
||||
return await action(client);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", stop);
|
||||
lifetime.abort();
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const remoteCreate = z
|
||||
.object({
|
||||
provider: z.string().min(1),
|
||||
title: z.string().trim().min(1).max(60),
|
||||
initialPrompt: z.string().min(1),
|
||||
workspaceId: z.string().min(1),
|
||||
labels: z.record(z.string(), z.string()).optional(),
|
||||
notifyOnFinish: z.boolean().optional(),
|
||||
idempotencyKey: z.string().optional(),
|
||||
settings: z
|
||||
.object({
|
||||
modeId: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
features: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
const result = (structuredContent: unknown): PaseoToolResult => ({
|
||||
content: [],
|
||||
structuredContent,
|
||||
});
|
||||
|
||||
export function createHostResolver(input: {
|
||||
serverId: string;
|
||||
local: HostOperations;
|
||||
hosts: () => ToolHosts;
|
||||
callerAgentId?: string;
|
||||
lockedCwd?: string;
|
||||
}) {
|
||||
return {
|
||||
directory: () => input.hosts().directory(),
|
||||
async use<T>(
|
||||
serverId: string | undefined,
|
||||
action: (host: HostOperations) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const destination = serverId ?? input.serverId;
|
||||
if (destination === input.serverId) return action(input.local);
|
||||
if (input.lockedCwd)
|
||||
throw new Error("This caller is restricted to its local working directory");
|
||||
return input.hosts().use(destination, async (client) =>
|
||||
action({
|
||||
async createAgent(args) {
|
||||
const parsed = remoteCreate.parse(args);
|
||||
const { provider, model } = resolveRequiredProviderModel(parsed.provider);
|
||||
const workspace = (await client.fetchWorkspaces({})).entries.find(
|
||||
(row) => row.id === parsed.workspaceId,
|
||||
);
|
||||
if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`);
|
||||
const labels = {
|
||||
...parsed.labels,
|
||||
...(input.callerAgentId
|
||||
? parentAddressLabels({ agentId: input.callerAgentId, serverId: input.serverId })
|
||||
: {}),
|
||||
};
|
||||
const snapshot = await client.createAgent({
|
||||
config: {
|
||||
provider,
|
||||
model,
|
||||
cwd: workspace.workspaceDirectory,
|
||||
title: parsed.title,
|
||||
modeId: parsed.settings?.modeId,
|
||||
thinkingOptionId: parsed.settings?.thinkingOptionId,
|
||||
featureValues: parsed.settings?.features,
|
||||
},
|
||||
workspaceId: parsed.workspaceId,
|
||||
labels,
|
||||
initialPrompt: parsed.initialPrompt,
|
||||
idempotencyKey: parsed.idempotencyKey,
|
||||
});
|
||||
return result({
|
||||
agentId: snapshot.id,
|
||||
type: snapshot.provider,
|
||||
status: snapshot.status,
|
||||
cwd: snapshot.cwd,
|
||||
workspaceId: snapshot.workspaceId,
|
||||
currentModeId: snapshot.currentModeId,
|
||||
availableModes: snapshot.availableModes,
|
||||
});
|
||||
},
|
||||
async sendPrompt(args) {
|
||||
const agentId = args.agentId as string;
|
||||
// Human sends retain the established mode RPC on older connected hosts.
|
||||
// Agent sends apply their requested mode when the recipient dispatches the batch.
|
||||
if (!input.callerAgentId && args.sessionMode !== undefined) {
|
||||
await client.setAgentMode(agentId, args.sessionMode as string);
|
||||
}
|
||||
const agent = (
|
||||
await client.fetchAgent(agentId).catch((error) => {
|
||||
// Snapshot preflight precedes sendMessage, so failure is definitely unsent.
|
||||
throw new HostOperationRejectedError(error);
|
||||
})
|
||||
)?.agent;
|
||||
if (!agent)
|
||||
throw new HostOperationRejectedError(new Error(`Agent ${agentId} not found`));
|
||||
const receipt = await client
|
||||
.sendMessage(agentId, args.prompt as string, {
|
||||
sessionMode: input.callerAgentId
|
||||
? (args.sessionMode as string | undefined)
|
||||
: undefined,
|
||||
sourceAgentId: input.callerAgentId,
|
||||
sourceAgentServerId: input.callerAgentId ? input.serverId : undefined,
|
||||
activeTurnBehavior:
|
||||
(args.activeTurnBehavior as ActiveTurnBehavior | undefined) ??
|
||||
(input.callerAgentId ? "steer" : "interrupt"),
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isAmbiguousPromptFailure(error)) throw error;
|
||||
throw new HostOperationRejectedError(error);
|
||||
});
|
||||
return result({
|
||||
success: true,
|
||||
...receipt,
|
||||
status: agent.status,
|
||||
guidance:
|
||||
receipt.deliveryStatus === "queued"
|
||||
? "Queued for delivery after two quiet seconds. You will get notified when the prompted agent finishes, errors, or needs permission unless notifyOnFinish is false."
|
||||
: "Dispatch acknowledged. Observe completion using get_agent_status/get_agent_activity or SDK/RPC subscriptions and wait APIs.",
|
||||
});
|
||||
},
|
||||
async getStatus(args) {
|
||||
const agent = (await client.fetchAgent(args.agentId as string))?.agent;
|
||||
if (!agent) throw new Error(`Agent ${args.agentId} not found`);
|
||||
return result({ status: agent.status, snapshot: agent });
|
||||
},
|
||||
async getActivity(args) {
|
||||
const agentId = args.agentId as string;
|
||||
const limit = args.limit as number | undefined;
|
||||
const timeline = await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: limit ?? 0,
|
||||
});
|
||||
const agent = (await client.fetchAgent(agentId))?.agent;
|
||||
if (!agent) throw new Error(`Agent ${agentId} not found`);
|
||||
return result({
|
||||
agentId,
|
||||
...summarizeAgentActivity(
|
||||
timeline.entries.map((entry) => entry.item),
|
||||
limit,
|
||||
),
|
||||
currentModeId: agent.currentModeId,
|
||||
});
|
||||
},
|
||||
async listAgents(args) {
|
||||
const { entries } = await client.fetchAgents({
|
||||
includeBackground: args.includeBackground === true,
|
||||
filter: { includeArchived: args.includeArchived === true },
|
||||
});
|
||||
return result({
|
||||
agents: selectAgentListItems(
|
||||
entries.map((entry) => entry.agent),
|
||||
{
|
||||
cwd: (args.cwd as string | undefined)?.trim()
|
||||
? expandUserPath(args.cwd as string)
|
||||
: undefined,
|
||||
statuses: args.statuses as AgentSnapshotPayload["status"][] | undefined,
|
||||
sinceHours: args.sinceHours as number | undefined,
|
||||
limit: args.limit as number | undefined,
|
||||
},
|
||||
),
|
||||
});
|
||||
},
|
||||
async listWorkspaces(args) {
|
||||
const { entries } = await client.fetchWorkspaces({
|
||||
includeBackground: args.includeBackground === true,
|
||||
});
|
||||
return result({ workspaces: entries.map(workspaceSummary) });
|
||||
},
|
||||
async createWorkspace(args) {
|
||||
const isolation = z.enum(["local", "worktree"]).parse(args.isolation);
|
||||
const path = z.string().min(1).parse(args.path);
|
||||
const response = await client.createWorkspace({
|
||||
source:
|
||||
isolation === "local"
|
||||
? { kind: "directory", path, projectId: args.projectId as string | undefined }
|
||||
: {
|
||||
kind: "worktree",
|
||||
cwd: path,
|
||||
projectId: args.projectId as string | undefined,
|
||||
action:
|
||||
args.mode === "checkout-branch" || args.mode === "checkout-pr"
|
||||
? "checkout"
|
||||
: "branch-off",
|
||||
baseBranch: args.baseBranch as string | undefined,
|
||||
branchName: args.branchName as string | undefined,
|
||||
refName: args.branch as string | undefined,
|
||||
githubPrNumber: args.prNumber as number | undefined,
|
||||
worktreeSlug: args.worktreeSlug as string | undefined,
|
||||
},
|
||||
title: args.title as string | undefined,
|
||||
background: args.background as boolean | undefined,
|
||||
});
|
||||
if (response.error || !response.workspace)
|
||||
throw new Error(response.error ?? "Workspace creation failed");
|
||||
return result(workspaceSummary(response.workspace));
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
function workspaceSummary(workspace: FetchWorkspacesEntry) {
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
projectId: workspace.projectId,
|
||||
cwd: workspace.workspaceDirectory,
|
||||
isolation: workspace.workspaceKind === "worktree" ? "worktree" : "local",
|
||||
kind: workspace.workspaceKind === "checkout" ? "local_checkout" : workspace.workspaceKind,
|
||||
title: workspace.title,
|
||||
background: workspace.background,
|
||||
};
|
||||
}
|
||||
|
||||
function remoteObservation(client: DaemonClient): HostObservation {
|
||||
return {
|
||||
async observe(agentId, handler, signal, onError) {
|
||||
const observation = client.observeTimeline([agentId], { signal });
|
||||
const refresh = async (event?: AgentStreamEvent) => {
|
||||
let snapshot = (await client.fetchAgent(agentId))?.agent ?? null;
|
||||
let lastMessage: string | null = null;
|
||||
if (
|
||||
snapshot &&
|
||||
(snapshot.status === "idle" ||
|
||||
snapshot.status === "closed" ||
|
||||
snapshot.status === "error" ||
|
||||
snapshot.pendingPermissions.length > 0)
|
||||
) {
|
||||
// A new turn may start between the snapshot and the current-response read.
|
||||
// Bound that wait and refresh state instead of attributing the old event to it.
|
||||
const current = await client.waitForFinish(agentId, 100);
|
||||
if (current.status === "timeout")
|
||||
snapshot = (await client.fetchAgent(agentId))?.agent ?? null;
|
||||
else {
|
||||
if (!current.final && current.error && current.error !== `Agent not found: ${agentId}`)
|
||||
throw new Error(current.error);
|
||||
snapshot = current.final;
|
||||
lastMessage = current.lastMessage;
|
||||
}
|
||||
}
|
||||
handler(snapshot, event, lastMessage);
|
||||
};
|
||||
let updates = Promise.resolve();
|
||||
const unsubscribe = observation.subscribe({
|
||||
snapshot: () => {},
|
||||
update: (message) => {
|
||||
if (message.type !== "agent_stream" || message.payload.agentId !== agentId) return;
|
||||
const event = message.payload.event;
|
||||
if (
|
||||
event.type === "permission_requested" ||
|
||||
event.type === "permission_resolved" ||
|
||||
event.type === "turn_started" ||
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_canceled" ||
|
||||
event.type === "turn_failed"
|
||||
)
|
||||
updates = updates.then(() => refresh(event)).catch(onError);
|
||||
},
|
||||
error: onError,
|
||||
});
|
||||
try {
|
||||
await observation.ready;
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
unsubscribe();
|
||||
await observation.release();
|
||||
throw error;
|
||||
}
|
||||
return async () => {
|
||||
unsubscribe();
|
||||
await observation.release();
|
||||
};
|
||||
},
|
||||
wait: (agentId, signal) => {
|
||||
const close = () => void client.close();
|
||||
signal.addEventListener("abort", close, { once: true });
|
||||
return client
|
||||
.waitForFinish(agentId, 0)
|
||||
.then((outcome) => {
|
||||
if (!outcome.final && outcome.error && outcome.error !== `Agent not found: ${agentId}`)
|
||||
throw new Error(outcome.error);
|
||||
return outcome;
|
||||
})
|
||||
.finally(() => signal.removeEventListener("abort", close));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Shares routed status observation with daemon services independently of MCP execution. */
|
||||
export function createHostObservationResolver(input: {
|
||||
serverId: string;
|
||||
local: HostObservation;
|
||||
hosts(): ToolHosts;
|
||||
}) {
|
||||
return {
|
||||
async use<T>(
|
||||
serverId: string | undefined,
|
||||
action: (host: HostObservation) => Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
if (!serverId || serverId === input.serverId) return action(input.local);
|
||||
const hosts = input.hosts();
|
||||
async function classify(error: unknown): Promise<HostObservationError> {
|
||||
// This is the origin's authoritative registration directory, not a destination RPC error.
|
||||
const row = (await hosts.directory()).hosts.find((host) => host.serverId === serverId);
|
||||
if (!row) return new HostObservationError("removed", error);
|
||||
if (row.status === "connecting") return new HostObservationError("connecting", error);
|
||||
return new HostObservationError("unreachable", error);
|
||||
}
|
||||
async function fail(error: unknown): Promise<never> {
|
||||
throw await classify(error);
|
||||
}
|
||||
try {
|
||||
return await hosts.use(
|
||||
serverId,
|
||||
(client) => {
|
||||
const remote = remoteObservation(client);
|
||||
return action({
|
||||
wait: (agentId, lifetime) => remote.wait(agentId, lifetime).catch(fail),
|
||||
observe(agentId, handler, lifetime, onError) {
|
||||
async function report(error: unknown): Promise<void> {
|
||||
try {
|
||||
onError(await classify(error));
|
||||
} catch (failure) {
|
||||
onError(failure);
|
||||
}
|
||||
}
|
||||
return remote.observe(agentId, handler, lifetime, report).catch(fail);
|
||||
},
|
||||
});
|
||||
},
|
||||
signal,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HostObservationError) throw error;
|
||||
return fail(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { expect, onTestFinished, test } from "vitest";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { createIdleRuntimeCleanup, type RuntimeCleanupCandidate } from "./index.js";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((yes) => {
|
||||
resolve = yes;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
class Runtime {
|
||||
readonly identity = {};
|
||||
busy = false;
|
||||
closed = false;
|
||||
resumable = true;
|
||||
activity = 0;
|
||||
parentAgentId: string | undefined;
|
||||
probe: (() => Promise<boolean>) | undefined;
|
||||
admitClose: (() => Promise<void>) | undefined;
|
||||
finishClose: (() => Promise<void>) | undefined;
|
||||
constructor(readonly id = "agent") {}
|
||||
capture(): RuntimeCleanupCandidate {
|
||||
const activity = this.activity;
|
||||
return {
|
||||
identity: this.identity,
|
||||
agentId: this.id,
|
||||
parentAgentId: this.parentAgentId,
|
||||
provider: "test",
|
||||
sessionId: this.resumable ? this.id : null,
|
||||
lastActivityAt: activity,
|
||||
isEligible: () => !this.closed && !this.busy && this.activity === activity,
|
||||
canStopRuntime: this.probe,
|
||||
closeIfEligible: async (eligible) => {
|
||||
await this.admitClose?.();
|
||||
if (!eligible()) return false;
|
||||
this.closed = true;
|
||||
await this.finishClose?.();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
function scenario(enabled = true) {
|
||||
const runtime = new Runtime();
|
||||
const runtimes = [runtime];
|
||||
let time = 0;
|
||||
let protectedCaller = false;
|
||||
let tick!: () => void;
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled,
|
||||
logger: createTestLogger(),
|
||||
now: () => time,
|
||||
runtimes: () => runtimes.filter((item) => !item.closed).map((item) => item.capture()),
|
||||
isProtected: () => protectedCaller,
|
||||
schedule: (callback, interval) => {
|
||||
expect(interval).toBe(60_000);
|
||||
tick = callback;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
onTestFinished(() => cleanup.close());
|
||||
return {
|
||||
runtime,
|
||||
runtimes,
|
||||
cleanup,
|
||||
tick: () => tick(),
|
||||
protect: (value: boolean) => {
|
||||
protectedCaller = value;
|
||||
},
|
||||
sweep: (minutes: number) => {
|
||||
time = minutes * 60_000;
|
||||
return cleanup.sweep();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("a runtime without a provider check is collected at exactly one hour", async () => {
|
||||
const s = scenario();
|
||||
expect((await s.sweep(59)).collected).toEqual([]);
|
||||
expect((await s.sweep(60)).collected).toEqual([
|
||||
{ agentId: "agent", provider: "test", sessionId: "agent" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("activity restarts the single one-hour idle period", async () => {
|
||||
const s = scenario();
|
||||
await s.sweep(59);
|
||||
s.runtime.activity = 59 * 60_000;
|
||||
expect((await s.sweep(118)).collected).toEqual([]);
|
||||
expect((await s.sweep(119)).collected).toHaveLength(1);
|
||||
});
|
||||
|
||||
test.each(["foreground", "finish subscription"])(
|
||||
"a positive provider check cannot override %s",
|
||||
async (blocker) => {
|
||||
const s = scenario();
|
||||
let calls = 0;
|
||||
s.runtime.probe = async () => {
|
||||
calls++;
|
||||
return true;
|
||||
};
|
||||
if (blocker === "foreground") s.runtime.busy = true;
|
||||
else s.protect(true);
|
||||
expect((await s.sweep(61)).collected).toEqual([]);
|
||||
expect(calls).toBe(0);
|
||||
s.runtime.busy = false;
|
||||
s.protect(false);
|
||||
expect((await s.sweep(62)).collected).toEqual([]);
|
||||
expect((await s.sweep(121)).collected).toEqual([]);
|
||||
expect((await s.sweep(122)).collected).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["veto", "error"])(
|
||||
"a supported provider %s retains the runtime until a fresh idle hour passes",
|
||||
async (decision) => {
|
||||
const s = scenario();
|
||||
s.runtime.probe = async () => {
|
||||
if (decision === "error") throw new Error("Probe failed");
|
||||
return false;
|
||||
};
|
||||
const blocked = await s.sweep(61);
|
||||
expect(blocked.collected).toEqual([]);
|
||||
expect(blocked.failures).toHaveLength(decision === "error" ? 1 : 0);
|
||||
await s.sweep(119);
|
||||
s.runtime.probe = async () => true;
|
||||
expect((await s.sweep(120)).collected).toEqual([]);
|
||||
expect((await s.sweep(179)).collected).toEqual([]);
|
||||
expect((await s.sweep(180)).collected).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
test("an idle managed descendant with provider-held work vetoes its parent", async () => {
|
||||
const s = scenario();
|
||||
const child = new Runtime("child");
|
||||
child.parentAgentId = s.runtime.id;
|
||||
child.probe = async () => false;
|
||||
s.runtimes.push(child);
|
||||
expect((await s.sweep(61)).collected).toEqual([]);
|
||||
child.probe = async () => true;
|
||||
expect((await s.sweep(62)).collected).toEqual([]);
|
||||
expect((await s.sweep(122)).collected).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("new caller protection during an async provider probe prevents stale collection", async () => {
|
||||
const s = scenario();
|
||||
const probe = deferred<boolean>();
|
||||
s.runtime.probe = () => probe.promise;
|
||||
const collecting = s.sweep(61);
|
||||
s.protect(true);
|
||||
probe.resolve(true);
|
||||
expect((await collecting).collected).toEqual([]);
|
||||
expect(s.runtime.closed).toBe(false);
|
||||
s.protect(false);
|
||||
expect((await s.sweep(62)).collected).toEqual([]);
|
||||
expect((await s.sweep(122)).collected).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a supported provider timeout retains ownership even if it later returns true", async () => {
|
||||
const s = scenario();
|
||||
const probe = deferred<boolean>();
|
||||
s.runtime.probe = () => probe.promise;
|
||||
const result = await s.sweep(61);
|
||||
expect(result.collected).toEqual([]);
|
||||
expect(result.failures[0]?.error).toEqual(new Error("Runtime safety probe timed out for agent"));
|
||||
probe.resolve(true);
|
||||
expect((await s.sweep(62)).collected).toEqual([]);
|
||||
expect(s.runtime.closed).toBe(false);
|
||||
}, 15_000);
|
||||
|
||||
test("the host opt-out disables explicit sweeps and scheduled ticks", async () => {
|
||||
const s = scenario(false);
|
||||
let calls = 0;
|
||||
s.runtime.probe = async () => {
|
||||
calls++;
|
||||
return true;
|
||||
};
|
||||
expect((await s.sweep(61)).collected).toEqual([]);
|
||||
s.tick();
|
||||
await s.cleanup.close();
|
||||
expect(calls).toBe(0);
|
||||
expect(s.runtime.closed).toBe(false);
|
||||
});
|
||||
|
||||
test("a managed descendant without persistence still protects its parent while working", async () => {
|
||||
const s = scenario();
|
||||
const child = new Runtime("initializing-child");
|
||||
child.parentAgentId = s.runtime.id;
|
||||
child.resumable = false;
|
||||
child.busy = true;
|
||||
s.runtimes.push(child);
|
||||
expect((await s.sweep(61)).collected).toEqual([]);
|
||||
child.busy = false;
|
||||
expect((await s.sweep(62)).collected).toEqual([]);
|
||||
expect((await s.sweep(122)).collected).toEqual([
|
||||
{ agentId: "agent", provider: "test", sessionId: "agent" },
|
||||
]);
|
||||
expect(child.closed).toBe(false);
|
||||
});
|
||||
|
||||
test("the live host switch retains eligible runtimes and re-enables with their existing idle clocks", async () => {
|
||||
const s = scenario();
|
||||
s.cleanup.setEnabled(false);
|
||||
expect((await s.sweep(61)).collected).toEqual([]);
|
||||
expect(s.runtime.closed).toBe(false);
|
||||
s.cleanup.setEnabled(true);
|
||||
s.tick();
|
||||
expect((await s.cleanup.sweep()).collected).toHaveLength(1);
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"disabling during a probe invalidates the sweep (re-enable: %s)",
|
||||
async (reEnable) => {
|
||||
const s = scenario();
|
||||
const probe = deferred<boolean>();
|
||||
s.runtime.probe = () => probe.promise;
|
||||
const collecting = s.sweep(61);
|
||||
s.cleanup.setEnabled(false);
|
||||
if (reEnable) s.cleanup.setEnabled(true);
|
||||
probe.resolve(true);
|
||||
expect((await collecting).collected).toEqual([]);
|
||||
expect(s.runtime.closed).toBe(false);
|
||||
s.cleanup.setEnabled(true);
|
||||
expect((await s.sweep(62)).collected).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([false, true])(
|
||||
"disabling while waiting for lifecycle admission prevents closure (re-enable: %s)",
|
||||
async (reEnable) => {
|
||||
const s = scenario();
|
||||
const admitted = deferred<void>();
|
||||
const waiting = deferred<void>();
|
||||
s.runtime.admitClose = () => {
|
||||
waiting.resolve();
|
||||
return admitted.promise;
|
||||
};
|
||||
const collecting = s.sweep(61);
|
||||
await waiting.promise;
|
||||
s.cleanup.setEnabled(false);
|
||||
if (reEnable) s.cleanup.setEnabled(true);
|
||||
admitted.resolve();
|
||||
expect((await collecting).collected).toEqual([]);
|
||||
expect(s.runtime.closed).toBe(false);
|
||||
s.cleanup.setEnabled(true);
|
||||
expect((await s.sweep(62)).collected).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["veto", "error"])(
|
||||
"an invalidated provider %s does not reset the idle clock",
|
||||
async (outcome) => {
|
||||
const s = scenario();
|
||||
const probe = deferred<boolean>();
|
||||
s.runtime.probe = async () => {
|
||||
await probe.promise;
|
||||
if (outcome === "error") throw new Error("Old probe failed");
|
||||
return false;
|
||||
};
|
||||
const collecting = s.sweep(61);
|
||||
s.cleanup.setEnabled(false);
|
||||
s.cleanup.setEnabled(true);
|
||||
probe.resolve(false);
|
||||
expect(await collecting).toEqual({ collected: [], failures: [] });
|
||||
s.runtime.probe = async () => true;
|
||||
expect((await s.sweep(62)).collected).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
test("disabling lets a started close finish but prevents the next closure in that sweep", async () => {
|
||||
const s = scenario();
|
||||
const sibling = new Runtime("sibling");
|
||||
s.runtimes.push(sibling);
|
||||
const closing = deferred<void>();
|
||||
const finished = deferred<void>();
|
||||
s.runtime.finishClose = () => {
|
||||
closing.resolve();
|
||||
return finished.promise;
|
||||
};
|
||||
const collecting = s.sweep(61);
|
||||
await closing.promise;
|
||||
expect(s.runtime.closed).toBe(true);
|
||||
s.cleanup.setEnabled(false);
|
||||
finished.resolve();
|
||||
expect((await collecting).collected).toEqual([
|
||||
{ agentId: "agent", provider: "test", sessionId: "agent" },
|
||||
]);
|
||||
expect(sibling.closed).toBe(false);
|
||||
s.cleanup.setEnabled(true);
|
||||
expect((await s.sweep(62)).collected).toEqual([
|
||||
{ agentId: "sibling", provider: "test", sessionId: "sibling" },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { Logger } from "pino";
|
||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
|
||||
/** A captured runtime capability. Its lifecycle owner revalidates before releasing it. */
|
||||
export interface RuntimeCleanupCandidate {
|
||||
readonly identity: object;
|
||||
readonly agentId: string;
|
||||
readonly parentAgentId: string | undefined;
|
||||
readonly provider: string;
|
||||
readonly sessionId: string | null;
|
||||
readonly lastActivityAt: number;
|
||||
isEligible(): boolean;
|
||||
canStopRuntime?(): Promise<boolean>;
|
||||
closeIfEligible(revalidate: () => boolean): Promise<boolean>;
|
||||
}
|
||||
interface Options {
|
||||
enabled: boolean;
|
||||
runtimes(): readonly RuntimeCleanupCandidate[];
|
||||
isProtected?(agentId: string): boolean;
|
||||
logger: Pick<Logger, "debug" | "info" | "warn">;
|
||||
now?: () => number;
|
||||
schedule?: (tick: () => void, intervalMs: number) => () => void;
|
||||
}
|
||||
interface CollectionEntry {
|
||||
agentId: string;
|
||||
provider: string;
|
||||
sessionId: string;
|
||||
}
|
||||
interface CollectionResult {
|
||||
collected: CollectionEntry[];
|
||||
failures: Array<{ agentId: string; error: unknown }>;
|
||||
}
|
||||
const IDLE_PERIOD_MS = 60 * 60_000;
|
||||
|
||||
/** Owns idle clocks, baseline eligibility, optional provider vetoes and the sweep lifetime. */
|
||||
export function createIdleRuntimeCleanup(options: Options) {
|
||||
const protectedAt = new WeakMap<object, { at: number; protected: boolean }>();
|
||||
let collection: Promise<CollectionResult> | null = null;
|
||||
let stopped = false;
|
||||
let enabled = options.enabled;
|
||||
let generation = 0;
|
||||
const isProtected = options.isProtected ?? (() => false);
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
function familyOf(root: RuntimeCleanupCandidate, runtimes: readonly RuntimeCleanupCandidate[]) {
|
||||
const family = new Map([[root.agentId, root]]);
|
||||
for (const parent of family.values()) {
|
||||
for (const child of runtimes) {
|
||||
if (child.parentAgentId === parent.agentId) family.set(child.agentId, child);
|
||||
}
|
||||
}
|
||||
return [...family.values()];
|
||||
}
|
||||
function baselineAllows(family: readonly RuntimeCleanupCandidate[]) {
|
||||
return family.every((runtime) => runtime.isEligible() && !isProtected(runtime.agentId));
|
||||
}
|
||||
async function providersAllow(
|
||||
family: readonly RuntimeCleanupCandidate[],
|
||||
isCurrent: () => boolean,
|
||||
) {
|
||||
for (const runtime of family) {
|
||||
if (!isCurrent()) return false;
|
||||
if (!runtime.canStopRuntime) continue;
|
||||
const allowed = await withTimeout(
|
||||
runtime.canStopRuntime(),
|
||||
10_000,
|
||||
`Runtime safety probe timed out for ${runtime.agentId}`,
|
||||
);
|
||||
if (allowed !== true) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
async function collect(): Promise<CollectionResult> {
|
||||
const result: CollectionResult = { collected: [], failures: [] };
|
||||
const sweepGeneration = generation;
|
||||
const isCurrent = () => enabled && !stopped && generation === sweepGeneration;
|
||||
if (!isCurrent()) return result;
|
||||
const timestamp = now();
|
||||
const runtimes = options.runtimes();
|
||||
for (const runtime of runtimes) {
|
||||
if (!isCurrent()) break;
|
||||
if (!runtime.sessionId) continue;
|
||||
const family = familyOf(runtime, runtimes);
|
||||
try {
|
||||
const allowed = baselineAllows(family) && (await providersAllow(family, isCurrent));
|
||||
if (!isCurrent()) break;
|
||||
if (!allowed) {
|
||||
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
|
||||
options.logger.debug(
|
||||
{ agentId: runtime.agentId },
|
||||
"Idle runtime retained by a baseline or provider veto",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const previous = protectedAt.get(runtime.identity);
|
||||
if (previous?.protected)
|
||||
protectedAt.set(runtime.identity, { at: timestamp, protected: false });
|
||||
const idleSince = Math.max(
|
||||
runtime.lastActivityAt,
|
||||
protectedAt.get(runtime.identity)?.at ?? 0,
|
||||
);
|
||||
if (timestamp - idleSince < IDLE_PERIOD_MS) continue;
|
||||
const revalidate = () => {
|
||||
const currentFamily = familyOf(runtime, options.runtimes());
|
||||
return (
|
||||
isCurrent() &&
|
||||
currentFamily.length === family.length &&
|
||||
currentFamily.every((current) =>
|
||||
family.some((captured) => captured.identity === current.identity),
|
||||
) &&
|
||||
baselineAllows(family)
|
||||
);
|
||||
};
|
||||
if (await runtime.closeIfEligible(revalidate)) {
|
||||
const entry = {
|
||||
agentId: runtime.agentId,
|
||||
provider: runtime.provider,
|
||||
sessionId: runtime.sessionId,
|
||||
};
|
||||
result.collected.push(entry);
|
||||
options.logger.info(entry, "Collected idle agent runtime");
|
||||
} else if (isCurrent()) {
|
||||
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCurrent()) break;
|
||||
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
|
||||
result.failures.push({ agentId: runtime.agentId, error });
|
||||
options.logger.warn(
|
||||
{ agentId: runtime.agentId, err: error },
|
||||
"Idle runtime cleanup failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function sweep(): Promise<CollectionResult> {
|
||||
if (collection) return collection;
|
||||
collection = collect().finally(() => {
|
||||
collection = null;
|
||||
});
|
||||
return collection;
|
||||
}
|
||||
const schedule =
|
||||
options.schedule ??
|
||||
((tick, intervalMs) => {
|
||||
const timer = setInterval(tick, intervalMs);
|
||||
timer.unref();
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
const cancel = schedule(() => {
|
||||
void sweep().catch((error) => options.logger.warn({ err: error }, "Idle runtime sweep failed"));
|
||||
}, 60_000);
|
||||
return {
|
||||
sweep,
|
||||
setEnabled(value: boolean) {
|
||||
if (enabled === value) return;
|
||||
enabled = value;
|
||||
generation++;
|
||||
},
|
||||
async close() {
|
||||
stopped = true;
|
||||
cancel();
|
||||
await collection;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -313,6 +313,124 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe("Suite A: Core Fixes", () => {
|
||||
test.each([
|
||||
{ relationship: "detached", reparent: false, notifications: 1 },
|
||||
{ relationship: "subagent", reparent: false, notifications: 1 },
|
||||
{ relationship: "subagent", reparent: true, notifications: 0 },
|
||||
] as const)(
|
||||
"create_agent $relationship (reparent=$reparent) sends $notifications finish notification(s)",
|
||||
async ({ relationship, reparent, notifications }) => {
|
||||
let releaseChild!: () => void;
|
||||
let childReachedGate!: () => void;
|
||||
const heldTurn = new Promise<void>((resolve) => {
|
||||
releaseChild = resolve;
|
||||
});
|
||||
const childAtGate = new Promise<void>((resolve) => {
|
||||
childReachedGate = resolve;
|
||||
});
|
||||
const callerPrompts: string[] = [];
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn(prompt, config) {
|
||||
if (config.title === "Notification caller" && typeof prompt === "string")
|
||||
callerPrompts.push(prompt);
|
||||
},
|
||||
async beforeTurnComplete(prompt) {
|
||||
if (
|
||||
typeof prompt === "string" &&
|
||||
parseAgentMessage(prompt).text === "Hold notification child"
|
||||
) {
|
||||
childReachedGate();
|
||||
await heldTurn;
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
let mcp: McpClient | undefined;
|
||||
try {
|
||||
await client.connect();
|
||||
const caller = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: daemon.paseoHome,
|
||||
title: "Notification caller",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
mcp = await createMcpClient(
|
||||
buildExpectedAgentMcpUrl({
|
||||
host: "127.0.0.1",
|
||||
port: daemon.port,
|
||||
agentId: caller.id,
|
||||
}),
|
||||
);
|
||||
const created = await callToolStructured(mcp, "create_agent", {
|
||||
relationship: { kind: relationship },
|
||||
workspace: { kind: "current" },
|
||||
provider: "codex/gpt-5.4",
|
||||
title: "Notification child",
|
||||
initialPrompt: "Hold notification child",
|
||||
notifyOnFinish: true,
|
||||
settings: { modeId: "full-access" },
|
||||
});
|
||||
const childId = str(created.agentId);
|
||||
await childAtGate;
|
||||
expect(daemon.daemon.agentManager.getAgent(childId)?.labels?.[PARENT_AGENT_ID_LABEL]).toBe(
|
||||
relationship === "subagent" ? caller.id : undefined,
|
||||
);
|
||||
if (reparent) {
|
||||
const other = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: daemon.paseoHome,
|
||||
title: "Other parent",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
await daemon.daemon.agentManager.setLabels(childId, {
|
||||
[PARENT_AGENT_ID_LABEL]: other.id,
|
||||
});
|
||||
}
|
||||
releaseChild();
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "child completion",
|
||||
check: () =>
|
||||
daemon.daemon.agentManager.getAgent(childId)?.lifecycle === "idle" ? true : null,
|
||||
});
|
||||
if (notifications) {
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "caller finish notification",
|
||||
check: () => (callerPrompts.length > 0 ? true : null),
|
||||
});
|
||||
expect(parseAgentMessage(callerPrompts[0]).source).toEqual({
|
||||
kind: "agent-notification",
|
||||
agentId: childId,
|
||||
title: "Notification child",
|
||||
event: "finished",
|
||||
});
|
||||
expect(parseAgentMessage(callerPrompts[0]).text).toContain(
|
||||
"Notification child) finished.",
|
||||
);
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "caller notification drained",
|
||||
check: () =>
|
||||
daemon.daemon.agentManager.getAgent(caller.id)?.lifecycle === "idle" ? true : null,
|
||||
});
|
||||
} else {
|
||||
// Allow the terminal event's asynchronous storage reads and notification dispatch to drain.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
expect(callerPrompts).toHaveLength(notifications);
|
||||
} finally {
|
||||
releaseChild();
|
||||
await mcp?.close();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
test.each([
|
||||
["Stop", "cancelAgent"],
|
||||
["Reload", "refreshAgent"],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import type { AgentPermissionRequest } from "./agent-sdk-types.js";
|
||||
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { selectItemsByProjectedLimit } from "./timeline-projection.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
import { serializeAgentSnapshot } from "../messages.js";
|
||||
import { StoredScheduleSchema } from "@getpaseo/protocol/schedule/types";
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
@@ -202,27 +202,36 @@ export function sanitizePermissionRequest(
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export async function resolveAgentTitle(
|
||||
async function resolveAgentStorageRecord(
|
||||
agentStorage: AgentStorage,
|
||||
agentId: string,
|
||||
logger: Logger,
|
||||
): Promise<string | null> {
|
||||
): Promise<StoredAgentRecord | null> {
|
||||
try {
|
||||
const record = await agentStorage.get(agentId);
|
||||
return record?.title ?? null;
|
||||
return (await agentStorage.get(agentId)) ?? null;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, agentId }, "Failed to load agent title");
|
||||
logger.error({ err: error, agentId }, "Failed to load agent record");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Storage owns the archive timestamp, and a live snapshot cannot carry it: an
|
||||
// archived agent is resumed back into memory to serve a history read (see the
|
||||
// `purpose: "history"` resume in agent-loading), so presence in the manager is
|
||||
// not evidence that the agent is active. Merge the stored record here, the same
|
||||
// way the WebSocket path does in Session.enrichAgentPayload, rather than
|
||||
// copying the value onto the managed agent, where it would be a second source
|
||||
// of truth that unarchive has to remember to clear.
|
||||
export async function serializeSnapshotWithMetadata(
|
||||
agentStorage: AgentStorage,
|
||||
snapshot: ManagedAgent,
|
||||
logger: Logger,
|
||||
) {
|
||||
const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
|
||||
return serializeAgentSnapshot(snapshot, { title });
|
||||
const record = await resolveAgentStorageRecord(agentStorage, snapshot.id, logger);
|
||||
return serializeAgentSnapshot(snapshot, {
|
||||
title: record?.title ?? null,
|
||||
archivedAt: record?.archivedAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseDurationString(input: string): number {
|
||||
|
||||
@@ -99,16 +99,17 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("mutable provider config owner", () => {
|
||||
test("publishes provider changes after commit and emits nothing on rollback", () => {
|
||||
test("publishes provider changes after commit and emits nothing on rollback", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, mutableConfig({ version: 1 }));
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
providerOverrides: { codex: { enabled: true } },
|
||||
providerOverrides: CONTROLLED_PROVIDERS,
|
||||
extraClients: { codex: controlledCodexClient(async () => catalog("initial")) },
|
||||
});
|
||||
const cwd = path.resolve("/tmp/provider-config-owner");
|
||||
manager.getSnapshot(cwd);
|
||||
await manager.refreshSnapshotForCwd({ cwd });
|
||||
const events: string[] = [];
|
||||
manager.on("change", ({ current }) => events.push(current.cwd));
|
||||
let agentManagerState = manager.getAgentManagerProviderState();
|
||||
@@ -125,7 +126,7 @@ describe("mutable provider config owner", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
store.patch({ providers: { codex: { enabled: false } } });
|
||||
await store.patch({ providers: { codex: { enabled: false } } });
|
||||
expect(events).toEqual([cwd]);
|
||||
expect(agentManagerState.providerDefinitions.codex).toMatchObject({ enabled: false });
|
||||
|
||||
@@ -133,7 +134,7 @@ describe("mutable provider config owner", () => {
|
||||
const unsubscribeFailure = store.onApply(() => {
|
||||
throw new Error("later owner failed");
|
||||
});
|
||||
expect(() => store.patch({ providers: { codex: { enabled: true } } })).toThrow(
|
||||
await expect(store.patch({ providers: { codex: { enabled: true } } })).rejects.toThrow(
|
||||
"later owner failed",
|
||||
);
|
||||
unsubscribeFailure();
|
||||
@@ -150,7 +151,7 @@ describe("mutable provider config owner", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("does no provider work for unrelated CORS, Git, and app URL reloads", () => {
|
||||
test("does no provider work for unrelated CORS, Git, and app URL reloads", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const initial: PersistedConfig = {
|
||||
@@ -174,9 +175,10 @@ describe("mutable provider config owner", () => {
|
||||
});
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
providerOverrides: { codex: { enabled: true } },
|
||||
providerOverrides: CONTROLLED_PROVIDERS,
|
||||
extraClients: { codex: controlledCodexClient(async () => catalog("initial")) },
|
||||
});
|
||||
manager.getSnapshot("/tmp/provider-config-owner");
|
||||
await manager.refreshSnapshotForCwd({ cwd: "/tmp/provider-config-owner" });
|
||||
const events: string[] = [];
|
||||
manager.on("change", ({ current }) => events.push(current.cwd));
|
||||
let registryUpdates = 0;
|
||||
@@ -200,7 +202,7 @@ describe("mutable provider config owner", () => {
|
||||
};
|
||||
writeFileSync(configPath, `${JSON.stringify(reloaded, null, 2)}\n`, "utf-8");
|
||||
|
||||
expect(store.reload().appliedPaths).toEqual([
|
||||
expect((await store.reload()).appliedPaths).toEqual([
|
||||
"app.baseUrl",
|
||||
"daemon.cors.allowedOrigins",
|
||||
"daemon.git.maxProcessConcurrency",
|
||||
@@ -245,7 +247,7 @@ describe("mutable provider config owner", () => {
|
||||
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1));
|
||||
expect(events).toEqual([]);
|
||||
|
||||
store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } });
|
||||
await store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } });
|
||||
expect(events).toEqual(["loading:none"]);
|
||||
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(2));
|
||||
|
||||
@@ -311,9 +313,9 @@ describe("mutable provider config owner", () => {
|
||||
expect(events).toEqual([]);
|
||||
|
||||
const before = manager.getSnapshot(cwd);
|
||||
expect(() =>
|
||||
await expect(
|
||||
store.patch({ providers: { codex: { enabled: true, label: "Rejected Codex" } } }),
|
||||
).toThrow("later owner failed");
|
||||
).rejects.toThrow("later owner failed");
|
||||
expect(events).toEqual([]);
|
||||
expect(observedDuringApply).toEqual([{ label: "Codex", snapshot: before }]);
|
||||
expect(observedDuringApply[0]!.snapshot).toBe(before);
|
||||
|
||||
@@ -32,7 +32,7 @@ class FakePermissionAgentManager {
|
||||
return this.permissionResult;
|
||||
}
|
||||
|
||||
tryRunOutOfBand(): boolean {
|
||||
async tryRunOutOfBand(): Promise<boolean> {
|
||||
return this.outOfBandHandled;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ class FakePermissionAgentManager {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async reloadStalePluginSession(): Promise<void> {}
|
||||
|
||||
hasInFlightRun(): boolean {
|
||||
return this.hasRunInFlight;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
|
||||
import { relative } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
ProviderConnection,
|
||||
ProviderLaunch,
|
||||
@@ -20,6 +27,21 @@ import {
|
||||
StaleProviderSessionError,
|
||||
} from "./stale-provider-session-error.js";
|
||||
|
||||
test("long concurrent plugin and child streams retain text-sized history and release it on archive", () => {
|
||||
const fixture = fileURLToPath(new URL("./test-utils/plugin-stream-memory.ts", import.meta.url));
|
||||
const output = execFileSync(
|
||||
process.execPath,
|
||||
["--expose-gc", "--max-old-space-size=512", "--import", "tsx", fixture],
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
const memory = JSON.parse(output) as { retained: number; closed: number };
|
||||
expect(memory.retained).toBeLessThan(24 * 1024 * 1024);
|
||||
expect(memory.closed).toBeLessThan(6 * 1024 * 1024);
|
||||
});
|
||||
|
||||
const CAPABILITIES = [
|
||||
"prompt.message",
|
||||
"session.configure",
|
||||
@@ -287,6 +309,99 @@ function expectNestedChildren(events: AgentStreamEvent[]) {
|
||||
}
|
||||
|
||||
describe("PluginAgentClientRegistry", () => {
|
||||
test("history keeps the tool's first position when completion follows later text", async () => {
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const session = await client.createSession({ provider: client.provider, cwd: "/tmp" });
|
||||
const opened = harness.inputs.find((input) => input.type === "session.open");
|
||||
if (opened?.type !== "session.open") throw new Error("missing session open");
|
||||
const tool = {
|
||||
type: "tool_call" as const,
|
||||
id: "tool-position",
|
||||
callId: "call-position",
|
||||
name: "read",
|
||||
detail: { type: "unknown" as const, input: null, output: null },
|
||||
error: null,
|
||||
};
|
||||
try {
|
||||
harness.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: opened.sessionId,
|
||||
item: { ...tool, status: "running" },
|
||||
});
|
||||
harness.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: opened.sessionId,
|
||||
item: { type: "assistant_message", id: "later-text", text: "Working" },
|
||||
});
|
||||
harness.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: opened.sessionId,
|
||||
item: { ...tool, status: "completed" },
|
||||
});
|
||||
const items = [];
|
||||
for await (const event of session.streamHistory())
|
||||
if (event.type === "timeline") items.push(event.item);
|
||||
expect(items).toEqual([
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "call-position",
|
||||
name: "read",
|
||||
detail: tool.detail,
|
||||
error: null,
|
||||
status: "completed",
|
||||
},
|
||||
{ type: "assistant_message", messageId: "later-text", text: "Working" },
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("detached text deltas preserve split surrogates and history replays one complete message", async () => {
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const session = await client.createSession({ provider: client.provider, cwd: "/tmp" });
|
||||
const opened = harness.inputs.find((input) => input.type === "session.open");
|
||||
if (opened?.type !== "session.open") throw new Error("missing session open");
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
const prefix = "long prefix ".repeat(10);
|
||||
const first = prefix + "a\uD83D";
|
||||
const complete = first + "\uDE00b";
|
||||
try {
|
||||
for (const text of [first, complete, complete]) {
|
||||
harness.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: opened.sessionId,
|
||||
item: { type: "assistant_message", id: "split-message", text },
|
||||
});
|
||||
}
|
||||
const deltas = events.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message"
|
||||
? [event.item.text]
|
||||
: [],
|
||||
);
|
||||
expect(deltas).toEqual([first, "\uDE00b"]);
|
||||
expect(deltas.join("")).toBe(prefix + "a😀b");
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) history.push(event);
|
||||
expect(
|
||||
history.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item] : [],
|
||||
),
|
||||
).toEqual([{ type: "assistant_message", messageId: "split-message", text: complete }]);
|
||||
} finally {
|
||||
await session.close();
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("stores only agent options while the plugin receives merged defaults", async () => {
|
||||
const logger = createTestLogger();
|
||||
const harness = createProviderHarness();
|
||||
@@ -305,7 +420,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
});
|
||||
const own = { nested: { replace: "agent" } };
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: "plugin-direct", cwd: "/tmp", providerOptions: own },
|
||||
{ provider: "plugin-direct", cwd: tmpdir(), providerOptions: own },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
@@ -481,6 +596,137 @@ describe("PluginAgentClientRegistry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["completed", "canceled", "failed"] as const)(
|
||||
"reports a reused plugin child as running until its next turn is %s",
|
||||
async (outcome) => {
|
||||
const logger = createTestLogger();
|
||||
const harness = createProviderHarness();
|
||||
const plugins = new PluginAgentClientRegistry(logger);
|
||||
plugins.replace([harness.registration]);
|
||||
const registry = buildProviderRegistry(logger, { pluginProviders: plugins.definitions() });
|
||||
const manager = new AgentManager({
|
||||
logger,
|
||||
clients: { "plugin-direct": registry["plugin-direct"].createClient(logger) },
|
||||
providerDefinitions: { "plugin-direct": registry["plugin-direct"] },
|
||||
});
|
||||
try {
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: "plugin-direct", cwd: tmpdir() },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const open = harness.inputs.find((input) => input.type === "session.open")!;
|
||||
harness.emit({
|
||||
type: "session.opened",
|
||||
sessionId: "reusable-child",
|
||||
parentSessionId: open.sessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
title: "Plugin child",
|
||||
cwd: tmpdir(),
|
||||
});
|
||||
harness.emit({
|
||||
type: "session.turn",
|
||||
sessionId: "reusable-child",
|
||||
turnId: "first-turn",
|
||||
state: "started",
|
||||
});
|
||||
harness.emit({
|
||||
type: "session.turn",
|
||||
sessionId: "reusable-child",
|
||||
turnId: "first-turn",
|
||||
state: "completed",
|
||||
});
|
||||
await nextTurn();
|
||||
const [child] = manager.listProviderSubagents(agent.id);
|
||||
expect(child).toMatchObject({
|
||||
id: "reusable-child",
|
||||
title: "Plugin child",
|
||||
status: "completed",
|
||||
});
|
||||
|
||||
harness.emit({
|
||||
type: "session.turn",
|
||||
sessionId: "reusable-child",
|
||||
turnId: "second-turn",
|
||||
state: "started",
|
||||
});
|
||||
await nextTurn();
|
||||
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
|
||||
{ id: child.id, title: child.title, status: "running" },
|
||||
]);
|
||||
|
||||
harness.emit({
|
||||
type: "session.turn",
|
||||
sessionId: "reusable-child",
|
||||
turnId: "second-turn",
|
||||
state: outcome,
|
||||
});
|
||||
await nextTurn();
|
||||
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
|
||||
{ id: child.id, title: child.title, status: outcome },
|
||||
]);
|
||||
} finally {
|
||||
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
|
||||
await plugins.shutdown();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["started", "completed", "canceled", "failed"] as const)(
|
||||
"replays a reused plugin child's latest turn state: %s",
|
||||
async (state) => {
|
||||
const harness = createProviderHarness({
|
||||
openChildren(rootSessionId, emit) {
|
||||
emit({
|
||||
type: "session.opened",
|
||||
sessionId: "reusable-child",
|
||||
parentSessionId: rootSessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: "/workspace",
|
||||
});
|
||||
for (const [turnId, turnState] of [
|
||||
["first-turn", "started"],
|
||||
["first-turn", "completed"],
|
||||
["second-turn", "started"],
|
||||
] as const) {
|
||||
emit({ type: "session.turn", sessionId: "reusable-child", turnId, state: turnState });
|
||||
}
|
||||
if (state !== "started") {
|
||||
emit({
|
||||
type: "session.turn",
|
||||
sessionId: "reusable-child",
|
||||
turnId: "second-turn",
|
||||
state,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
try {
|
||||
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: "/workspace",
|
||||
});
|
||||
const statuses = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
if (
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "upsert" &&
|
||||
event.event.id === "reusable-child"
|
||||
) {
|
||||
statuses.push(event.event.status);
|
||||
}
|
||||
}
|
||||
expect(statuses.at(-1)).toBe(state === "started" ? "running" : state);
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("gates persistence operations on negotiated provider capabilities", async () => {
|
||||
const harness = createProviderHarness({ capabilities: ["session.persistence"] });
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
@@ -1234,3 +1480,110 @@ test("config-required plugin membership is independent of enablement and other p
|
||||
await plugins.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([true, false, "error", "wrong-session", "missing"] as const)(
|
||||
"plugin runtime safety query: %s",
|
||||
async (answer) => {
|
||||
const harness = createProviderHarness({
|
||||
capabilities:
|
||||
answer === "missing" ? CAPABILITIES : [...CAPABILITIES, "session.can_stop_runtime"],
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.can_stop_runtime") return false;
|
||||
if (answer === "error")
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "Probe failed" },
|
||||
});
|
||||
else
|
||||
emit({
|
||||
type: "session.can_stop_runtime",
|
||||
requestId: input.requestId,
|
||||
sessionId: answer === "wrong-session" ? "child-1" : input.sessionId,
|
||||
canStop: answer === true,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const session = await client.createSession({ provider: harness.registration.id, cwd: "/tmp" });
|
||||
try {
|
||||
if (answer === "error" || answer === "wrong-session") {
|
||||
await expect(session.canStopRuntime!()).rejects.toThrow(
|
||||
answer === "error" ? "Probe failed" : "another session",
|
||||
);
|
||||
} else if (answer === "missing") expect(session.canStopRuntime).toBeUndefined();
|
||||
else expect(await session.canStopRuntime!()).toBe(answer === true);
|
||||
const requests = harness.inputs.filter((input) => input.type === "session.can_stop_runtime");
|
||||
expect(requests).toHaveLength(answer === "missing" ? 0 : 1);
|
||||
if (answer !== "missing")
|
||||
expect(requests[0]).toMatchObject({
|
||||
sessionId: harness.inputs.find((input) => input.type === "session.open")?.sessionId,
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
await registry.shutdown();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("plugin without a runtime safety capability uses baseline collection without a query", async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), "plugin-baseline-cleanup-"));
|
||||
const logger = createTestLogger();
|
||||
const storage = new AgentStorage(join(home, "agents"), logger);
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(logger);
|
||||
registry.replace([harness.registration]);
|
||||
const manager = new AgentManager({ clients: registry.clients(), registry: storage, logger });
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: harness.registration.id, cwd: home },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled: true,
|
||||
logger,
|
||||
runtimes: () => manager.getRuntimeCleanupCandidates(),
|
||||
now: () => agent.updatedAt.getTime() + 60 * 60_000,
|
||||
schedule: () => () => {},
|
||||
});
|
||||
try {
|
||||
expect(agent.session?.canStopRuntime).toBeUndefined();
|
||||
expect((await cleanup.sweep()).collected).toHaveLength(1);
|
||||
expect(manager.getAgent(agent.id)).toBeNull();
|
||||
expect(harness.inputs.filter((input) => input.type === "session.can_stop_runtime")).toEqual([]);
|
||||
} finally {
|
||||
await cleanup.close();
|
||||
await Promise.all(manager.listAgents().map((item) => manager.closeAgent(item.id)));
|
||||
await registry.shutdown();
|
||||
await storage.flush();
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("plugin provider import launches with runtime MCP servers and returns only saved configuration", async () => {
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const storedConfig = { provider: harness.registration.id, cwd: "/project" };
|
||||
const company = { type: "http", url: "https://tools.example.com/mcp" } as const;
|
||||
try {
|
||||
const imported = await client.importSession!(
|
||||
{ providerHandleId: 'plugin:{"version":1,"data":{"token":"root"}}', cwd: "/project" },
|
||||
{ config: { ...storedConfig, mcpServers: { company } }, storedConfig },
|
||||
);
|
||||
expect(harness.inputs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "session.open",
|
||||
config: expect.objectContaining({ mcpServers: { company } }),
|
||||
}),
|
||||
);
|
||||
expect(imported.config).toEqual(storedConfig);
|
||||
await imported.session.close();
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { PluginSessionHistory } from "./plugin-session-history.js";
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
@@ -113,9 +114,15 @@ function providerError(error: ProviderError): Error {
|
||||
|
||||
function isProviderRequestReply(
|
||||
event: ProviderEvent,
|
||||
): event is Extract<ProviderEvent, { type: "request.completed" | "catalog" | "sessions" }> {
|
||||
): event is Extract<
|
||||
ProviderEvent,
|
||||
{ type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" }
|
||||
> {
|
||||
return (
|
||||
event.type === "request.completed" || event.type === "catalog" || event.type === "sessions"
|
||||
event.type === "request.completed" ||
|
||||
event.type === "catalog" ||
|
||||
event.type === "sessions" ||
|
||||
event.type === "session.can_stop_runtime"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -564,10 +571,21 @@ class ProviderRuntime {
|
||||
}
|
||||
|
||||
private finishRequest(
|
||||
event: Extract<ProviderEvent, { type: "request.completed" | "catalog" | "sessions" }>,
|
||||
event: Extract<
|
||||
ProviderEvent,
|
||||
{ type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" }
|
||||
>,
|
||||
): void {
|
||||
const request = this.requests.get(event.requestId);
|
||||
if (!request) return;
|
||||
if (
|
||||
event.type === "session.can_stop_runtime" &&
|
||||
this.providerSessions.get(event.sessionId)?.id !== request.sessionId
|
||||
) {
|
||||
this.requests.delete(event.requestId);
|
||||
request.reject(new Error("Runtime safety response belongs to another session"));
|
||||
return;
|
||||
}
|
||||
this.requests.delete(event.requestId);
|
||||
request.resolve(event);
|
||||
}
|
||||
@@ -575,6 +593,7 @@ class ProviderRuntime {
|
||||
|
||||
class ProviderRuntimeSession {
|
||||
readonly history: ProviderEvent[] = [];
|
||||
private historyClaimed = false;
|
||||
private readonly listeners = new Set<(event: ProviderEvent) => void>();
|
||||
private capabilities: readonly string[] = [];
|
||||
private openRequest: { requestId: string; deferred: Deferred<void> } | null = null;
|
||||
@@ -605,6 +624,10 @@ class ProviderRuntimeSession {
|
||||
}
|
||||
|
||||
onEvent(listener: (event: ProviderEvent) => void): () => void {
|
||||
// This buffer bridges session.open and adapter attachment. Once replayed,
|
||||
// the adapter owns history; retaining live cumulative snapshots is quadratic.
|
||||
this.historyClaimed = true;
|
||||
this.history.length = 0;
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
@@ -635,6 +658,27 @@ class ProviderRuntimeSession {
|
||||
}
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (
|
||||
this.terminal ||
|
||||
this.prompts.size ||
|
||||
this.activeTurnIds.size ||
|
||||
!this.capabilities.includes("session.can_stop_runtime")
|
||||
)
|
||||
return false;
|
||||
const event = await this.runtime.complete(
|
||||
{
|
||||
type: "session.can_stop_runtime",
|
||||
requestId: randomUUID(),
|
||||
sessionId: this.providerSessionId,
|
||||
},
|
||||
AbortSignal.timeout(10_000),
|
||||
);
|
||||
if (event.type !== "session.can_stop_runtime")
|
||||
throw new Error("Invalid runtime safety response");
|
||||
return event.canStop;
|
||||
}
|
||||
|
||||
async configure(changes: ProviderConfigChanges): Promise<void> {
|
||||
const input: Extract<ProviderInput, { type: "session.configure" }> = {
|
||||
type: "session.configure",
|
||||
@@ -675,6 +719,7 @@ class ProviderRuntimeSession {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.history.length = 0;
|
||||
if (this.restoration === "parent") {
|
||||
this.runtime.removeSession(this.id, this.providerSessionId);
|
||||
return;
|
||||
@@ -788,7 +833,7 @@ class ProviderRuntimeSession {
|
||||
if (event.type === "session.persistence" && this.restoration === "core") {
|
||||
this.persistence = event.persistence;
|
||||
}
|
||||
this.history.push(event);
|
||||
if (!this.historyClaimed) this.history.push(event);
|
||||
for (const listener of this.listeners) listener(event);
|
||||
}
|
||||
}
|
||||
@@ -1014,7 +1059,7 @@ class PluginAgentClient implements AgentClient {
|
||||
if (!persistence) throw new Error(`Plugin provider '${this.provider}' did not persist import`);
|
||||
return {
|
||||
session,
|
||||
config: { ...context.config, provider: this.provider, cwd: input.cwd },
|
||||
config: { ...context.storedConfig, provider: this.provider, cwd: input.cwd },
|
||||
persistence,
|
||||
timeline: session.timelineHistory(),
|
||||
providerSubagentEvents: session.subagentHistory(),
|
||||
@@ -1090,17 +1135,30 @@ class PluginAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
interface TimelineTextSnapshot {
|
||||
type: "assistant_message" | "reasoning" | "user_message";
|
||||
text: string;
|
||||
}
|
||||
|
||||
function isTextSnapshotItem(
|
||||
item: ProviderTimelineItem,
|
||||
): item is Extract<ProviderTimelineItem, { type: TimelineTextSnapshot["type"] }> {
|
||||
return (
|
||||
item.type === "assistant_message" || item.type === "reasoning" || item.type === "user_message"
|
||||
);
|
||||
}
|
||||
|
||||
class PluginAgentSession implements AgentSession {
|
||||
readonly initialTimeline: ImportedProviderSession["timeline"];
|
||||
private readonly listeners = new Set<(event: AgentStreamEvent) => void>();
|
||||
private readonly history: AgentStreamEvent[] = [];
|
||||
private readonly history = new PluginSessionHistory();
|
||||
private readonly pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||
private readonly permissionResponses = new Map<string, AgentPermissionResponse>();
|
||||
private readonly revertTokens = new Map<string, ProviderTimelineItem["revertToken"]>();
|
||||
private readonly timelineSnapshots = new Map<string, ProviderTimelineItem>();
|
||||
private readonly timelineSnapshots = new Map<string, TimelineTextSnapshot>();
|
||||
private readonly subagentIdsBySession = new Map<string, string | null>();
|
||||
private readonly childUnsubscribes = new Map<string, () => void>();
|
||||
private readonly childSnapshots = new Map<string, Map<string, ProviderTimelineItem>>();
|
||||
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private currentTurnId: string | null = null;
|
||||
private closed = false;
|
||||
@@ -1198,7 +1256,12 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
for (const event of this.history) yield event;
|
||||
for (const event of this.history.read(this.provider)) yield event;
|
||||
}
|
||||
|
||||
get canStopRuntime(): (() => Promise<boolean>) | undefined {
|
||||
if (!this.bridge.negotiatedCapabilities.includes("session.can_stop_runtime")) return undefined;
|
||||
return () => this.bridge.canStopRuntime();
|
||||
}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
@@ -1261,6 +1324,12 @@ class PluginAgentSession implements AgentSession {
|
||||
for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe();
|
||||
this.childUnsubscribes.clear();
|
||||
this.subagentIdsBySession.clear();
|
||||
this.history.clear();
|
||||
this.timelineSnapshots.clear();
|
||||
this.childSnapshots.clear();
|
||||
this.revertTokens.clear();
|
||||
this.pendingPermissions.clear();
|
||||
this.permissionResponses.clear();
|
||||
this.listeners.clear();
|
||||
this.onClose();
|
||||
await this.bridge.close();
|
||||
@@ -1301,16 +1370,20 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
timelineHistory(): ImportedProviderSession["timeline"] {
|
||||
return this.history.flatMap((event) =>
|
||||
event.type === "timeline" ? [{ item: event.item, timestamp: event.timestamp }] : [],
|
||||
);
|
||||
return this.history
|
||||
.read(this.provider)
|
||||
.flatMap((event) =>
|
||||
event.type === "timeline" ? [{ item: event.item, timestamp: event.timestamp }] : [],
|
||||
);
|
||||
}
|
||||
|
||||
subagentHistory(): NonNullable<ImportedProviderSession["providerSubagentEvents"]> {
|
||||
return this.history.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "provider_subagent" }> =>
|
||||
event.type === "provider_subagent",
|
||||
);
|
||||
return this.history
|
||||
.read(this.provider)
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "provider_subagent" }> =>
|
||||
event.type === "provider_subagent",
|
||||
);
|
||||
}
|
||||
|
||||
attachChild(
|
||||
@@ -1339,7 +1412,7 @@ class PluginAgentSession implements AgentSession {
|
||||
cwd: opened.cwd,
|
||||
},
|
||||
});
|
||||
const snapshots = new Map<string, ProviderTimelineItem>();
|
||||
const snapshots = new Map<string, TimelineTextSnapshot>();
|
||||
this.childSnapshots.set(childId, snapshots);
|
||||
for (const event of child.history) this.acceptChildEvent(childId, event, snapshots);
|
||||
this.childUnsubscribes.set(
|
||||
@@ -1525,7 +1598,7 @@ class PluginAgentSession implements AgentSession {
|
||||
private acceptChildEvent(
|
||||
childId: string,
|
||||
event: ProviderEvent,
|
||||
snapshots: Map<string, ProviderTimelineItem>,
|
||||
snapshots: Map<string, TimelineTextSnapshot>,
|
||||
): void {
|
||||
if (event.type === "timeline.item") {
|
||||
const item = mapTimelineItem(event.item, snapshots);
|
||||
@@ -1538,11 +1611,15 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.type === "session.turn" && event.state !== "started") {
|
||||
if (event.type === "session.turn") {
|
||||
this.publish({
|
||||
type: "provider_subagent",
|
||||
provider: this.provider,
|
||||
event: { type: "upsert", id: childId, status: event.state },
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: childId,
|
||||
status: event.state === "started" ? "running" : event.state,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1674,24 +1751,24 @@ function mapPromptContent(prompt: AgentPromptInput): ProviderContent[] {
|
||||
|
||||
function mapTimelineItem(
|
||||
item: ProviderTimelineItem,
|
||||
snapshots: Map<string, ProviderTimelineItem>,
|
||||
snapshots: Map<string, TimelineTextSnapshot>,
|
||||
revertTokens?: Map<string, ProviderTimelineItem["revertToken"]>,
|
||||
): AgentTimelineItem | null {
|
||||
const previous = snapshots.get(item.id);
|
||||
snapshots.set(item.id, item);
|
||||
const { id, revertToken, ...withoutIdentity } = item;
|
||||
if (revertToken !== undefined && revertTokens) {
|
||||
revertTokens.set(id, revertToken);
|
||||
if (item.type === "user_message" || item.type === "assistant_message") {
|
||||
revertTokens.set(item.messageId ?? id, revertToken);
|
||||
}
|
||||
if (isTextSnapshotItem(item)) {
|
||||
snapshots.set(item.id, { type: item.type, text: item.text });
|
||||
}
|
||||
const { id, revertToken: _revertToken, ...withoutIdentity } = item;
|
||||
rememberRevertToken(item, revertTokens);
|
||||
|
||||
if (item.type === "assistant_message" || item.type === "reasoning") {
|
||||
const previousText = previous?.type === item.type ? previous.text : "";
|
||||
const text = item.text.startsWith(previousText)
|
||||
const suffix = item.text.startsWith(previousText)
|
||||
? item.text.slice(previousText.length)
|
||||
: item.text;
|
||||
// Match OpenCode's detachedSuffix: a sliced delta otherwise keeps its entire
|
||||
// cumulative IPC snapshot alive in the projected timeline. Preserve split surrogates.
|
||||
const text = Buffer.from(suffix, "utf16le").toString("utf16le");
|
||||
if (text.length === 0) return null;
|
||||
return item.type === "assistant_message"
|
||||
? { type: "assistant_message", text, messageId: item.messageId ?? id }
|
||||
@@ -1719,6 +1796,17 @@ function mapTimelineItem(
|
||||
return withoutIdentity as AgentTimelineItem;
|
||||
}
|
||||
|
||||
function rememberRevertToken(
|
||||
item: ProviderTimelineItem,
|
||||
tokens?: Map<string, ProviderTimelineItem["revertToken"]>,
|
||||
): void {
|
||||
if (item.revertToken === undefined || !tokens) return;
|
||||
tokens.set(item.id, item.revertToken);
|
||||
if (item.type === "user_message" || item.type === "assistant_message") {
|
||||
tokens.set(item.messageId ?? item.id, item.revertToken);
|
||||
}
|
||||
}
|
||||
|
||||
function persistenceHandle(
|
||||
provider: string,
|
||||
persistence: ProviderPersistence,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { TimelineProjection } from "./timeline-projection.js";
|
||||
import type { AgentStreamEvent } from "./agent-sdk-types.js";
|
||||
|
||||
// Replay canonical items, rather than retaining every streamed delta or tool state.
|
||||
// Sequence numbers here only preserve adapter replay order, not wire cursors.
|
||||
export class PluginSessionHistory {
|
||||
private readonly timelines = new Map<
|
||||
string,
|
||||
{ projection: TimelineProjection; seq: number; positions: Map<number, number> }
|
||||
>();
|
||||
private readonly events: Array<{ seq: number; event: AgentStreamEvent }> = [];
|
||||
private seq = 0;
|
||||
|
||||
push(event: AgentStreamEvent): void {
|
||||
const seq = ++this.seq;
|
||||
const child =
|
||||
event.type === "provider_subagent" && event.event.type === "timeline" ? event.event : null;
|
||||
if (event.type !== "timeline" && !child) {
|
||||
this.events.push({ seq, event });
|
||||
return;
|
||||
}
|
||||
const key = child?.id ?? "";
|
||||
let projection = this.timelines.get(key);
|
||||
if (!projection) {
|
||||
projection = { projection: new TimelineProjection(), seq: 0, positions: new Map() };
|
||||
this.timelines.set(key, projection);
|
||||
}
|
||||
const timeline = event.type === "timeline" ? event : child!;
|
||||
const row = projection.projection.append({
|
||||
seq: ++projection.seq,
|
||||
item: timeline.item,
|
||||
timestamp: timeline.timestamp ?? "",
|
||||
...(event.type === "timeline" && event.turnId ? { turnId: event.turnId } : {}),
|
||||
});
|
||||
if (!projection.positions.has(row.seqStart)) projection.positions.set(row.seqStart, seq);
|
||||
}
|
||||
|
||||
read(provider: string): AgentStreamEvent[] {
|
||||
const events = [...this.events];
|
||||
for (const [id, projection] of this.timelines) {
|
||||
for (const row of projection.projection.getRows()) {
|
||||
const timestamp = row.timestamp ? { timestamp: row.timestamp } : {};
|
||||
const event: AgentStreamEvent = id
|
||||
? {
|
||||
type: "provider_subagent",
|
||||
provider,
|
||||
event: { type: "timeline", id, item: row.item, ...timestamp },
|
||||
}
|
||||
: {
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: row.item,
|
||||
...timestamp,
|
||||
...(row.turnId ? { turnId: row.turnId } : {}),
|
||||
};
|
||||
events.push({ seq: projection.positions.get(row.seqStart)!, event });
|
||||
}
|
||||
}
|
||||
return events.sort((a, b) => a.seq - b.seq).map(({ event }) => event);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.timelines.clear();
|
||||
this.events.length = 0;
|
||||
}
|
||||
}
|
||||
@@ -69,13 +69,13 @@ describe("prompt attachments", () => {
|
||||
).toContain("GitLab MR !123: Fix race in worktree setup");
|
||||
});
|
||||
|
||||
it("renders review attachments with compact file, line, comment, and context details", () => {
|
||||
it.each(["base", "uncommitted", "all"] as const)("renders %s reviews", (mode) => {
|
||||
expect(
|
||||
renderPromptAttachmentAsText({
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/tmp/repo",
|
||||
mode: "base",
|
||||
mode,
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
@@ -111,7 +111,7 @@ describe("prompt attachments", () => {
|
||||
}),
|
||||
).toBe(
|
||||
[
|
||||
"Paseo review attachment (base)",
|
||||
`Paseo review attachment (${mode})`,
|
||||
"CWD: /tmp/repo",
|
||||
"Base: main",
|
||||
"",
|
||||
|
||||
@@ -469,6 +469,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
||||
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
|
||||
interrupt: () => inner.interrupt(),
|
||||
close: () => inner.close(),
|
||||
canStopRuntime: inner.canStopRuntime?.bind(inner),
|
||||
listCommands: inner.listCommands?.bind(inner),
|
||||
setModel: inner.setModel?.bind(inner),
|
||||
setThinkingOption: inner.setThinkingOption?.bind(inner),
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
|
||||
export interface AgentProviderRuntime {
|
||||
snapshotManager: ProviderSnapshotManager;
|
||||
setPaseoToolCatalog(catalog: PaseoToolCatalog | null): void;
|
||||
setPaseoToolCatalog(catalog: Pick<PaseoToolCatalog, "tools"> | null): void;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
resolveACPModelSelection,
|
||||
summarizeACPRequestError,
|
||||
} from "./acp-agent.js";
|
||||
import { createIdleRuntimeCleanup } from "../idle-runtime-cleanup/index.js";
|
||||
import type { ProcessTerminator, TreeKillTarget } from "../../../utils/tree-kill.js";
|
||||
import {
|
||||
COPILOT_AGENT_FEATURE_OPTION,
|
||||
@@ -810,6 +811,46 @@ describe("ACPAgentSession terminal tools", () => {
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("trims large terminal output without rescanning the retained suffix", async () => {
|
||||
const session = createSession();
|
||||
const terminal = await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command: process.execPath,
|
||||
args: ["-e", 'process.stdout.write("x".repeat(1024 * 1024))'],
|
||||
cwd: process.cwd(),
|
||||
outputByteLimit: 1024,
|
||||
});
|
||||
await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId });
|
||||
|
||||
await expect(
|
||||
session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }),
|
||||
).resolves.toEqual({
|
||||
output: "x".repeat(1024),
|
||||
truncated: true,
|
||||
exitStatus: { exitCode: 0, signal: null },
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
test("trims terminal output at a UTF-8 character boundary", async () => {
|
||||
const session = createSession();
|
||||
const terminal = await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command: process.execPath,
|
||||
args: ["-e", 'process.stdout.write("😀ab")'],
|
||||
cwd: process.cwd(),
|
||||
outputByteLimit: 3,
|
||||
});
|
||||
await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId });
|
||||
|
||||
await expect(
|
||||
session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }),
|
||||
).resolves.toEqual({
|
||||
output: "ab",
|
||||
truncated: true,
|
||||
exitStatus: { exitCode: 0, signal: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapACPUsage", () => {
|
||||
@@ -4424,6 +4465,161 @@ new AgentSideConnection(
|
||||
);
|
||||
`;
|
||||
|
||||
describe("ACP idle runtime cleanup", () => {
|
||||
test("pending permissions veto cleanup until answered", async () => {
|
||||
const session = createSession();
|
||||
try {
|
||||
expect(await session.canStopRuntime()).toBe(true);
|
||||
const permission = session.requestPermission({
|
||||
sessionId: "session-1",
|
||||
toolCall: { toolCallId: "tool-1", title: "Run command", status: "pending" },
|
||||
options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
|
||||
});
|
||||
expect(await session.canStopRuntime()).toBe(false);
|
||||
await session.respondToPermission(session.getPendingPermissions()[0]!.id, {
|
||||
behavior: "allow",
|
||||
});
|
||||
await permission;
|
||||
expect(await session.canStopRuntime()).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed terminal spawn does not pin the runtime or discard its diagnostic", async () => {
|
||||
const session = createSession();
|
||||
try {
|
||||
const terminal = await session.createTerminal({
|
||||
sessionId: "session-1",
|
||||
command: path.join(tmpdir(), "paseo-no-such-command", "missing"),
|
||||
});
|
||||
await expect(
|
||||
session.waitForTerminalExit({ sessionId: "session-1", ...terminal }),
|
||||
).rejects.toThrow("ENOENT");
|
||||
expect(await session.canStopRuntime()).toBe(true);
|
||||
expect(
|
||||
(await session.terminalOutput({ sessionId: "session-1", ...terminal })).output,
|
||||
).toContain("ENOENT");
|
||||
await session.releaseTerminal({ sessionId: "session-1", ...terminal });
|
||||
expect(await session.canStopRuntime()).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("retains a quiet terminal after a finished turn, then waits a fresh idle hour after exit", async () => {
|
||||
const dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-idle-"));
|
||||
const agentScript = path.join(dir, "agent.mjs");
|
||||
await writeFile(agentScript, PERMISSIVE_MODEL_ACP_AGENT);
|
||||
const session = new ACPAgentSession(
|
||||
{ provider: "test-acp", cwd: dir },
|
||||
{
|
||||
provider: "test-acp",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: [process.execPath, agentScript],
|
||||
defaultModes: [],
|
||||
capabilities: { supportsStreaming: true, supportsSessionPersistence: true },
|
||||
launchEnv: {
|
||||
ACP_SDK_URL: pathToFileURL(
|
||||
createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"),
|
||||
).href,
|
||||
},
|
||||
},
|
||||
);
|
||||
const hour = 60 * 60_000;
|
||||
let now = 0;
|
||||
let closed = false;
|
||||
const candidate = {
|
||||
identity: session,
|
||||
agentId: "test-agent",
|
||||
parentAgentId: undefined,
|
||||
provider: session.provider,
|
||||
sessionId: "permissive-session",
|
||||
lastActivityAt: 0,
|
||||
isEligible: () => !closed,
|
||||
canStopRuntime: (session as AgentSession).canStopRuntime?.bind(session),
|
||||
async closeIfEligible(revalidate: () => boolean) {
|
||||
if (!revalidate()) return false;
|
||||
await session.close();
|
||||
closed = true;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled: true,
|
||||
now: () => now,
|
||||
runtimes: () => (closed ? [] : [candidate]),
|
||||
schedule: () => () => {},
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
try {
|
||||
await session.initializeNewSession();
|
||||
const completed = new Promise<void>((resolve) =>
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "turn_completed") resolve();
|
||||
}),
|
||||
);
|
||||
let activeTurnSafety: Promise<boolean> | undefined;
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "turn_started") activeTurnSafety = session.canStopRuntime();
|
||||
});
|
||||
await session.startTurn("Finish this foreground turn");
|
||||
await expect(activeTurnSafety).resolves.toBe(false);
|
||||
await completed;
|
||||
expect(await session.canStopRuntime()).toBe(true);
|
||||
await session.sessionUpdate({
|
||||
sessionId: candidate.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "buffered" },
|
||||
},
|
||||
});
|
||||
expect(await session.canStopRuntime()).toBe(false);
|
||||
await session.sessionUpdate({
|
||||
sessionId: candidate.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "flush" },
|
||||
},
|
||||
});
|
||||
expect(await session.canStopRuntime()).toBe(true);
|
||||
const terminal = await session.createTerminal({
|
||||
sessionId: candidate.sessionId,
|
||||
command: process.execPath,
|
||||
args: ["-e", "setInterval(() => {}, 1000)"],
|
||||
});
|
||||
now = 2 * hour;
|
||||
expect((await cleanup.sweep()).collected).toEqual([]);
|
||||
expect(closed).toBe(false);
|
||||
expect(await session.terminalOutput({ sessionId: candidate.sessionId, ...terminal })).toEqual(
|
||||
{
|
||||
output: "",
|
||||
truncated: false,
|
||||
exitStatus: undefined,
|
||||
},
|
||||
);
|
||||
await session.killTerminal({ sessionId: candidate.sessionId, ...terminal });
|
||||
await session.waitForTerminalExit({ sessionId: candidate.sessionId, ...terminal });
|
||||
expect((await cleanup.sweep()).collected).toEqual([]);
|
||||
now += hour - 1;
|
||||
expect((await cleanup.sweep()).collected).toEqual([]);
|
||||
now += 1;
|
||||
expect((await cleanup.sweep()).collected).toEqual([
|
||||
{
|
||||
agentId: candidate.agentId,
|
||||
provider: candidate.provider,
|
||||
sessionId: candidate.sessionId,
|
||||
},
|
||||
]);
|
||||
expect(closed).toBe(true);
|
||||
} finally {
|
||||
await cleanup.close();
|
||||
await session.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom ACP provider with configured models", () => {
|
||||
let dir: string;
|
||||
let agentScript: string;
|
||||
|
||||
@@ -649,6 +649,7 @@ interface TerminalExit {
|
||||
}
|
||||
|
||||
interface TerminalEntry {
|
||||
running: boolean;
|
||||
id: string;
|
||||
child: ChildProcess;
|
||||
output: string;
|
||||
@@ -2554,6 +2555,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
return (
|
||||
this.activeForegroundTurnId === null &&
|
||||
this.pendingPermissions.size === 0 &&
|
||||
this.pendingUserMessage === null &&
|
||||
!Array.from(this.terminalEntries.values()).some((terminal) => terminal.running)
|
||||
);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
@@ -2589,12 +2599,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
}
|
||||
|
||||
const terminalTerminations = Array.from(this.terminalEntries.values(), (terminal) =>
|
||||
this.terminateProcess(terminal.child, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
}),
|
||||
);
|
||||
const terminalTerminations = Array.from(this.terminalEntries.values())
|
||||
.filter((terminal) => terminal.running)
|
||||
.map((terminal) =>
|
||||
this.terminateProcess(terminal.child, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
}),
|
||||
);
|
||||
await Promise.all(terminalTerminations);
|
||||
this.terminalEntries.clear();
|
||||
|
||||
@@ -2796,6 +2808,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
waitForExit.catch(() => undefined);
|
||||
|
||||
const entry: TerminalEntry = {
|
||||
running: true,
|
||||
id: terminalId,
|
||||
child,
|
||||
output: "",
|
||||
@@ -2816,10 +2829,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
child.once("error", (error) => {
|
||||
const spawnError = error instanceof Error ? error : new Error(String(error));
|
||||
appendTerminalOutput(entry, `${spawnError.message}\n`);
|
||||
// A failed spawn has no exit event. Other process errors do not prove exit.
|
||||
if (child.pid === undefined) entry.running = false;
|
||||
rejectExit(spawnError);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
const exit = { exitCode: code, signal };
|
||||
entry.running = false;
|
||||
entry.exit = exit;
|
||||
resolveExit(exit);
|
||||
});
|
||||
@@ -2844,7 +2860,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
|
||||
async releaseTerminal(params: { sessionId: string; terminalId: string }): Promise<void> {
|
||||
const entry = this.getTerminalEntry(params.terminalId);
|
||||
if (!entry.exit) {
|
||||
if (entry.running) {
|
||||
await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 });
|
||||
}
|
||||
this.terminalEntries.delete(params.terminalId);
|
||||
@@ -2852,7 +2868,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
|
||||
async killTerminal(params: KillTerminalRequest): Promise<Record<string, never>> {
|
||||
const entry = this.getTerminalEntry(params.terminalId);
|
||||
if (!entry.exit) {
|
||||
if (entry.running) {
|
||||
await this.terminateProcess(entry.child, { gracefulTimeoutMs: 2_000, forceTimeoutMs: 2_000 });
|
||||
}
|
||||
return {};
|
||||
@@ -4077,10 +4093,16 @@ function appendTerminalOutput(entry: TerminalEntry, chunk: string): void {
|
||||
if (!limit) {
|
||||
return;
|
||||
}
|
||||
while (Buffer.byteLength(entry.output, "utf8") > limit && entry.output.length > 0) {
|
||||
entry.output = entry.output.slice(1);
|
||||
entry.truncated = true;
|
||||
const output = Buffer.from(entry.output, "utf8");
|
||||
if (output.length <= limit) {
|
||||
return;
|
||||
}
|
||||
let start = output.length - limit;
|
||||
while (start < output.length && (output[start] & 0xc0) === 0x80) {
|
||||
start += 1;
|
||||
}
|
||||
entry.output = output.subarray(start).toString("utf8");
|
||||
entry.truncated = true;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { CanUseTool, PermissionMode, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { AgentPermissionRequest } from "../../agent-sdk-types.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
// The injected SDK adapter supplies requests through Claude's permission callback.
|
||||
async function createPlanApproval(mode: PermissionMode) {
|
||||
let requestPermission: CanUseTool | undefined;
|
||||
let sdkMode: PermissionMode = mode;
|
||||
const query = Object.assign((async function* () {})(), {
|
||||
async setPermissionMode(next: PermissionMode) {
|
||||
sdkMode = next;
|
||||
},
|
||||
close() {},
|
||||
});
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveBinary: async () => "/test/claude",
|
||||
queryFactory: ({ options }) => {
|
||||
requestPermission = options.canUseTool;
|
||||
return query as Query;
|
||||
},
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
modeId: mode,
|
||||
});
|
||||
let request: AgentPermissionRequest | undefined;
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "permission_requested") request = event.request;
|
||||
});
|
||||
await session.setMode("plan");
|
||||
if (!requestPermission) throw new Error("SDK permission callback was not installed");
|
||||
const resolution = requestPermission(
|
||||
"ExitPlanMode",
|
||||
{ plan: "Implement the plan" },
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
toolUseID: "plan-approval",
|
||||
},
|
||||
);
|
||||
if (!request) throw new Error("No plan approval request was emitted");
|
||||
return { session, request, resolution, sdkMode: () => sdkMode };
|
||||
}
|
||||
|
||||
test.each([
|
||||
["auto", "Implement with Auto mode"],
|
||||
["bypassPermissions", "Implement with Bypass"],
|
||||
] as const)("plan approval offers an explicit return to %s", async (mode, label) => {
|
||||
const plan = await createPlanApproval(mode);
|
||||
try {
|
||||
expect(plan.request.actions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "implement_resume",
|
||||
label,
|
||||
behavior: "allow",
|
||||
intent: "implement_resume",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await plan.session.respondToPermission(plan.request.id, { behavior: "deny" });
|
||||
await plan.resolution;
|
||||
await plan.session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
["auto", "implement_resume", "auto"],
|
||||
["bypassPermissions", "implement_resume", "bypassPermissions"],
|
||||
["auto", "implement", "acceptEdits"],
|
||||
["bypassPermissions", "implement", "acceptEdits"],
|
||||
["default", "implement_resume", "acceptEdits"],
|
||||
["acceptEdits", "implement_resume", "acceptEdits"],
|
||||
] as const)("approving from %s with %s enters %s", async (mode, action, expected) => {
|
||||
const plan = await createPlanApproval(mode);
|
||||
try {
|
||||
await plan.session.respondToPermission(plan.request.id, {
|
||||
behavior: "allow",
|
||||
selectedActionId: action,
|
||||
});
|
||||
await expect(plan.resolution).resolves.toMatchObject({ behavior: "allow" });
|
||||
expect(plan.sdkMode()).toBe(expected);
|
||||
expect(await plan.session.getCurrentMode()).toBe(expected);
|
||||
if (mode === "default" || mode === "acceptEdits") {
|
||||
expect(plan.request.actions?.some((entry) => entry.id === "implement_resume")).toBe(false);
|
||||
}
|
||||
} finally {
|
||||
await plan.session.close();
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
||||
import type { Query, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -706,3 +708,137 @@ describe("background Claude subagents", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Claude runtime safety protects silent shells, monitors and pending completion notifications", () => {
|
||||
const safety = new ClaudeRuntimeSafety();
|
||||
const observe = (message: unknown) => safety.observe(message as SDKMessage);
|
||||
observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" });
|
||||
expect(safety.canStopRuntime()).toBe(true);
|
||||
observe({
|
||||
type: "system",
|
||||
subtype: "background_tasks_changed",
|
||||
tasks: [
|
||||
{ task_id: "quiet-shell", task_type: "local_bash", description: "sleep 7200" },
|
||||
{ task_id: "monitor", task_type: "local_bash", description: "watch build" },
|
||||
{ task_id: "native-child", task_type: "local_agent", description: "research" },
|
||||
],
|
||||
});
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
for (const task_id of ["quiet-shell", "monitor"])
|
||||
observe({ type: "system", subtype: "task_notification", task_id });
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
observe({ type: "system", subtype: "task_notification", task_id: "native-child" });
|
||||
expect(safety.canStopRuntime()).toBe(true);
|
||||
safety.reset();
|
||||
observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" });
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
|
||||
expect(safety.canStopRuntime()).toBe(true);
|
||||
});
|
||||
|
||||
test("Claude session runtime safety observes pumped background work and autonomous replies", async () => {
|
||||
let wake: (() => void) | undefined;
|
||||
const channel = { closed: false };
|
||||
const queue: Array<{ message: SDKMessage; consumed(): void }> = [];
|
||||
const pump = (async function* () {
|
||||
while (!channel.closed) {
|
||||
const entry = queue.shift();
|
||||
if (!entry) {
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
yield entry.message;
|
||||
entry.consumed();
|
||||
}
|
||||
})();
|
||||
const push = (message: unknown) =>
|
||||
new Promise<void>((consumed) => {
|
||||
queue.push({ message: message as SDKMessage, consumed });
|
||||
wake?.();
|
||||
});
|
||||
const end = () => {
|
||||
channel.closed = true;
|
||||
wake?.();
|
||||
};
|
||||
let nativeInput!: AsyncIterator<SDKUserMessage>;
|
||||
let connected!: () => void;
|
||||
const connection = new Promise<void>((resolve) => {
|
||||
connected = resolve;
|
||||
});
|
||||
// The SDK Query is injected through the production adapter's existing launch port.
|
||||
const query = {
|
||||
next: () => pump.next(),
|
||||
return: async () => {
|
||||
end();
|
||||
return pump.return(undefined);
|
||||
},
|
||||
close: end,
|
||||
interrupt: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
setModel: async () => {},
|
||||
supportedModels: async () => [],
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Query;
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
queryFactory: ({ prompt }) => {
|
||||
if (typeof prompt === "string") throw new Error("Expected streaming input");
|
||||
nativeInput = prompt[Symbol.asyncIterator]();
|
||||
connected();
|
||||
return query;
|
||||
},
|
||||
}).createSession({ provider: "claude", cwd: process.cwd() });
|
||||
const init = {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "runtime-safety",
|
||||
claude_code_version: "2.1.292",
|
||||
permissionMode: "default",
|
||||
};
|
||||
const result = {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
total_cost_usd: 0,
|
||||
};
|
||||
try {
|
||||
await session.startTurn("start background work");
|
||||
await connection;
|
||||
const input = await nativeInput.next();
|
||||
await push({ type: "command_lifecycle", command_uuid: input.value.uuid, state: "started" });
|
||||
await push(init);
|
||||
await push(result);
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
await push({
|
||||
type: "system",
|
||||
subtype: "background_tasks_changed",
|
||||
tasks: [{ task_id: "monitor", task_type: "local_bash", description: "quiet monitor" }],
|
||||
});
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await push({ type: "system", subtype: "background_tasks_changed", tasks: [] });
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await push({
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: "monitor",
|
||||
status: "completed",
|
||||
output: "ready",
|
||||
summary: "ready",
|
||||
});
|
||||
// Completion can wake Claude autonomously after the foreground turn has ended.
|
||||
await push(init);
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await push(result);
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { once } from "node:events";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { AgentLaunchContext } from "../../agent-sdk-types.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import type { ClaudeQueryInput } from "./query.js";
|
||||
import { claudeQuery, type ClaudeQueryInput } from "./query.js";
|
||||
|
||||
function createQueryMock(events: unknown[]): Query {
|
||||
let index = 0;
|
||||
@@ -29,6 +30,55 @@ function createQueryMock(events: unknown[]): Query {
|
||||
}
|
||||
|
||||
describe("Claude SDK env", () => {
|
||||
test.each(
|
||||
["node", "bun", process.execPath].flatMap((command) =>
|
||||
[undefined, "Paseo", "custom-client"].map((entrypoint) => ({ command, entrypoint })),
|
||||
),
|
||||
)(
|
||||
"preserves explicit launch identity $entrypoint in $command while clearing inherited session env",
|
||||
async ({ command, entrypoint }) => {
|
||||
let output = "";
|
||||
let exited = Promise.resolve<unknown[]>([]);
|
||||
const query = claudeQuery(
|
||||
{ prompt: "", options: {} },
|
||||
{
|
||||
runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } },
|
||||
launchEnv: entrypoint === undefined ? {} : { CLAUDE_CODE_ENTRYPOINT: entrypoint },
|
||||
queryFactory: ({ options }) => {
|
||||
if (!options.spawnClaudeCodeProcess) {
|
||||
throw new Error("Missing Claude process launcher");
|
||||
}
|
||||
const child = options.spawnClaudeCodeProcess({
|
||||
command,
|
||||
args: [
|
||||
"-e",
|
||||
"process.stdout.write(JSON.stringify({ entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT, parentSession: process.env.CLAUDECODE ?? null }))",
|
||||
],
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: "sdk-ts", CLAUDECODE: "1" },
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
exited = once(child, "close");
|
||||
return createQueryMock([]);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
expect(await exited).toEqual([0, null]);
|
||||
expect(JSON.parse(output)).toEqual({
|
||||
...(entrypoint === undefined ? {} : { entrypoint }),
|
||||
parentSession: null,
|
||||
});
|
||||
} finally {
|
||||
query.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("forwards launch-context env through Claude process env", async () => {
|
||||
let capturedEnv: Record<string, string | undefined> | undefined;
|
||||
let capturedPerTaskStopAffordance: boolean | undefined;
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { measureMainThreadWork } from "../../../../test-utils/main-thread-work.js";
|
||||
import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { open } from "node:fs/promises";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import pino from "pino";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js";
|
||||
import type { ClaudeRewindSdk } from "./rewind.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { withClaudeReplayHistory } from "./transcript-history.js";
|
||||
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import type { ClaudeQueryFactory } from "./query.js";
|
||||
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
|
||||
|
||||
/**
|
||||
* A resumed Claude agent whose transcript is missing or unreadable opens with an empty timeline.
|
||||
@@ -28,19 +37,47 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
let configDir: string;
|
||||
let records: LogRecord[];
|
||||
|
||||
async function resume(): Promise<void> {
|
||||
async function resume(
|
||||
queryFactory?: ClaudeQueryFactory,
|
||||
rewindSdk?: ClaudeRewindSdk,
|
||||
): Promise<AgentSession> {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
|
||||
queryFactory: vi.fn(() => {
|
||||
throw new Error("history load must not start a query");
|
||||
}),
|
||||
queryFactory:
|
||||
queryFactory ??
|
||||
(() => {
|
||||
throw new Error("history load must not start a query");
|
||||
}),
|
||||
resolveBinary: async () => process.execPath,
|
||||
resolveVersion: async () => "2.1.220",
|
||||
rewindSdk,
|
||||
});
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "claude", sessionId: SESSION_ID },
|
||||
{ cwd },
|
||||
);
|
||||
await session.close();
|
||||
return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd });
|
||||
}
|
||||
|
||||
function replyFactory(text: string, messageId = "live-reply"): ClaudeQueryFactory {
|
||||
return (() => {
|
||||
const messages = [
|
||||
{ type: "assistant", uuid: messageId, message: { content: text } },
|
||||
{ type: "result", subtype: "success", total_cost_usd: 0 },
|
||||
];
|
||||
return {
|
||||
async next() {
|
||||
const value = messages.shift();
|
||||
return value ? { done: false, value } : { done: true, value: undefined };
|
||||
},
|
||||
close() {},
|
||||
async return() {
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
async supportedCommands() {
|
||||
return [];
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}) as ClaudeQueryFactory;
|
||||
}
|
||||
|
||||
function transcriptPath(): string {
|
||||
@@ -57,12 +94,498 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("serves unrelated health requests while resuming 32 MiB of ordered history", async () => {
|
||||
const startedAt = performance.now();
|
||||
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
|
||||
const file = await open(transcriptPath(), "w");
|
||||
const text = "x".repeat(16 * 1024);
|
||||
let bytes = 0;
|
||||
let count = 0;
|
||||
try {
|
||||
while (bytes < 32 * 1024 * 1024) {
|
||||
const line =
|
||||
JSON.stringify({ type: "assistant", message: { content: `${count}:${text}` } }) + "\n";
|
||||
bytes += Buffer.byteLength(line);
|
||||
await file.write(line);
|
||||
count++;
|
||||
}
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
let loading = true;
|
||||
let answeredDuringLoad = false;
|
||||
const health = fetch(`http://127.0.0.1:${daemon.port}/api/health`).then(async (response) => {
|
||||
expect(response.status).toBe(200);
|
||||
await response.text();
|
||||
answeredDuringLoad = loading;
|
||||
return undefined;
|
||||
});
|
||||
let session: AgentSession | undefined;
|
||||
try {
|
||||
const {
|
||||
result: { resumed: loadedSession, historyCount },
|
||||
blockingMs,
|
||||
} = await measureMainThreadWork("Claude history main-thread work", async () => {
|
||||
const resumed = await resume(replyFactory("live after large resume"));
|
||||
session = resumed;
|
||||
let index = 0;
|
||||
for await (const event of resumed.streamHistory()) {
|
||||
expect(event).toEqual({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
timestamp: undefined,
|
||||
item: { type: "assistant_message", text: `${index}:${text}` },
|
||||
});
|
||||
index++;
|
||||
}
|
||||
loading = false;
|
||||
await health;
|
||||
return { resumed, historyCount: index };
|
||||
});
|
||||
console.info(
|
||||
JSON.stringify({
|
||||
bytes,
|
||||
count,
|
||||
blockingMs,
|
||||
answeredDuringLoad,
|
||||
elapsedMs: performance.now() - startedAt,
|
||||
}),
|
||||
);
|
||||
expect(historyCount).toBe(count);
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
expect(answeredDuringLoad).toBe(true);
|
||||
const live = await collectSessionTurnEvents(loadedSession, "follow up");
|
||||
expect(
|
||||
live
|
||||
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
|
||||
.map((event) => (event.type === "timeline" ? event.item : null)),
|
||||
).toEqual([
|
||||
{ type: "assistant_message", messageId: "live-reply", text: "live after large resume" },
|
||||
]);
|
||||
} finally {
|
||||
await session?.close();
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("rebinds ordered history before returning from rewind and accepts a live turn", async () => {
|
||||
const entries = [
|
||||
{ type: "user", uuid: "u1", message: { content: "first" } },
|
||||
{ type: "assistant", uuid: "a1", message: { content: "first reply" } },
|
||||
{ type: "user", uuid: "u2", message: { content: "second" } },
|
||||
{ type: "assistant", uuid: "a2", message: { content: "second reply" } },
|
||||
];
|
||||
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
|
||||
writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n"));
|
||||
const forkId = "rebound-history";
|
||||
const forkFile = path.join(path.dirname(transcriptPath()), `${forkId}.jsonl`);
|
||||
writeFileSync(
|
||||
forkFile,
|
||||
entries
|
||||
.slice(0, 2)
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n"),
|
||||
);
|
||||
const forks: unknown[] = [];
|
||||
const late = {
|
||||
type: "assistant",
|
||||
uuid: "retired-reply",
|
||||
message: { content: "retired query" },
|
||||
};
|
||||
let pendingRead: ((value: unknown) => void) | undefined;
|
||||
let releaseRetirement!: () => void;
|
||||
const retirementGate = new Promise<void>((resolve) => {
|
||||
releaseRetirement = resolve;
|
||||
});
|
||||
let retirementStarted!: () => void;
|
||||
const retiring = new Promise<void>((resolve) => {
|
||||
retirementStarted = resolve;
|
||||
});
|
||||
let firstQuery = true;
|
||||
let retired = false;
|
||||
const queryFactory = (() => {
|
||||
if (!firstQuery) return replyFactory("live after rebind")({} as never);
|
||||
firstQuery = false;
|
||||
const messages = [
|
||||
{ type: "assistant", uuid: "before-rebind", message: { content: "before rebind" } },
|
||||
{ type: "result", subtype: "success", total_cost_usd: 0 },
|
||||
];
|
||||
return {
|
||||
async next() {
|
||||
const value = messages.shift();
|
||||
if (value) return { done: false, value };
|
||||
if (retired) return { done: true, value: undefined };
|
||||
return new Promise((resolve) => {
|
||||
pendingRead = resolve;
|
||||
});
|
||||
},
|
||||
close() {
|
||||
retired = true;
|
||||
pendingRead?.({ done: false, value: late });
|
||||
},
|
||||
async return() {
|
||||
retirementStarted();
|
||||
await retirementGate;
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
async supportedCommands() {
|
||||
return [];
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}) as ClaudeQueryFactory;
|
||||
const session = await resume(queryFactory, {
|
||||
async forkSession(sessionId, options) {
|
||||
forks.push({ sessionId, ...options });
|
||||
return { sessionId: forkId };
|
||||
},
|
||||
});
|
||||
try {
|
||||
const original = await Array.fromAsync(session.streamHistory());
|
||||
expect(original).toHaveLength(4);
|
||||
await collectSessionTurnEvents(session, "before rewind");
|
||||
const emitted: AgentStreamEvent[] = [];
|
||||
const subscription = session.subscribe((event) => emitted.push(event));
|
||||
let rewindDone = false;
|
||||
const rewind = (async () => {
|
||||
await session.revertConversation!({ messageId: "u2" });
|
||||
rewindDone = true;
|
||||
})();
|
||||
await retiring;
|
||||
let historyDone = false;
|
||||
const reading = (async () => {
|
||||
const history = await Array.fromAsync(session.streamHistory());
|
||||
historyDone = true;
|
||||
return history;
|
||||
})();
|
||||
const readingAgain = Array.fromAsync(session.streamHistory());
|
||||
await Promise.resolve();
|
||||
expect(rewindDone).toBe(false);
|
||||
expect(historyDone).toBe(false);
|
||||
releaseRetirement();
|
||||
await rewind;
|
||||
subscription();
|
||||
expect(retired).toBe(true);
|
||||
expect(
|
||||
emitted.filter(
|
||||
(event) => event.type === "timeline" && event.item.type === "assistant_message",
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: `Claude switched to a new session: ${SESSION_ID} -> ${forkId}`,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(forks).toEqual([
|
||||
{ sessionId: SESSION_ID, upToMessageId: "a1", transcriptPath: transcriptPath() },
|
||||
]);
|
||||
expect(session.id).toBe(forkId);
|
||||
const [history, secondRead] = await Promise.all([reading, readingAgain]);
|
||||
expect(history).toEqual(original.slice(0, 2));
|
||||
expect(secondRead).toEqual([]);
|
||||
const live = await collectSessionTurnEvents(session, "continue the fork");
|
||||
expect(
|
||||
live
|
||||
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
|
||||
.map((event) => (event.type === "timeline" ? event.item : null)),
|
||||
).toEqual([
|
||||
{ type: "assistant_message", messageId: "live-reply", text: "live after rebind" },
|
||||
]);
|
||||
} finally {
|
||||
releaseRetirement();
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("reports a failed rewind without poisoning readiness when query retirement throws", async () => {
|
||||
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
|
||||
writeFileSync(
|
||||
transcriptPath(),
|
||||
[
|
||||
{ type: "user", uuid: "u1", message: { content: "first" } },
|
||||
{ type: "assistant", uuid: "a1", message: { content: "reply" } },
|
||||
{ type: "user", uuid: "u2", message: { content: "second" } },
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n"),
|
||||
);
|
||||
let firstQuery = true;
|
||||
let finishRead: ((value: unknown) => void) | undefined;
|
||||
const queryFactory = ((input) => {
|
||||
const query = replyFactory(
|
||||
firstQuery ? "before retirement" : "still available",
|
||||
firstQuery ? "before-retirement" : "live-reply",
|
||||
)(input);
|
||||
if (!firstQuery) return query;
|
||||
firstQuery = false;
|
||||
let reads = 0;
|
||||
return {
|
||||
...query,
|
||||
async next() {
|
||||
if (++reads <= 2) return query.next();
|
||||
return new Promise((resolve) => {
|
||||
finishRead = resolve;
|
||||
});
|
||||
},
|
||||
close() {
|
||||
finishRead?.({ done: true, value: undefined });
|
||||
throw new Error("retirement failed");
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}) as ClaudeQueryFactory;
|
||||
const session = await resume(queryFactory, {
|
||||
async forkSession() {
|
||||
return { sessionId: "failed-rebind" };
|
||||
},
|
||||
});
|
||||
try {
|
||||
await Array.fromAsync(session.streamHistory());
|
||||
await collectSessionTurnEvents(session, "before rewind");
|
||||
await expect(session.revertConversation!({ messageId: "u2" })).rejects.toThrow(
|
||||
"retirement failed",
|
||||
);
|
||||
expect(records).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: pino.levels.values.warn,
|
||||
msg: "Failed to rebind Claude session",
|
||||
sessionId: "failed-rebind",
|
||||
err: expect.objectContaining({ message: "retirement failed" }),
|
||||
}),
|
||||
);
|
||||
expect(session.id).toBe(SESSION_ID);
|
||||
const live = await collectSessionTurnEvents(session, "after failed rebind");
|
||||
expect(
|
||||
live
|
||||
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
|
||||
.map((event) => (event.type === "timeline" ? event.item : null)),
|
||||
).toEqual([{ type: "assistant_message", messageId: "live-reply", text: "still available" }]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["file_path", "files"])(
|
||||
"replays %s without probing targets and preserves live existence checks",
|
||||
async (field) => {
|
||||
const existingPath = path.join(cwd, "existing.txt");
|
||||
const missingPath = path.join(cwd, "missing.txt");
|
||||
writeFileSync(existingPath, "existing");
|
||||
const targets = [existingPath, missingPath];
|
||||
const inputs = targets.map((target) => ({
|
||||
[field]: field === "files" ? [target] : target,
|
||||
}));
|
||||
const toolBlocks = inputs.map((input, index) => ({
|
||||
type: "tool_use",
|
||||
id: `change-${index}`,
|
||||
name: "FileChange",
|
||||
input,
|
||||
}));
|
||||
const resultBlocks = toolBlocks.map((block) => ({
|
||||
type: "tool_result",
|
||||
tool_use_id: block.id,
|
||||
content: "done",
|
||||
}));
|
||||
const transcript = [
|
||||
{ type: "user", message: { content: "Change files" } },
|
||||
{ type: "assistant", message: { content: toolBlocks } },
|
||||
{ type: "user", message: { content: resultBlocks } },
|
||||
{ type: "assistant", message: { content: "Finished" } },
|
||||
];
|
||||
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
|
||||
writeFileSync(transcriptPath(), transcript.map((entry) => JSON.stringify(entry)).join("\n"));
|
||||
|
||||
// Observe the real filesystem calls; neither existing nor missing targets are mocked.
|
||||
const exists = vi.spyOn(fs, "existsSync");
|
||||
const liveMessages = [
|
||||
{ type: "assistant", message: { content: toolBlocks } },
|
||||
{ type: "user", message: { content: resultBlocks } },
|
||||
{ type: "result", subtype: "success", total_cost_usd: 0 },
|
||||
];
|
||||
const queryFactory = vi.fn();
|
||||
queryFactory.mockReturnValue({
|
||||
async next() {
|
||||
const value = liveMessages.shift();
|
||||
return value ? { done: false, value } : { done: true, value: undefined };
|
||||
},
|
||||
close() {},
|
||||
async return() {
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
async supportedCommands() {
|
||||
return [];
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
});
|
||||
const session = await resume(queryFactory);
|
||||
try {
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) history.push(event);
|
||||
expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual(
|
||||
[],
|
||||
);
|
||||
const running = inputs.map((input, index) => ({
|
||||
type: "tool_call",
|
||||
callId: `change-${index}`,
|
||||
name: "FileChange",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "unknown", input, output: null },
|
||||
}));
|
||||
const completed = inputs.map((input, index) => ({
|
||||
type: "tool_call",
|
||||
callId: `change-${index}`,
|
||||
name: "FileChange",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input,
|
||||
output: {
|
||||
output: "done",
|
||||
files: [{ path: index === 0 ? "existing.txt" : "missing.txt", kind: "update" }],
|
||||
},
|
||||
},
|
||||
}));
|
||||
expect(history).toEqual(
|
||||
[
|
||||
{ type: "user_message", text: "Change files" },
|
||||
...running,
|
||||
...completed,
|
||||
{ type: "assistant_message", text: "Finished" },
|
||||
].map((item) => ({ type: "timeline", provider: "claude", timestamp: undefined, item })),
|
||||
);
|
||||
|
||||
exists.mockClear();
|
||||
const live = await collectSessionTurnEvents(session, "Change files again");
|
||||
expect(exists.mock.calls.filter(([target]) => targets.includes(String(target)))).toEqual([
|
||||
[existingPath],
|
||||
[missingPath],
|
||||
]);
|
||||
const liveCompleted = live.flatMap((event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.status === "completed"
|
||||
? [event.item]
|
||||
: [],
|
||||
);
|
||||
const missing = completed[1];
|
||||
expect(liveCompleted).toEqual([
|
||||
completed[0],
|
||||
{
|
||||
...missing,
|
||||
detail: {
|
||||
...missing.detail,
|
||||
output: { output: "done", files: [{ path: "missing.txt", kind: "add" }] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["parent", "subagent"])(
|
||||
"replays %s Write and Edit calls without probing their targets",
|
||||
async (scope) => {
|
||||
const filePath = path.join(cwd, "unavailable", "file.txt");
|
||||
const content = ["Write", "Edit"].map((name) => ({
|
||||
type: "tool_use",
|
||||
id: name,
|
||||
name,
|
||||
input: { file_path: filePath },
|
||||
}));
|
||||
const entry = { type: "assistant", message: { content } };
|
||||
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
|
||||
if (scope === "subagent") {
|
||||
const directory = path.join(path.dirname(transcriptPath()), SESSION_ID, "subagents");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(directory, "agent-child.meta.json"),
|
||||
JSON.stringify({ toolUseId: "task" }),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(directory, "agent-child.jsonl"),
|
||||
JSON.stringify({
|
||||
...entry,
|
||||
isSidechain: true,
|
||||
agentId: "child",
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
transcriptPath(),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "task",
|
||||
name: "Task",
|
||||
input: { description: "Change files" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
writeFileSync(transcriptPath(), JSON.stringify(entry));
|
||||
}
|
||||
|
||||
const exists = vi.spyOn(fs, "existsSync");
|
||||
const session = await resume();
|
||||
try {
|
||||
const items = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
if (scope === "parent" && event.type === "timeline") items.push(event.item);
|
||||
if (event.type === "provider_subagent" && event.event.type === "timeline") {
|
||||
items.push(event.event.item);
|
||||
}
|
||||
}
|
||||
expect(exists.mock.calls.filter(([target]) => target === filePath)).toEqual([]);
|
||||
expect(items).toEqual([
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "Write",
|
||||
name: "Write",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "write", filePath },
|
||||
},
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "Edit",
|
||||
name: "Edit",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "edit", filePath },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("logs the resolved transcript path when the transcript does not exist", async () => {
|
||||
await resume();
|
||||
await (await resume()).close();
|
||||
|
||||
expect(records).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -78,7 +601,7 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
// A directory at the transcript path exists but cannot be read as a file.
|
||||
mkdirSync(transcriptPath(), { recursive: true });
|
||||
|
||||
await resume();
|
||||
await (await resume()).close();
|
||||
|
||||
expect(records).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -91,6 +614,55 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("loads readable history when the temporary directory is unavailable", async () => {
|
||||
const file = transcriptPath();
|
||||
const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } };
|
||||
mkdirSync(path.dirname(file), { recursive: true });
|
||||
writeFileSync(file, JSON.stringify(entry));
|
||||
const unavailable = path.join(tempRoot, "unavailable-tmp");
|
||||
for (const variable of ["TMPDIR", "TEMP", "TMP"]) vi.stubEnv(variable, unavailable);
|
||||
await withClaudeReplayHistory(file, async (history) => {
|
||||
expect(await Array.fromAsync(history.parentEntries)).toEqual([entry]);
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["parent", "child", "workflow"])(
|
||||
"preserves indexed %s history when its transcript disappears between passes",
|
||||
async (target) => {
|
||||
const parent = transcriptPath();
|
||||
const sessionDirectory = parent.slice(0, -".jsonl".length);
|
||||
const child = path.join(sessionDirectory, "subagents", "agent-child.jsonl");
|
||||
const workflow = path.join(
|
||||
sessionDirectory,
|
||||
"subagents",
|
||||
"workflows",
|
||||
"wf_test",
|
||||
"agent-child.jsonl",
|
||||
);
|
||||
const parentEntry = {
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "text", text: "parent" }] },
|
||||
};
|
||||
const childEntry = { ...parentEntry, isSidechain: true, agentId: "child" };
|
||||
mkdirSync(path.dirname(workflow), { recursive: true });
|
||||
writeFileSync(parent, JSON.stringify(parentEntry));
|
||||
writeFileSync(child, JSON.stringify(childEntry));
|
||||
writeFileSync(workflow, JSON.stringify(childEntry));
|
||||
await withClaudeReplayHistory(parent, async (history) => {
|
||||
rmSync({ parent, child, workflow }[target]);
|
||||
// Ownership facts and visible payloads are separate passes over the same source.
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
expect(await Array.fromAsync(history.parentEntries)).toEqual([parentEntry]);
|
||||
expect(history.subagents).toHaveLength(1);
|
||||
expect(await Array.fromAsync(history.subagents[0]!.entries)).toEqual([childEntry]);
|
||||
expect(await Array.fromAsync(history.workflowEntriesByRunId.get("wf_test")!)).toEqual([
|
||||
childEntry,
|
||||
]);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// chmod does not remove read access on Windows, or for root.
|
||||
test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"logs a warning with the error when Claude's project folders cannot be listed",
|
||||
@@ -100,7 +672,7 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
chmodSync(path.join(configDir, "projects"), 0o000);
|
||||
|
||||
try {
|
||||
await resume();
|
||||
await (await resume()).close();
|
||||
} finally {
|
||||
chmodSync(path.join(configDir, "projects"), 0o755);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ const MATERIALIZED_PNG_PATH_PATTERN =
|
||||
/paseo-attachments(?:-[^\\/]+)?[\\/](?:[^\\/]+[\\/])?[0-9a-f]{64}\.png$/;
|
||||
|
||||
interface ClaudeImageTestSession {
|
||||
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
||||
convertHistoryEntry(entry: unknown): AgentTimelineItem[];
|
||||
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
|
||||
convertHistoryEntry(entry: unknown): Promise<AgentTimelineItem[]>;
|
||||
}
|
||||
|
||||
async function createSession(): Promise<ClaudeImageTestSession> {
|
||||
@@ -157,7 +157,7 @@ describe("Claude tool_result image rendering", () => {
|
||||
test("emits the image as assistant markdown and keeps base64 out of the live tool output", async () => {
|
||||
const session = await createSession();
|
||||
|
||||
const events = session.translateMessageToEvents(imageToolResultUserMessage());
|
||||
const events = await session.translateMessageToEvents(imageToolResultUserMessage());
|
||||
|
||||
const timelineItems = events
|
||||
.filter((event) => event.type === "timeline")
|
||||
@@ -181,7 +181,7 @@ describe("Claude tool_result image rendering", () => {
|
||||
test("replays the image as assistant markdown through history conversion", async () => {
|
||||
const session = await createSession();
|
||||
|
||||
const items = session.convertHistoryEntry(imageToolResultHistoryEntry());
|
||||
const items = await session.convertHistoryEntry(imageToolResultHistoryEntry());
|
||||
|
||||
const [imageMessage, ...extraImages] = imageMessages(items);
|
||||
expect(extraImages).toEqual([]);
|
||||
@@ -202,7 +202,7 @@ describe("Claude tool_result image rendering", () => {
|
||||
test("keeps base64 out of an errored tool_result that carries an image", async () => {
|
||||
const session = await createSession();
|
||||
|
||||
const events = session.translateMessageToEvents(erroredImageToolResultUserMessage());
|
||||
const events = await session.translateMessageToEvents(erroredImageToolResultUserMessage());
|
||||
|
||||
const timelineItems = events
|
||||
.filter((event) => event.type === "timeline")
|
||||
@@ -226,7 +226,7 @@ describe("Claude tool_result image rendering", () => {
|
||||
test("emits one image message per image block in a multi-image tool_result", async () => {
|
||||
const session = await createSession();
|
||||
|
||||
const events = session.translateMessageToEvents(multiImageToolResultUserMessage());
|
||||
const events = await session.translateMessageToEvents(multiImageToolResultUserMessage());
|
||||
|
||||
const timelineItems = events
|
||||
.filter((event) => event.type === "timeline")
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test, expect } from "vitest";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import pino from "pino";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
test("lists a session containing a large image record and retains the final prompt", async () => {
|
||||
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-import-image-"));
|
||||
try {
|
||||
const project = path.join(configDir, "projects", "image-project");
|
||||
await fs.mkdir(project, { recursive: true });
|
||||
const first = {
|
||||
type: "user",
|
||||
sessionId: "image-session",
|
||||
cwd: "/image-project",
|
||||
message: { content: "Describe the image" },
|
||||
};
|
||||
const image = {
|
||||
type: "user",
|
||||
sessionId: "image-session",
|
||||
cwd: "/image-project",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "a".repeat(5 * 1024 * 1024) },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const last = { type: "user", message: { content: "What changed?" } };
|
||||
await fs.writeFile(
|
||||
path.join(project, "image-session.jsonl"),
|
||||
[first, image, last].map((record) => JSON.stringify(record)).join("\n"),
|
||||
);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
|
||||
});
|
||||
const sessions = await client.listImportableSessions({ limit: 1 });
|
||||
expect(sessions).toEqual([
|
||||
expect.objectContaining({
|
||||
providerHandleId: "image-session",
|
||||
title: "Describe the image",
|
||||
firstPromptPreview: "Describe the image",
|
||||
lastPromptPreview: "What changed?",
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await fs.rm(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { projectTimelineRows } from "../../timeline-projection.js";
|
||||
|
||||
describe("Claude session request journal replay", () => {
|
||||
let root: string;
|
||||
let cwd: string;
|
||||
let configDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(path.join(os.tmpdir(), "claude-request-journal-"));
|
||||
cwd = path.join(root, "repo");
|
||||
configDir = path.join(root, "claude");
|
||||
mkdirSync(cwd);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function replay(records: unknown[]): Promise<AgentTimelineItem[]> {
|
||||
const historyDir = claudeProjectDirSync(cwd, { configDir });
|
||||
mkdirSync(historyDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(historyDir, "journal-session.jsonl"),
|
||||
records.map((record) => JSON.stringify(record)).join("\n"),
|
||||
);
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "claude", sessionId: "journal-session" },
|
||||
{ cwd },
|
||||
{ env: { CLAUDE_CONFIG_DIR: configDir } },
|
||||
);
|
||||
try {
|
||||
const items: AgentTimelineItem[] = [];
|
||||
for await (const event of session.streamHistory()) {
|
||||
if (event.type === "timeline") items.push(event.item);
|
||||
}
|
||||
return items;
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
test.each(["api-request-shape", "api-request", "api-request-blob", "future-journal-record"])(
|
||||
"ignores %s content while preserving the conversation and tool calls",
|
||||
async (type) => {
|
||||
const items = await replay([
|
||||
{ type: "user", message: { role: "user", content: "Read package.json" } },
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "read-1",
|
||||
name: "Read",
|
||||
input: { file_path: "package.json" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
...["user", "system", "assistant"].map((role) => ({
|
||||
type,
|
||||
message: {
|
||||
role,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "SessionStart hook output\n# MCP Server Instructions\n<total_tokens>1000 tokens left</total_tokens>",
|
||||
},
|
||||
{
|
||||
type: "tool_addition",
|
||||
tool: {
|
||||
name: "deferred_tool",
|
||||
description: "Deferred tool",
|
||||
input_schema: { type: "object", properties: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "journal-only",
|
||||
name: "Read",
|
||||
input: { file_path: "old.json" },
|
||||
},
|
||||
],
|
||||
},
|
||||
})),
|
||||
{
|
||||
type: "assistant",
|
||||
message: { role: "assistant", content: [{ type: "text", text: "paseo" }] },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({ type: "user_message", text: "Read package.json" }),
|
||||
expect.objectContaining({ type: "tool_call", callId: "read-1", name: "Read" }),
|
||||
expect.objectContaining({ type: "assistant_message", text: "paseo" }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test("preserves system compaction and background task notifications", async () => {
|
||||
const items = await replay([
|
||||
{
|
||||
type: "system",
|
||||
subtype: "compact_boundary",
|
||||
compactMetadata: { trigger: "manual", preTokens: 1000 },
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
uuid: "note-1",
|
||||
task_id: "task-1",
|
||||
status: "completed",
|
||||
summary: "Background work finished",
|
||||
},
|
||||
]);
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
trigger: "manual",
|
||||
preTokens: 1000,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "tool_call",
|
||||
name: "task_notification",
|
||||
status: "completed",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test.each(["remove", "user"])(
|
||||
"replays a background completion once across queue enqueue and %s records",
|
||||
async (delivery) => {
|
||||
const content =
|
||||
"<task-notification>\n<task-id>bg-1</task-id>\n<status>completed</status>\n<summary>Background command completed</summary>\n</task-notification>";
|
||||
const items = await replay([
|
||||
{ type: "queue-operation", operation: "enqueue", content },
|
||||
delivery === "remove"
|
||||
? { type: "queue-operation", operation: "remove", content }
|
||||
: {
|
||||
type: "user",
|
||||
uuid: "notification-delivery",
|
||||
origin: { kind: "task-notification" },
|
||||
message: { role: "user", content },
|
||||
},
|
||||
]);
|
||||
const projected = projectTimelineRows({
|
||||
rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })),
|
||||
mode: "projected",
|
||||
});
|
||||
expect(projected.map((row) => row.item)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool_call",
|
||||
name: "task_notification",
|
||||
status: "completed",
|
||||
metadata: expect.objectContaining({ taskId: "bg-1" }),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
test("restores each Monitor event and completion of one task once", async () => {
|
||||
const notifications = ["1", "2", "3", "4"].map(
|
||||
(event) =>
|
||||
`<task-notification>\n<task-id>monitor-1</task-id>\n<summary>Monitor event</summary>\n<event>${event}</event>\n</task-notification>`,
|
||||
);
|
||||
notifications.push(
|
||||
"<task-notification>\n<task-id>monitor-1</task-id>\n<status>completed</status>\n<summary>Monitor stream ended</summary>\n</task-notification>",
|
||||
);
|
||||
const items = await replay(
|
||||
notifications.flatMap((content, index) => [
|
||||
{ type: "queue-operation", operation: "enqueue", content },
|
||||
{ type: "user", uuid: `delivery-${index}`, message: { role: "user", content } },
|
||||
]),
|
||||
);
|
||||
const projected = projectTimelineRows({
|
||||
rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })),
|
||||
mode: "projected",
|
||||
});
|
||||
expect(projected.map(({ item }) => item.type === "tool_call" && item.detail)).toEqual(
|
||||
notifications.map((text, index) =>
|
||||
expect.objectContaining({
|
||||
label: index === 4 ? "Monitor stream ended" : "Monitor event",
|
||||
text,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
test.each(["user", "remove", "system"])(
|
||||
"preserves identical notifications from separate occurrences with %s deliveries",
|
||||
async (delivery) => {
|
||||
const content =
|
||||
"<task-notification>\n<task-id>monitor-repeat</task-id>\n<status>completed</status>\n<summary>Monitor event</summary>\n</task-notification>";
|
||||
const queued = [1, 2].map(() => ({ type: "queue-operation", operation: "enqueue", content }));
|
||||
const delivered = [1, 2].map((index) => {
|
||||
if (delivery === "user") {
|
||||
return { type: "user", uuid: `repeat-${index}`, message: { role: "user", content } };
|
||||
}
|
||||
if (delivery === "remove") {
|
||||
return { type: "queue-operation", operation: "remove", content };
|
||||
}
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
uuid: `repeat-${index}`,
|
||||
task_id: "monitor-repeat",
|
||||
status: "completed",
|
||||
summary: "Monitor event",
|
||||
};
|
||||
});
|
||||
const items = await replay([
|
||||
...queued,
|
||||
...delivered,
|
||||
{ type: "queue-operation", operation: "enqueue", content },
|
||||
]);
|
||||
const projected = projectTimelineRows({
|
||||
rows: items.map((item, seq) => ({ item, seq, timestamp: "2026-10-23T00:00:00.000Z" })),
|
||||
mode: "projected",
|
||||
});
|
||||
expect(projected.map(({ item }) => item.type === "tool_call" && item.detail?.label)).toEqual([
|
||||
"Monitor event",
|
||||
"Monitor event",
|
||||
"Monitor event",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { generateReplayCorpus, generateWorkflowReplayCorpus } from "./replay-memory.fixture.js";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
@@ -419,6 +422,237 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a background subagent", () => {
|
||||
// Shapes recorded from Claude Code 2.1.x: the launch returns at once with an async result,
|
||||
// and a child stopped by an interrupt ends on the interrupt placeholder, not an end_turn.
|
||||
function backgroundLaunch(): string[] {
|
||||
return [
|
||||
parentEntry([
|
||||
{
|
||||
type: "tool_use",
|
||||
id: TOOL_USE_ID,
|
||||
name: "Agent",
|
||||
input: {
|
||||
subagent_type: "general-purpose",
|
||||
description: "probe sleeper",
|
||||
run_in_background: true,
|
||||
},
|
||||
},
|
||||
]),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: "replay-session",
|
||||
timestamp: "2026-07-26T06:27:48.000Z",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: TOOL_USE_ID,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Async agent launched successfully.\nagentId: ${AGENT_ID}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
toolUseResult: { isAsync: true, status: "async_launched", agentId: AGENT_ID },
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function readStatus(event: { status?: string }): string | undefined {
|
||||
return event.status;
|
||||
}
|
||||
|
||||
function interruptedChild(): string[] {
|
||||
return [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
isSidechain: true,
|
||||
agentId: AGENT_ID,
|
||||
timestamp: "2026-07-26T06:27:50.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "toolu_child_bash", name: "Bash", input: {} }],
|
||||
stop_reason: "tool_use",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
isSidechain: true,
|
||||
agentId: AGENT_ID,
|
||||
timestamp: "2026-07-26T06:28:05.000Z",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_child_bash",
|
||||
content: "The user doesn't want to proceed with this tool use.",
|
||||
is_error: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
isSidechain: true,
|
||||
agentId: AGENT_ID,
|
||||
timestamp: "2026-07-26T06:28:05.001Z",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "[Request interrupted by user for tool use]" }],
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
test.each([
|
||||
["the meta sidecar", JSON.stringify({ toolUseId: TOOL_USE_ID })],
|
||||
["the legacy scrape", null],
|
||||
])("is canceled when an interrupt stopped it, linked through %s", async (_link, meta) => {
|
||||
writeSession({ parentLines: backgroundLaunch(), meta, sidechainLines: interruptedChild() });
|
||||
|
||||
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
|
||||
id: TOOL_USE_ID,
|
||||
status: "canceled",
|
||||
});
|
||||
});
|
||||
|
||||
test("stays running when the child was given a new prompt after the interrupt", async () => {
|
||||
writeSession({
|
||||
parentLines: backgroundLaunch(),
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [
|
||||
...interruptedChild(),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
isSidechain: true,
|
||||
agentId: AGENT_ID,
|
||||
timestamp: "2026-07-26T06:29:00.000Z",
|
||||
message: { role: "user", content: [{ type: "text", text: "Carry on with the task." }] },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const statuses = upserts(await replayDescriptors()).map(readStatus);
|
||||
expect(statuses).not.toContain("canceled");
|
||||
expect(statuses).toContain("running");
|
||||
});
|
||||
|
||||
test.each(["interrupted", "running"])(
|
||||
"ignores an API request copy of a %s child's launch result",
|
||||
async (state) => {
|
||||
const launch = backgroundLaunch();
|
||||
const result = JSON.parse(launch[1]!);
|
||||
writeSession({
|
||||
parentLines: [
|
||||
...launch,
|
||||
JSON.stringify({
|
||||
type: "api-request-blob",
|
||||
sessionId: "replay-session",
|
||||
hash: "request-copy",
|
||||
message: result.message,
|
||||
}),
|
||||
],
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines:
|
||||
state === "interrupted"
|
||||
? interruptedChild()
|
||||
: [sidechainEntry({ stopReason: "tool_use" })],
|
||||
});
|
||||
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
|
||||
id: TOOL_USE_ID,
|
||||
status: state === "interrupted" ? "canceled" : "running",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("stays running while the child has not finished", async () => {
|
||||
writeSession({
|
||||
parentLines: backgroundLaunch(),
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [sidechainEntry({ stopReason: "tool_use" })],
|
||||
});
|
||||
|
||||
const statuses = upserts(await replayDescriptors()).map(readStatus);
|
||||
expect(statuses).not.toContain("completed");
|
||||
expect(statuses).toContain("running");
|
||||
});
|
||||
|
||||
function taskNotification(status: string, form = "user"): string {
|
||||
const text = `<task-notification>\n<task-id>${AGENT_ID}</task-id>\n<tool-use-id>${TOOL_USE_ID}</tool-use-id>\n<status>${status}</status>\n<summary>Background task ended</summary>\n</task-notification>`;
|
||||
const records = {
|
||||
user: { message: { role: "user", content: [{ type: "text", text }] } },
|
||||
system: {
|
||||
subtype: "task_notification",
|
||||
task_id: AGENT_ID,
|
||||
tool_use_id: TOOL_USE_ID,
|
||||
status,
|
||||
},
|
||||
"queue-operation": { operation: "enqueue", content: text },
|
||||
};
|
||||
return JSON.stringify({
|
||||
type: form,
|
||||
timestamp: "2026-07-26T06:29:00.000Z",
|
||||
...records[form as keyof typeof records],
|
||||
});
|
||||
}
|
||||
|
||||
for (const form of ["user", "system", "queue-operation"]) {
|
||||
test.each([
|
||||
["completed", "completed"],
|
||||
["failed", "failed"],
|
||||
["killed", "canceled"],
|
||||
["stopped", "canceled"],
|
||||
])(
|
||||
`restores a %s task notification from ${form} when the child stop_reason is null`,
|
||||
async (status, expected) => {
|
||||
writeSession({
|
||||
parentLines: [...backgroundLaunch(), taskNotification(status, form)],
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [sidechainEntry({ stopReason: null })],
|
||||
});
|
||||
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
|
||||
id: TOOL_USE_ID,
|
||||
status: expected,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("prefers a stopped notification over the child's end_turn", async () => {
|
||||
writeSession({
|
||||
parentLines: [...backgroundLaunch(), taskNotification("stopped")],
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
|
||||
});
|
||||
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "canceled" });
|
||||
});
|
||||
|
||||
test("does not invent an outcome for an unknown notification status", async () => {
|
||||
writeSession({
|
||||
parentLines: [...backgroundLaunch(), taskNotification("unknown")],
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [sidechainEntry({ stopReason: null })],
|
||||
});
|
||||
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "running" });
|
||||
});
|
||||
|
||||
test("completes from the child's own end_turn", async () => {
|
||||
writeSession({
|
||||
parentLines: backgroundLaunch(),
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
|
||||
});
|
||||
|
||||
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "completed" });
|
||||
});
|
||||
});
|
||||
|
||||
test("does not replay a skill-spawned subagent the parent never named", async () => {
|
||||
// The reported bug. A /code-review subagent has no Task tool_use, so its sidecar carries only
|
||||
// agentType and its id appears nowhere in the parent — it used to replay as running forever,
|
||||
@@ -432,6 +666,267 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
|
||||
expect(upserts(await replayDescriptors())).toEqual([]);
|
||||
});
|
||||
|
||||
test("replays several legacy children interleaved in the parent transcript", async () => {
|
||||
const lines: string[] = [];
|
||||
for (let index = 0; index < 8; index++) {
|
||||
const agentId = `legacy-${index}`;
|
||||
const toolUseId = `task-${index}`;
|
||||
lines.push(parentEntry([{ type: "tool_use", id: toolUseId, name: "Task", input: {} }]));
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: toolUseId, content: `agentId: ${agentId}\ndone` },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
lines.push(sidechainEntry({ agentId, stopReason: "end_turn" }));
|
||||
}
|
||||
writeParentSession(lines);
|
||||
const events = await replayDescriptors();
|
||||
const histories = events
|
||||
.map((event) => event.event)
|
||||
.filter((event) => event.type === "timeline");
|
||||
expect(histories.map((event) => event.id).sort()).toEqual(
|
||||
Array.from({ length: 8 }, (_, index) => `task-${index}`),
|
||||
);
|
||||
expect(histories.map((event) => event.item)).toEqual(
|
||||
Array.from({ length: 8 }, () => ({ type: "assistant_message", text: "summary of the docs" })),
|
||||
);
|
||||
});
|
||||
|
||||
test("replays more child events than V8 permits in one spread call", async () => {
|
||||
const childCount = 16;
|
||||
const entriesPerChild = 10_000;
|
||||
const children = Array.from({ length: childCount }, (_, index) => ({
|
||||
agentId: `large-child-${index}`,
|
||||
toolUseId: `large-task-${index}`,
|
||||
}));
|
||||
const parentLines = children.flatMap(({ agentId, toolUseId }) => [
|
||||
parentEntry([{ type: "tool_use", id: toolUseId, name: "Task", input: {} }]),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: toolUseId,
|
||||
content: `agentId: ${agentId}\ndone`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const subagentDir = writeParentSession(parentLines);
|
||||
for (const { agentId, toolUseId } of children) {
|
||||
writeSubagent({
|
||||
subagentDir,
|
||||
agentId,
|
||||
meta: JSON.stringify({ toolUseId }),
|
||||
sidechainLines: Array.from({ length: entriesPerChild }, () => sidechainEntry({ agentId })),
|
||||
});
|
||||
}
|
||||
|
||||
const events = await replayDescriptors();
|
||||
const timelineCount = events.filter((event) => event.event.type === "timeline").length;
|
||||
expect(timelineCount).toBe(childCount * entriesPerChild);
|
||||
expect(upserts(events).filter((event) => event.status === "completed")).toHaveLength(
|
||||
childCount,
|
||||
);
|
||||
}, 120_000);
|
||||
|
||||
test("limits retained child shell output to the timeline's existing content limit", async () => {
|
||||
const output = "x".repeat(128 * 1024);
|
||||
writeSession({
|
||||
parentLines: [taskToolUse(), taskToolResult()],
|
||||
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
|
||||
sidechainLines: [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
isSidechain: true,
|
||||
agentId: AGENT_ID,
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "bash-large",
|
||||
name: "Bash",
|
||||
input: { command: "echo large" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
isSidechain: true,
|
||||
agentId: AGENT_ID,
|
||||
message: {
|
||||
content: [{ type: "tool_result", tool_use_id: "bash-large", content: output }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
const events = await replayDescriptors();
|
||||
const completed = events
|
||||
.map((event) => event.event)
|
||||
.filter(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.status === "completed",
|
||||
);
|
||||
expect(completed).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
id: TOOL_USE_ID,
|
||||
item: expect.objectContaining({
|
||||
detail: expect.objectContaining({ type: "shell", output: output.slice(0, 64 * 1024) }),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
label: "a parent transcript without sidechains",
|
||||
mode: "children",
|
||||
// Cross V8's whole-string limit only in the opt-in reproduction, keeping routine CI small.
|
||||
generate: (root: string) =>
|
||||
generateReplayCorpus(root, {
|
||||
parentMiB: process.env.PASEO_TEST_LARGE_CLAUDE_REPLAY === "1" ? 550 : 2,
|
||||
childrenMiB: 0,
|
||||
children: 0,
|
||||
}),
|
||||
expected: { parentItems: 1, completed: 0, histories: 0 },
|
||||
},
|
||||
{
|
||||
label: "4 MiB parent and 20 sidechains",
|
||||
mode: "children",
|
||||
generate: (root: string) =>
|
||||
generateReplayCorpus(root, { parentMiB: 4, childrenMiB: 4, children: 20 }),
|
||||
expected: { parentItems: 41, completed: 20, histories: 20 },
|
||||
},
|
||||
{
|
||||
label: "five 2 MiB workflow sidechains",
|
||||
mode: "workflow",
|
||||
generate: (root: string) => generateWorkflowReplayCorpus(root, 2),
|
||||
expected: { parentItems: 3, completed: 1, histories: 5 },
|
||||
},
|
||||
])(
|
||||
"opens $label with all history under a 256 MB heap",
|
||||
({ mode, generate, expected }) => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "claude-replay-"));
|
||||
try {
|
||||
generate(root);
|
||||
const worker = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--max-old-space-size=256",
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("./replay-memory.fixture.ts", import.meta.url)),
|
||||
root,
|
||||
mode,
|
||||
],
|
||||
{ encoding: "utf8", env: { ...process.env, NODE_NO_WARNINGS: "1" }, timeout: 120_000 },
|
||||
);
|
||||
expect({ status: worker.status, signal: worker.signal, stderr: worker.stderr }).toEqual({
|
||||
status: 0,
|
||||
signal: null,
|
||||
stderr: "",
|
||||
});
|
||||
const result = JSON.parse(worker.stdout);
|
||||
expect(result).toMatchObject(expected);
|
||||
console.log("Claude replay memory:", result);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
150_000,
|
||||
);
|
||||
|
||||
test("replays UTF-8 across read chunks, corrupt rows, CRLF and an unterminated final row", async () => {
|
||||
const text = "é🙂".repeat(30_000);
|
||||
const historyDir = claudeProjectDirSync(cwd, { configDir });
|
||||
mkdirSync(historyDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(historyDir, "replay-session.jsonl"),
|
||||
parentEntry([{ type: "text", text }]) +
|
||||
"\r\nnot json\r\n" +
|
||||
parentEntry([{ type: "text", text: "final row" }]),
|
||||
);
|
||||
const events = await replayEvents();
|
||||
expect(events.filter((event) => event.type === "timeline").map((event) => event.item)).toEqual([
|
||||
{ type: "assistant_message", text },
|
||||
{ type: "assistant_message", text: "final row" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("converts workflow tools in timestamp order before correlating their results", async () => {
|
||||
writeWorkflowSession("completed");
|
||||
const directory = path.join(
|
||||
claudeProjectDirSync(cwd, { configDir }),
|
||||
"replay-session",
|
||||
"subagents",
|
||||
"workflows",
|
||||
WORKFLOW_RUN_ID,
|
||||
);
|
||||
writeSubagent({
|
||||
subagentDir: directory,
|
||||
agentId: "a-result",
|
||||
sidechainLines: [
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
timestamp: "2026-07-26T06:28:01.000Z",
|
||||
message: {
|
||||
content: [{ type: "tool_result", tool_use_id: "workflow-bash", content: "finished" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
writeSubagent({
|
||||
subagentDir: directory,
|
||||
agentId: "z-call",
|
||||
sidechainLines: [
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-26T06:28:00.000Z",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "workflow-bash",
|
||||
name: "Bash",
|
||||
input: { command: "echo finished" },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
const events = await replayDescriptors();
|
||||
const calls = events
|
||||
.map((event) => event.event)
|
||||
.filter((event) => event.type === "timeline" && event.item.type === "tool_call");
|
||||
expect(calls).toEqual([
|
||||
expect.objectContaining({
|
||||
id: WORKFLOW_TOOL_USE_ID,
|
||||
item: expect.objectContaining({ callId: "workflow-bash", name: "Bash", status: "running" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: WORKFLOW_TOOL_USE_ID,
|
||||
item: expect.objectContaining({
|
||||
callId: "workflow-bash",
|
||||
name: "Bash",
|
||||
status: "completed",
|
||||
detail: expect.objectContaining({ type: "shell", output: "finished" }),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not accumulate internal workflow agents as replay-only running rows", async () => {
|
||||
const subagentRoot = writeParentSession([
|
||||
parentEntry([
|
||||
@@ -570,7 +1065,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
|
||||
.filter((item) => item.type === "tool_call" && item.name === "task_notification"),
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
callId: "task_notification_workflow-notification",
|
||||
callId: expect.stringMatching(/^task_notification_[a-f0-9]+_1$/),
|
||||
status: "completed",
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import pino from "pino";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { PermissionResult, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
@@ -15,9 +18,11 @@ import {
|
||||
resolveClaudeCodeVersion,
|
||||
toClaudeSdkMcpConfig,
|
||||
} from "./agent.js";
|
||||
import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
import type {
|
||||
ImportableProviderSession,
|
||||
AgentPromptInput,
|
||||
AgentSession,
|
||||
AgentTimelineItem,
|
||||
@@ -27,7 +32,7 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
|
||||
interface TestClaudeSession {
|
||||
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
||||
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -48,7 +53,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("convertClaudeHistoryEntry", () => {
|
||||
test("maps user tool results to timeline items", () => {
|
||||
test("maps user tool results to timeline items", async () => {
|
||||
const toolUseId = "toolu_test";
|
||||
const entry = {
|
||||
type: "user",
|
||||
@@ -74,14 +79,14 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
];
|
||||
|
||||
const mapBlocks = vi.fn().mockReturnValue(stubTimeline);
|
||||
const result = convertClaudeHistoryEntry(entry, mapBlocks);
|
||||
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
|
||||
|
||||
expect(result).toEqual(stubTimeline);
|
||||
expect(mapBlocks).toHaveBeenCalledTimes(1);
|
||||
expect(Array.isArray(mapBlocks.mock.calls[0][0])).toBe(true);
|
||||
});
|
||||
|
||||
test("replays persisted Claude tool results as completed tool calls", () => {
|
||||
test("replays persisted Claude tool results as completed tool calls", async () => {
|
||||
const entry = {
|
||||
type: "user",
|
||||
message: {
|
||||
@@ -119,12 +124,12 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
|
||||
const mapPersistedToolResultBlocks = (): AgentTimelineItem[] => completedToolCall;
|
||||
|
||||
expect(convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual(
|
||||
expect(await convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual(
|
||||
completedToolCall,
|
||||
);
|
||||
});
|
||||
|
||||
test("returns user messages when no tool blocks exist", () => {
|
||||
test("returns user messages when no tool blocks exist", async () => {
|
||||
const entry = {
|
||||
type: "user",
|
||||
message: {
|
||||
@@ -133,7 +138,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
|
||||
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
text: "Run npm test",
|
||||
@@ -141,7 +146,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("converts compact boundary metadata variants", () => {
|
||||
test("converts compact boundary metadata variants", async () => {
|
||||
const fixtures = [
|
||||
{
|
||||
entry: {
|
||||
@@ -170,7 +175,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
] as const;
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
expect(convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([
|
||||
expect(await convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([
|
||||
{
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
@@ -181,7 +186,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("skips synthetic user entries", () => {
|
||||
test("skips synthetic user entries", async () => {
|
||||
const entry = {
|
||||
type: "user",
|
||||
isSynthetic: true,
|
||||
@@ -192,13 +197,13 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
};
|
||||
|
||||
const mapBlocks = vi.fn().mockReturnValue([]);
|
||||
const result = convertClaudeHistoryEntry(entry, mapBlocks);
|
||||
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mapBlocks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips meta user entries from Claude skill loading", () => {
|
||||
test("skips meta user entries from Claude skill loading", async () => {
|
||||
const entry = {
|
||||
type: "user",
|
||||
isMeta: true,
|
||||
@@ -214,13 +219,13 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
};
|
||||
|
||||
const mapBlocks = vi.fn().mockReturnValue([]);
|
||||
const result = convertClaudeHistoryEntry(entry, mapBlocks);
|
||||
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mapBlocks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips interrupt placeholder transcript noise", () => {
|
||||
test("skips interrupt placeholder transcript noise", async () => {
|
||||
const interruptEntry = {
|
||||
type: "user",
|
||||
message: {
|
||||
@@ -241,11 +246,11 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
.fn()
|
||||
.mockReturnValue([{ type: "assistant_message", text: "No response requested." }]);
|
||||
|
||||
expect(convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
|
||||
expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips <local-command-stdout> messages (model switch, /context, etc.)", () => {
|
||||
test("skips <local-command-stdout> messages (model switch, /context, etc.)", async () => {
|
||||
// Real entries from Claude Code JSONL history files
|
||||
const modelSwitch = {
|
||||
type: "user",
|
||||
@@ -300,24 +305,24 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
|
||||
const mapBlocks = vi.fn().mockReturnValue([]);
|
||||
|
||||
expect(convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]);
|
||||
expect(convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]);
|
||||
expect(convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]);
|
||||
expect(convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]);
|
||||
expect(convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]);
|
||||
expect(convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]);
|
||||
|
||||
// Real user messages must NOT be filtered
|
||||
const realMessage = {
|
||||
type: "user",
|
||||
message: { role: "user", content: "fix the bug in auth.ts" },
|
||||
};
|
||||
expect(convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
|
||||
expect(await convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
|
||||
{ type: "user_message", text: "fix the bug in auth.ts" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps task notifications to synthetic tool calls", () => {
|
||||
test("maps task notifications to synthetic tool calls", async () => {
|
||||
const entry = {
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
@@ -328,7 +333,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
output_file: "/tmp/bg-fail-1.txt",
|
||||
};
|
||||
|
||||
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
|
||||
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "task_notification_task-note-system-1",
|
||||
@@ -352,7 +357,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps queue-operation task notifications to synthetic tool calls", () => {
|
||||
test("maps queue-operation task notifications to synthetic tool calls", async () => {
|
||||
const entry = {
|
||||
type: "queue-operation",
|
||||
operation: "enqueue",
|
||||
@@ -367,7 +372,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
|
||||
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "task_notification_task-note-queue-1",
|
||||
@@ -391,7 +396,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("passes assistant content blocks through to the mapper", () => {
|
||||
test("passes assistant content blocks through to the mapper", async () => {
|
||||
const entry = {
|
||||
type: "assistant",
|
||||
message: {
|
||||
@@ -409,7 +414,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
];
|
||||
const mapBlocks = vi.fn().mockReturnValue(mappedTimeline);
|
||||
|
||||
expect(convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline);
|
||||
expect(await convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline);
|
||||
expect(mapBlocks).toHaveBeenCalledWith(entry.message.content);
|
||||
});
|
||||
});
|
||||
@@ -882,6 +887,68 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("keeps an extraArgs settings file when Paseo sets Claude flag settings", async () => {
|
||||
const settingsDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-settings-"));
|
||||
const settingsPath = path.join(settingsDir, "settings.json");
|
||||
await fs.writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify({ env: { ENABLE_TOOL_SEARCH: "false" }, permissions: { deny: ["Monitor"] } }),
|
||||
);
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-5-5",
|
||||
providerOptions: {
|
||||
extraArgs: { chrome: null, settings: settingsPath },
|
||||
},
|
||||
});
|
||||
|
||||
await session.startTurn("hello");
|
||||
|
||||
const options = queryFactory.mock.calls[0]?.[0].options;
|
||||
expect(options?.extraArgs).toEqual({ chrome: null });
|
||||
expect(options?.settings).toEqual({
|
||||
env: { ENABLE_TOOL_SEARCH: "false" },
|
||||
permissions: { deny: ["Monitor"] },
|
||||
fastMode: false,
|
||||
});
|
||||
await session.close();
|
||||
await fs.rm(settingsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("keeps inline extraArgs settings JSON for internal agents", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
internal: true,
|
||||
providerOptions: {
|
||||
extraArgs: { settings: '{"env":{"ENABLE_TOOL_SEARCH":"false"}}' },
|
||||
},
|
||||
});
|
||||
|
||||
await session.startTurn("hello");
|
||||
|
||||
const options = queryFactory.mock.calls[0]?.[0].options;
|
||||
expect(options?.extraArgs).toEqual({});
|
||||
expect(options?.settings).toEqual({
|
||||
env: { ENABLE_TOOL_SEARCH: "false" },
|
||||
disableAllHooks: true,
|
||||
});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("lists fast mode only for supported Opus models", async () => {
|
||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
||||
|
||||
@@ -1203,7 +1270,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
input: Record<string, unknown>,
|
||||
options: Record<string, unknown>,
|
||||
): Promise<PermissionResult>;
|
||||
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
||||
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1243,7 +1310,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
),
|
||||
).resolves.toMatchObject({ behavior: "deny", interrupt: undefined });
|
||||
|
||||
internal.translateMessageToEvents({
|
||||
await internal.translateMessageToEvents({
|
||||
type: "command_lifecycle",
|
||||
command_uuid: steerUuid,
|
||||
state: "started",
|
||||
@@ -1740,6 +1807,404 @@ describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
|
||||
});
|
||||
|
||||
describe("ClaudeAgentClient.listImportableSessions", () => {
|
||||
async function descriptorFixture(count: number) {
|
||||
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-errors-"));
|
||||
const projectDir = path.join(configDir, "projects", "errors");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const files: string[] = [];
|
||||
const descriptors: ImportableProviderSession[] = [];
|
||||
const logs: unknown[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const file = path.join(projectDir, `${index}.jsonl`);
|
||||
const timestamp = new Date(Date.UTC(2026, 0, 1) + (count - index) * 1000);
|
||||
await fs.writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: `session-${index}`,
|
||||
cwd: "/errors",
|
||||
message: { content: `Prompt ${index}` },
|
||||
}),
|
||||
);
|
||||
await fs.utimes(file, timestamp, timestamp);
|
||||
files.push(file);
|
||||
descriptors.push({
|
||||
providerHandleId: `session-${index}`,
|
||||
cwd: "/errors",
|
||||
title: `Prompt ${index}`,
|
||||
firstPromptPreview: `Prompt ${index}`,
|
||||
lastPromptPreview: `Prompt ${index}`,
|
||||
lastActivityAt: timestamp,
|
||||
});
|
||||
}
|
||||
const options = {
|
||||
logger: pino({ level: "warn" }, { write: (line: string) => logs.push(JSON.parse(line)) }),
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
|
||||
};
|
||||
return {
|
||||
files,
|
||||
descriptors,
|
||||
logs,
|
||||
options,
|
||||
cleanup: () => fs.rm(configDir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
const cannotEnforceFilePermissions = process.platform === "win32" || process.getuid?.() === 0;
|
||||
const discoveryPermissionCases = [
|
||||
{ label: "project enumeration", mode: 0, deniedSuffix: "", existingSuffix: "" },
|
||||
{
|
||||
label: "candidate stat",
|
||||
mode: 0o400,
|
||||
deniedSuffix: "hidden.jsonl",
|
||||
existingSuffix: "0.jsonl",
|
||||
},
|
||||
];
|
||||
test.skipIf(cannotEnforceFilePermissions).each(discoveryPermissionCases)(
|
||||
"logs denied $label while discovering a healthy project",
|
||||
async ({ mode, deniedSuffix }) => {
|
||||
const fixture = await descriptorFixture(1);
|
||||
const projectsRoot = path.dirname(path.dirname(fixture.files[0]!));
|
||||
const deniedProject = path.join(projectsRoot, "denied-project");
|
||||
await fs.mkdir(deniedProject);
|
||||
try {
|
||||
await fs.writeFile(path.join(deniedProject, "hidden.jsonl"), '{"type":"user"}');
|
||||
await fs.chmod(deniedProject, mode);
|
||||
const client = new ClaudeAgentClient(fixture.options);
|
||||
await expect(client.listImportableSessions({ limit: 2 })).resolves.toEqual(
|
||||
fixture.descriptors,
|
||||
);
|
||||
expect(fixture.logs).toEqual([
|
||||
expect.objectContaining({
|
||||
path: path.join(deniedProject, deniedSuffix),
|
||||
err: expect.objectContaining({ code: "EACCES" }),
|
||||
msg: "Skipping Claude session discovery path with denied access",
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await fs.chmod(deniedProject, 0o700);
|
||||
await fixture.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(cannotEnforceFilePermissions).each(discoveryPermissionCases)(
|
||||
"surfaces a provider error when $label denies discovery in every project",
|
||||
async ({ mode, deniedSuffix, existingSuffix }) => {
|
||||
const fixture = await descriptorFixture(1);
|
||||
const existingProject = path.dirname(fixture.files[0]!);
|
||||
const deniedProject = path.join(path.dirname(existingProject), "denied-project");
|
||||
await fs.mkdir(deniedProject);
|
||||
try {
|
||||
await fs.writeFile(path.join(deniedProject, "hidden.jsonl"), '{"type":"user"}');
|
||||
await fs.chmod(existingProject, mode);
|
||||
await fs.chmod(deniedProject, mode);
|
||||
const client = new ClaudeAgentClient(fixture.options);
|
||||
await expect(client.listImportableSessions({ limit: 2 })).rejects.toMatchObject({
|
||||
name: "AggregateError",
|
||||
message: expect.stringContaining("no transcripts could be discovered"),
|
||||
errors: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
code: "EACCES",
|
||||
path: path.join(existingProject, existingSuffix),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
code: "EACCES",
|
||||
path: path.join(deniedProject, deniedSuffix),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(fixture.logs).toHaveLength(2);
|
||||
expect(fixture.logs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: path.join(existingProject, existingSuffix),
|
||||
err: expect.objectContaining({ code: "EACCES" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: path.join(deniedProject, deniedSuffix),
|
||||
err: expect.objectContaining({ code: "EACCES" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await fs.chmod(existingProject, 0o700);
|
||||
await fs.chmod(deniedProject, 0o700);
|
||||
await fixture.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(cannotEnforceFilePermissions)(
|
||||
"isolates an actual access-denied transcript while returning a healthy session",
|
||||
async () => {
|
||||
const fixture = await descriptorFixture(2);
|
||||
try {
|
||||
await fs.chmod(fixture.files[0]!, 0);
|
||||
const client = new ClaudeAgentClient(fixture.options);
|
||||
await expect(client.listImportableSessions({ limit: 2 })).resolves.toEqual([
|
||||
fixture.descriptors[1],
|
||||
]);
|
||||
expect(fixture.logs).toEqual([
|
||||
expect.objectContaining({
|
||||
path: fixture.files[0],
|
||||
err: expect.objectContaining({ code: "EACCES" }),
|
||||
msg: "Skipping Claude transcript with unreadable import metadata",
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(cannotEnforceFilePermissions)(
|
||||
"surfaces a provider error when every actual candidate is access denied",
|
||||
async () => {
|
||||
const fixture = await descriptorFixture(2);
|
||||
try {
|
||||
await Promise.all(fixture.files.map((file) => fs.chmod(file, 0)));
|
||||
const client = new ClaudeAgentClient(fixture.options);
|
||||
await expect(client.listImportableSessions({ limit: 2 })).rejects.toMatchObject({
|
||||
name: "AggregateError",
|
||||
errors: fixture.files.map((file) =>
|
||||
expect.objectContaining({ code: "EACCES", path: file }),
|
||||
),
|
||||
message: expect.stringContaining(
|
||||
"all 2 candidate transcripts have unreadable import metadata",
|
||||
),
|
||||
});
|
||||
expect(fixture.logs).toHaveLength(2);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("returns an empty listing for readable transcripts without import metadata", async () => {
|
||||
const fixture = await descriptorFixture(2);
|
||||
try {
|
||||
await Promise.all(fixture.files.map((file) => fs.writeFile(file, '{"type":"assistant"}')));
|
||||
const client = new ClaudeAgentClient(fixture.options);
|
||||
await expect(client.listImportableSessions({ limit: 2 })).resolves.toEqual([]);
|
||||
expect(fixture.logs).toEqual([]);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshes descriptors after append, rewrite, replacement, and deletion", async () => {
|
||||
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-cache-"));
|
||||
try {
|
||||
const projectDir = path.join(configDir, "projects", "changing-transcript");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const sessionFile = path.join(projectDir, "changing.jsonl");
|
||||
const initial = `${JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: "changing-session",
|
||||
cwd: "/changing-project",
|
||||
message: { content: "First prompt" },
|
||||
})}\n`;
|
||||
function title(customTitle: string): string {
|
||||
return `${JSON.stringify({ type: "custom-title", customTitle })}\n`;
|
||||
}
|
||||
await fs.writeFile(sessionFile, initial + title("First title"));
|
||||
const timestamp = new Date("2026-06-01T12:00:00.000Z");
|
||||
await fs.utimes(sessionFile, timestamp, timestamp);
|
||||
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
|
||||
});
|
||||
const first = await client.listImportableSessions({ limit: 1 });
|
||||
expect(first.map((session) => session.title)).toEqual(["First title"]);
|
||||
// Callers must not be able to mutate a cached descriptor, including its Date.
|
||||
first[0]!.title = "Changed by caller";
|
||||
first[0]!.lastActivityAt.setTime(0);
|
||||
const repeated = await Promise.all([
|
||||
client.listImportableSessions({ limit: 1 }),
|
||||
client.listImportableSessions({ limit: 1 }),
|
||||
]);
|
||||
expect(repeated.map((sessions) => [sessions[0]!.title, sessions[0]!.lastActivityAt])).toEqual(
|
||||
[
|
||||
["First title", timestamp],
|
||||
["First title", timestamp],
|
||||
],
|
||||
);
|
||||
|
||||
repeated[0]![0]!.title = "Changed by overlapping caller";
|
||||
repeated[0]![0]!.lastActivityAt.setTime(0);
|
||||
expect([repeated[1]![0]!.title, repeated[1]![0]!.lastActivityAt]).toEqual([
|
||||
"First title",
|
||||
timestamp,
|
||||
]);
|
||||
|
||||
await fs.appendFile(sessionFile, title("After append"));
|
||||
expect(
|
||||
(await client.listImportableSessions({ limit: 1 })).map((session) => session.title),
|
||||
).toEqual(["After append"]);
|
||||
|
||||
// Same-length rewrites with a restored mtime still invalidate the descriptor.
|
||||
await fs.writeFile(sessionFile, initial + title("Other title"));
|
||||
await fs.utimes(sessionFile, timestamp, timestamp);
|
||||
expect(
|
||||
(await client.listImportableSessions({ limit: 1 })).map((session) => session.title),
|
||||
).toEqual(["Other title"]);
|
||||
|
||||
const replacement = path.join(projectDir, "replacement.tmp");
|
||||
await fs.writeFile(replacement, initial + title("Final title"));
|
||||
await fs.utimes(replacement, timestamp, timestamp);
|
||||
await fs.rename(replacement, sessionFile);
|
||||
expect(
|
||||
(await client.listImportableSessions({ limit: 1 })).map((session) => session.title),
|
||||
).toEqual(["Final title"]);
|
||||
|
||||
await fs.unlink(sessionFile);
|
||||
await expect(client.listImportableSessions({ limit: 1 })).resolves.toEqual([]);
|
||||
} finally {
|
||||
await fs.rm(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ providers: 1, requestsPerProvider: 4 },
|
||||
{ providers: 5, requestsPerProvider: 3 },
|
||||
])(
|
||||
"discovers large histories with $providers providers and $requestsPerProvider requests each",
|
||||
async ({ providers, requestsPerProvider }) => {
|
||||
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-heap-"));
|
||||
try {
|
||||
const projectDir = path.join(configDir, "projects", "large-transcript");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const sessionFile = path.join(projectDir, "large.jsonl");
|
||||
const transcript = await fs.open(sessionFile, "w");
|
||||
try {
|
||||
await transcript.writeFile(
|
||||
`${JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: "large-session",
|
||||
cwd: "/large-project",
|
||||
message: { content: "First prompt" },
|
||||
})}\r\n`,
|
||||
);
|
||||
const block = `${JSON.stringify({ type: "assistant", text: "x".repeat(1024) })}\n`.repeat(
|
||||
1024,
|
||||
);
|
||||
for (let index = 0; index < 128; index += 1) {
|
||||
await transcript.writeFile(block);
|
||||
}
|
||||
// A malformed record must not hide the final unterminated record.
|
||||
await transcript.writeFile(
|
||||
`incomplete json\n${JSON.stringify({
|
||||
type: "user",
|
||||
message: { content: "Last prompt 🐢" },
|
||||
})}`,
|
||||
);
|
||||
} finally {
|
||||
await transcript.close();
|
||||
}
|
||||
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
"--max-old-space-size=96",
|
||||
"--import",
|
||||
import.meta.resolve("tsx"),
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
`
|
||||
import { deepStrictEqual } from "node:assert";
|
||||
import { ClaudeAgentClient } from ${JSON.stringify(new URL("./agent.ts", import.meta.url).href)};
|
||||
import pino from ${JSON.stringify(import.meta.resolve("pino"))};
|
||||
import { mkdir, symlink } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
const clients = [];
|
||||
for (let index = 0; index < ${providers}; index++) {
|
||||
const providerConfigDir = path.join(${JSON.stringify(configDir)}, "alias-" + index);
|
||||
await mkdir(providerConfigDir);
|
||||
await symlink(path.join(${JSON.stringify(configDir)}, "projects"), path.join(providerConfigDir, "projects"), "junction");
|
||||
clients.push(new ClaudeAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } },
|
||||
}));
|
||||
}
|
||||
const listings = await Promise.all(clients.flatMap(client =>
|
||||
Array.from({ length: ${requestsPerProvider} }, () => client.listImportableSessions({ limit: 1 }))
|
||||
));
|
||||
const sessions = listings[0];
|
||||
for (const listing of listings) deepStrictEqual(listing, sessions);
|
||||
console.log(JSON.stringify(sessions));
|
||||
`,
|
||||
],
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
expect(JSON.parse(stdout)).toEqual([
|
||||
{
|
||||
providerHandleId: "large-session",
|
||||
cwd: "/large-project",
|
||||
title: "First prompt",
|
||||
firstPromptPreview: "First prompt",
|
||||
lastPromptPreview: "Last prompt 🐢",
|
||||
lastActivityAt: (await fs.stat(sessionFile)).mtime.toISOString(),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await fs.rm(configDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("keeps metadata and UTF-8 across read chunk boundaries", async () => {
|
||||
const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-limit-"));
|
||||
try {
|
||||
const projectDir = path.join(configDir, "projects", "boundary");
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
const prefix = '{"padding":"';
|
||||
// Split the four-byte turtle across a 64 KiB input chunk boundary.
|
||||
const padding = "x".repeat(64 * 1024 - Buffer.byteLength(prefix) - 1);
|
||||
const suffix =
|
||||
'","type":"user","sessionId":"boundary-session","cwd":"/boundary","message":{"content":"Hello 🐢"}}';
|
||||
const remaining = 4 * 1024 * 1024 - Buffer.byteLength(prefix + padding + "🐢" + suffix);
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, "boundary.jsonl"),
|
||||
prefix + padding + "🐢" + "x".repeat(remaining) + suffix,
|
||||
);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
|
||||
});
|
||||
expect(
|
||||
(await client.listImportableSessions({ limit: 1 })).map((session) => ({
|
||||
providerHandleId: session.providerHandleId,
|
||||
cwd: session.cwd,
|
||||
title: session.title,
|
||||
firstPromptPreview: session.firstPromptPreview,
|
||||
lastPromptPreview: session.lastPromptPreview,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
providerHandleId: "boundary-session",
|
||||
cwd: "/boundary",
|
||||
title: "Hello 🐢",
|
||||
firstPromptPreview: "Hello 🐢",
|
||||
lastPromptPreview: "Hello 🐢",
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await fs.rm(configDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects a cancelled listing before doing filesystem work", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("Picker closed"));
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
await expect(client.listImportableSessions({ signal: controller.signal })).rejects.toThrow(
|
||||
"Picker closed",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the latest native custom title and leaves fixture mtimes unchanged", async () => {
|
||||
const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-"));
|
||||
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
@@ -1992,7 +2457,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
test("emits canonical task snapshots from Claude TaskCreate results", async () => {
|
||||
const session = await createSessionForTest();
|
||||
try {
|
||||
session.translateMessageToEvents({
|
||||
await session.translateMessageToEvents({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
@@ -2006,7 +2471,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
},
|
||||
} as unknown as SDKMessage);
|
||||
|
||||
const events = session.translateMessageToEvents({
|
||||
const events = await session.translateMessageToEvents({
|
||||
type: "user",
|
||||
message: {
|
||||
content: [{ type: "tool_result", tool_use_id: "task-create-1", content: "created" }],
|
||||
@@ -3208,9 +3673,9 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
]);
|
||||
|
||||
try {
|
||||
const compactEvents = (session as unknown as TestClaudeSession).translateMessageToEvents(
|
||||
createCompactBoundary(),
|
||||
);
|
||||
const compactEvents = await (
|
||||
session as unknown as TestClaudeSession
|
||||
).translateMessageToEvents(createCompactBoundary());
|
||||
expect(compactEvents).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "usage_updated",
|
||||
@@ -3396,7 +3861,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
test("result.result is surfaced as an assistant message when no model output was produced", async () => {
|
||||
const session = await createSessionForTest();
|
||||
|
||||
const events = session.translateMessageToEvents({
|
||||
const events = await session.translateMessageToEvents({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "Unknown command: /foo-doesnt-exist",
|
||||
@@ -3431,7 +3896,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
test("result.result is not duplicated when the model produced output during the turn", async () => {
|
||||
const session = await createSessionForTest();
|
||||
|
||||
const events = session.translateMessageToEvents({
|
||||
const events = await session.translateMessageToEvents({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "Here is the answer.",
|
||||
@@ -3695,3 +4160,184 @@ describe("Claude question permission notifications", () => {
|
||||
expect(request.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude import descriptor scheduling", () => {
|
||||
function controlledReads() {
|
||||
const started: string[] = [];
|
||||
const reads = new Map<
|
||||
string,
|
||||
{ resolve: (value: string) => void; reject: (reason: unknown) => void }
|
||||
>();
|
||||
const signals = new Map<string, AbortSignal>();
|
||||
const sharedScheduler = createImportDescriptorScheduler(
|
||||
(candidate: { cacheKey: string }, signal) => {
|
||||
started.push(candidate.cacheKey);
|
||||
signals.set(candidate.cacheKey, signal);
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
reads.set(candidate.cacheKey, { resolve, reject });
|
||||
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
||||
});
|
||||
},
|
||||
);
|
||||
function scheduler(candidates: string[], signal: AbortSignal): Promise<string[]> {
|
||||
return sharedScheduler(
|
||||
candidates.map((cacheKey) => ({ cacheKey })),
|
||||
signal,
|
||||
);
|
||||
}
|
||||
return { scheduler, started, reads, signals };
|
||||
}
|
||||
|
||||
test("shares a cold read across overlapping listings and leaves capacity for unrelated work", async () => {
|
||||
const { scheduler, started, reads } = controlledReads();
|
||||
const signal = new AbortController().signal;
|
||||
const overlapping = Array.from({ length: 4 }, () => scheduler(["shared"], signal));
|
||||
await vi.waitFor(() => expect(started).toEqual(["shared"]));
|
||||
const unrelated = scheduler(["unrelated"], signal);
|
||||
await vi.waitFor(() => expect(started).toEqual(["shared", "unrelated"]));
|
||||
reads.get("unrelated")!.resolve("unrelated descriptor");
|
||||
await expect(unrelated).resolves.toEqual(["unrelated descriptor"]);
|
||||
reads.get("shared")!.resolve("shared descriptor");
|
||||
await expect(Promise.all(overlapping)).resolves.toEqual(
|
||||
Array.from({ length: 4 }, () => ["shared descriptor"]),
|
||||
);
|
||||
// A settled entry must not become an accidental second cache.
|
||||
const later = scheduler(["shared"], signal);
|
||||
await vi.waitFor(() => expect(started).toEqual(["shared", "unrelated", "shared"]));
|
||||
reads.get("shared")!.resolve("updated descriptor");
|
||||
await expect(later).resolves.toEqual(["updated descriptor"]);
|
||||
});
|
||||
|
||||
test("cancelling the initiating listing keeps its other subscriber alive", async () => {
|
||||
const { scheduler, started, reads } = controlledReads();
|
||||
const initiator = new AbortController();
|
||||
const first = scheduler(["shared"], initiator.signal);
|
||||
const firstRejected = expect(first).rejects.toThrow("First picker closed");
|
||||
const second = scheduler(["shared"], new AbortController().signal);
|
||||
await vi.waitFor(() => expect(started).toEqual(["shared"]));
|
||||
initiator.abort(new Error("First picker closed"));
|
||||
await firstRejected;
|
||||
reads.get("shared")!.resolve("shared descriptor");
|
||||
await expect(second).resolves.toEqual(["shared descriptor"]);
|
||||
expect(started).toEqual(["shared"]);
|
||||
});
|
||||
|
||||
test("the final subscriber cancellation aborts the read and allows a fresh subscription", async () => {
|
||||
const { scheduler, started, signals } = controlledReads();
|
||||
const firstController = new AbortController();
|
||||
const secondController = new AbortController();
|
||||
const first = scheduler(["shared"], firstController.signal);
|
||||
const second = scheduler(["shared"], secondController.signal);
|
||||
const firstRejected = expect(first).rejects.toThrow("First picker closed");
|
||||
const secondRejected = expect(second).rejects.toThrow("Final picker closed");
|
||||
await vi.waitFor(() => expect(started).toEqual(["shared"]));
|
||||
firstController.abort(new Error("First picker closed"));
|
||||
await firstRejected;
|
||||
expect(signals.get("shared")!.aborted).toEqual(false);
|
||||
secondController.abort(new Error("Final picker closed"));
|
||||
await secondRejected;
|
||||
expect(signals.get("shared")!.aborted).toEqual(true);
|
||||
const retryController = new AbortController();
|
||||
const retry = scheduler(["shared"], retryController.signal);
|
||||
const retryRejected = expect(retry).rejects.toThrow("Retry closed");
|
||||
await vi.waitFor(() => expect(started).toEqual(["shared", "shared"]));
|
||||
retryController.abort(new Error("Retry closed"));
|
||||
await retryRejected;
|
||||
expect(signals.get("shared")!.aborted).toEqual(true);
|
||||
});
|
||||
|
||||
test("runs four reads per listing and gives a later small listing a bounded turn", async () => {
|
||||
const { scheduler, started, reads, signals } = controlledReads();
|
||||
const controller = new AbortController();
|
||||
const large = scheduler(
|
||||
Array.from({ length: 500 }, (_, index) => `large-${index}`),
|
||||
controller.signal,
|
||||
);
|
||||
const largeRejected = expect(large).rejects.toThrow("Fairness check complete");
|
||||
await vi.waitFor(() => expect(started).toEqual(["large-0", "large-1", "large-2", "large-3"]));
|
||||
const small = scheduler(["small"], new AbortController().signal);
|
||||
reads.get("large-0")!.resolve("first descriptor");
|
||||
await vi.waitFor(() =>
|
||||
expect(started).toEqual(["large-0", "large-1", "large-2", "large-3", "small"]),
|
||||
);
|
||||
reads.get("small")!.resolve("small descriptor");
|
||||
await expect(small).resolves.toEqual(["small descriptor"]);
|
||||
controller.abort(new Error("Fairness check complete"));
|
||||
await largeRejected;
|
||||
expect(signals.get("large-1")!.aborted).toEqual(true);
|
||||
expect(signals.get("large-2")!.aborted).toEqual(true);
|
||||
expect(signals.get("large-3")!.aborted).toEqual(true);
|
||||
expect(started.length).toBeLessThanOrEqual(6);
|
||||
});
|
||||
|
||||
test("refills four workers while retaining candidate order after out-of-order completions", async () => {
|
||||
const { scheduler, started, reads } = controlledReads();
|
||||
const listing = scheduler(["0", "1", "2", "3", "4", "5"], new AbortController().signal);
|
||||
await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3"]));
|
||||
reads.get("3")!.resolve("descriptor-3");
|
||||
await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3", "4"]));
|
||||
reads.get("2")!.resolve("descriptor-2");
|
||||
await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3", "4", "5"]));
|
||||
reads.get("5")!.resolve("descriptor-5");
|
||||
reads.get("4")!.resolve("descriptor-4");
|
||||
reads.get("1")!.resolve("descriptor-1");
|
||||
reads.get("0")!.resolve("descriptor-0");
|
||||
await expect(listing).resolves.toEqual([
|
||||
"descriptor-0",
|
||||
"descriptor-1",
|
||||
"descriptor-2",
|
||||
"descriptor-3",
|
||||
"descriptor-4",
|
||||
"descriptor-5",
|
||||
]);
|
||||
});
|
||||
|
||||
test("an unexpected read failure cancels sibling workers without starting remaining candidates", async () => {
|
||||
const { scheduler, started, reads, signals } = controlledReads();
|
||||
const listing = scheduler(["0", "1", "2", "3", "4", "5"], new AbortController().signal);
|
||||
const rejected = expect(listing).rejects.toThrow("Unexpected read failure");
|
||||
await vi.waitFor(() => expect(started).toEqual(["0", "1", "2", "3"]));
|
||||
reads.get("0")!.reject(new TypeError("Unexpected read failure"));
|
||||
await rejected;
|
||||
expect(signals.get("1")!.aborted).toEqual(true);
|
||||
expect(signals.get("2")!.aborted).toEqual(true);
|
||||
expect(signals.get("3")!.aborted).toEqual(true);
|
||||
expect(started).toEqual(["0", "1", "2", "3"]);
|
||||
});
|
||||
|
||||
test("bounds global reads and skips cancelled queued candidates", async () => {
|
||||
const { scheduler, started, reads } = controlledReads();
|
||||
const controllers = Array.from({ length: 5 }, () => new AbortController());
|
||||
const listings = controllers.map((controller, index) =>
|
||||
scheduler([`${index}-first`], controller.signal),
|
||||
);
|
||||
// Attach handlers before aborting any promises.
|
||||
function errorMessage(error: Error): string {
|
||||
return error.message;
|
||||
}
|
||||
const settled = listings.map((listing) => listing.catch(errorMessage));
|
||||
await vi.waitFor(() => expect(started).toEqual(["0-first", "1-first", "2-first", "3-first"]));
|
||||
controllers[4]!.abort(new Error("Queued picker closed"));
|
||||
await expect(settled[4]).resolves.toEqual("Queued picker closed");
|
||||
controllers[0]!.abort(new Error("Active picker closed"));
|
||||
await expect(settled[0]).resolves.toEqual("Active picker closed");
|
||||
reads.get("1-first")!.reject(new Error("Stop 1"));
|
||||
reads.get("2-first")!.reject(new Error("Stop 2"));
|
||||
reads.get("3-first")!.reject(new Error("Stop 3"));
|
||||
await expect(Promise.all(settled)).resolves.toEqual([
|
||||
"Active picker closed",
|
||||
"Stop 1",
|
||||
"Stop 2",
|
||||
"Stop 3",
|
||||
"Queued picker closed",
|
||||
]);
|
||||
expect(started).toEqual(["0-first", "1-first", "2-first", "3-first"]);
|
||||
// A cancelled queue entry must not consume a later listing's read slot.
|
||||
const later = scheduler(["later"], new AbortController().signal);
|
||||
await vi.waitFor(() =>
|
||||
expect(started).toEqual(["0-first", "1-first", "2-first", "3-first", "later"]),
|
||||
);
|
||||
reads.get("later")!.resolve("later descriptor");
|
||||
await expect(later).resolves.toEqual(["later descriptor"]);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
import pLimit from "p-limit";
|
||||
|
||||
interface ImportDescriptorCandidate {
|
||||
cacheKey: string;
|
||||
}
|
||||
|
||||
interface PendingDescriptor<Descriptor> {
|
||||
controller: AbortController;
|
||||
subscribers: number;
|
||||
settled: boolean;
|
||||
promise: Promise<Descriptor>;
|
||||
}
|
||||
|
||||
export function createImportDescriptorScheduler<
|
||||
Candidate extends ImportDescriptorCandidate,
|
||||
Descriptor,
|
||||
>(
|
||||
read: (candidate: Candidate, signal: AbortSignal) => Promise<Descriptor>,
|
||||
): (candidates: Candidate[], signal: AbortSignal) => Promise<Descriptor[]> {
|
||||
const limit = pLimit(4);
|
||||
const reads = new Map<string, PendingDescriptor<Descriptor>>();
|
||||
|
||||
function acquire(candidate: Candidate): PendingDescriptor<Descriptor> {
|
||||
let pending = reads.get(candidate.cacheKey);
|
||||
if (!pending) {
|
||||
const controller = new AbortController();
|
||||
const entry: PendingDescriptor<Descriptor> = {
|
||||
controller,
|
||||
subscribers: 0,
|
||||
settled: false,
|
||||
promise: limit(() => {
|
||||
controller.signal.throwIfAborted();
|
||||
return read(candidate, controller.signal);
|
||||
}).finally(() => {
|
||||
entry.settled = true;
|
||||
if (reads.get(candidate.cacheKey) === entry) reads.delete(candidate.cacheKey);
|
||||
}),
|
||||
};
|
||||
reads.set(candidate.cacheKey, entry);
|
||||
pending = entry;
|
||||
}
|
||||
pending.subscribers += 1;
|
||||
return pending;
|
||||
}
|
||||
|
||||
function release(candidate: Candidate, pending: PendingDescriptor<Descriptor>): void {
|
||||
pending.subscribers -= 1;
|
||||
// The initiating listing has no special ownership of a shared read.
|
||||
if (pending.subscribers === 0 && !pending.settled) {
|
||||
if (reads.get(candidate.cacheKey) === pending) reads.delete(candidate.cacheKey);
|
||||
pending.controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
return async (candidates, signal) => {
|
||||
signal.throwIfAborted();
|
||||
const controller = new AbortController();
|
||||
signal = AbortSignal.any([signal, controller.signal]);
|
||||
let onAbort: (() => void) | undefined;
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
onAbort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
const results: Descriptor[] = [];
|
||||
let nextIndex = 0;
|
||||
async function worker(): Promise<void> {
|
||||
while (nextIndex < candidates.length) {
|
||||
signal.throwIfAborted();
|
||||
const index = nextIndex++;
|
||||
const candidate = candidates[index]!;
|
||||
// Each worker queues only its next read. Later listings enter the
|
||||
// shared FIFO after at most four outstanding reads from this listing.
|
||||
const pending = acquire(candidate);
|
||||
try {
|
||||
results[index] = await Promise.race([pending.promise, aborted]);
|
||||
} finally {
|
||||
release(candidate, pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const workers = Array.from({ length: Math.min(4, candidates.length) }, () => worker());
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
} catch (error) {
|
||||
controller.abort(error);
|
||||
throw error;
|
||||
} finally {
|
||||
if (onAbort) signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/;
|
||||
|
||||
/** The text Claude Code records in place of a turn that an interrupt stopped. */
|
||||
export function isClaudeInterruptPlaceholderText(value: unknown): boolean {
|
||||
return typeof value === "string" && INTERRUPT_PLACEHOLDER_PATTERN.test(value.trim());
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { realpathSync } from "node:fs";
|
||||
import { realpath, access, readdir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -35,21 +35,21 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti
|
||||
// one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the
|
||||
// cwd under another name (a Windows binary under WSL). Returns the expected path when no
|
||||
// transcript exists anywhere.
|
||||
export function claudeTranscriptPathSync(input: {
|
||||
export async function claudeTranscriptPath(input: {
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
configDir?: string;
|
||||
}): string {
|
||||
}): Promise<string> {
|
||||
const options = { configDir: input.configDir };
|
||||
const fileName = `${input.sessionId}.jsonl`;
|
||||
const expected = join(claudeProjectDirSync(input.cwd, options), fileName);
|
||||
if (existsSync(expected)) {
|
||||
const expected = join(await claudeProjectDir(input.cwd, options), fileName);
|
||||
if (await exists(expected)) {
|
||||
return expected;
|
||||
}
|
||||
const projectsRoot = join(resolveConfigDir(options), "projects");
|
||||
let projectDirs: string[];
|
||||
try {
|
||||
projectDirs = readdirSync(projectsRoot);
|
||||
projectDirs = await readdir(projectsRoot);
|
||||
} catch (error) {
|
||||
if (isMissingDirectoryError(error)) {
|
||||
return expected;
|
||||
@@ -58,13 +58,23 @@ export function claudeTranscriptPathSync(input: {
|
||||
}
|
||||
for (const projectDir of projectDirs) {
|
||||
const candidate = join(projectsRoot, projectDir, fileName);
|
||||
if (existsSync(candidate)) {
|
||||
if (await exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
async function exists(file: string): Promise<boolean> {
|
||||
try {
|
||||
await access(file);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissingDirectoryError(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isMissingDirectoryError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | null)?.code;
|
||||
return code === "ENOENT" || code === "ENOTDIR";
|
||||
|
||||
@@ -2,11 +2,10 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:chi
|
||||
import { query, type Options, type Query, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import {
|
||||
createProviderEnv,
|
||||
createProviderEnvSpec,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../../provider-launch-config.js";
|
||||
import { buildSelfNodeCommand } from "../../../paseo-env.js";
|
||||
import { buildSelfNodeCommand, createExternalProcessEnv } from "../../../paseo-env.js";
|
||||
import { spawnProcess } from "../../../../utils/spawn.js";
|
||||
|
||||
// Keep the raw SDK query import in this module only. Claude process launch behavior
|
||||
@@ -74,11 +73,10 @@ function applyRuntimeSettingsToClaudeOptions(
|
||||
runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
});
|
||||
const providerEnv = createProviderEnv({
|
||||
baseEnv: spawnOptions.env,
|
||||
runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
});
|
||||
// Session-open hooks may explicitly supply a client identity. Keep inherited
|
||||
// SDK/parent identities cleared while honoring this launch-only contribution.
|
||||
providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = launchEnv?.CLAUDE_CODE_ENTRYPOINT;
|
||||
const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay);
|
||||
const selfNodeCommand = isDefaultRuntime
|
||||
? buildSelfNodeCommand(resolved.args, providerEnv)
|
||||
: null;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import pino from "pino";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
|
||||
/** Full-scale reproduction is opt-in: run this file with ROOT children --generate. */
|
||||
export function generateReplayCorpus(
|
||||
root: string,
|
||||
size = { parentMiB: 395, childrenMiB: 362, children: 269 },
|
||||
): { cwd: string; configDir: string } {
|
||||
const cwd = path.join(root, "repo");
|
||||
const configDir = path.join(root, "claude");
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
const project = claudeProjectDirSync(cwd, { configDir });
|
||||
const subagents = path.join(project, "large-session", "subagents");
|
||||
mkdirSync(subagents, { recursive: true });
|
||||
const parent = path.join(project, "large-session.jsonl");
|
||||
const row = (value: unknown) => JSON.stringify(value) + "\n";
|
||||
// Large transcripts include records and context summaries that never become timeline items.
|
||||
// Generate those incrementally: the fixture generator itself must not hold the corpus.
|
||||
const padding = row({ type: "progress", data: "x".repeat(64 * 1024) });
|
||||
writeFileSync(parent, "");
|
||||
for (let i = 0; i < Math.ceil((size.parentMiB * 1024 * 1024) / padding.length); i++) {
|
||||
appendFileSync(parent, padding);
|
||||
}
|
||||
appendFileSync(parent, row({ type: "user", message: { content: "Open the large session" } }));
|
||||
for (let i = 0; i < size.children; i++) {
|
||||
const agentId = `child-${i}`;
|
||||
const id = `task-${i}`;
|
||||
appendFileSync(
|
||||
parent,
|
||||
row({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "tool_use", id, name: "Task", input: { description: agentId } }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
appendFileSync(
|
||||
parent,
|
||||
row({
|
||||
type: "user",
|
||||
message: { content: [{ type: "tool_result", tool_use_id: id, content: "done" }] },
|
||||
}),
|
||||
);
|
||||
const file = path.join(subagents, `agent-${agentId}.jsonl`);
|
||||
writeFileSync(file, "");
|
||||
const summary = row({
|
||||
type: "user",
|
||||
isCompactSummary: true,
|
||||
isSidechain: true,
|
||||
agentId,
|
||||
message: { content: "x".repeat(64 * 1024) },
|
||||
});
|
||||
for (
|
||||
let j = 0;
|
||||
j < Math.ceil((size.childrenMiB * 1024 * 1024) / size.children / summary.length);
|
||||
j++
|
||||
)
|
||||
appendFileSync(file, summary);
|
||||
appendFileSync(
|
||||
file,
|
||||
row({
|
||||
type: "assistant",
|
||||
isSidechain: true,
|
||||
agentId,
|
||||
timestamp: "2026-07-26T06:28:00.000Z",
|
||||
message: {
|
||||
content: [{ type: "text", text: `History of ${agentId}` }],
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(subagents, `agent-${agentId}.meta.json`),
|
||||
JSON.stringify({ toolUseId: id }),
|
||||
);
|
||||
}
|
||||
return { cwd, configDir };
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const root = process.argv[2];
|
||||
if (!root) throw new Error("Usage: replay-memory.fixture.ts ROOT children|workflow [--generate]");
|
||||
if (process.argv.includes("--generate")) {
|
||||
if (process.argv[3] === "workflow") generateWorkflowReplayCorpus(root);
|
||||
else generateReplayCorpus(root);
|
||||
}
|
||||
process.env.CLAUDE_CONFIG_DIR = path.join(root, "claude");
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
queryFactory: () => {
|
||||
throw new Error("Replay must not query Claude");
|
||||
},
|
||||
resolveVersion: async () => "2.1.220",
|
||||
});
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "claude", sessionId: "large-session" },
|
||||
{ cwd: path.join(root, "repo") },
|
||||
);
|
||||
let parentItems = 0;
|
||||
const completed = new Set<string>();
|
||||
const histories = new Set<string>();
|
||||
for await (const event of session.streamHistory()) {
|
||||
if (event.type === "timeline") parentItems++;
|
||||
if (
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "upsert" &&
|
||||
event.event.status === "completed"
|
||||
)
|
||||
completed.add(event.event.id);
|
||||
if (
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "timeline" &&
|
||||
event.event.item.type === "assistant_message"
|
||||
)
|
||||
histories.add(event.event.item.text);
|
||||
}
|
||||
await session.close();
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
parentItems,
|
||||
completed: completed.size,
|
||||
histories: histories.size,
|
||||
maxRSSKiB: process.resourceUsage().maxRSS,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function generateWorkflowReplayCorpus(
|
||||
root: string,
|
||||
childMiB = 320,
|
||||
): { cwd: string; configDir: string } {
|
||||
const cwd = path.join(root, "repo");
|
||||
const configDir = path.join(root, "claude");
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
const project = claudeProjectDirSync(cwd, { configDir });
|
||||
const runId = "wf_large";
|
||||
const sessionDirectory = path.join(project, "large-session");
|
||||
const childrenDirectory = path.join(sessionDirectory, "subagents", "workflows", runId);
|
||||
mkdirSync(childrenDirectory, { recursive: true });
|
||||
mkdirSync(path.join(sessionDirectory, "workflows"), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(project, "large-session.jsonl"),
|
||||
[
|
||||
{ type: "user", message: { content: "Open the large session" } },
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{ type: "tool_use", id: "workflow-task", name: "Workflow", input: {} }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "workflow-task",
|
||||
content: `Workflow launched in background.\nRun ID: ${runId}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n"),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(sessionDirectory, "workflows", `${runId}.json`),
|
||||
JSON.stringify({ runId, status: "completed", summary: "Large workflow" }),
|
||||
);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const file = path.join(childrenDirectory, `agent-child-${i}.jsonl`);
|
||||
writeFileSync(file, "");
|
||||
const prompt =
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
isSidechain: true,
|
||||
agentId: `child-${i}`,
|
||||
message: { content: "x".repeat(64 * 1024) },
|
||||
}) + "\n";
|
||||
for (let j = 0; j < Math.ceil((childMiB * 1024 * 1024) / prompt.length); j++)
|
||||
appendFileSync(file, prompt);
|
||||
appendFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
isSidechain: true,
|
||||
agentId: `child-${i}`,
|
||||
timestamp: `2026-07-26T06:28:0${4 - i}.000Z`,
|
||||
message: {
|
||||
content: [{ type: "text", text: `History of child-${i}` }],
|
||||
stop_reason: "end_turn",
|
||||
},
|
||||
}) + "\n",
|
||||
);
|
||||
}
|
||||
return { cwd, configDir };
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export async function revertClaudeConversation(input: {
|
||||
transcriptPath: string | null;
|
||||
messageId: string;
|
||||
resolveMessageId?: (messageId: string) => string | Promise<string>;
|
||||
setSessionId: (sessionId: string) => void;
|
||||
setSessionId: (sessionId: string) => void | Promise<void>;
|
||||
}): Promise<void> {
|
||||
if (!input.sessionId || !input.transcriptPath) {
|
||||
throw new Error("Claude session is not ready for rewind");
|
||||
@@ -86,7 +86,7 @@ export async function revertClaudeConversation(input: {
|
||||
upToMessageId: messageId,
|
||||
transcriptPath: input.transcriptPath,
|
||||
});
|
||||
input.setSessionId(fork.sessionId);
|
||||
await input.setSessionId(fork.sessionId);
|
||||
}
|
||||
|
||||
export async function revertClaudeFiles(input: {
|
||||
@@ -108,7 +108,7 @@ export async function revertClaudeConversationAndFiles(input: {
|
||||
transcriptPath: string | null;
|
||||
messageId: string;
|
||||
resolveMessageId?: (messageId: string) => string | Promise<string>;
|
||||
setSessionId: (sessionId: string) => void;
|
||||
setSessionId: (sessionId: string) => void | Promise<void>;
|
||||
}): Promise<void> {
|
||||
await revertClaudeFiles({
|
||||
query: input.query,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { parseClaudeCodeVersion } from "./model-manifest.js";
|
||||
|
||||
/** Process-local background ownership. Completion delivery can follow an empty roster. */
|
||||
export class ClaudeRuntimeSafety {
|
||||
private supported = false;
|
||||
private liveTasks = new Set<string>();
|
||||
private readonly awaitingNotifications = new Set<string>();
|
||||
|
||||
reset(): void {
|
||||
this.supported = false;
|
||||
this.liveTasks.clear();
|
||||
this.awaitingNotifications.clear();
|
||||
}
|
||||
|
||||
observe(message: SDKMessage): void {
|
||||
if (message.type !== "system") return;
|
||||
if (message.subtype === "init") {
|
||||
// 2.1.292 is a known roster-capable release. Older binaries can demonstrate
|
||||
// support by sending a roster; a fresh process starts with an empty roster.
|
||||
const version = parseClaudeCodeVersion(message.claude_code_version ?? "");
|
||||
if (
|
||||
version &&
|
||||
(version[0] > 2 ||
|
||||
(version[0] === 2 && (version[1] > 1 || (version[1] === 1 && version[2] >= 292))))
|
||||
)
|
||||
this.supported = true;
|
||||
}
|
||||
if (message.subtype === "background_tasks_changed") {
|
||||
this.supported = true;
|
||||
this.liveTasks = new Set(message.tasks.map((task) => task.task_id));
|
||||
for (const id of this.liveTasks) this.awaitingNotifications.add(id);
|
||||
}
|
||||
if (message.subtype === "task_notification") {
|
||||
this.awaitingNotifications.delete(message.task_id);
|
||||
}
|
||||
}
|
||||
|
||||
canStopRuntime(): boolean {
|
||||
return this.supported && this.liveTasks.size === 0 && this.awaitingNotifications.size === 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
|
||||
test("the real SDK passes the user's settings file contents alongside Paseo flags", async () => {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-settings-argv-"));
|
||||
const capturePath = path.join(cwd, "argv.json");
|
||||
const binaryPath = path.join(cwd, "capture.mjs");
|
||||
const settingsPath = path.join(cwd, "settings.json");
|
||||
const userSettings = {
|
||||
env: { ENABLE_TOOL_SEARCH: "false" },
|
||||
permissions: { deny: ["Monitor"] },
|
||||
};
|
||||
try {
|
||||
// A CLI fixture records the real SDK's arguments without making a model request.
|
||||
await fs.writeFile(
|
||||
binaryPath,
|
||||
`import fs from "node:fs"; fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify(process.argv.slice(2)));`,
|
||||
);
|
||||
await fs.writeFile(settingsPath, JSON.stringify(userSettings));
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
resolveBinary: async () => binaryPath,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
model: "claude-opus-5-5",
|
||||
providerOptions: { extraArgs: { settings: settingsPath } },
|
||||
});
|
||||
try {
|
||||
await session.startTurn("hello");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
const argv: string[] = JSON.parse(await fs.readFile(capturePath, "utf8"));
|
||||
const settingsIndex = argv.indexOf("--settings");
|
||||
expect(settingsIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(JSON.parse(argv[settingsIndex + 1]!)).toEqual({ ...userSettings, fastMode: false });
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
|
||||
import { mapTaskStatus } from "./task-status.js";
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import type { AgentMetadata } from "../../../agent-sdk-types.js";
|
||||
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
|
||||
import { resolveObservedClaudeModelId } from "../models.js";
|
||||
import type { SubagentObservation } from "./observation.js";
|
||||
import {
|
||||
@@ -120,28 +121,6 @@ function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The descriptor has no paused or pending state, and neither is terminal, so both read as
|
||||
* running. `killed` is a cancellation, not a failure — the child was stopped, it did not error.
|
||||
*/
|
||||
function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
case "running":
|
||||
case "paused":
|
||||
return "running";
|
||||
case "completed":
|
||||
return "completed";
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "killed":
|
||||
case "stopped":
|
||||
return "canceled";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ClaudeTaskProtocolSourceInput {
|
||||
/**
|
||||
* The parent's Task tool input, by tool_use id.
|
||||
|
||||
+346
-265
@@ -1,3 +1,9 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
materializeProviderImage,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
} from "../../provider-image-output.js";
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
@@ -30,7 +36,7 @@ function parentWithTaskCall(failed = false): ClaudeReplayParentFacts {
|
||||
],
|
||||
]),
|
||||
linksByAgentId: new Map(),
|
||||
outcomesByToolCallId: new Map([[TOOL_USE_ID, { failed }]]),
|
||||
outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: failed ? "failed" : "completed" }]]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,7 +51,7 @@ function applyToStore(observations: SubagentObservation[]): ProviderSubagentDesc
|
||||
}
|
||||
|
||||
describe("parseClaudeSubagentMeta", () => {
|
||||
it("reads the real sidecar shape", () => {
|
||||
it("reads the real sidecar shape", async () => {
|
||||
expect(
|
||||
parseClaudeSubagentMeta(
|
||||
'{"agentType":"general-purpose","description":"Reply banana","toolUseId":"toolu_013i","spawnDepth":1}',
|
||||
@@ -68,7 +74,7 @@ describe("parseClaudeSubagentMeta", () => {
|
||||
expect(parseClaudeSubagentMeta(contents)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the good fields when one field has the wrong type", () => {
|
||||
it("keeps the good fields when one field has the wrong type", async () => {
|
||||
expect(parseClaudeSubagentMeta('{"agentType":"Explore","toolUseId":42}')).toEqual({
|
||||
agentType: "Explore",
|
||||
description: undefined,
|
||||
@@ -79,12 +85,14 @@ describe("parseClaudeSubagentMeta", () => {
|
||||
});
|
||||
|
||||
describe("observeReplaySubagents", () => {
|
||||
it("links a subagent to its Task call through meta.toolUseId", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
it("links a subagent to its Task call through meta.toolUseId", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
|
||||
expect(observations[0]).toMatchObject({
|
||||
kind: "declared",
|
||||
@@ -95,31 +103,33 @@ describe("observeReplaySubagents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("replays descendants beneath the sidechain that declared their Task", () => {
|
||||
it("replays descendants beneath the sidechain that declared their Task", async () => {
|
||||
const nestedToolUseId = "toolu_012rzYnFZA";
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 },
|
||||
entries: [],
|
||||
parentFacts: {
|
||||
toolCalls: new Map([
|
||||
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
|
||||
]),
|
||||
linksByAgentId: new Map(),
|
||||
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 },
|
||||
entries: [],
|
||||
parentFacts: {
|
||||
toolCalls: new Map([
|
||||
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
|
||||
]),
|
||||
linksByAgentId: new Map(),
|
||||
outcomesByToolCallId: new Map([[nestedToolUseId, { status: "completed" }]]),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: "a6acb4b898",
|
||||
meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 },
|
||||
entries: [],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
{
|
||||
agentId: "a6acb4b898",
|
||||
meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 },
|
||||
entries: [],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
|
||||
expect(observations).toContainEqual(
|
||||
expect.objectContaining({
|
||||
@@ -130,89 +140,143 @@ describe("observeReplaySubagents", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a subagent recorded before spawnDepth existed", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
it("keeps a subagent recorded before spawnDepth existed", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
|
||||
expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID });
|
||||
});
|
||||
|
||||
it("drops a meta-linked subagent when the parent never declared its Task", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" },
|
||||
entries: [],
|
||||
},
|
||||
],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
it("drops a meta-linked subagent when the parent never declared its Task", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" },
|
||||
entries: [],
|
||||
},
|
||||
],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
|
||||
expect(observations).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to the scraped link when there is no meta file", () => {
|
||||
it("falls back to the scraped link when there is no meta file", async () => {
|
||||
const parent: ClaudeReplayParentFacts = {
|
||||
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
|
||||
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]),
|
||||
outcomesByToolCallId: new Map(),
|
||||
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]),
|
||||
outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: "completed" }]]),
|
||||
};
|
||||
|
||||
const [declared, ...rest] = observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
|
||||
parent,
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
const [declared, ...rest] = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
|
||||
parent,
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
|
||||
expect(declared).toMatchObject({ id: TOOL_USE_ID, toolCallId: TOOL_USE_ID, title: "Explore" });
|
||||
expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" });
|
||||
});
|
||||
|
||||
it("drops an unlinkable subagent", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
it("drops an unlinkable subagent", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
expect(observations).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops a terminal subagent the parent has no Task call for", () => {
|
||||
it("drops a terminal subagent the parent has no Task call for", async () => {
|
||||
// A legacy grandchild: its toolUseId names a Task call made inside a sibling's transcript, so
|
||||
// no tool_result for it can ever reach the parent. Left running, it never stops.
|
||||
const descriptor = applyToStore(
|
||||
observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-31T03:41:00.000Z",
|
||||
message: { stop_reason: "end_turn" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
}).observations,
|
||||
(
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-31T03:41:00.000Z",
|
||||
message: { stop_reason: "end_turn" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations,
|
||||
);
|
||||
|
||||
expect(descriptor).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a terminal skill-spawned subagent that has no link at all", () => {
|
||||
it("drops a terminal skill-spawned subagent that has no link at all", async () => {
|
||||
// A /code-review run: no Task tool_use exists to name it, so its sidecar carries only
|
||||
// agentType and its id appears nowhere in the parent transcript.
|
||||
const descriptor = applyToStore(
|
||||
observeReplaySubagents({
|
||||
(
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { agentType: "general-purpose" },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-31T03:41:00.000Z",
|
||||
message: { stop_reason: "end_turn" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations,
|
||||
);
|
||||
|
||||
expect(descriptor).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a scraped link's failure as failed", async () => {
|
||||
const descriptor = applyToStore(
|
||||
(
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
|
||||
parent: {
|
||||
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
|
||||
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]),
|
||||
outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: "failed" }]]),
|
||||
},
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations,
|
||||
);
|
||||
|
||||
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
|
||||
});
|
||||
|
||||
it("drops a non-terminal linkless subagent", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
@@ -221,111 +285,81 @@ describe("observeReplaySubagents", () => {
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-31T03:41:00.000Z",
|
||||
message: { stop_reason: "end_turn" },
|
||||
message: { stop_reason: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
}).observations,
|
||||
);
|
||||
|
||||
expect(descriptor).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a scraped link's failure as failed", () => {
|
||||
const descriptor = applyToStore(
|
||||
observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
|
||||
parent: {
|
||||
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
|
||||
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]),
|
||||
outcomesByToolCallId: new Map(),
|
||||
},
|
||||
convertEntry: () => [],
|
||||
}).observations,
|
||||
);
|
||||
|
||||
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
|
||||
});
|
||||
|
||||
it("drops a non-terminal linkless subagent", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { agentType: "general-purpose" },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-31T03:41:00.000Z",
|
||||
message: { stop_reason: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: emptyParent(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
})
|
||||
).observations;
|
||||
|
||||
expect(observations).toEqual([]);
|
||||
});
|
||||
|
||||
it("finishes a terminal Task child when the parent has not recorded its outcome", () => {
|
||||
it("finishes a terminal Task child when the parent has not recorded its outcome", async () => {
|
||||
const descriptor = applyToStore(
|
||||
observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-31T03:41:00.000Z",
|
||||
message: { stop_reason: "end_turn" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
|
||||
convertEntry: () => [],
|
||||
}).observations,
|
||||
(
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-31T03:41:00.000Z",
|
||||
message: { stop_reason: "end_turn" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations,
|
||||
);
|
||||
|
||||
expect(descriptor).toMatchObject({ status: "completed" });
|
||||
});
|
||||
|
||||
it("leaves a subagent with no recorded outcome running", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
it("leaves a subagent with no recorded outcome running", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
expect(observations.some((o) => o.kind === "status")).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a failed outcome as failed", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(true),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
it("reports a failed outcome as failed", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(true),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
expect(observations.at(-1)).toMatchObject({ kind: "status", status: "failed" });
|
||||
});
|
||||
|
||||
it("carries entry timestamps onto timeline observations", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [{ type: "reasoning", text: "thinking" }],
|
||||
}).observations;
|
||||
it("carries entry timestamps onto timeline observations", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [{ type: "reasoning", text: "thinking" }],
|
||||
})
|
||||
).observations;
|
||||
|
||||
expect(observations.find((observation) => observation.kind === "timeline")).toMatchObject({
|
||||
kind: "timeline",
|
||||
@@ -336,18 +370,20 @@ describe("observeReplaySubagents", () => {
|
||||
});
|
||||
|
||||
describe("replay runtime", () => {
|
||||
it("reads the model and effort off the subagent's own transcript", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
it("reads the model and effort off the subagent's own transcript", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
|
||||
expect(observations.find((o) => o.kind === "subtitle")).toEqual({
|
||||
kind: "subtitle",
|
||||
@@ -356,49 +392,55 @@ describe("replay runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the last observed values when the model changes mid-transcript", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } },
|
||||
{ type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
it("takes the last observed values when the model changes mid-transcript", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } },
|
||||
{ type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
|
||||
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
|
||||
subtitle: "general-purpose · Sonnet 5 · Low",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports nothing when the transcript never names a model or effort", () => {
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
it("reports nothing when the transcript never names a model or effort", async () => {
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
expect(observations.some((o) => o.kind === "subtitle")).toBe(false);
|
||||
});
|
||||
|
||||
it("passes through a model the manifest does not know", () => {
|
||||
it("passes through a model the manifest does not know", async () => {
|
||||
// Claude Code is an Anthropic-compatible client; subagents legitimately report other models.
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [{ type: "assistant", message: { model: "glm-5.1" } }],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [{ type: "assistant", message: { model: "glm-5.1" } }],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
|
||||
subtitle: "general-purpose · glm-5.1",
|
||||
});
|
||||
@@ -406,20 +448,24 @@ describe("replay runtime", () => {
|
||||
});
|
||||
|
||||
describe("replay usage", () => {
|
||||
function subtitleOf(entries: ClaudeReplayEntry[]): SubagentObservation | undefined {
|
||||
return observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations.find((observation) => observation.kind === "subtitle");
|
||||
async function subtitleOf(
|
||||
entries: ClaudeReplayEntry[],
|
||||
): Promise<SubagentObservation | undefined> {
|
||||
return (
|
||||
await observeReplaySubagents({
|
||||
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations.find((observation) => observation.kind === "subtitle");
|
||||
}
|
||||
|
||||
it("derives the counters Claude Code itself reports, from a real transcript's shape", () => {
|
||||
it("derives the counters Claude Code itself reports, from a real transcript's shape", async () => {
|
||||
// Shape and numbers taken from an on-disk subagent transcript. Claude Code finalizes a
|
||||
// subagent from the LAST assistant entry's usage, so the earlier, smaller turn must not win
|
||||
// and must not be added in.
|
||||
expect(
|
||||
subtitleOf([
|
||||
await subtitleOf([
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-23T23:18:43.023Z",
|
||||
@@ -462,9 +508,9 @@ describe("replay usage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("formats tokens for a child that only answered", () => {
|
||||
it("formats tokens for a child that only answered", async () => {
|
||||
expect(
|
||||
subtitleOf([
|
||||
await subtitleOf([
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-27T16:42:14.698Z",
|
||||
@@ -479,12 +525,12 @@ describe("replay usage", () => {
|
||||
).toMatchObject({ subtitle: "general-purpose · 16.4k tokens" });
|
||||
});
|
||||
|
||||
it("emits nothing for a transcript with no entries", () => {
|
||||
expect(subtitleOf([])).toBeUndefined();
|
||||
it("emits nothing for a transcript with no entries", async () => {
|
||||
expect(await subtitleOf([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps duration out of the compact subtitle", () => {
|
||||
const observation = subtitleOf([
|
||||
it("keeps duration out of the compact subtitle", async () => {
|
||||
const observation = await subtitleOf([
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-27T16:42:14.698Z",
|
||||
@@ -494,8 +540,8 @@ describe("replay usage", () => {
|
||||
expect(observation).toMatchObject({ subtitle: "general-purpose · 10 tokens" });
|
||||
});
|
||||
|
||||
it("omits totalTokens when no entry carries a usage block", () => {
|
||||
const observation = subtitleOf([
|
||||
it("omits totalTokens when no entry carries a usage block", async () => {
|
||||
const observation = await subtitleOf([
|
||||
{ type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", message: { content: [] } },
|
||||
{
|
||||
type: "assistant",
|
||||
@@ -506,9 +552,9 @@ describe("replay usage", () => {
|
||||
expect(observation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the last usage-bearing entry when the final assistant entry has none", () => {
|
||||
it("keeps the last usage-bearing entry when the final assistant entry has none", async () => {
|
||||
expect(
|
||||
subtitleOf([
|
||||
await subtitleOf([
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-27T16:42:14.698Z",
|
||||
@@ -519,34 +565,36 @@ describe("replay usage", () => {
|
||||
).toMatchObject({ subtitle: "general-purpose · 1k tokens" });
|
||||
});
|
||||
|
||||
it("only reports counters the transcript actually shows", () => {
|
||||
it("only reports counters the transcript actually shows", async () => {
|
||||
// A user-only transcript says nothing about cost: no assistant turn, so no tool count either.
|
||||
const observation = subtitleOf([
|
||||
const observation = await subtitleOf([
|
||||
{ type: "user", timestamp: "2026-07-27T16:42:14.698Z", message: { content: "prompt" } },
|
||||
{ type: "user", timestamp: "2026-07-27T16:42:20.000Z", message: { content: "more" } },
|
||||
]);
|
||||
expect(observation).toBeUndefined();
|
||||
});
|
||||
|
||||
it("lands the complete provider-owned subtitle on the descriptor", () => {
|
||||
it("lands the complete provider-owned subtitle on the descriptor", async () => {
|
||||
const store = new ProviderSubagentStore();
|
||||
const observations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-27T16:42:14.698Z",
|
||||
message: { content: [], usage: { input_tokens: 7 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
const observations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [
|
||||
{
|
||||
type: "assistant",
|
||||
timestamp: "2026-07-27T16:42:14.698Z",
|
||||
message: { content: [], usage: { input_tokens: 7 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
for (const event of foldSubagentObservations(observations)) {
|
||||
store.apply("parent", "claude", event);
|
||||
}
|
||||
@@ -559,7 +607,7 @@ describe("live and replay agree", () => {
|
||||
// The property the design exists to guarantee: one session, two sources, one descriptor.
|
||||
// This is inexpressible against the inference implementation, where the paths share no
|
||||
// vocabulary and each derives identity and status its own way.
|
||||
it("produces identical descriptor state from the wire and from disk", () => {
|
||||
it("produces identical descriptor state from the wire and from disk", async () => {
|
||||
const live = new ClaudeTaskProtocolSource();
|
||||
const liveObservations = [
|
||||
...live.observe({
|
||||
@@ -579,21 +627,23 @@ describe("live and replay agree", () => {
|
||||
} as unknown as SDKMessage),
|
||||
];
|
||||
|
||||
const replayObservations = observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: {
|
||||
toolUseId: TOOL_USE_ID,
|
||||
agentType: "general-purpose",
|
||||
description: "Summarize hover and unistyles docs",
|
||||
const replayObservations = (
|
||||
await observeReplaySubagents({
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: {
|
||||
toolUseId: TOOL_USE_ID,
|
||||
agentType: "general-purpose",
|
||||
description: "Summarize hover and unistyles docs",
|
||||
},
|
||||
entries: [],
|
||||
},
|
||||
entries: [],
|
||||
},
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
}).observations;
|
||||
],
|
||||
parent: parentWithTaskCall(),
|
||||
convertEntry: () => [],
|
||||
})
|
||||
).observations;
|
||||
|
||||
const fromWire = applyToStore(liveObservations);
|
||||
const fromDisk = applyToStore(replayObservations);
|
||||
@@ -616,3 +666,34 @@ describe("live and replay agree", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("awaits image materialization before publishing replay observations", async () => {
|
||||
const bytes = Buffer.from("async replay image fixture");
|
||||
const convertEntry = async () => {
|
||||
const item = await renderProviderImageOutputAsAssistantMarkdown(
|
||||
{ data: bytes.toString("base64"), mimeType: "image/png" },
|
||||
{ materialize: materializeProviderImage },
|
||||
);
|
||||
return item ? [item] : [];
|
||||
};
|
||||
const { observations } = await observeReplaySubagents({
|
||||
parent: parentWithTaskCall(),
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [{ type: "assistant", message: { content: [] } }],
|
||||
},
|
||||
],
|
||||
convertEntry,
|
||||
});
|
||||
const image = observations.find(
|
||||
(observation) =>
|
||||
observation.kind === "timeline" && observation.item.type === "assistant_message",
|
||||
);
|
||||
if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message")
|
||||
throw new Error("Expected replay image");
|
||||
const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1];
|
||||
expect(source).toBeTruthy();
|
||||
expect(await readFile(fileURLToPath(source!))).toEqual(bytes);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import type { AgentTimelineItem } from "../../../agent-sdk-types.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js";
|
||||
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
|
||||
import { isClaudeInterruptPlaceholderText } from "../interrupt-placeholder.js";
|
||||
import { resolveObservedClaudeModelId } from "../models.js";
|
||||
import type { SubagentObservation } from "./observation.js";
|
||||
import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js";
|
||||
@@ -65,17 +66,12 @@ export interface ClaudeReplayEntry {
|
||||
* Effort is replay-only: the SDK's live assistant message does not carry it, and it can be
|
||||
* silently downgraded for the selected model, so any live value would be a guess.
|
||||
*/
|
||||
function readRuntime(entries: readonly ClaudeReplayEntry[]): { model?: string; effort?: string } {
|
||||
const runtime: { model?: string; effort?: string } = {};
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== "assistant") continue;
|
||||
const effort = typeof entry.effort === "string" ? entry.effort.trim() : "";
|
||||
if (effort) runtime.effort = effort;
|
||||
const rawModel = (entry.message as { model?: unknown } | undefined)?.model;
|
||||
const model = resolveObservedClaudeModelId(typeof rawModel === "string" ? rawModel : undefined);
|
||||
if (model) runtime.model = model;
|
||||
}
|
||||
return runtime;
|
||||
function readRuntime(entry: ClaudeReplayEntry): { model?: string; effort?: string } {
|
||||
if (entry.type !== "assistant") return {};
|
||||
const effort = typeof entry.effort === "string" ? entry.effort.trim() : "";
|
||||
const rawModel = entry.message?.model;
|
||||
const model = resolveObservedClaudeModelId(typeof rawModel === "string" ? rawModel : undefined);
|
||||
return { ...(effort ? { effort } : {}), ...(model ? { model } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,22 +122,16 @@ function readTotalTokens(raw: unknown): number | undefined {
|
||||
*
|
||||
* The last assistant entry's summed usage matches Claude Code's live `total_tokens` definition.
|
||||
*/
|
||||
function readUsage(entries: readonly ClaudeReplayEntry[]): ClaudeSubagentUsage | null {
|
||||
let totalTokens: number | undefined;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== "assistant") continue;
|
||||
const observed = readTotalTokens(entry.message?.usage);
|
||||
if (observed !== undefined) totalTokens = observed;
|
||||
}
|
||||
|
||||
function readUsage(entry: ClaudeReplayEntry): ClaudeSubagentUsage | null {
|
||||
if (entry.type !== "assistant") return null;
|
||||
const totalTokens = readTotalTokens(entry.message?.usage);
|
||||
return totalTokens === undefined ? null : { totalTokens };
|
||||
}
|
||||
|
||||
export interface ClaudeReplaySubagentInput {
|
||||
agentId: string;
|
||||
meta: ClaudeSubagentMeta | null;
|
||||
entries: ClaudeReplayEntry[];
|
||||
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
|
||||
/** Task declarations made by this subagent, used to attach its direct children. */
|
||||
parentFacts?: ClaudeReplayParentFacts;
|
||||
}
|
||||
@@ -150,9 +140,12 @@ export interface ClaudeReplayParentFacts {
|
||||
/** Task `tool_use` id -> identity declared in the parent's tool input. */
|
||||
toolCalls: ReadonlyMap<string, { title?: string; description?: string }>;
|
||||
/** agentId -> tool call, recovered by scraping tool-result text. Legacy fallback only. */
|
||||
linksByAgentId: ReadonlyMap<string, { toolCallId: string; failed: boolean }>;
|
||||
/** Task `tool_use` id -> outcome. Usable once meta.json supplies the link directly. */
|
||||
outcomesByToolCallId: ReadonlyMap<string, { failed: boolean }>;
|
||||
linksByAgentId: ReadonlyMap<string, { toolCallId: string }>;
|
||||
/**
|
||||
* Task `tool_use` id -> outcome, whichever way the child was linked. A background launch has
|
||||
* none: its immediate result only says the child started.
|
||||
*/
|
||||
outcomesByToolCallId: ReadonlyMap<string, { status: ProviderSubagentStatus }>;
|
||||
}
|
||||
|
||||
interface ParentLink {
|
||||
@@ -188,39 +181,14 @@ function resolveParentLink(
|
||||
parent: ClaudeReplayParentFacts,
|
||||
): ParentLink | null {
|
||||
const metaToolUseId = subagent.meta?.toolUseId?.trim();
|
||||
if (metaToolUseId && parent.toolCalls.has(metaToolUseId)) {
|
||||
const outcome = parent.outcomesByToolCallId.get(metaToolUseId);
|
||||
let status: ProviderSubagentStatus | null = null;
|
||||
if (outcome) status = outcome.failed ? "failed" : "completed";
|
||||
return {
|
||||
id: metaToolUseId,
|
||||
toolCallId: metaToolUseId,
|
||||
status,
|
||||
};
|
||||
}
|
||||
const toolCallId =
|
||||
metaToolUseId && parent.toolCalls.has(metaToolUseId)
|
||||
? metaToolUseId
|
||||
: parent.linksByAgentId.get(subagent.agentId)?.toolCallId;
|
||||
if (!toolCallId || !parent.toolCalls.has(toolCallId)) return null;
|
||||
|
||||
const scraped = parent.linksByAgentId.get(subagent.agentId);
|
||||
if (scraped && parent.toolCalls.has(scraped.toolCallId)) {
|
||||
return {
|
||||
id: scraped.toolCallId,
|
||||
toolCallId: scraped.toolCallId,
|
||||
status: scraped.failed ? "failed" : "completed",
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A final `end_turn` is the child's own completion signal when the parent result is absent. */
|
||||
function readChildTerminalStatus(
|
||||
entries: readonly ClaudeReplayEntry[],
|
||||
): ProviderSubagentStatus | null {
|
||||
for (let index = entries.length - 1; index >= 0; index--) {
|
||||
const entry = entries[index];
|
||||
if (entry?.type !== "assistant") continue;
|
||||
return entry.message?.stop_reason === "end_turn" ? "completed" : null;
|
||||
}
|
||||
return null;
|
||||
const status = parent.outcomesByToolCallId.get(toolCallId)?.status ?? null;
|
||||
return { id: toolCallId, toolCallId, status };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,11 +199,11 @@ function declareSubagent(
|
||||
subagent: ClaudeReplaySubagentInput,
|
||||
link: ParentLink,
|
||||
toolCall: { title?: string; description?: string } | undefined,
|
||||
parentSubagentId?: string,
|
||||
parentSubagentId: string | undefined,
|
||||
firstTimestamp: string | null,
|
||||
): SubagentObservation {
|
||||
const title = toolCall?.title ?? subagent.meta?.agentType;
|
||||
const description = toolCall?.description ?? subagent.meta?.description;
|
||||
const firstTimestamp = normalizeProviderReplayTimestamp(subagent.entries[0]?.timestamp);
|
||||
return {
|
||||
kind: "declared",
|
||||
id: link.id,
|
||||
@@ -248,12 +216,12 @@ function declareSubagent(
|
||||
}
|
||||
|
||||
function observeSubtitle(
|
||||
subagent: ClaudeReplaySubagentInput,
|
||||
link: ParentLink,
|
||||
title: string | undefined,
|
||||
runtime: { model?: string; effort?: string },
|
||||
usage: ClaudeSubagentUsage | null,
|
||||
timestamp: string | null,
|
||||
): SubagentObservation | null {
|
||||
const runtime = readRuntime(subagent.entries);
|
||||
const usage = readUsage(subagent.entries);
|
||||
const hasDetails =
|
||||
runtime.model !== undefined ||
|
||||
runtime.effort !== undefined ||
|
||||
@@ -265,7 +233,6 @@ function observeSubtitle(
|
||||
...(usage ? { usage } : {}),
|
||||
});
|
||||
if (!subtitle) return null;
|
||||
const timestamp = normalizeProviderReplayTimestamp(subagent.entries.at(-1)?.timestamp);
|
||||
return {
|
||||
kind: "subtitle",
|
||||
id: link.id,
|
||||
@@ -274,39 +241,52 @@ function observeSubtitle(
|
||||
};
|
||||
}
|
||||
|
||||
function observeSubagent(
|
||||
async function observeSubagent(
|
||||
subagent: ClaudeReplaySubagentInput,
|
||||
parent: ClaudeReplayParentFacts,
|
||||
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[],
|
||||
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
|
||||
toolOwners: Map<string, string>,
|
||||
parentSubagentId?: string,
|
||||
): SubagentObservation[] {
|
||||
): Promise<SubagentObservation[]> {
|
||||
const link = resolveParentLink(subagent, parent);
|
||||
if (!link) return [];
|
||||
const toolCall = parent.toolCalls.get(link.toolCallId);
|
||||
const timeline: SubagentObservation[] = [];
|
||||
const runtime: { model?: string; effort?: string } = {};
|
||||
let usage: ClaudeSubagentUsage | null = null;
|
||||
let firstTimestamp: string | null = null;
|
||||
let lastTimestamp: string | null = null;
|
||||
let seenEntry = false;
|
||||
let childTerminalStatus: ProviderSubagentStatus | null = null;
|
||||
|
||||
const observations: SubagentObservation[] = [
|
||||
declareSubagent(subagent, link, toolCall, parentSubagentId),
|
||||
];
|
||||
const subtitle = observeSubtitle(subagent, link, toolCall?.title ?? subagent.meta?.agentType);
|
||||
if (subtitle) observations.push(subtitle);
|
||||
|
||||
for (const entry of subagent.entries) {
|
||||
// Consume each source once for presentation and timeline; keep no raw transcript records.
|
||||
for await (const entry of subagent.entries) {
|
||||
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
|
||||
for (const item of convertEntry(entry)) {
|
||||
observations.push({
|
||||
kind: "timeline",
|
||||
id: link.id,
|
||||
item,
|
||||
...(timestamp ? { timestamp } : {}),
|
||||
});
|
||||
if (!seenEntry) firstTimestamp = timestamp;
|
||||
seenEntry = true;
|
||||
lastTimestamp = timestamp;
|
||||
Object.assign(runtime, readRuntime(entry));
|
||||
usage = readUsage(entry) ?? usage;
|
||||
childTerminalStatus = readChildTerminalStatus(entry, childTerminalStatus);
|
||||
recordReplayToolOwners(toolOwners, entry, link.id);
|
||||
for (const item of await convertEntry(entry)) {
|
||||
timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) });
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer the parent's result because it carries failure. If that result is missing, the child's
|
||||
// final end_turn still proves completion. Any other shape remains running.
|
||||
const terminalStatus = link.status ?? readChildTerminalStatus(subagent.entries);
|
||||
const observations = [
|
||||
declareSubagent(subagent, link, toolCall, parentSubagentId, firstTimestamp),
|
||||
];
|
||||
const subtitle = observeSubtitle(
|
||||
link,
|
||||
toolCall?.title ?? subagent.meta?.agentType,
|
||||
runtime,
|
||||
usage,
|
||||
lastTimestamp,
|
||||
);
|
||||
if (subtitle) observations.push(subtitle);
|
||||
for (const observation of timeline) observations.push(observation);
|
||||
const terminalStatus = link.status ?? childTerminalStatus;
|
||||
if (terminalStatus) {
|
||||
const lastTimestamp = normalizeProviderReplayTimestamp(subagent.entries.at(-1)?.timestamp);
|
||||
observations.push({
|
||||
kind: "status",
|
||||
id: link.id,
|
||||
@@ -314,30 +294,50 @@ function observeSubagent(
|
||||
...(lastTimestamp ? { timestamp: lastTimestamp } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return observations;
|
||||
}
|
||||
|
||||
/** Advance the child's fallback outcome without retaining transcript entries. */
|
||||
function readChildTerminalStatus(
|
||||
entry: ClaudeReplayEntry,
|
||||
previous: ProviderSubagentStatus | null,
|
||||
): ProviderSubagentStatus | null {
|
||||
if (entry.type === "assistant") {
|
||||
return entry.message?.stop_reason === "end_turn" ? "completed" : null;
|
||||
}
|
||||
if (entry.type !== "user") return previous;
|
||||
const texts = readUserTexts(entry);
|
||||
if (texts.length === 0) return previous;
|
||||
return texts.some(isClaudeInterruptPlaceholderText) ? "canceled" : null;
|
||||
}
|
||||
|
||||
/** Tool results carry no user text and do not change the child's terminal status. */
|
||||
function readUserTexts(entry: ClaudeReplayEntry): string[] {
|
||||
const content = entry.message?.content;
|
||||
if (typeof content === "string") return [content];
|
||||
if (!Array.isArray(content)) return [];
|
||||
return content.flatMap((block: { type?: unknown; text?: unknown } | null) =>
|
||||
block?.type === "text" && typeof block.text === "string" ? [block.text] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function recordReplayToolOwners(
|
||||
owners: Map<string, string>,
|
||||
entries: readonly ClaudeReplayEntry[],
|
||||
entry: ClaudeReplayEntry,
|
||||
subagentId: string,
|
||||
): void {
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== "assistant" || !Array.isArray(entry.message?.content)) continue;
|
||||
for (const block of entry.message.content) {
|
||||
if (block?.type === "tool_use" && typeof block.id === "string") {
|
||||
owners.set(block.id, subagentId);
|
||||
}
|
||||
}
|
||||
if (entry.type !== "assistant" || !Array.isArray(entry.message?.content)) return;
|
||||
for (const block of entry.message.content) {
|
||||
if (block?.type === "tool_use" && typeof block.id === "string")
|
||||
owners.set(block.id, subagentId);
|
||||
}
|
||||
}
|
||||
|
||||
export function observeReplaySubagents(input: {
|
||||
export async function observeReplaySubagents(input: {
|
||||
subagents: readonly ClaudeReplaySubagentInput[];
|
||||
parent: ClaudeReplayParentFacts;
|
||||
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
|
||||
}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> } {
|
||||
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
|
||||
}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> {
|
||||
const observations: SubagentObservation[] = [];
|
||||
const toolOwners = new Map<string, string>();
|
||||
const unresolved = [...input.subagents].sort(
|
||||
@@ -359,8 +359,15 @@ export function observeReplaySubagents(input: {
|
||||
const { ownerId, parent, link } = resolved;
|
||||
|
||||
// Only proven descendants may own notifications; ambient sidecars cannot claim them.
|
||||
recordReplayToolOwners(toolOwners, subagent.entries, link.id);
|
||||
observations.push(...observeSubagent(subagent, parent, input.convertEntry, ownerId));
|
||||
for (const observation of await observeSubagent(
|
||||
subagent,
|
||||
parent,
|
||||
input.convertEntry,
|
||||
toolOwners,
|
||||
ownerId,
|
||||
)) {
|
||||
observations.push(observation);
|
||||
}
|
||||
if (subagent.parentFacts) resolvedParents.set(link.id, subagent.parentFacts);
|
||||
unresolved.splice(index, 1);
|
||||
madeProgress = true;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
|
||||
|
||||
/**
|
||||
* The descriptor has no paused or pending state, and neither is terminal, so both read as
|
||||
* running. `killed` is a cancellation, not a failure — the child was stopped, it did not error.
|
||||
*/
|
||||
export function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
case "running":
|
||||
case "paused":
|
||||
return "running";
|
||||
case "completed":
|
||||
return "completed";
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "killed":
|
||||
case "stopped":
|
||||
return "canceled";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+45
-13
@@ -1,3 +1,9 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
materializeProviderImage,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
} from "../../provider-image-output.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ProviderSubagentStore } from "../../../provider-subagents/store.js";
|
||||
@@ -35,7 +41,7 @@ function parentEntries(
|
||||
}
|
||||
|
||||
describe("Claude workflow replay", () => {
|
||||
it("parses the persisted run summary observed from Claude Code 2.1.220", () => {
|
||||
it("parses the persisted run summary observed from Claude Code 2.1.220", async () => {
|
||||
expect(
|
||||
parseClaudeWorkflowRun(
|
||||
JSON.stringify({
|
||||
@@ -62,8 +68,8 @@ describe("Claude workflow replay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rebuilds the same generic completed descriptor through the parent Workflow call", () => {
|
||||
const observations = observeReplayWorkflows({
|
||||
it("rebuilds the same generic completed descriptor through the parent Workflow call", async () => {
|
||||
const observations = await observeReplayWorkflows({
|
||||
workflows: [
|
||||
{
|
||||
runId: RUN_ID,
|
||||
@@ -91,8 +97,8 @@ describe("Claude workflow replay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rebuilds a workflow when Claude persists its result as structured text blocks", () => {
|
||||
const observations = observeReplayWorkflows({
|
||||
it("rebuilds a workflow when Claude persists its result as structured text blocks", async () => {
|
||||
const observations = await observeReplayWorkflows({
|
||||
workflows: [{ runId: RUN_ID, summary: "Structured result workflow", status: "completed" }],
|
||||
parentEntries: parentEntries([
|
||||
{
|
||||
@@ -114,7 +120,7 @@ describe("Claude workflow replay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("replays the workflow result without exposing its script or child prompt", () => {
|
||||
it("replays the workflow result without exposing its script or child prompt", async () => {
|
||||
const workflow = parseClaudeWorkflowRun(
|
||||
JSON.stringify({
|
||||
runId: RUN_ID,
|
||||
@@ -128,7 +134,7 @@ describe("Claude workflow replay", () => {
|
||||
expect(workflow).not.toBeNull();
|
||||
if (!workflow) throw new Error("expected workflow");
|
||||
|
||||
const observations = observeReplayWorkflows({
|
||||
const observations = await observeReplayWorkflows({
|
||||
workflows: [workflow],
|
||||
parentEntries: parentEntries(),
|
||||
});
|
||||
@@ -145,9 +151,9 @@ describe("Claude workflow replay", () => {
|
||||
expect(JSON.stringify(observations)).not.toContain("SECRET CHILD PROMPT");
|
||||
});
|
||||
|
||||
it("does not duplicate a result already present in the child transcript", () => {
|
||||
it("does not duplicate a result already present in the child transcript", async () => {
|
||||
const report = "What Paseo is\nA local-first coding-agent environment.";
|
||||
const observations = observeReplayWorkflows({
|
||||
const observations = await observeReplayWorkflows({
|
||||
workflows: [
|
||||
{ runId: RUN_ID, summary: "Inspect Paseo", status: "completed", result: { report } },
|
||||
],
|
||||
@@ -166,8 +172,8 @@ describe("Claude workflow replay", () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("fails a nonterminal persisted run because its former runtime cannot update it", () => {
|
||||
const observations = observeReplayWorkflows({
|
||||
it("fails a nonterminal persisted run because its former runtime cannot update it", async () => {
|
||||
const observations = await observeReplayWorkflows({
|
||||
workflows: [{ runId: RUN_ID, summary: "Interrupted workflow", status: "running" }],
|
||||
parentEntries: parentEntries(),
|
||||
});
|
||||
@@ -179,9 +185,9 @@ describe("Claude workflow replay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a run that no parent Workflow tool call declared", () => {
|
||||
it("drops a run that no parent Workflow tool call declared", async () => {
|
||||
expect(
|
||||
observeReplayWorkflows({
|
||||
await observeReplayWorkflows({
|
||||
workflows: [{ runId: RUN_ID, status: "completed" }],
|
||||
parentEntries: [],
|
||||
}),
|
||||
@@ -195,3 +201,29 @@ describe("Claude workflow replay", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("awaits image materialization before publishing replay observations", async () => {
|
||||
const bytes = Buffer.from("async replay image fixture");
|
||||
const convertEntry = async () => {
|
||||
const item = await renderProviderImageOutputAsAssistantMarkdown(
|
||||
{ data: bytes.toString("base64"), mimeType: "image/png" },
|
||||
{ materialize: materializeProviderImage },
|
||||
);
|
||||
return item ? [item] : [];
|
||||
};
|
||||
const observations = await observeReplayWorkflows({
|
||||
workflows: [{ runId: RUN_ID, status: "completed" }],
|
||||
parentEntries: parentEntries(),
|
||||
entriesByRunId: new Map([[RUN_ID, [{ type: "assistant", message: { content: [] } }]]]),
|
||||
convertEntry,
|
||||
});
|
||||
const image = observations.find(
|
||||
(observation) =>
|
||||
observation.kind === "timeline" && observation.item.type === "assistant_message",
|
||||
);
|
||||
if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message")
|
||||
throw new Error("Expected replay image");
|
||||
const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1];
|
||||
expect(source).toBeTruthy();
|
||||
expect(await readFile(fileURLToPath(source!))).toEqual(bytes);
|
||||
});
|
||||
|
||||
+30
-34
@@ -45,13 +45,17 @@ export function parseClaudeWorkflowRun(contents: string): ClaudeWorkflowRun | nu
|
||||
* provider runtime was recreated, so replay terminalizes it as failed instead of resurrecting a
|
||||
* row that can never receive another update.
|
||||
*/
|
||||
export function observeReplayWorkflows(input: {
|
||||
export async function observeReplayWorkflows(input: {
|
||||
workflows: readonly ClaudeWorkflowRun[];
|
||||
parentEntries: readonly ClaudeWorkflowParentEntry[];
|
||||
entriesByRunId?: ReadonlyMap<string, readonly ClaudeReplayEntry[]>;
|
||||
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
|
||||
}): SubagentObservation[] {
|
||||
const toolCallIdByRunId = readWorkflowLinks(input.parentEntries);
|
||||
parentEntries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>;
|
||||
/** Repeatable entries in chronological order, supplied by the transcript reader. */
|
||||
entriesByRunId?: ReadonlyMap<
|
||||
string,
|
||||
Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>
|
||||
>;
|
||||
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
|
||||
}): Promise<SubagentObservation[]> {
|
||||
const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries);
|
||||
const observations: SubagentObservation[] = [];
|
||||
|
||||
for (const workflow of input.workflows) {
|
||||
@@ -78,15 +82,15 @@ export function observeReplayWorkflows(input: {
|
||||
});
|
||||
}
|
||||
|
||||
observations.push(
|
||||
...observeWorkflowTimeline({
|
||||
id,
|
||||
result: workflow.result,
|
||||
entries: input.entriesByRunId?.get(workflow.runId) ?? [],
|
||||
convertEntry: input.convertEntry,
|
||||
finishedAt,
|
||||
}),
|
||||
);
|
||||
for (const observation of await observeWorkflowTimeline({
|
||||
id,
|
||||
result: workflow.result,
|
||||
entries: input.entriesByRunId?.get(workflow.runId) ?? [],
|
||||
convertEntry: input.convertEntry,
|
||||
finishedAt,
|
||||
})) {
|
||||
observations.push(observation);
|
||||
}
|
||||
|
||||
const subtitle = buildClaudeSubagentSubtitle({
|
||||
title: "Workflow",
|
||||
@@ -114,20 +118,19 @@ export function observeReplayWorkflows(input: {
|
||||
return observations;
|
||||
}
|
||||
|
||||
function observeWorkflowTimeline(input: {
|
||||
async function observeWorkflowTimeline(input: {
|
||||
id: string;
|
||||
result: unknown;
|
||||
entries: readonly ClaudeReplayEntry[];
|
||||
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
|
||||
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
|
||||
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
|
||||
finishedAt?: string;
|
||||
}): SubagentObservation[] {
|
||||
}): Promise<SubagentObservation[]> {
|
||||
const observations: SubagentObservation[] = [];
|
||||
const replayedAssistantText = new Set<string>();
|
||||
const entries = [...input.entries].sort(compareReplayTimestamps);
|
||||
|
||||
for (const entry of entries) {
|
||||
for await (const entry of input.entries) {
|
||||
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
|
||||
const items = input.convertEntry?.(entry) ?? [];
|
||||
const items = (await input.convertEntry?.(entry)) ?? [];
|
||||
for (const item of items) {
|
||||
if (item.type === "assistant_message") replayedAssistantText.add(item.text.trim());
|
||||
observations.push({
|
||||
@@ -150,20 +153,13 @@ function observeWorkflowTimeline(input: {
|
||||
return observations;
|
||||
}
|
||||
|
||||
function compareReplayTimestamps(a: ClaudeReplayEntry, b: ClaudeReplayEntry): number {
|
||||
const aTimestamp = normalizeProviderReplayTimestamp(a.timestamp);
|
||||
const bTimestamp = normalizeProviderReplayTimestamp(b.timestamp);
|
||||
if (!aTimestamp && !bTimestamp) return 0;
|
||||
if (!aTimestamp) return 1;
|
||||
if (!bTimestamp) return -1;
|
||||
return Date.parse(aTimestamp) - Date.parse(bTimestamp);
|
||||
}
|
||||
|
||||
function readWorkflowLinks(entries: readonly ClaudeWorkflowParentEntry[]): Map<string, string> {
|
||||
async function readWorkflowLinks(
|
||||
entries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>,
|
||||
): Promise<Map<string, string>> {
|
||||
const workflowToolCallIds = new Set<string>();
|
||||
const toolCallIdByRunId = new Map<string, string>();
|
||||
|
||||
for (const entry of entries) {
|
||||
for await (const entry of entries) {
|
||||
const content = entry.message?.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const value of content) {
|
||||
@@ -174,7 +170,7 @@ function readWorkflowLinks(entries: readonly ClaudeWorkflowParentEntry[]): Map<s
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
for await (const entry of entries) {
|
||||
const content = entry.message?.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const value of content) {
|
||||
|
||||
@@ -97,4 +97,17 @@ describe("task-notification-tool-call", () => {
|
||||
|
||||
expect(item).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps notifications without task IDs distinct by their message IDs", () => {
|
||||
const items = ["message-1", "message-2"].map((messageId) =>
|
||||
mapTaskNotificationUserContentToToolCall({
|
||||
content: "<task-notification><status>completed</status></task-notification>",
|
||||
messageId,
|
||||
}),
|
||||
);
|
||||
expect(items.map((item) => item?.callId)).toEqual([
|
||||
"task_notification_message-1",
|
||||
"task_notification_message-2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ const OptionalNonEmptyTrimmedStringSchema = z.preprocess(
|
||||
);
|
||||
|
||||
const TaskNotificationEnvelopeSchema = z.object({
|
||||
notificationId: z.string().optional(),
|
||||
messageId: z.string().nullable(),
|
||||
taskId: z.string().nullable(),
|
||||
toolUseId: z.string().nullable(),
|
||||
@@ -35,7 +36,9 @@ const TaskNotificationUserContentSchema = z.union([
|
||||
|
||||
const TaskNotificationHistoryRecordSchema = z
|
||||
.object({
|
||||
notificationId: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
operation: z.string().optional(),
|
||||
subtype: z.string().optional(),
|
||||
uuid: z.string().optional(),
|
||||
message_id: z.string().optional(),
|
||||
@@ -52,6 +55,7 @@ const TaskNotificationHistoryRecordSchema = z
|
||||
const MapTaskNotificationUserContentToToolCallInputSchema = z.object({
|
||||
content: z.unknown(),
|
||||
messageId: z.string().optional().nullable(),
|
||||
notificationId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TaskNotificationEnvelope = z.infer<typeof TaskNotificationEnvelopeSchema>;
|
||||
@@ -155,6 +159,7 @@ function parseTaskNotificationFromUserContent(
|
||||
}
|
||||
|
||||
return TaskNotificationEnvelopeSchema.parse({
|
||||
notificationId: parsedInput.data.notificationId,
|
||||
messageId: toNonEmptyString(parsedInput.data.messageId),
|
||||
taskId: readTaskNotificationTagValue({ text: rawText, tagName: "task-id" }),
|
||||
toolUseId:
|
||||
@@ -186,6 +191,7 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
|
||||
const rawText = toNonEmptyString(systemRecord.content);
|
||||
|
||||
return TaskNotificationEnvelopeSchema.parse({
|
||||
notificationId: systemRecord.notificationId,
|
||||
messageId: toNonEmptyString(systemRecord.uuid) ?? toNonEmptyString(systemRecord.message_id),
|
||||
taskId:
|
||||
toNonEmptyString(systemRecord.task_id) ??
|
||||
@@ -212,25 +218,78 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalize notification identity for one transcript traversal. Queue bookkeeping and delivery
|
||||
* are separate records of the same occurrence; each source counts repeated identical bodies
|
||||
* independently so the second event is not merged with the first. */
|
||||
export async function* assignTaskNotificationReplayIds<T>(
|
||||
entries: AsyncIterable<T>,
|
||||
): AsyncGenerator<T> {
|
||||
const occurrences = new Map<string, Map<string, number>>();
|
||||
for await (const entry of entries) {
|
||||
const record = TaskNotificationHistoryRecordSchema.safeParse(entry);
|
||||
if (!record.success) {
|
||||
yield entry;
|
||||
continue;
|
||||
}
|
||||
const data = record.data;
|
||||
const envelope =
|
||||
data.type === "user"
|
||||
? parseTaskNotificationFromUserContent({ content: data.message?.content })
|
||||
: parseTaskNotificationFromSystemRecord(data);
|
||||
if (!envelope?.taskId || !data.type) {
|
||||
yield entry;
|
||||
continue;
|
||||
}
|
||||
const signature = taskNotificationReplaySignature(envelope);
|
||||
const source = data.type === "queue-operation" ? `queue-${data.operation}` : data.type;
|
||||
const counts = occurrences.get(signature) ?? new Map<string, number>();
|
||||
const occurrence = (counts.get(source) ?? 0) + 1;
|
||||
counts.set(source, occurrence);
|
||||
occurrences.set(signature, counts);
|
||||
yield { ...entry, notificationId: `task_notification_${signature}_${occurrence}` };
|
||||
}
|
||||
}
|
||||
|
||||
function taskNotificationReplaySignature(envelope: TaskNotificationEnvelope): string {
|
||||
// Known fields are canonical across XML user/queue records and structured system records.
|
||||
// Keep the remaining body too: Monitor events share a summary but carry different <event>s.
|
||||
const remainder = (envelope.rawText ?? "")
|
||||
.replace(/<\/?task-notification>/gi, "")
|
||||
.replace(
|
||||
/<(task-id|tool-use-id|tool_use_id|status|summary|output-file|output_file)>[\s\S]*?<\/\1>/gi,
|
||||
"",
|
||||
)
|
||||
.trim();
|
||||
return createHash("sha1")
|
||||
.update(
|
||||
JSON.stringify([
|
||||
envelope.taskId,
|
||||
envelope.toolUseId,
|
||||
envelope.status,
|
||||
envelope.summary,
|
||||
envelope.outputFile,
|
||||
remainder,
|
||||
]),
|
||||
)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function normalizeTaskNotificationCallIdSegment(segment: string): string | null {
|
||||
const normalized = segment.trim().replace(/[^a-zA-Z0-9._:-]+/g, "_");
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function buildTaskNotificationCallId(envelope: TaskNotificationEnvelope): string {
|
||||
if (envelope.notificationId) return envelope.notificationId;
|
||||
const messageSegment = envelope.messageId
|
||||
? normalizeTaskNotificationCallIdSegment(envelope.messageId)
|
||||
: null;
|
||||
if (messageSegment) {
|
||||
return `task_notification_${messageSegment}`;
|
||||
}
|
||||
if (messageSegment) return `task_notification_${messageSegment}`;
|
||||
|
||||
const taskSegment = envelope.taskId
|
||||
? normalizeTaskNotificationCallIdSegment(envelope.taskId)
|
||||
: null;
|
||||
if (taskSegment) {
|
||||
return `task_notification_${taskSegment}`;
|
||||
}
|
||||
if (taskSegment) return `task_notification_${taskSegment}`;
|
||||
|
||||
const seed =
|
||||
[envelope.status, envelope.summary, envelope.outputFile, envelope.rawText]
|
||||
@@ -348,19 +407,21 @@ export function mapTaskNotificationSystemRecordToToolCall(
|
||||
}
|
||||
|
||||
export function readTaskNotificationToolUseIdFromHistoryRecord(record: unknown): string | null {
|
||||
return readTaskNotificationFromHistoryRecord(record)?.toolUseId ?? null;
|
||||
}
|
||||
|
||||
export function readTaskNotificationFromHistoryRecord(
|
||||
record: unknown,
|
||||
): TaskNotificationEnvelope | null {
|
||||
const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record);
|
||||
if (!parsedRecord.success) {
|
||||
return null;
|
||||
}
|
||||
if (!parsedRecord.success) return null;
|
||||
if (parsedRecord.data.type === "user" && parsedRecord.data.message) {
|
||||
return (
|
||||
parseTaskNotificationFromUserContent({
|
||||
content: parsedRecord.data.message.content,
|
||||
messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id,
|
||||
})?.toolUseId ?? null
|
||||
);
|
||||
return parseTaskNotificationFromUserContent({
|
||||
content: parsedRecord.data.message.content,
|
||||
messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id,
|
||||
});
|
||||
}
|
||||
return parseTaskNotificationFromSystemRecord(record)?.toolUseId ?? null;
|
||||
return parseTaskNotificationFromSystemRecord(record);
|
||||
}
|
||||
|
||||
export function coerceTaskNotificationHistoryRecordToSystemMessage(
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js";
|
||||
import fs, { type FileHandle } from "node:fs/promises";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import path from "node:path";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
import {
|
||||
parseClaudeSubagentMeta,
|
||||
type ClaudeReplayEntry,
|
||||
type ClaudeSubagentMeta,
|
||||
} from "./subagents/replay-source.js";
|
||||
import {
|
||||
parseClaudeWorkflowRun,
|
||||
type ClaudeWorkflowRun,
|
||||
} from "./subagents/workflow-replay-source.js";
|
||||
|
||||
interface TranscriptEntry extends ClaudeReplayEntry {
|
||||
isSidechain?: unknown;
|
||||
}
|
||||
|
||||
interface RecordLocation {
|
||||
file: string;
|
||||
offset: number;
|
||||
length: number;
|
||||
timestamp: string | null;
|
||||
}
|
||||
|
||||
/** Chunk in bytes so UTF-8 and multi-chunk records are decoded once, at a line boundary. */
|
||||
async function* locatedRecords(
|
||||
file: string,
|
||||
fd: FileHandle,
|
||||
): AsyncGenerator<{ entry: TranscriptEntry; location: RecordLocation }> {
|
||||
const stat = await fd.stat();
|
||||
// Opening a directory succeeds on some platforms; it must never look like empty history.
|
||||
if (stat.isDirectory()) {
|
||||
throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${file}'`), {
|
||||
code: "EISDIR",
|
||||
syscall: "read",
|
||||
path: file,
|
||||
});
|
||||
}
|
||||
let remaining = stat.size;
|
||||
let offset = 0;
|
||||
let pieces: Buffer[] = [];
|
||||
let length = 0;
|
||||
while (remaining > 0) {
|
||||
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining));
|
||||
const { bytesRead: count } = await fd.read(buffer, 0, buffer.length, null);
|
||||
if (count === 0) break;
|
||||
remaining -= count;
|
||||
let start = 0;
|
||||
let end: number;
|
||||
while ((end = buffer.indexOf(10, start)) !== -1 && end < count) {
|
||||
pieces.push(buffer.subarray(start, end));
|
||||
length += end - start;
|
||||
const entry = parseRecord(Buffer.concat(pieces, length).toString("utf8"));
|
||||
const location = {
|
||||
file,
|
||||
offset,
|
||||
length,
|
||||
timestamp: normalizeProviderReplayTimestamp(entry?.timestamp),
|
||||
};
|
||||
offset += length + 1;
|
||||
pieces = [];
|
||||
length = 0;
|
||||
if (entry) yield { entry, location };
|
||||
start = end + 1;
|
||||
}
|
||||
// Bound parsing work as well as IO; microtasks alone do not service timers or sockets.
|
||||
await setImmediate();
|
||||
if (start < count) {
|
||||
pieces.push(buffer.subarray(start, count));
|
||||
length += count - start;
|
||||
}
|
||||
}
|
||||
const entry = parseRecord(Buffer.concat(pieces, length).toString("utf8"));
|
||||
if (entry)
|
||||
yield {
|
||||
entry,
|
||||
location: {
|
||||
file,
|
||||
offset,
|
||||
length,
|
||||
timestamp: normalizeProviderReplayTimestamp(entry.timestamp),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Indexed sources parse only their own records using descriptors owned by this replay. */
|
||||
function indexedRecords(
|
||||
locations: readonly RecordLocation[],
|
||||
descriptors: ReadonlyMap<string, FileHandle>,
|
||||
): AsyncIterable<TranscriptEntry> {
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield* assignTaskNotificationReplayIds(readEntries());
|
||||
},
|
||||
};
|
||||
async function* readEntries(): AsyncGenerator<TranscriptEntry> {
|
||||
for (const location of locations) {
|
||||
const fd = descriptors.get(location.file)!;
|
||||
const buffer = Buffer.allocUnsafe(location.length);
|
||||
let read = 0;
|
||||
while (read < buffer.length) {
|
||||
const { bytesRead: count } = await fd.read(
|
||||
buffer,
|
||||
read,
|
||||
buffer.length - read,
|
||||
location.offset + read,
|
||||
);
|
||||
if (count === 0)
|
||||
throw new Error(`Claude transcript was truncated during replay: ${location.file}`);
|
||||
read += count;
|
||||
}
|
||||
const entry = parseRecord(buffer.toString("utf8"));
|
||||
if (entry) yield entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isToolResult(entry: TranscriptEntry): boolean {
|
||||
return (
|
||||
Array.isArray(entry.message?.content) &&
|
||||
entry.message.content.some((block) => block?.type === "tool_result")
|
||||
);
|
||||
}
|
||||
|
||||
function parseRecord(line: string): TranscriptEntry | null {
|
||||
try {
|
||||
const value: unknown = JSON.parse(line);
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as TranscriptEntry)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readOptionalFile(file: string): Promise<string | null> {
|
||||
try {
|
||||
return await fs.readFile(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Discover sources without retaining transcript contents. Unrelated sidechains stay excluded
|
||||
* by the replay ownership resolver; workflow children retain their separate run ownership. */
|
||||
export interface ClaudeReplayHistory {
|
||||
parentEntries: AsyncIterable<TranscriptEntry>;
|
||||
subagents: {
|
||||
agentId: string;
|
||||
meta: ClaudeSubagentMeta | null;
|
||||
entries: AsyncIterable<TranscriptEntry>;
|
||||
}[];
|
||||
workflows: ClaudeWorkflowRun[];
|
||||
workflowEntriesByRunId: Map<string, AsyncIterable<TranscriptEntry>>;
|
||||
}
|
||||
|
||||
/** Own descriptors through every pass so unlink or rename cannot invalidate discovered history.
|
||||
* Sources must be consumed before the callback returns. No transcript copies are needed. */
|
||||
export async function withClaudeReplayHistory<T>(
|
||||
historyPath: string,
|
||||
consume: (history: ClaudeReplayHistory) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const descriptors = new Map<string, FileHandle>();
|
||||
try {
|
||||
return await consume(await indexHistory(historyPath, descriptors));
|
||||
} finally {
|
||||
await closeReplaySources(descriptors.values());
|
||||
}
|
||||
}
|
||||
|
||||
async function closeReplaySources(descriptors: Iterable<FileHandle>): Promise<void> {
|
||||
const results = await Promise.allSettled([...descriptors].map((fd) => fd.close()));
|
||||
const failure = results.find((result) => result.status === "rejected");
|
||||
if (failure) throw failure.reason;
|
||||
}
|
||||
|
||||
async function indexHistory(
|
||||
historyPath: string,
|
||||
descriptors: Map<string, FileHandle>,
|
||||
): Promise<ClaudeReplayHistory> {
|
||||
const sessionDirectory = path.join(
|
||||
path.dirname(historyPath),
|
||||
path.basename(historyPath, ".jsonl"),
|
||||
);
|
||||
const sidechainDirectory = path.join(sessionDirectory, "subagents");
|
||||
const sidechainFiles = [historyPath];
|
||||
const workflowFiles = new Map<string, string[]>();
|
||||
const metaByAgentId = new Map<string, ClaudeSubagentMeta>();
|
||||
const directories = [sidechainDirectory];
|
||||
while (directories.length) {
|
||||
const directory = directories.pop()!;
|
||||
for (const entry of await optionalDirectory(directory)) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
directories.push(file);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
if (entry.name.endsWith(".jsonl")) {
|
||||
const parts = path.relative(sidechainDirectory, file).split(path.sep);
|
||||
if (parts[0] === "workflows" && parts.length >= 3) {
|
||||
const runId = parts[1]!;
|
||||
const files = workflowFiles.get(runId) ?? [];
|
||||
files.push(file);
|
||||
workflowFiles.set(runId, files);
|
||||
} else sidechainFiles.push(file);
|
||||
continue;
|
||||
}
|
||||
const agentId = /^agent-(.+)\.meta\.json$/.exec(entry.name)?.[1];
|
||||
if (!agentId) continue;
|
||||
const contents = await readOptionalFile(file);
|
||||
const meta = contents === null ? null : parseClaudeSubagentMeta(contents);
|
||||
if (meta) metaByAgentId.set(agentId, meta);
|
||||
}
|
||||
}
|
||||
const parentLocations: RecordLocation[] = [];
|
||||
const locationsByAgentId = new Map<string, RecordLocation[]>();
|
||||
async function scan(
|
||||
file: string,
|
||||
visit: (entry: TranscriptEntry, location: RecordLocation) => void,
|
||||
): Promise<void> {
|
||||
const fd = await fs.open(file, "r");
|
||||
descriptors.set(file, fd);
|
||||
for await (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
|
||||
}
|
||||
for (const file of sidechainFiles) {
|
||||
await scan(file, (entry, location) => {
|
||||
if (file === historyPath && entry.isSidechain !== true) parentLocations.push(location);
|
||||
if (entry.isSidechain !== true || typeof entry.agentId !== "string") return;
|
||||
const locations = locationsByAgentId.get(entry.agentId) ?? [];
|
||||
locations.push(location);
|
||||
locationsByAgentId.set(entry.agentId, locations);
|
||||
});
|
||||
}
|
||||
const workflowEntriesByRunId = new Map<string, AsyncIterable<TranscriptEntry>>();
|
||||
for (const [runId, files] of workflowFiles) {
|
||||
const locations: RecordLocation[] = [];
|
||||
for (const file of files)
|
||||
await scan(file, (entry, location) => {
|
||||
if (entry.type !== "user" || isToolResult(entry)) locations.push(location);
|
||||
});
|
||||
locations.sort((a, b) => {
|
||||
if (!a.timestamp && !b.timestamp) return 0;
|
||||
if (!a.timestamp) return 1;
|
||||
if (!b.timestamp) return -1;
|
||||
return Date.parse(a.timestamp) - Date.parse(b.timestamp);
|
||||
});
|
||||
workflowEntriesByRunId.set(runId, indexedRecords(locations, descriptors));
|
||||
}
|
||||
return {
|
||||
parentEntries: indexedRecords(parentLocations, descriptors),
|
||||
subagents: [...locationsByAgentId].map(([agentId, locations]) => ({
|
||||
agentId,
|
||||
meta: metaByAgentId.get(agentId) ?? null,
|
||||
entries: indexedRecords(locations, descriptors),
|
||||
})),
|
||||
workflows: await readWorkflows(sessionDirectory),
|
||||
workflowEntriesByRunId,
|
||||
};
|
||||
}
|
||||
|
||||
async function readWorkflows(sessionDirectory: string): Promise<ClaudeWorkflowRun[]> {
|
||||
const workflows: ClaudeWorkflowRun[] = [];
|
||||
const workflowDirectory = path.join(sessionDirectory, "workflows");
|
||||
for (const entry of await optionalDirectory(workflowDirectory)) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
const contents = await readOptionalFile(path.join(workflowDirectory, entry.name));
|
||||
const workflow = contents === null ? null : parseClaudeWorkflowRun(contents);
|
||||
if (workflow) workflows.push(workflow);
|
||||
}
|
||||
return workflows;
|
||||
}
|
||||
|
||||
async function optionalDirectory(directory: string) {
|
||||
try {
|
||||
return await fs.readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -92,12 +92,18 @@ import {
|
||||
type CodexAppServerTraceContext,
|
||||
} from "./codex/app-server-transport.js";
|
||||
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
|
||||
import {
|
||||
type CodexHistoryRow,
|
||||
CodexThreadHistoryTooLargeError,
|
||||
readCodexThreadHistory,
|
||||
} from "./codex/thread-history.js";
|
||||
import {
|
||||
materializeProviderImage,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
type ProviderImageOutput,
|
||||
} from "./provider-image-output.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
|
||||
import { limitAgentTimelineItemContent } from "../agent-timeline-content.js";
|
||||
import {
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
@@ -496,6 +502,11 @@ interface PersistedTimelineEntry {
|
||||
providerTurnId?: string;
|
||||
}
|
||||
|
||||
interface CodexThreadHistory {
|
||||
timeline: PersistedTimelineEntry[];
|
||||
providerSubagentEvents: AgentStreamEvent[];
|
||||
}
|
||||
|
||||
interface PersistedSubAgentRoute {
|
||||
childThreadId: string;
|
||||
toolCall: ToolCallTimelineItem;
|
||||
@@ -1799,22 +1810,13 @@ function readCodexHistoryTimestamp(item: unknown): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
function readCodexTurnHistoryTimestamp(
|
||||
turn: unknown,
|
||||
/** Falls back to the row's start time for a user message and its end time otherwise. */
|
||||
function readCodexHistoryRowTimestamp(
|
||||
row: CodexHistoryRow,
|
||||
timelineItem: AgentTimelineItem,
|
||||
): string | null {
|
||||
const record = toObjectRecord(turn);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startedAt =
|
||||
normalizeProviderReplayTimestamp(record.startedAt) ??
|
||||
normalizeProviderReplayTimestamp(record.started_at);
|
||||
const completedAt =
|
||||
normalizeProviderReplayTimestamp(record.completedAt) ??
|
||||
normalizeProviderReplayTimestamp(record.completed_at);
|
||||
|
||||
const startedAt = normalizeProviderReplayTimestamp(row.startedAt);
|
||||
const completedAt = normalizeProviderReplayTimestamp(row.completedAt);
|
||||
if (timelineItem.type === "user_message") {
|
||||
return startedAt ?? completedAt;
|
||||
}
|
||||
@@ -1939,12 +1941,12 @@ function codexImageOutputFromResult(result: unknown): ProviderImageOutput | null
|
||||
};
|
||||
}
|
||||
|
||||
function mapCodexThreadImageItem(
|
||||
async function mapCodexThreadImageItem(
|
||||
normalizedType: string,
|
||||
normalizedItem: Record<string, unknown>,
|
||||
): AgentTimelineItem | null {
|
||||
): Promise<AgentTimelineItem | null> {
|
||||
if (normalizedType === "imageView") {
|
||||
return renderProviderImageOutputAsAssistantMarkdown({
|
||||
return await renderProviderImageOutputAsAssistantMarkdown({
|
||||
path: firstStringField(normalizedItem, ["path"]),
|
||||
});
|
||||
}
|
||||
@@ -1960,7 +1962,7 @@ function mapCodexThreadImageItem(
|
||||
|
||||
const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]);
|
||||
const result = codexImageOutputFromResult(normalizedItem.result);
|
||||
return renderProviderImageOutputAsAssistantMarkdown(
|
||||
return await renderProviderImageOutputAsAssistantMarkdown(
|
||||
{
|
||||
path: savedPath ?? result?.path ?? null,
|
||||
url: result?.url ?? null,
|
||||
@@ -1982,10 +1984,10 @@ function mapCodexAgentMessage(item: Record<string, unknown>): AgentTimelineItem
|
||||
};
|
||||
}
|
||||
|
||||
export function threadItemToTimeline(
|
||||
export async function threadItemToTimeline(
|
||||
item: unknown,
|
||||
options?: { includeUserMessage?: boolean; cwd?: string | null },
|
||||
): AgentTimelineItem | null {
|
||||
): Promise<AgentTimelineItem | null> {
|
||||
const itemRecord = toObjectRecord(item);
|
||||
if (!itemRecord) return null;
|
||||
const includeUserMessage = options?.includeUserMessage ?? true;
|
||||
@@ -1999,7 +2001,7 @@ export function threadItemToTimeline(
|
||||
: itemRecord;
|
||||
|
||||
if (normalizedType === "imageView" || normalizedType === "imageGeneration") {
|
||||
return mapCodexThreadImageItem(normalizedType, normalizedItem);
|
||||
return await mapCodexThreadImageItem(normalizedType, normalizedItem);
|
||||
}
|
||||
if (normalizedType && CODEX_TOOL_THREAD_ITEM_TYPES.has(normalizedType)) {
|
||||
return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
|
||||
@@ -2024,7 +2026,7 @@ export function threadItemToTimeline(
|
||||
}
|
||||
}
|
||||
|
||||
function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] {
|
||||
async function mcpToolResultImagesToTimeline(item: unknown): Promise<AgentTimelineItem[]> {
|
||||
const itemRecord = toObjectRecord(item);
|
||||
if (!itemRecord) {
|
||||
return [];
|
||||
@@ -2037,104 +2039,40 @@ function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] {
|
||||
}
|
||||
|
||||
const { images } = splitCodexMcpToolResultImages(itemRecord.result);
|
||||
return images
|
||||
.map((image) =>
|
||||
renderProviderImageOutputAsAssistantMarkdown(image, {
|
||||
materialize: materializeProviderImage,
|
||||
}),
|
||||
)
|
||||
.filter((timelineItem): timelineItem is AgentTimelineItem => timelineItem !== null);
|
||||
const items: AgentTimelineItem[] = [];
|
||||
for (const image of images) {
|
||||
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
|
||||
materialize: materializeProviderImage,
|
||||
});
|
||||
if (imageItem) items.push(imageItem);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function threadItemToTimelineEntries(
|
||||
async function threadItemToTimelineEntries(
|
||||
item: unknown,
|
||||
options?: { includeUserMessage?: boolean; cwd?: string | null },
|
||||
): AgentTimelineItem[] {
|
||||
const timelineItem = threadItemToTimeline(item, options);
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
const timelineItem = await threadItemToTimeline(item, options);
|
||||
if (!timelineItem) {
|
||||
return [];
|
||||
}
|
||||
return [timelineItem, ...mcpToolResultImagesToTimeline(item)];
|
||||
}
|
||||
|
||||
const CodexThreadReadResponseSchema = z
|
||||
.object({
|
||||
thread: z
|
||||
.object({
|
||||
turns: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
items: z.array(z.unknown()).default([]),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
.passthrough()
|
||||
.default({ turns: [] }),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type CodexThreadReadResponse = z.infer<typeof CodexThreadReadResponseSchema>;
|
||||
type CodexThreadReadRequest = (threadId: string) => Promise<unknown>;
|
||||
|
||||
async function requestCodexThreadHistory(
|
||||
requestThread: CodexThreadReadRequest,
|
||||
threadId: string,
|
||||
): Promise<CodexThreadReadResponse> {
|
||||
const response = await requestThread(threadId);
|
||||
return CodexThreadReadResponseSchema.parse(response);
|
||||
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
|
||||
}
|
||||
|
||||
async function loadCodexThreadHistoryTimeline(params: {
|
||||
client: CodexAppServerClientLike;
|
||||
threadId: string;
|
||||
cwd: string | null;
|
||||
requestThread: CodexThreadReadRequest;
|
||||
}): Promise<CodexThreadHistoryProjection> {
|
||||
const response = await requestCodexThreadHistory(params.requestThread, params.threadId);
|
||||
const timeline: PersistedTimelineEntry[] = [];
|
||||
const subAgentTimelineIndexByThreadId = new Map<string, number>();
|
||||
for (const turn of response.thread.turns) {
|
||||
for (const item of turn.items) {
|
||||
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
|
||||
if (historicalSubAgentActivity) {
|
||||
const existingIndex = subAgentTimelineIndexByThreadId.get(
|
||||
historicalSubAgentActivity.agentThreadId,
|
||||
);
|
||||
if (existingIndex !== undefined) {
|
||||
const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd });
|
||||
updateHistoricalSubAgentActivity(
|
||||
timeline,
|
||||
existingIndex,
|
||||
historicalSubAgentActivity.kind,
|
||||
activityTimelineItem?.type === "tool_call" &&
|
||||
activityTimelineItem.detail.type === "sub_agent"
|
||||
? activityTimelineItem.detail.subAgentType
|
||||
: undefined,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const timelineItem of threadItemToTimelineEntries(item, { cwd: params.cwd })) {
|
||||
const timestamp =
|
||||
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
|
||||
const settledTimelineItem =
|
||||
historicalSubAgentActivity && timelineItem.type === "tool_call"
|
||||
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
|
||||
: timelineItem;
|
||||
timeline.push({
|
||||
item: settledTimelineItem,
|
||||
timestamp: timestamp ?? undefined,
|
||||
...(timelineItem.type === "user_message" && typeof turn.id === "string"
|
||||
? { providerTurnId: turn.id }
|
||||
: {}),
|
||||
});
|
||||
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
|
||||
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await projectCodexThreadHistory(params, { timeline, subAgentTimelineIndexByThreadId });
|
||||
} catch (error) {
|
||||
if (!(error instanceof CodexThreadHistoryTooLargeError)) throw error;
|
||||
// The session stays usable; the row says why the history stops here.
|
||||
timeline.push({ item: { type: "notification", level: "warning", message: error.message } });
|
||||
}
|
||||
const subAgentRoutes = Array.from(subAgentTimelineIndexByThreadId.entries()).flatMap(
|
||||
([childThreadId, timelineIndex]): PersistedSubAgentRoute[] => {
|
||||
@@ -2147,11 +2085,55 @@ async function loadCodexThreadHistoryTimeline(params: {
|
||||
return { timeline, subAgentRoutes };
|
||||
}
|
||||
|
||||
function readCodexThread(client: CodexAppServerClientLike, threadId: string): Promise<unknown> {
|
||||
return client.request("thread/read", {
|
||||
threadId,
|
||||
includeTurns: true,
|
||||
});
|
||||
async function projectCodexThreadHistory(
|
||||
params: { client: CodexAppServerClientLike; threadId: string; cwd: string | null },
|
||||
projection: {
|
||||
timeline: PersistedTimelineEntry[];
|
||||
subAgentTimelineIndexByThreadId: Map<string, number>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { timeline, subAgentTimelineIndexByThreadId } = projection;
|
||||
for await (const row of readCodexThreadHistory(params.client, params.threadId)) {
|
||||
const { item } = row;
|
||||
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
|
||||
if (historicalSubAgentActivity) {
|
||||
const existingIndex = subAgentTimelineIndexByThreadId.get(
|
||||
historicalSubAgentActivity.agentThreadId,
|
||||
);
|
||||
if (existingIndex !== undefined) {
|
||||
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
|
||||
updateHistoricalSubAgentActivity(
|
||||
timeline,
|
||||
existingIndex,
|
||||
historicalSubAgentActivity.kind,
|
||||
activityTimelineItem?.type === "tool_call" &&
|
||||
activityTimelineItem.detail.type === "sub_agent"
|
||||
? activityTimelineItem.detail.subAgentType
|
||||
: undefined,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
|
||||
const timestamp =
|
||||
readCodexHistoryTimestamp(item) ?? readCodexHistoryRowTimestamp(row, timelineItem);
|
||||
const settledTimelineItem =
|
||||
historicalSubAgentActivity && timelineItem.type === "tool_call"
|
||||
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
|
||||
: timelineItem;
|
||||
timeline.push({
|
||||
// Cap each item as it is projected so a large history is never held uncapped.
|
||||
item: limitAgentTimelineItemContent(settledTimelineItem),
|
||||
timestamp: timestamp ?? undefined,
|
||||
...(timelineItem.type === "user_message" && row.turnId !== undefined
|
||||
? { providerTurnId: row.turnId }
|
||||
: {}),
|
||||
});
|
||||
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
|
||||
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function forkCodexThread(
|
||||
@@ -3290,10 +3272,12 @@ export async function codexAppServerTurnInputFromPrompt(
|
||||
}
|
||||
if (block.type === "image") {
|
||||
try {
|
||||
const filePath = materializeProviderImage({
|
||||
data: block.data,
|
||||
mimeType: block.mimeType,
|
||||
}).path;
|
||||
const filePath = (
|
||||
await materializeProviderImage({
|
||||
data: block.data,
|
||||
mimeType: block.mimeType,
|
||||
})
|
||||
).path;
|
||||
output.push({ type: "localImage", path: filePath });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -3453,6 +3437,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private client: CodexAppServerClient | null = null;
|
||||
private threadRollbackAvailable = true;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private startupEvents: AgentStreamEvent[] | null = null;
|
||||
private nextTurnOrdinal = 0;
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private activeClientMessageId: string | null = null;
|
||||
@@ -3460,10 +3445,12 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private serviceTier: string | null = null;
|
||||
private speedModels: CodexModel[] = [];
|
||||
private planModeEnabled = false;
|
||||
private historyPending = false;
|
||||
private persistedHistory: PersistedTimelineEntry[] = [];
|
||||
private loadingPersistedHistory = false;
|
||||
private persistedProviderSubagentEvents: AgentStreamEvent[] = [];
|
||||
// History read by rewind or an archived resume, held until streamHistory consumes it.
|
||||
private stagedHistory: CodexThreadHistory | null = null;
|
||||
// Collects provider subagent events emitted while a thread history read rebuilds routes.
|
||||
private historySubagentEvents: AgentStreamEvent[] | null = null;
|
||||
// A resumed thread's earlier turns and sub-agents are known only after reading its history.
|
||||
private historyIndexed = true;
|
||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||
private mcpElicitationPermissionIds = new Map<number, string>();
|
||||
private pendingPermissionHandlers = new Map<string, CodexPendingPermissionHandler>();
|
||||
@@ -3570,7 +3557,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
if (this.resumeHandle?.sessionId) {
|
||||
this.currentThreadId = this.resumeHandle.sessionId;
|
||||
this.historyPending = true;
|
||||
this.historyIndexed = false;
|
||||
// Resuming can start a goal before the caller receives the session.
|
||||
if (this.initialResumePurpose === "interactive") this.startupEvents = [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3616,7 +3605,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
private async establishConnection(): Promise<void> {
|
||||
if (this.initialResumePurpose === "history") {
|
||||
await this.readArchivedHistory();
|
||||
this.stagedHistory = await this.readArchivedHistory();
|
||||
this.connectionState = "history-ready";
|
||||
return;
|
||||
}
|
||||
@@ -3649,7 +3638,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
await this.loadPersistedHistory(this.client);
|
||||
await this.applyDefaultModelAndThinking();
|
||||
}
|
||||
|
||||
@@ -3964,39 +3952,40 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private async readArchivedHistory(): Promise<void> {
|
||||
private async readArchivedHistory(): Promise<CodexThreadHistory> {
|
||||
const child = await this.spawnAppServer();
|
||||
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
|
||||
try {
|
||||
await client.request("initialize", buildCodexAppServerInitializeParams());
|
||||
client.notify("initialized", {});
|
||||
await this.loadPersistedHistory(client);
|
||||
return await this.readThreadHistory(client);
|
||||
} finally {
|
||||
await client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPersistedHistory(client: CodexAppServerClientLike | null): Promise<void> {
|
||||
if (!client || !this.currentThreadId) return;
|
||||
const threadId = this.currentThreadId;
|
||||
|
||||
const history = await loadCodexThreadHistoryTimeline({
|
||||
threadId,
|
||||
// Reads the thread and its sub-agent threads, rebuilding the turn index and
|
||||
// sub-agent routes. The caller owns the returned history; nothing retains it.
|
||||
private async readThreadHistory(
|
||||
client: CodexAppServerClientLike | null,
|
||||
): Promise<CodexThreadHistory> {
|
||||
if (!client || !this.currentThreadId) {
|
||||
return { timeline: [], providerSubagentEvents: [] };
|
||||
}
|
||||
const { timeline, subAgentRoutes } = await loadCodexThreadHistoryTimeline({
|
||||
client,
|
||||
threadId: this.currentThreadId,
|
||||
cwd: this.config.cwd ?? null,
|
||||
requestThread: (threadIdToRead) => {
|
||||
return readCodexThread(client, threadIdToRead);
|
||||
},
|
||||
});
|
||||
const { timeline, subAgentRoutes } = history;
|
||||
this.subAgentCallsByCallId.clear();
|
||||
this.subAgentCallIdByChildThreadId.clear();
|
||||
this.pendingSubAgentNotificationsByThreadId.clear();
|
||||
this.persistedProviderSubagentEvents = [];
|
||||
this.loadingPersistedHistory = true;
|
||||
const providerSubagentEvents: AgentStreamEvent[] = [];
|
||||
this.historySubagentEvents = providerSubagentEvents;
|
||||
try {
|
||||
await this.loadPersistedSubAgentHistories(client, subAgentRoutes);
|
||||
} finally {
|
||||
this.loadingPersistedHistory = false;
|
||||
this.historySubagentEvents = null;
|
||||
}
|
||||
this.resetCodexUserMessageTurns();
|
||||
for (const entry of timeline) {
|
||||
@@ -4007,8 +3996,15 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId);
|
||||
}
|
||||
}
|
||||
this.persistedHistory = timeline;
|
||||
this.historyPending = timeline.length > 0;
|
||||
this.historyIndexed = true;
|
||||
return { timeline, providerSubagentEvents };
|
||||
}
|
||||
|
||||
// Turns and rewind route by the thread's earlier turns and sub-agents. A resumed
|
||||
// session whose projection Paseo already holds reads them here and drops the rest.
|
||||
private async ensureHistoryIndexed(): Promise<void> {
|
||||
if (this.historyIndexed) return;
|
||||
await this.readThreadHistory(this.client);
|
||||
}
|
||||
|
||||
private async loadPersistedSubAgentHistories(
|
||||
@@ -4035,9 +4031,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
try {
|
||||
const childHistory = await loadCodexThreadHistoryTimeline({
|
||||
client,
|
||||
threadId: next.route.childThreadId,
|
||||
cwd: this.config.cwd ?? null,
|
||||
requestThread: (childThreadId) => readCodexThread(client, childThreadId),
|
||||
});
|
||||
for (const entry of childHistory.timeline) {
|
||||
this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp);
|
||||
@@ -4060,7 +4056,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
private async ensureThreadLoaded(): Promise<void> {
|
||||
if (!this.client || !this.currentThreadId) return;
|
||||
const params: Record<string, unknown> = { threadId: this.currentThreadId };
|
||||
// Paseo reads history only when it replays it; the resume response needs no turns.
|
||||
const params: Record<string, unknown> = { threadId: this.currentThreadId, excludeTurns: true };
|
||||
const developerInstructions = composeSystemPromptParts(
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
@@ -4367,6 +4364,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.dismissPendingPlanApprovals("Dismissed by a new prompt");
|
||||
|
||||
try {
|
||||
await this.pendingNotifications;
|
||||
await this.connect();
|
||||
if (!this.client) {
|
||||
throw new Error("Codex client not initialized");
|
||||
@@ -4379,6 +4377,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
await this.ensureHistoryIndexed();
|
||||
} else {
|
||||
await this.ensureThread();
|
||||
}
|
||||
@@ -4433,9 +4432,18 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
): Promise<SteerResult> {
|
||||
const client = this.client;
|
||||
const threadId = this.currentThreadId;
|
||||
const nativeTurnId = this.currentTurnId;
|
||||
let nativeTurnId = this.currentTurnId;
|
||||
const foregroundTurnId = this.activeForegroundTurnId;
|
||||
if (!client || !threadId || !nativeTurnId || foregroundTurnId !== options.expectedTurnId) {
|
||||
if (!client || !threadId || foregroundTurnId !== options.expectedTurnId) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
const pendingIdentification = this.pendingForegroundTurnIdentification;
|
||||
// The start acknowledgement precedes the notification that identifies the native
|
||||
// turn. Keep this steer attached to the accepted foreground turn while it arrives.
|
||||
if (!nativeTurnId && pendingIdentification?.foregroundTurnId === foregroundTurnId) {
|
||||
nativeTurnId = await pendingIdentification.promise;
|
||||
}
|
||||
if (!nativeTurnId) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
if (await this.resolveSlashCommandInvocation(prompt)) return { status: "unavailable" };
|
||||
@@ -4543,27 +4551,21 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
const startupEvents = this.startupEvents;
|
||||
this.startupEvents = null;
|
||||
for (const event of startupEvents ?? []) this.notifySubscribers(event);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
if (
|
||||
(!this.historyPending || this.persistedHistory.length === 0) &&
|
||||
this.persistedProviderSubagentEvents.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const history = this.persistedHistory;
|
||||
const providerSubagents = this.persistedProviderSubagentEvents;
|
||||
this.persistedHistory = [];
|
||||
this.persistedProviderSubagentEvents = [];
|
||||
this.historyPending = false;
|
||||
for (const event of providerSubagents) {
|
||||
const history = this.stagedHistory ?? (await this.readThreadHistory(this.client));
|
||||
this.stagedHistory = null;
|
||||
for (const event of history.providerSubagentEvents) {
|
||||
yield event;
|
||||
}
|
||||
for (const entry of history) {
|
||||
for (const entry of history.timeline) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
@@ -4946,6 +4948,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
} else {
|
||||
await this.ensureThread();
|
||||
}
|
||||
await this.ensureHistoryIndexed();
|
||||
|
||||
await revertCodexConversation({
|
||||
client: this.client,
|
||||
@@ -4960,10 +4963,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
setThreadId: async (threadId) => {
|
||||
this.currentThreadId = threadId;
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.persistedHistory = [];
|
||||
this.historyPending = false;
|
||||
await this.loadPersistedHistory(this.client);
|
||||
this.reconcileAsyncQuestionsAfterRewind();
|
||||
this.stagedHistory = await this.readThreadHistory(this.client);
|
||||
this.reconcileAsyncQuestionsAfterRewind(this.stagedHistory.timeline);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -5023,11 +5024,40 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (
|
||||
!this.client ||
|
||||
this.client.hasPendingRequests() ||
|
||||
!this.currentThreadId ||
|
||||
this.activeForegroundTurnId ||
|
||||
this.currentTurnId ||
|
||||
this.pendingForegroundStart ||
|
||||
this.pendingPermissionHandlers.size ||
|
||||
this.getPendingPermissions().length ||
|
||||
this.pendingSubAgentNotificationsByThreadId.size
|
||||
)
|
||||
return false;
|
||||
// Older app servers reject this request. A failed or malformed probe must retain
|
||||
// the process: command completion alone does not mean its terminal has exited.
|
||||
const loaded = z
|
||||
.object({ data: z.array(z.string()) })
|
||||
.parse(await this.client.request("thread/loaded/list", {}, 10_000));
|
||||
const threadIds = new Set([this.currentThreadId, ...loaded.data]);
|
||||
for (const threadId of threadIds) {
|
||||
const response = z
|
||||
.object({ data: z.array(z.unknown()), nextCursor: z.string().nullable() })
|
||||
.parse(await this.client.request("thread/backgroundTerminals/list", { threadId }, 10_000));
|
||||
if (response.data.length > 0 || response.nextCursor !== null) return false;
|
||||
}
|
||||
return !this.client.hasPendingRequests();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.clearPendingPermissions();
|
||||
this.pendingSubAgentNotificationsByThreadId.clear();
|
||||
this.subscribers.clear();
|
||||
this.startupEvents = null;
|
||||
this.activeForegroundTurnId = null;
|
||||
this.activeClientMessageId = null;
|
||||
this.pendingForegroundTurnIdentification?.resolve(null);
|
||||
@@ -5172,6 +5202,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
await this.connect();
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
// A goal runs turns of its own.
|
||||
await this.ensureHistoryIndexed();
|
||||
} else {
|
||||
await this.ensureThread();
|
||||
}
|
||||
@@ -5365,8 +5397,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private emitEvent(event: AgentStreamEvent): void {
|
||||
if (this.loadingPersistedHistory && event.type === "provider_subagent") {
|
||||
this.persistedProviderSubagentEvents.push(event);
|
||||
if (this.historySubagentEvents && event.type === "provider_subagent") {
|
||||
this.historySubagentEvents.push(event);
|
||||
return;
|
||||
}
|
||||
this.notifySubscribers(event);
|
||||
@@ -5375,6 +5407,10 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private notifySubscribers(event: AgentStreamEvent): void {
|
||||
const turnId = getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId;
|
||||
const tagged = turnId ? { ...event, turnId } : event;
|
||||
if (this.startupEvents) {
|
||||
this.startupEvents.push(tagged);
|
||||
return;
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId: this.agentId,
|
||||
@@ -5398,7 +5434,21 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return `codex-turn-${this.nextTurnOrdinal++}`;
|
||||
}
|
||||
|
||||
private handleNotification(method: string, params: unknown): void {
|
||||
private pendingNotifications: Promise<void> = Promise.resolve();
|
||||
|
||||
private handleNotification(method: string, params: unknown): Promise<void> {
|
||||
// An image must exist before its event, and completion must follow that event.
|
||||
// Transport callbacks cannot await: the session owns their ordered drain.
|
||||
const next = this.pendingNotifications.then(() => {
|
||||
return this.closed ? undefined : this.processNotification(method, params);
|
||||
});
|
||||
this.pendingNotifications = next.catch((error: unknown) => {
|
||||
this.handleUnexpectedTermination(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
return this.pendingNotifications;
|
||||
}
|
||||
|
||||
private async processNotification(method: string, params: unknown): Promise<void> {
|
||||
const notificationParams = toObjectRecord(params);
|
||||
if (method === "serverRequest/resolved" && typeof notificationParams?.requestId === "number") {
|
||||
const requestId = this.mcpElicitationPermissionIds.get(notificationParams.requestId);
|
||||
@@ -5428,13 +5478,16 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
if (route.kind === "sub_agent") {
|
||||
this.dispatchSubAgentNotification(parsed, route.callId);
|
||||
await this.dispatchSubAgentNotification(parsed, route.callId);
|
||||
return;
|
||||
}
|
||||
this.dispatchParsedNotification(parsed);
|
||||
await this.dispatchParsedNotification(parsed);
|
||||
}
|
||||
|
||||
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
|
||||
private async dispatchSubAgentNotification(
|
||||
parsed: ParsedCodexNotification,
|
||||
callId: string,
|
||||
): Promise<void> {
|
||||
switch (parsed.kind) {
|
||||
case "thread_started":
|
||||
this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
|
||||
@@ -5445,7 +5498,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
case "reasoning_delta":
|
||||
case "item_started":
|
||||
case "item_completed":
|
||||
this.dispatchParsedNotification(parsed);
|
||||
await this.dispatchParsedNotification(parsed);
|
||||
return;
|
||||
case "exec_command_output_delta":
|
||||
case "file_change_output_delta":
|
||||
@@ -5471,7 +5524,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchParsedNotification(parsed: ParsedCodexNotification): void {
|
||||
private async dispatchParsedNotification(parsed: ParsedCodexNotification): Promise<void> {
|
||||
if (isCodexDeltaNotification(parsed)) {
|
||||
this.handleCodexDeltaNotification(parsed);
|
||||
return;
|
||||
@@ -5516,10 +5569,10 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.handlePatchApplyCompletedNotification(parsed);
|
||||
return;
|
||||
case "item_completed":
|
||||
this.handleItemCompletedNotification(parsed);
|
||||
await this.handleItemCompletedNotification(parsed);
|
||||
return;
|
||||
case "item_started":
|
||||
this.handleItemStartedNotification(parsed);
|
||||
await this.handleItemStartedNotification(parsed);
|
||||
return;
|
||||
case "invalid_payload":
|
||||
this.warnInvalidNotificationPayload(parsed.method, parsed.params);
|
||||
@@ -5561,7 +5614,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
pending.push(parsed);
|
||||
}
|
||||
|
||||
private replayPendingSubAgentNotifications(threadIds: readonly string[]): void {
|
||||
private async replayPendingSubAgentNotifications(threadIds: readonly string[]): Promise<void> {
|
||||
for (const threadId of threadIds) {
|
||||
const pending = this.pendingSubAgentNotificationsByThreadId.get(threadId);
|
||||
if (!pending) {
|
||||
@@ -5573,7 +5626,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
continue;
|
||||
}
|
||||
for (const parsed of pending) {
|
||||
this.dispatchSubAgentNotification(parsed, callId);
|
||||
await this.dispatchSubAgentNotification(parsed, callId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6523,9 +6576,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private reconcileAsyncQuestionsAfterRewind(): void {
|
||||
private reconcileAsyncQuestionsAfterRewind(timeline: readonly PersistedTimelineEntry[]): void {
|
||||
const retainedIds = new Set(
|
||||
this.persistedHistory.flatMap(({ item }) =>
|
||||
timeline.flatMap(({ item }) =>
|
||||
item.type === "tool_call" && item.name === "request_user_input_async" ? [item.callId] : [],
|
||||
),
|
||||
);
|
||||
@@ -6539,9 +6592,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private handleItemCompletedNotification(
|
||||
private ownsNotificationTurn(turnId: string | null): boolean {
|
||||
return !this.closed && this.activeForegroundTurnId === turnId;
|
||||
}
|
||||
|
||||
private async handleItemCompletedNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
|
||||
// and canonical `item/*`. Render ordinary items only from the canonical
|
||||
// channel, but accept a legacy-only child announcement so it can establish
|
||||
@@ -6551,20 +6608,23 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
this.receiveAsyncQuestion(parsed.threadId, parsed.item);
|
||||
if (this.isUserMessageItem(parsed.item)) {
|
||||
this.handleUserMessageItem(parsed);
|
||||
await this.handleUserMessageItem(parsed);
|
||||
return;
|
||||
}
|
||||
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||
if (this.handleCompletedSpecialItem(parsed, childSubAgentCallId)) {
|
||||
return;
|
||||
}
|
||||
const timelineItem = threadItemToTimeline(parsed.item, {
|
||||
const foregroundTurnId = this.activeForegroundTurnId;
|
||||
const timelineItem = await threadItemToTimeline(parsed.item, {
|
||||
includeUserMessage: false,
|
||||
cwd: this.config.cwd ?? null,
|
||||
});
|
||||
if (!timelineItem) {
|
||||
return;
|
||||
}
|
||||
const imageItems = await mcpToolResultImagesToTimeline(parsed.item);
|
||||
if (!this.ownsNotificationTurn(foregroundTurnId)) return;
|
||||
const registeredChildThreadIds =
|
||||
timelineItem.type === "tool_call"
|
||||
? this.registerSubAgentToolCall({
|
||||
@@ -6574,12 +6634,11 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
|
||||
})
|
||||
: [];
|
||||
const imageItems = mcpToolResultImagesToTimeline(parsed.item);
|
||||
if (childSubAgentCallId) {
|
||||
this.emitCompletedProviderSubagentItem(parsed, timelineItem);
|
||||
this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem);
|
||||
this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems);
|
||||
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
return;
|
||||
}
|
||||
const normalizedItemType = normalizeCodexThreadItemType(
|
||||
@@ -6587,7 +6646,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
);
|
||||
const itemId = parsed.item.id;
|
||||
if (this.shouldSkipCompletedThreadItem(timelineItem, normalizedItemType, itemId)) {
|
||||
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
return;
|
||||
}
|
||||
if (this.consumeStreamedTextCompletion(timelineItem, itemId)) {
|
||||
@@ -6598,7 +6657,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.emittedItemCompletedIds.add(itemId);
|
||||
this.emittedItemStartedIds.delete(itemId);
|
||||
}
|
||||
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
return;
|
||||
}
|
||||
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
|
||||
@@ -6627,7 +6686,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.pendingCommandOutputDeltas.delete(itemId);
|
||||
this.pendingFileChangeOutputDeltas.delete(itemId);
|
||||
}
|
||||
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
}
|
||||
|
||||
private consumeStreamedTextCompletion(
|
||||
@@ -6699,14 +6758,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private handleItemStartedNotification(
|
||||
private async handleItemStartedNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
||||
return;
|
||||
}
|
||||
if (this.isUserMessageItem(parsed.item)) {
|
||||
this.handleUserMessageItem(parsed);
|
||||
await this.handleUserMessageItem(parsed);
|
||||
return;
|
||||
}
|
||||
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||
@@ -6728,7 +6787,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (this.handleRegisteredSubAgentActivity(parsed.item)) {
|
||||
return;
|
||||
}
|
||||
const timelineItem = threadItemToTimeline(parsed.item, {
|
||||
const timelineItem = await threadItemToTimeline(parsed.item, {
|
||||
includeUserMessage: false,
|
||||
cwd: this.config.cwd ?? null,
|
||||
});
|
||||
@@ -6747,7 +6806,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
|
||||
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
return;
|
||||
}
|
||||
const normalizedItemType = normalizeCodexThreadItemType(
|
||||
@@ -6771,14 +6830,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.pendingCommandOutputDeltas.delete(itemId);
|
||||
this.pendingFileChangeOutputDeltas.delete(itemId);
|
||||
}
|
||||
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
}
|
||||
|
||||
private handleUserMessageItem(
|
||||
private async handleUserMessageItem(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const itemId = parsed.item.id;
|
||||
const timelineItem = threadItemToTimeline(parsed.item, {
|
||||
const timelineItem = await threadItemToTimeline(parsed.item, {
|
||||
includeUserMessage: true,
|
||||
cwd: this.config.cwd ?? null,
|
||||
});
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
// Regression for #3905. Real Codex, synthetic on-disk history, no model turns.
|
||||
// Opt in because the oversized cases need approximately 2 GiB of memory and over
|
||||
// 1 GiB of temporary storage.
|
||||
const enabled = process.env.PASEO_CODEX_HISTORY_STRING_LIMIT_REPRO === "1";
|
||||
const script = fileURLToPath(
|
||||
new URL("../../../../scripts/reproduce-codex-history-string-limit.mts", import.meta.url),
|
||||
);
|
||||
|
||||
test.runIf(enabled).each([
|
||||
{ megabytes: 2, historyMode: "legacy", expected: "History loaded: 3 timeline rows" },
|
||||
{ megabytes: 520, historyMode: "legacy", expected: "codex migrate-rollouts --apply --thread" },
|
||||
{ megabytes: 520, historyMode: "paginated", expected: "History loaded: 521 timeline rows" },
|
||||
])(
|
||||
"opening $megabytes MiB of $historyMode Codex history does not crash its hosting process",
|
||||
async ({ megabytes, historyMode, expected }) => {
|
||||
const root = await mkdtemp(path.join(process.cwd(), ".codex-history-test-"));
|
||||
try {
|
||||
const result = await new Promise<{ code: number; stdout: string; stderr: string }>(
|
||||
(resolve) => {
|
||||
const child = execFile(
|
||||
process.execPath,
|
||||
["--import", "tsx", script, String(megabytes), historyMode],
|
||||
{
|
||||
cwd: root,
|
||||
detached: true,
|
||||
timeout: 120_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
try {
|
||||
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
||||
} catch (killError) {
|
||||
if ((killError as NodeJS.ErrnoException).code !== "ESRCH") throw killError;
|
||||
}
|
||||
resolve({ code: error ? Number(error.code ?? 1) : 0, stdout, stderr });
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain(expected);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
@@ -183,3 +183,56 @@ describe("Codex app-server transport", () => {
|
||||
codex.child.stdin.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex stdout byte admission", () => {
|
||||
test("an oversized response fails only its own request", async () => {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
const terminations: Error[] = [];
|
||||
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
|
||||
const oversized = client.request("thread/items/list", {}).catch((error: Error) => error);
|
||||
const other = client.request("model/list", {});
|
||||
const chunk = Buffer.alloc(64 * 1024, "x");
|
||||
child.stdout.write('{"id":1,"result":"');
|
||||
for (let index = 0; index < 2049; index++) child.stdout.write(chunk);
|
||||
child.stdout.write('"}\n{"id":2,"result":{"data":[]}}\n');
|
||||
|
||||
expect(await oversized).toMatchObject({
|
||||
name: "CodexAppServerResponseTooLargeError",
|
||||
method: "thread/items/list",
|
||||
limitBytes: 128 * 1024 * 1024,
|
||||
});
|
||||
await expect(other).resolves.toEqual({ data: [] });
|
||||
const next = client.request("model/list", {});
|
||||
child.stdout.write('{"id":3,"result":{"data":["next"]}}\n');
|
||||
await expect(next).resolves.toEqual({ data: ["next"] });
|
||||
expect(terminations).toEqual([]);
|
||||
await client.dispose();
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
});
|
||||
|
||||
test("an oversized notification fails pending requests and the session", async () => {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
const terminations: Error[] = [];
|
||||
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
|
||||
const request = client.request("thread/resume", {}, 1000).catch((error: Error) => error);
|
||||
const chunk = Buffer.alloc(64 * 1024, "x");
|
||||
child.stdout.write('{"method":"item/completed","params":"');
|
||||
for (let index = 0; index < 2048; index++) child.stdout.write(chunk);
|
||||
const error = await request;
|
||||
expect(error).toMatchObject({
|
||||
name: "ProviderStreamLimitError",
|
||||
code: "PROVIDER_STREAM_LINE_TOO_LARGE",
|
||||
limitBytes: 128 * 1024 * 1024,
|
||||
});
|
||||
expect(terminations).toEqual([error]);
|
||||
expect(client.hasPendingRequests()).toBe(false);
|
||||
child.stdout.write('"}\n{"id":1,"result":"late"}\n');
|
||||
await expect(client.request("model/list")).rejects.toThrow("closed");
|
||||
await client.dispose();
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
|
||||
|
||||
import { ProviderLineReader, ProviderStreamLimitError } from "../jsonl-frame-decoder.js";
|
||||
|
||||
// A measured long-thread resume was 19.75 MB; preserve the existing 32 MiB fixture
|
||||
// with 4x margin for longer threads. This is provider stdout, not the client protocol.
|
||||
export const CODEX_PROVIDER_LINE_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
|
||||
const APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
|
||||
@@ -28,11 +33,26 @@ interface JsonRpcNotification {
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
method: string;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/** A response past the provider line cap. Only its request fails; the session stays up. */
|
||||
export class CodexAppServerResponseTooLargeError extends Error {
|
||||
constructor(
|
||||
readonly method: string,
|
||||
readonly limitBytes: number,
|
||||
) {
|
||||
super(`Codex app-server response to ${method} exceeds ${limitBytes} bytes`);
|
||||
this.name = "CodexAppServerResponseTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
// Codex serializes a response with `id` first, so its head names the request.
|
||||
const OVERSIZED_RESPONSE_HEAD = /^\{"id":(\d+),"result":/;
|
||||
|
||||
export class CodexAppServerRpcError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -167,41 +187,9 @@ function readProviderTurnId(params: unknown): string | undefined {
|
||||
return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits stdout into lines on "\n" only. `node:readline` additionally treats U+2028 and U+2029
|
||||
* as line terminators, but Codex emits those characters raw inside JSON strings, so readline
|
||||
* would cut a single JSON-RPC message into pieces that fail to parse.
|
||||
*/
|
||||
function createNewlineLineReader(
|
||||
input: NodeJS.ReadableStream,
|
||||
onLine: (line: string) => void,
|
||||
): { close(): void } {
|
||||
const decoder = new StringDecoder("utf8");
|
||||
// Text after the last "\n", kept as parts and joined once its line ends, so a large line
|
||||
// arriving in many chunks is scanned and copied once instead of once per chunk.
|
||||
let unterminated: string[] = [];
|
||||
const onData = (chunk: Buffer | string): void => {
|
||||
const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
|
||||
let lineStart = 0;
|
||||
let index = text.indexOf("\n");
|
||||
while (index !== -1) {
|
||||
unterminated.push(text.slice(lineStart, index));
|
||||
const line = unterminated.join("");
|
||||
unterminated = [];
|
||||
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
||||
lineStart = index + 1;
|
||||
index = text.indexOf("\n", lineStart);
|
||||
}
|
||||
if (lineStart < text.length) {
|
||||
unterminated.push(text.slice(lineStart));
|
||||
}
|
||||
};
|
||||
input.on("data", onData);
|
||||
return { close: () => void input.off("data", onData) };
|
||||
}
|
||||
|
||||
export class CodexAppServerClient {
|
||||
private readonly stdoutLineReader: { close(): void };
|
||||
private activeProviderRequests = 0;
|
||||
private readonly pending = new Map<number, PendingRequest>();
|
||||
private readonly requestHandlers = new Map<string, RequestHandler>();
|
||||
private notificationHandler: NotificationHandler | null = null;
|
||||
@@ -215,11 +203,24 @@ export class CodexAppServerClient {
|
||||
private readonly logger: Logger,
|
||||
private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}),
|
||||
) {
|
||||
this.stdoutLineReader = createNewlineLineReader(child.stdout, (line) => {
|
||||
void this.handleLine(line).catch((error) => {
|
||||
this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line");
|
||||
});
|
||||
});
|
||||
// Split only on LF: U+2028/U+2029 are valid raw characters inside Codex JSON.
|
||||
const reader = new ProviderLineReader(
|
||||
CODEX_PROVIDER_LINE_BYTES,
|
||||
(line) => {
|
||||
void this.handleLine(line).catch((error) => {
|
||||
this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line");
|
||||
});
|
||||
},
|
||||
(error) => this.handleStreamLimit(error),
|
||||
);
|
||||
const onData = (chunk: Buffer | string) => reader.write(chunk);
|
||||
child.stdout.on("data", onData);
|
||||
this.stdoutLineReader = {
|
||||
close: () => {
|
||||
reader.close();
|
||||
child.stdout.off("data", onData);
|
||||
},
|
||||
};
|
||||
|
||||
child.stderr.on("data", (chunk) => {
|
||||
this.stderrBuffer += chunk.toString();
|
||||
@@ -255,6 +256,10 @@ export class CodexAppServerClient {
|
||||
this.requestHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
hasPendingRequests(): boolean {
|
||||
return this.pending.size > 0 || this.activeProviderRequests > 0;
|
||||
}
|
||||
|
||||
request(method: string, params?: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(new Error("Codex app-server client is closed"));
|
||||
@@ -268,7 +273,7 @@ export class CodexAppServerClient {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Codex app-server request timed out for ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.pending.set(id, { method, resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -313,6 +318,28 @@ export class CodexAppServerClient {
|
||||
}
|
||||
}
|
||||
|
||||
private handleStreamLimit(failure: ProviderStreamLimitError): void {
|
||||
const responseId = OVERSIZED_RESPONSE_HEAD.exec(failure.lineHead)?.[1];
|
||||
const pending = responseId === undefined ? undefined : this.pending.get(Number(responseId));
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(Number(responseId));
|
||||
this.logger.warn(
|
||||
{ method: pending.method, limitBytes: failure.limitBytes },
|
||||
"Codex app-server response exceeded the line cap",
|
||||
);
|
||||
pending.reject(new CodexAppServerResponseTooLargeError(pending.method, failure.limitBytes));
|
||||
return;
|
||||
}
|
||||
// A lost notification or server request cannot be replayed. Fail the session,
|
||||
// including live turns with no pending RPC, rather than dropping a stream unit.
|
||||
this.stdoutLineReader.close();
|
||||
this.handleUnexpectedTermination(failure);
|
||||
void this.dispose().catch((err) => {
|
||||
this.logger.warn({ err }, "Failed to terminate oversized Codex stdout producer");
|
||||
});
|
||||
}
|
||||
|
||||
private handleUnexpectedTermination(error: Error): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
@@ -392,6 +419,7 @@ export class CodexAppServerClient {
|
||||
this.traceRawEvent(request);
|
||||
const handler = this.requestHandlers.get(request.method);
|
||||
try {
|
||||
this.activeProviderRequests++;
|
||||
const result = handler ? await handler(request.params, request.id) : {};
|
||||
this.writeJsonRpcResponse({ id: request.id, result });
|
||||
} catch (error) {
|
||||
@@ -399,6 +427,8 @@ export class CodexAppServerClient {
|
||||
id: request.id,
|
||||
error: { message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
} finally {
|
||||
this.activeProviderRequests--;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("Codex Rewind", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
excludeTurns: false,
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
@@ -190,7 +190,7 @@ describe("Codex Rewind", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
excludeTurns: false,
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
parseCodexThreadForkResponse,
|
||||
parseCodexThreadRollbackResponse,
|
||||
} from "./app-server-transport.js";
|
||||
import { readCodexThreadHistoryMode } from "./thread-history.js";
|
||||
|
||||
export interface CodexRewindClient {
|
||||
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
|
||||
@@ -20,26 +21,6 @@ export interface CodexUserMessageTurnIndex {
|
||||
count(): number;
|
||||
}
|
||||
|
||||
type CodexThreadHistoryMode = "legacy" | "paginated";
|
||||
|
||||
async function readCodexThreadHistoryMode(
|
||||
client: CodexRewindClient,
|
||||
threadId: string,
|
||||
): Promise<CodexThreadHistoryMode> {
|
||||
const response = await client.request("thread/read", { threadId, includeTurns: false });
|
||||
if (typeof response !== "object" || response === null || !("thread" in response)) {
|
||||
throw new Error("Codex thread/read did not return thread metadata");
|
||||
}
|
||||
const thread = response.thread;
|
||||
if (typeof thread !== "object" || thread === null || !("historyMode" in thread)) {
|
||||
return "legacy";
|
||||
}
|
||||
if (thread.historyMode === "legacy" || thread.historyMode === "paginated") {
|
||||
return thread.historyMode;
|
||||
}
|
||||
throw new Error(`Codex thread/read returned unknown history mode ${String(thread.historyMode)}`);
|
||||
}
|
||||
|
||||
async function forkCodexThread(
|
||||
client: CodexRewindClient,
|
||||
params: CodexThreadForkParams,
|
||||
@@ -95,7 +76,8 @@ export async function revertCodexConversation(input: {
|
||||
model: input.model ?? null,
|
||||
serviceTier: input.serviceTier ?? null,
|
||||
...(input.config ? { config: input.config } : {}),
|
||||
excludeTurns: false,
|
||||
// Paseo re-reads the rewound thread through paged history, not the fork reply.
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js";
|
||||
import { CODEX_PROVIDER_LINE_BYTES } from "../app-server-transport.js";
|
||||
|
||||
/** A handler result that the fake writes as a response line past the provider line cap. */
|
||||
export const OVERSIZED_RESPONSE = Symbol("oversized Codex response");
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type FakeCodexAppServerHandler = (params: unknown) => unknown;
|
||||
@@ -229,8 +233,18 @@ export function createFakeCodexAppServer(
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve(handler(message.params))
|
||||
let response: unknown;
|
||||
try {
|
||||
response = handler(message.params);
|
||||
} catch (error) {
|
||||
response = Promise.reject(error);
|
||||
}
|
||||
Promise.resolve(response)
|
||||
.then((result) => {
|
||||
if (result === OVERSIZED_RESPONSE) {
|
||||
writeOversizedResponse(message.id as number);
|
||||
return undefined;
|
||||
}
|
||||
const rpcError = toJsonObject(result).__jsonRpcError;
|
||||
if (rpcError) {
|
||||
child.stdout.write(`${JSON.stringify({ id: message.id, error: rpcError })}\n`);
|
||||
@@ -313,6 +327,16 @@ export function createFakeCodexAppServer(
|
||||
});
|
||||
}
|
||||
|
||||
// Codex writes `id` first, then a result past the provider line cap.
|
||||
function writeOversizedResponse(id: number): void {
|
||||
child.stdout.write(`{"id":${id},"result":{"data":[{"item":{"text":"`);
|
||||
const chunk = Buffer.alloc(1024 * 1024, "x");
|
||||
for (let written = 0; written <= CODEX_PROVIDER_LINE_BYTES; written += chunk.byteLength) {
|
||||
child.stdout.write(chunk);
|
||||
}
|
||||
child.stdout.write('"}}]}}\n');
|
||||
}
|
||||
|
||||
function writeNotification(method: string, params: JsonObject): void {
|
||||
child.stdout.write(`${JSON.stringify({ method, params })}\n`);
|
||||
}
|
||||
@@ -696,3 +720,81 @@ export function waitForProviderSubagent(
|
||||
): Promise<ProviderSubagentEvent> {
|
||||
return waitForNextEvent(session, "provider_subagent", (event) => event.event.id === id);
|
||||
}
|
||||
|
||||
export interface FakeCodexHistoryTurn {
|
||||
id: string;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
items: JsonObject[];
|
||||
}
|
||||
|
||||
export interface FakeCodexHistoryThread {
|
||||
/** Omitted on Codex builds older than 0.143, which only have legacy history. */
|
||||
historyMode?: "legacy" | "paginated";
|
||||
turns: FakeCodexHistoryTurn[];
|
||||
}
|
||||
|
||||
export interface FakeCodexHistoryOptions {
|
||||
/** Any response carrying one of these items is written past the line cap. */
|
||||
oversizedItemIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex's persisted thread history: `thread/read` for metadata and legacy full
|
||||
* reads, `thread/items/list` for paginated threads. Paginated threads reject
|
||||
* the full read as Codex 0.145 and 0.146 do.
|
||||
*/
|
||||
export function fakeCodexThreadHistory(
|
||||
threads: Record<string, FakeCodexHistoryThread>,
|
||||
options: FakeCodexHistoryOptions = {},
|
||||
): Record<string, FakeCodexAppServerHandler> {
|
||||
const oversizedItemIds = new Set(options.oversizedItemIds ?? []);
|
||||
const isOversized = (items: JsonObject[]) =>
|
||||
items.some((item) => oversizedItemIds.has(String(item.id)));
|
||||
|
||||
function readThread(params: unknown): { id: string; thread: FakeCodexHistoryThread } {
|
||||
const id = String(toJsonObject(params).threadId);
|
||||
const thread = threads[id];
|
||||
if (!thread) throw new Error(`thread not found: ${id}`);
|
||||
return { id, thread };
|
||||
}
|
||||
|
||||
return {
|
||||
"thread/read": (params) => {
|
||||
const { id, thread } = readThread(params);
|
||||
if (!toJsonObject(params).includeTurns) {
|
||||
return {
|
||||
thread: { id, ...(thread.historyMode ? { historyMode: thread.historyMode } : {}) },
|
||||
};
|
||||
}
|
||||
if (thread.historyMode === "paginated") {
|
||||
throw new Error("paginated threads do not support thread/read(includeTurns=true)");
|
||||
}
|
||||
if (isOversized(thread.turns.flatMap((turn) => turn.items))) return OVERSIZED_RESPONSE;
|
||||
return { thread: { id, turns: thread.turns } };
|
||||
},
|
||||
"thread/items/list": (params) => {
|
||||
const { thread } = readThread(params);
|
||||
if (thread.historyMode !== "paginated") {
|
||||
throw new Error("thread/items/list is not supported for legacy threads");
|
||||
}
|
||||
const request = toJsonObject(params);
|
||||
// Codex records each item's own times; the fake gives every item its turn's.
|
||||
const rows = thread.turns.flatMap((turn) =>
|
||||
turn.items.map((item) => ({
|
||||
turnId: turn.id,
|
||||
item,
|
||||
startedAtMs: turn.startedAt === undefined ? null : turn.startedAt * 1000,
|
||||
completedAtMs: turn.completedAt === undefined ? null : turn.completedAt * 1000,
|
||||
})),
|
||||
);
|
||||
const limit = Math.min(typeof request.limit === "number" ? request.limit : 25, 100);
|
||||
const start =
|
||||
typeof request.cursor === "string" ? Number(request.cursor.slice("cursor-".length)) : 0;
|
||||
const end = Math.min(rows.length, start + limit);
|
||||
const data = rows.slice(start, end);
|
||||
if (isOversized(data.map((row) => row.item))) return OVERSIZED_RESPONSE;
|
||||
return { data, nextCursor: end < rows.length ? `cursor-${end}` : null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { CodexAppServerAgentClient } from "../codex-app-server-agent.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
type FakeCodexAppServer,
|
||||
type FakeCodexHistoryOptions,
|
||||
type FakeCodexHistoryThread,
|
||||
fakeCodexThreadHistory,
|
||||
} from "./test-utils/fake-app-server.js";
|
||||
|
||||
function userItem(id: string, text: string) {
|
||||
return { type: "userMessage", id, content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
function agentItem(id: string, text: string) {
|
||||
return { type: "agentMessage", id, text };
|
||||
}
|
||||
|
||||
async function openCodexThread(
|
||||
thread: FakeCodexHistoryThread,
|
||||
options?: FakeCodexHistoryOptions,
|
||||
): Promise<{ appServer: FakeCodexAppServer; session: AgentSession }> {
|
||||
const appServer = createFakeCodexAppServer(
|
||||
fakeCodexThreadHistory({ "saved-thread": thread }, options),
|
||||
);
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
const internals = provider as unknown as {
|
||||
goalsEnabledPromise: Promise<boolean> | null;
|
||||
autoReviewEnabledPromise: Promise<boolean> | null;
|
||||
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
|
||||
};
|
||||
internals.goalsEnabledPromise = Promise.resolve(false);
|
||||
internals.autoReviewEnabledPromise = Promise.resolve(false);
|
||||
internals.spawnAppServer = async () => appServer.child;
|
||||
const session = await provider.resumeSession({ sessionId: "saved-thread" });
|
||||
return { appServer, session };
|
||||
}
|
||||
|
||||
async function readHistory(session: AgentSession): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) events.push(event);
|
||||
return events;
|
||||
}
|
||||
|
||||
function timelineItems(events: AgentStreamEvent[]) {
|
||||
return events.flatMap((event) => (event.type === "timeline" ? [event.item] : []));
|
||||
}
|
||||
|
||||
function historyRequests(appServer: FakeCodexAppServer) {
|
||||
return appServer
|
||||
.requests()
|
||||
.filter(
|
||||
(request) =>
|
||||
request.method === "thread/read" ||
|
||||
request.method === "thread/turns/list" ||
|
||||
request.method === "thread/items/list",
|
||||
)
|
||||
.map((request) => ({ method: request.method, params: request.params }));
|
||||
}
|
||||
|
||||
describe("Codex thread history", () => {
|
||||
test("loads a paginated thread through pages", async () => {
|
||||
const answers = Array.from({ length: 150 }, (_, index) =>
|
||||
agentItem(`agent-${index}`, `Answer ${index}`),
|
||||
);
|
||||
const { appServer, session } = await openCodexThread({
|
||||
historyMode: "paginated",
|
||||
turns: [
|
||||
{
|
||||
id: "turn-1",
|
||||
startedAt: 1_778_832_941,
|
||||
completedAt: 1_778_833_000,
|
||||
items: [userItem("user-1", "First"), ...answers.slice(0, 120)],
|
||||
},
|
||||
{
|
||||
id: "turn-2",
|
||||
startedAt: 1_778_833_094,
|
||||
completedAt: 1_778_833_100,
|
||||
items: [userItem("user-2", "Second"), ...answers.slice(120)],
|
||||
},
|
||||
],
|
||||
});
|
||||
try {
|
||||
const events = await readHistory(session);
|
||||
|
||||
expect(timelineItems(events)).toEqual([
|
||||
{ type: "user_message", text: "First", messageId: "user-1" },
|
||||
...answers.slice(0, 120).map((answer) => ({
|
||||
type: "assistant_message",
|
||||
text: answer.text,
|
||||
messageId: answer.id,
|
||||
})),
|
||||
{ type: "user_message", text: "Second", messageId: "user-2" },
|
||||
...answers.slice(120).map((answer) => ({
|
||||
type: "assistant_message",
|
||||
text: answer.text,
|
||||
messageId: answer.id,
|
||||
})),
|
||||
]);
|
||||
const timestamps = events.flatMap((event) =>
|
||||
event.type === "timeline" ? [event.timestamp] : [],
|
||||
);
|
||||
expect(timestamps[0]).toBe("2026-05-15T08:15:41.000Z");
|
||||
expect(timestamps[1]).toBe("2026-05-15T08:16:40.000Z");
|
||||
expect(timestamps[121]).toBe("2026-05-15T08:18:14.000Z");
|
||||
const requests = historyRequests(appServer);
|
||||
expect(requests).not.toContainEqual(
|
||||
expect.objectContaining({ params: expect.objectContaining({ includeTurns: true }) }),
|
||||
);
|
||||
const itemPages = requests.filter((request) => request.method === "thread/items/list");
|
||||
expect(itemPages.length).toBeGreaterThan(1);
|
||||
for (const page of itemPages) {
|
||||
expect(page.params).toMatchObject({ threadId: "saved-thread", sortDirection: "asc" });
|
||||
expect((page.params as { limit: number }).limit).toBeLessThanOrEqual(100);
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("loads a paginated thread whose full read Codex 0.145 rejects", async () => {
|
||||
const { appServer, session } = await openCodexThread({
|
||||
historyMode: "paginated",
|
||||
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }],
|
||||
});
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual([
|
||||
{ type: "user_message", text: "Hello", messageId: "user-1" },
|
||||
{ type: "assistant_message", text: "Hi", messageId: "agent-1" },
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a page too big to send shows a warning row and stops loading", async () => {
|
||||
const answers = Array.from({ length: 120 }, (_, index) =>
|
||||
agentItem(`agent-${index}`, `Answer ${index}`),
|
||||
);
|
||||
const { appServer, session } = await openCodexThread(
|
||||
{
|
||||
historyMode: "paginated",
|
||||
turns: [
|
||||
{ id: "turn-1", items: [userItem("user-1", "One"), ...answers] },
|
||||
{ id: "turn-2", items: [userItem("user-2", "Two"), agentItem("huge", "never sent")] },
|
||||
],
|
||||
},
|
||||
{ oversizedItemIds: ["huge"] },
|
||||
);
|
||||
const loaded = [
|
||||
{ type: "user_message", text: "One", messageId: "user-1" },
|
||||
...answers.slice(0, 99).map((answer) => ({
|
||||
type: "assistant_message",
|
||||
text: answer.text,
|
||||
messageId: answer.id,
|
||||
})),
|
||||
{
|
||||
type: "notification",
|
||||
level: "warning",
|
||||
message: "The rest of this Codex thread's history is too large to load.",
|
||||
},
|
||||
];
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual(loaded);
|
||||
// The oversized reply failed only its own request; the session still answers.
|
||||
expect(timelineItems(await readHistory(session))).toEqual(loaded);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("loads a legacy thread that fits in one read", async () => {
|
||||
const { appServer, session } = await openCodexThread({
|
||||
historyMode: "legacy",
|
||||
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }],
|
||||
});
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual([
|
||||
{ type: "user_message", text: "Hello", messageId: "user-1" },
|
||||
{ type: "assistant_message", text: "Hi", messageId: "agent-1" },
|
||||
]);
|
||||
expect(historyRequests(appServer).map((request) => request.method)).not.toContain(
|
||||
"thread/items/list",
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a legacy thread too big for one read shows the migration command", async () => {
|
||||
const { appServer, session } = await openCodexThread(
|
||||
{
|
||||
historyMode: "legacy",
|
||||
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("huge", "x")] }],
|
||||
},
|
||||
{ oversizedItemIds: ["huge"] },
|
||||
);
|
||||
const migrationRow = {
|
||||
type: "notification",
|
||||
level: "warning",
|
||||
message: expect.stringContaining("codex migrate-rollouts --apply --thread saved-thread"),
|
||||
};
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual([migrationRow]);
|
||||
// The session stays up for the user to prompt until they archive it to migrate.
|
||||
expect(timelineItems(await readHistory(session))).toEqual([migrationRow]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { CodexAppServerResponseTooLargeError } from "./app-server-transport.js";
|
||||
|
||||
// Codex clamps history pages to 100 rows.
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export interface CodexThreadHistoryClient {
|
||||
request(method: string, params?: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
export type CodexThreadHistoryMode = "legacy" | "paginated";
|
||||
|
||||
export interface CodexHistoryRow {
|
||||
item: unknown;
|
||||
turnId: string | undefined;
|
||||
/** The item's times on a paginated thread; its turn's on a legacy thread. */
|
||||
startedAt: unknown;
|
||||
completedAt: unknown;
|
||||
}
|
||||
|
||||
/** A history reply over the line cap; the rows before it were already read. */
|
||||
export class CodexThreadHistoryTooLargeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CodexThreadHistoryTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
const CodexThreadMetadataSchema = z
|
||||
.object({
|
||||
thread: z.object({ historyMode: z.enum(["legacy", "paginated"]).optional() }).passthrough(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CodexLegacyThreadReadSchema = z
|
||||
.object({
|
||||
thread: z
|
||||
.object({
|
||||
turns: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
startedAt: z.unknown().optional(),
|
||||
completedAt: z.unknown().optional(),
|
||||
items: z.array(z.unknown()).default([]),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
.passthrough()
|
||||
.default({ turns: [] }),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CodexItemsPageSchema = z
|
||||
.object({
|
||||
data: z.array(
|
||||
z
|
||||
.object({
|
||||
turnId: z.string(),
|
||||
item: z.unknown(),
|
||||
startedAtMs: z.number().nullable().optional(),
|
||||
completedAtMs: z.number().nullable().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
),
|
||||
nextCursor: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** Codex builds before 0.143 report no history mode; their threads are legacy. */
|
||||
export async function readCodexThreadHistoryMode(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): Promise<CodexThreadHistoryMode> {
|
||||
const response = CodexThreadMetadataSchema.parse(
|
||||
await client.request("thread/read", { threadId, includeTurns: false }),
|
||||
);
|
||||
return response.thread.historyMode ?? "legacy";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a thread's persisted items in order. Paginated threads are read in
|
||||
* pages; a legacy thread arrives in one `thread/read` reply, as Codex offers
|
||||
* no pages for it.
|
||||
*/
|
||||
export async function* readCodexThreadHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): AsyncGenerator<CodexHistoryRow> {
|
||||
if ((await readCodexThreadHistoryMode(client, threadId)) === "paginated") {
|
||||
yield* readPaginatedHistory(client, threadId);
|
||||
} else {
|
||||
yield* readLegacyHistory(client, threadId);
|
||||
}
|
||||
}
|
||||
|
||||
async function* readLegacyHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): AsyncGenerator<CodexHistoryRow> {
|
||||
const response = await requestHistory(
|
||||
client,
|
||||
"thread/read",
|
||||
{ threadId, includeTurns: true },
|
||||
`This Codex thread's history is too large to load in one read. ` +
|
||||
`To move it to paginated history, archive this agent, ` +
|
||||
`run \`codex migrate-rollouts --apply --thread ${threadId}\` (Codex 0.148 or newer), ` +
|
||||
`then unarchive the agent.`,
|
||||
);
|
||||
for (const turn of CodexLegacyThreadReadSchema.parse(response).thread.turns) {
|
||||
for (const item of turn.items) {
|
||||
yield { item, turnId: turn.id, startedAt: turn.startedAt, completedAt: turn.completedAt };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function* readPaginatedHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): AsyncGenerator<CodexHistoryRow> {
|
||||
const seenCursors = new Set<string>();
|
||||
let cursor: string | undefined;
|
||||
for (;;) {
|
||||
const page = CodexItemsPageSchema.parse(
|
||||
await requestHistory(
|
||||
client,
|
||||
"thread/items/list",
|
||||
{ threadId, limit: PAGE_SIZE, sortDirection: "asc", cursor },
|
||||
"The rest of this Codex thread's history is too large to load.",
|
||||
),
|
||||
);
|
||||
for (const row of page.data) {
|
||||
yield {
|
||||
item: row.item,
|
||||
turnId: row.turnId,
|
||||
startedAt: row.startedAtMs,
|
||||
completedAt: row.completedAtMs,
|
||||
};
|
||||
}
|
||||
if (page.nextCursor == null) return;
|
||||
// Codex's own client guards against a cursor that does not advance.
|
||||
if (seenCursors.has(page.nextCursor)) {
|
||||
throw new Error(`Codex repeated history cursor for thread ${threadId}`);
|
||||
}
|
||||
seenCursors.add(page.nextCursor);
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
method: string,
|
||||
params: unknown,
|
||||
tooLargeMessage: string,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
return await client.request(method, params);
|
||||
} catch (error) {
|
||||
if (error instanceof CodexAppServerResponseTooLargeError) {
|
||||
throw new CodexThreadHistoryTooLargeError(tooLargeMessage);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { JsonlFrameDecoder, type JsonlFrameProblem } from "./jsonl-frame-decoder.js";
|
||||
|
||||
@@ -36,6 +37,16 @@ describe("JsonlFrameDecoder", () => {
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves UTF-8 characters split across raw stdout buffers", () => {
|
||||
const { decoder, frames, problems } = decoderHarness();
|
||||
const bytes = Buffer.from('{"type":"notice","text":"é🙂"}\n');
|
||||
const split = bytes.indexOf(Buffer.from("🙂")) + 1;
|
||||
decoder.write(bytes.subarray(0, split));
|
||||
decoder.write(bytes.subarray(split));
|
||||
expect(frames).toEqual([{ type: "notice", text: "é🙂" }]);
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
it("reassembles a large ordered v2 frame", () => {
|
||||
const { decoder, frames, problems } = decoderHarness();
|
||||
const frame = { type: "models", value: "x".repeat(1024 * 1024) };
|
||||
@@ -55,3 +66,46 @@ describe("JsonlFrameDecoder", () => {
|
||||
expect(problems).toEqual(["out-of-order-chunk"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider line admission", () => {
|
||||
it("keeps the loop responsive while accumulating a fragmented provider response", async () => {
|
||||
const { decoder, frames, problems } = decoderHarness();
|
||||
const part = "x".repeat(512);
|
||||
const { blockingMs } = await measureMainThreadWork("fragmented JSONL main-thread work", () => {
|
||||
decoder.write('{"type":"notice","text":"');
|
||||
// 1 MiB exposes the former rescan of every accumulated prefix.
|
||||
for (let index = 0; index < 2048; index++) decoder.write(part);
|
||||
decoder.write('"}\n');
|
||||
});
|
||||
expect(frames).toEqual([{ type: "notice", text: part.repeat(2048) }]);
|
||||
expect(problems).toEqual([]);
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it("rejects an unterminated over-cap UTF-8 line and stops accepting frames", () => {
|
||||
const frames: Record<string, unknown>[] = [];
|
||||
const failures: Array<{ problem: string; detail: unknown }> = [];
|
||||
const decoder = new JsonlFrameDecoder({
|
||||
frame: (frame) => frames.push(frame),
|
||||
problem: (problem, detail) => failures.push({ problem, detail }),
|
||||
});
|
||||
// 64 MiB of UTF-8, only 32 Mi UTF-16 code units. No newline or JSON parse needed.
|
||||
const chunk = "é".repeat(32 * 1024);
|
||||
for (let index = 0; index < 1024; index++) decoder.write(chunk);
|
||||
expect(failures).toEqual([]);
|
||||
decoder.write("é");
|
||||
expect(failures).toEqual([
|
||||
{
|
||||
problem: "line-too-large",
|
||||
detail: expect.objectContaining({
|
||||
name: "ProviderStreamLimitError",
|
||||
code: "PROVIDER_STREAM_LINE_TOO_LARGE",
|
||||
limitBytes: 64 * 1024 * 1024,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
decoder.write('\n{"type":"ready"}\n');
|
||||
expect(frames).toEqual([]);
|
||||
expect(failures).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,92 @@
|
||||
// Provider-process stdout only; these are not app/daemon wire limits.
|
||||
export const JSONL_PROVIDER_LINE_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
// Enough of an oversized line to read which response it was, e.g. `{"id":12,"result":`.
|
||||
const OVERSIZED_LINE_HEAD_BYTES = 64;
|
||||
|
||||
export class ProviderStreamLimitError extends Error {
|
||||
readonly code = "PROVIDER_STREAM_LINE_TOO_LARGE";
|
||||
|
||||
constructor(
|
||||
readonly limitBytes: number,
|
||||
/** The first bytes of the oversized line; the rest is discarded unread. */
|
||||
readonly lineHead = "",
|
||||
) {
|
||||
super(
|
||||
`Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`,
|
||||
);
|
||||
this.name = "ProviderStreamLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
function readLineHead(parts: Buffer[]): string {
|
||||
const head: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
for (const part of parts) {
|
||||
if (bytes >= OVERSIZED_LINE_HEAD_BYTES) break;
|
||||
head.push(part.subarray(0, OVERSIZED_LINE_HEAD_BYTES - bytes));
|
||||
bytes += head[head.length - 1].byteLength;
|
||||
}
|
||||
return Buffer.concat(head).toString("utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Admits raw UTF-8 bytes before retaining, decoding or joining a complete line.
|
||||
* An oversized line is reported with its head and skipped up to its newline;
|
||||
* the owner closes the reader if the stream cannot continue without it.
|
||||
*/
|
||||
export class ProviderLineReader {
|
||||
private parts: Buffer[] = [];
|
||||
private bytes = 0;
|
||||
private skippingOversizedLine = false;
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
private readonly limitBytes: number,
|
||||
private readonly onLine: (line: string) => void,
|
||||
private readonly onLimit: (error: ProviderStreamLimitError) => void,
|
||||
) {}
|
||||
|
||||
write(chunk: Buffer | string): void {
|
||||
if (this.stopped) return;
|
||||
const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
||||
let start = 0;
|
||||
while (start < buffer.byteLength && !this.stopped) {
|
||||
const newline = buffer.indexOf(10, start);
|
||||
const end = newline === -1 ? buffer.byteLength : newline;
|
||||
if (this.skippingOversizedLine) {
|
||||
if (newline === -1) return;
|
||||
this.skippingOversizedLine = false;
|
||||
start = newline + 1;
|
||||
continue;
|
||||
}
|
||||
const length = end - start;
|
||||
if (length > this.limitBytes - this.bytes) {
|
||||
const head = readLineHead([...this.parts, buffer.subarray(start, end)]);
|
||||
this.parts = [];
|
||||
this.bytes = 0;
|
||||
this.skippingOversizedLine = true;
|
||||
this.onLimit(new ProviderStreamLimitError(this.limitBytes, head));
|
||||
continue;
|
||||
}
|
||||
if (length) this.parts.push(buffer.subarray(start, end));
|
||||
this.bytes += length;
|
||||
if (newline === -1) return;
|
||||
const line = Buffer.concat(this.parts, this.bytes).toString("utf8");
|
||||
this.parts = [];
|
||||
this.bytes = 0;
|
||||
this.onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
||||
start = newline + 1;
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.stopped = true;
|
||||
this.parts = [];
|
||||
this.bytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const V2_FRAME_BYTES = 1024 * 1024;
|
||||
const V2_REASSEMBLED_BYTES = 64 * 1024 * 1024;
|
||||
const V2_CHUNK_BYTES = 256 * 1024;
|
||||
@@ -5,6 +94,7 @@ const V2_BASE64_CHARS = Math.ceil(V2_CHUNK_BYTES / 3) * 4;
|
||||
const V2_MAX_CHUNKS = Math.ceil(V2_REASSEMBLED_BYTES / V2_CHUNK_BYTES);
|
||||
|
||||
export type JsonlFrameProblem =
|
||||
| "line-too-large"
|
||||
| "invalid-json"
|
||||
| "invalid-chunk"
|
||||
| "invalid-base64"
|
||||
@@ -78,7 +168,7 @@ function chunkBytes(data: string): Buffer | null {
|
||||
}
|
||||
|
||||
export class JsonlFrameDecoder {
|
||||
private lineBuffer = "";
|
||||
private readonly lines: ProviderLineReader;
|
||||
private assembly: ChunkAssembly | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -86,17 +176,21 @@ export class JsonlFrameDecoder {
|
||||
frame(message: Record<string, unknown>): void;
|
||||
problem(problem: JsonlFrameProblem, detail?: unknown): void;
|
||||
},
|
||||
) {}
|
||||
) {
|
||||
this.lines = new ProviderLineReader(
|
||||
JSONL_PROVIDER_LINE_BYTES,
|
||||
(line) => {
|
||||
if (line.trim()) this.consumeLine(line);
|
||||
},
|
||||
(error) => {
|
||||
this.lines.close();
|
||||
this.drop("line-too-large", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
write(text: string): void {
|
||||
this.lineBuffer += text;
|
||||
let newline = this.lineBuffer.indexOf("\n");
|
||||
while (newline !== -1) {
|
||||
const line = this.lineBuffer.slice(0, newline).replace(/\r$/, "");
|
||||
this.lineBuffer = this.lineBuffer.slice(newline + 1);
|
||||
if (line.trim()) this.consumeLine(line);
|
||||
newline = this.lineBuffer.indexOf("\n");
|
||||
}
|
||||
write(chunk: Buffer | string): void {
|
||||
this.lines.write(chunk);
|
||||
}
|
||||
|
||||
private consumeLine(line: string): void {
|
||||
|
||||
@@ -471,3 +471,25 @@ describe("JsonlRpcProcess", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("JSONL stdout byte admission", () => {
|
||||
test("fails pending requests and publishes the typed failure to live-turn consumers", async () => {
|
||||
const child = createInMemoryChildProcess();
|
||||
const process = startProcess({ child });
|
||||
const exit = new Promise<JsonlRpcExit>((resolve) => process.onExit(resolve));
|
||||
const request = process.request({ type: "hang" }, 1000).catch((error: Error) => error);
|
||||
const chunk = Buffer.alloc(64 * 1024, "x");
|
||||
child.stdout.write('{"type":"notice","text":"');
|
||||
for (let index = 0; index < 1024; index++) child.stdout.write(chunk);
|
||||
const error = await request;
|
||||
expect(error).toMatchObject({
|
||||
name: "ProviderStreamLimitError",
|
||||
code: "PROVIDER_STREAM_LINE_TOO_LARGE",
|
||||
limitBytes: 64 * 1024 * 1024,
|
||||
});
|
||||
expect((await exit).error).toBe(error);
|
||||
await process.close();
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Logger } from "pino";
|
||||
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||
import { JsonlFrameDecoder } from "./jsonl-frame-decoder.js";
|
||||
import { JsonlFrameDecoder, ProviderStreamLimitError } from "./jsonl-frame-decoder.js";
|
||||
export { supportsJsonlRpcProtocolV2 } from "./jsonl-frame-decoder.js";
|
||||
|
||||
/** Default wall-clock timeout for control-plane / short RPC calls. */
|
||||
@@ -81,6 +81,7 @@ export class JsonlRpcProcess {
|
||||
private stderrBuffer = "";
|
||||
private nextRequestId = 1;
|
||||
private disposed = false;
|
||||
private streamFailure: ProviderStreamLimitError | null = null;
|
||||
private exited = false;
|
||||
private closing: Promise<void> | null = null;
|
||||
private readonly frameDecoder: JsonlFrameDecoder;
|
||||
@@ -90,6 +91,14 @@ export class JsonlRpcProcess {
|
||||
this.frameDecoder = new JsonlFrameDecoder({
|
||||
frame: (message) => this.dispatchFrame(message),
|
||||
problem: (problem, detail) => {
|
||||
if (problem === "line-too-large" && detail instanceof ProviderStreamLimitError) {
|
||||
// Stop the producer: silently skipping a response or turn event loses state.
|
||||
this.streamFailure = detail;
|
||||
void this.close(detail).catch((err) => {
|
||||
this.options.logger.warn({ err }, `Failed to close ${this.diagnosticName}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.options.logger.warn(
|
||||
{ problem, detail },
|
||||
`Ignoring invalid ${this.diagnosticName} frame`,
|
||||
@@ -98,7 +107,7 @@ export class JsonlRpcProcess {
|
||||
});
|
||||
this.child = (options.spawn ?? spawnJsonlRpcProcess)(options.launch);
|
||||
this.child.stdout.on("data", (chunk) => {
|
||||
this.handleStdoutChunk(chunk.toString());
|
||||
this.handleStdoutChunk(chunk);
|
||||
});
|
||||
this.child.stderr.on("data", (chunk) => {
|
||||
this.stderrBuffer += chunk.toString();
|
||||
@@ -114,9 +123,11 @@ export class JsonlRpcProcess {
|
||||
});
|
||||
this.child.on("exit", (code, signal) => {
|
||||
this.exited = true;
|
||||
const error = new Error(
|
||||
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
|
||||
);
|
||||
const error =
|
||||
this.streamFailure ??
|
||||
new Error(
|
||||
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
|
||||
);
|
||||
const exit = { code, signal, error };
|
||||
for (const subscriber of this.exitSubscribers) {
|
||||
subscriber(exit);
|
||||
@@ -254,7 +265,7 @@ export class JsonlRpcProcess {
|
||||
}
|
||||
}
|
||||
|
||||
private handleStdoutChunk(chunk: string): void {
|
||||
private handleStdoutChunk(chunk: Buffer | string): void {
|
||||
this.frameDecoder.write(chunk);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AgentManager } from "../agent-manager.js";
|
||||
import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
import {
|
||||
MOCK_LOAD_TEST_DEFAULT_MODEL_ID,
|
||||
MOCK_LOAD_TEST_HANDLED_COMMAND,
|
||||
MockLoadTestAgentClient,
|
||||
} from "./mock-load-test-agent.js";
|
||||
|
||||
@@ -49,6 +50,18 @@ describe("MockLoadTestAgentClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("lists the command it handles without starting a turn", async () => {
|
||||
const client = new MockLoadTestAgentClient();
|
||||
const session = await client.createSession({ provider: "mock", cwd: process.cwd() });
|
||||
|
||||
const commands = (await session.listCommands?.()) ?? [];
|
||||
|
||||
expect(commands.map((command) => `/${command.name} ${command.argumentHint}`)).toEqual([
|
||||
MOCK_LOAD_TEST_HANDLED_COMMAND,
|
||||
]);
|
||||
expect(session.tryHandleOutOfBand?.(MOCK_LOAD_TEST_HANDLED_COMMAND)).not.toBeNull();
|
||||
});
|
||||
|
||||
test("rejects the configured number of prompts before starting a retry", async () => {
|
||||
const client = new MockLoadTestAgentClient();
|
||||
const session = await client.createSession({
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
AgentRuntimeInfo,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentSlashCommand,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
FetchCatalogOptions,
|
||||
@@ -35,7 +36,13 @@ import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest
|
||||
|
||||
export const MOCK_LOAD_TEST_PROVIDER_ID = "mock";
|
||||
export const MOCK_LOAD_TEST_DEFAULT_MODEL_ID = "five-minute-stream";
|
||||
export const MOCK_LOAD_TEST_HANDLED_COMMAND = "/mock handled-command";
|
||||
const MOCK_LOAD_TEST_COMMAND: AgentSlashCommand = {
|
||||
name: "mock",
|
||||
description: "Reply without starting a turn",
|
||||
argumentHint: "handled-command",
|
||||
kind: "command",
|
||||
};
|
||||
export const MOCK_LOAD_TEST_HANDLED_COMMAND = `/${MOCK_LOAD_TEST_COMMAND.name} ${MOCK_LOAD_TEST_COMMAND.argumentHint}`;
|
||||
const MOCK_LOAD_TEST_MODE_ID = "load-test";
|
||||
const MOCK_LOAD_TEST_DURATION_MS = 5 * 60 * 1000;
|
||||
const MOCK_LOAD_TEST_INTERVAL_MS = 40;
|
||||
@@ -223,6 +230,7 @@ interface LargeAgentStreamPayloadRequest {
|
||||
}
|
||||
|
||||
interface AgentStreamStressRequest {
|
||||
tools: boolean;
|
||||
count: number;
|
||||
coalesced: boolean;
|
||||
}
|
||||
@@ -288,6 +296,35 @@ function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRe
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/with\s+links/i.test(text)) {
|
||||
return {
|
||||
questions: [
|
||||
{
|
||||
question:
|
||||
"Sign in at https://example.com/login, then follow [the setup guide](https://example.com/setup). Done?",
|
||||
header: "signIn",
|
||||
options: [
|
||||
{
|
||||
label: "Done",
|
||||
description: "Logs are at https://example.com/logs if sign-in fails.",
|
||||
},
|
||||
{ label: "Not yet, see [status](https://example.com/status)" },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: "Which checks should run?",
|
||||
header: "checks",
|
||||
options: [
|
||||
{ label: "Lint", description: "Rules: https://example.com/lint" },
|
||||
{ label: "Typecheck" },
|
||||
],
|
||||
multiSelect: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (/free[-\s]?write|freeform|text[-\s]?only/i.test(text)) {
|
||||
return {
|
||||
questions: [
|
||||
@@ -425,7 +462,7 @@ function parseLargeAgentStreamPayloadPrompt(
|
||||
|
||||
function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStressRequest | null {
|
||||
const text = promptToText(prompt);
|
||||
const match = /emit\s+(\d+)\s+(coalesced\s+)?agent stream updates/i.exec(text);
|
||||
const match = /emit\s+(\d+)\s+(coalesced\s+|tool\s+)?agent stream updates/i.exec(text);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
@@ -435,7 +472,8 @@ function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStre
|
||||
}
|
||||
return {
|
||||
count: Math.min(count, 5_000),
|
||||
coalesced: Boolean(match[2]),
|
||||
coalesced: match[2]?.trim() === "coalesced",
|
||||
tools: match[2]?.trim() === "tool",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -954,6 +992,10 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
async listCommands(): Promise<AgentSlashCommand[]> {
|
||||
return [MOCK_LOAD_TEST_COMMAND];
|
||||
}
|
||||
|
||||
tryHandleOutOfBand(
|
||||
prompt: AgentPromptInput,
|
||||
): { run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void> } | null {
|
||||
@@ -1412,21 +1454,37 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
this.emitTurnStarted(turn);
|
||||
|
||||
for (let index = 0; index < stress.count; index += 1) {
|
||||
this.emitTimeline(
|
||||
turn.turnId,
|
||||
stress.coalesced
|
||||
? {
|
||||
type: "assistant_message",
|
||||
text: `stress-update-${index}`,
|
||||
messageId: turn.assistantMessageId,
|
||||
}
|
||||
: {
|
||||
type: "todo",
|
||||
items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }],
|
||||
},
|
||||
);
|
||||
let item: AgentTimelineItem;
|
||||
if (stress.tools) {
|
||||
item = {
|
||||
type: "tool_call",
|
||||
callId: `${turn.turnId}:stress:${index}`,
|
||||
name: "Bash",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "shell", command: `echo stress-${index}`, output: `stress-${index}` },
|
||||
};
|
||||
} else if (stress.coalesced) {
|
||||
item = {
|
||||
type: "assistant_message",
|
||||
text: `stress-update-${index}`,
|
||||
messageId: turn.assistantMessageId,
|
||||
};
|
||||
} else {
|
||||
item = {
|
||||
type: "todo",
|
||||
items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }],
|
||||
};
|
||||
}
|
||||
this.emitTimeline(turn.turnId, item);
|
||||
}
|
||||
|
||||
if (stress.tools)
|
||||
this.emitTimeline(turn.turnId, {
|
||||
type: "assistant_message",
|
||||
text: `Completed ${stress.count} commands`,
|
||||
messageId: turn.assistantMessageId,
|
||||
});
|
||||
this.activeTurn = null;
|
||||
const usage = {
|
||||
inputTokens: 1,
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { OmpAgentMessage } from "./rpc-types.js";
|
||||
import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js";
|
||||
import type { OmpUsagePollScheduler } from "./usage-poller.js";
|
||||
import { resolveOmpProviderOptions } from "./provider-config.js";
|
||||
import { OmpRuntimeEventSchema } from "./rpc-types.js";
|
||||
import { OmpRuntimeEventSchema, OmpSessionStateSchema } from "./rpc-types.js";
|
||||
import { OmpHarness } from "./test-utils/omp-harness.js";
|
||||
import { OmpAgentClient } from "./agent.js";
|
||||
import { FakeOmp } from "./test-utils/fake-omp.js";
|
||||
@@ -1133,6 +1133,17 @@ describe("OMP agent client and session", () => {
|
||||
messageId: "assistant-history",
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
omp.runPrompt("continue after resume", "resumed follow-up complete"),
|
||||
).resolves.toMatchObject({
|
||||
finalText: "resumed follow-up complete",
|
||||
});
|
||||
expect(omp.timeline()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
text: "resumed follow-up complete",
|
||||
}),
|
||||
);
|
||||
expect(omp.usageSession()).toMatchObject({ provider: "omp", sessionKey: expect.any(String) });
|
||||
await omp.close();
|
||||
expect(omp.usageSession()).toBeNull();
|
||||
@@ -1332,13 +1343,14 @@ describe("OMP agent client and session", () => {
|
||||
expect(omp.runtimeLaunches()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("closes a replacement process if the session closes during relaunch", async () => {
|
||||
test("rejects a pending recovery start and closes its processes when the session closes", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
omp.processExit("OMP RPC process exited with code 1 and signal null");
|
||||
const turn = omp.startTurnDetached("continue");
|
||||
const rejected = expect(turn).rejects.toThrow("closed");
|
||||
await omp.close();
|
||||
await turn;
|
||||
await rejected;
|
||||
await waitForImmediate();
|
||||
expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true);
|
||||
});
|
||||
@@ -1544,3 +1556,176 @@ describe("OMP agent client and session", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("omp runtime safety protects queued replies and runtime-owned activity", async () => {
|
||||
const runtime = new FakeOmp();
|
||||
const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({
|
||||
provider: "omp",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
const native = runtime.latestSession();
|
||||
native.state.isSettled = true;
|
||||
native.state.hasPendingAsyncWork = false;
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
native.state.queuedMessageCount = 1;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.queuedMessageCount = 0;
|
||||
native.state.isStreaming = true;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.isStreaming = false;
|
||||
native.state.isCompacting = true;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.isCompacting = false;
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
native.getStateError = new Error("Unable to prove idle");
|
||||
await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle");
|
||||
});
|
||||
|
||||
test("OMP runtime safety retains async work after the foreground turn finishes", async () => {
|
||||
const runtime = new FakeOmp();
|
||||
const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({
|
||||
provider: "omp",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
const native = runtime.latestSession();
|
||||
native.state.hasPendingAsyncWork = true;
|
||||
native.state.isSettled = false;
|
||||
const prompted = native.nextPrompt();
|
||||
const run = session.run("start a background job");
|
||||
await prompted;
|
||||
native.beginTurn();
|
||||
native.acceptPrompt("start a background job", "async-user");
|
||||
native.streamAssistantText("Job started in background");
|
||||
native.finishTurn();
|
||||
await run;
|
||||
expect(native.state.isStreaming).toBe(false);
|
||||
expect(native.state.queuedMessageCount).toBe(0);
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
// The delivery is drained, but an admitted/scheduled continuation still prevents settlement.
|
||||
native.state.hasPendingAsyncWork = false;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.isSettled = true;
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ hasPendingAsyncWork: true, isSettled: true, expected: false },
|
||||
{ hasPendingAsyncWork: false, isSettled: false, expected: false },
|
||||
{ hasPendingAsyncWork: false, isSettled: true, expected: true },
|
||||
{ hasPendingAsyncWork: undefined, isSettled: true, expected: false },
|
||||
{ hasPendingAsyncWork: false, isSettled: undefined, expected: false },
|
||||
{ hasPendingAsyncWork: undefined, isSettled: undefined, expected: false },
|
||||
])(
|
||||
"OMP runtime safety requires explicit settlement: %j",
|
||||
async ({ hasPendingAsyncWork, isSettled, expected }) => {
|
||||
const runtime = new FakeOmp();
|
||||
const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession(
|
||||
{
|
||||
provider: "omp",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
);
|
||||
onTestFinished(() => session.close());
|
||||
const native = runtime.latestSession();
|
||||
const response = { ...native.state };
|
||||
if (hasPendingAsyncWork !== undefined) response.hasPendingAsyncWork = hasPendingAsyncWork;
|
||||
if (isSettled !== undefined) response.isSettled = isSettled;
|
||||
// Legacy get_state responses stay valid; missing safety fields never imply settlement.
|
||||
native.state = OmpSessionStateSchema.parse(response);
|
||||
expect(native.state.hasPendingAsyncWork).toBe(hasPendingAsyncWork);
|
||||
expect(native.state.isSettled).toBe(isSettled);
|
||||
expect(await session.canStopRuntime!()).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([false, true])(
|
||||
"image prompt keeps OMP's vision capability contract (vision: %s)",
|
||||
async (vision) => {
|
||||
const runtime = new FakeOmp();
|
||||
runtime.queueSessionSetup((session) => {
|
||||
session.state = {
|
||||
...session.state,
|
||||
model: {
|
||||
provider: "test",
|
||||
id: "image-model",
|
||||
input: vision ? ["text", "image"] : ["text"],
|
||||
},
|
||||
};
|
||||
});
|
||||
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
|
||||
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
|
||||
onTestFinished(() => session.close());
|
||||
const bytes = Buffer.from("OMP attachment fixture");
|
||||
const first = session.startTurn([
|
||||
{ type: "text", text: "Describe this image." },
|
||||
{ type: "image", data: bytes.toString("base64"), mimeType: "image/png" },
|
||||
]);
|
||||
await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active");
|
||||
await first;
|
||||
const [prompt] = runtime.latestSession().prompts;
|
||||
expect(prompt.imageCount).toBe(vision ? 1 : 0);
|
||||
if (vision) expect(prompt.message).toBe("Describe this image.");
|
||||
else {
|
||||
const imagePath = prompt.message.match(/\[Image available at: (.+)\]/)?.[1];
|
||||
expect(imagePath).toBeTruthy();
|
||||
const { readFile, rm } = await import("node:fs/promises");
|
||||
expect(await readFile(imagePath!)).toEqual(bytes);
|
||||
await rm(imagePath!);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("interrupting OMP during image conversion cancels the admitted turn and permits a follow-up", async () => {
|
||||
const runtime = new FakeOmp();
|
||||
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
|
||||
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
|
||||
onTestFinished(() => session.close());
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
let admitted = false;
|
||||
const first = session
|
||||
.startTurn([
|
||||
{
|
||||
type: "image",
|
||||
data: Buffer.alloc(16 * 1024 * 1024, 53).toString("base64"),
|
||||
mimeType: "image/png",
|
||||
},
|
||||
])
|
||||
.then((result) => {
|
||||
admitted = true;
|
||||
return result;
|
||||
});
|
||||
await waitForImmediate();
|
||||
expect(admitted).toBe(false);
|
||||
expect(runtime.latestSession().prompts).toHaveLength(0);
|
||||
await session.interrupt();
|
||||
const followup = await session.startTurn("follow-up after image interrupt");
|
||||
const original = await first;
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }),
|
||||
);
|
||||
expect(followup.turnId).not.toBe(original.turnId);
|
||||
expect(runtime.latestSession().prompts).toEqual([
|
||||
{ message: "follow-up after image interrupt", imageCount: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("closing OMP during image conversion rejects its pending start", async () => {
|
||||
const runtime = new FakeOmp();
|
||||
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
|
||||
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
|
||||
const first = session.startTurn([
|
||||
{
|
||||
type: "image",
|
||||
data: Buffer.alloc(16 * 1024 * 1024, 54).toString("base64"),
|
||||
mimeType: "image/png",
|
||||
},
|
||||
]);
|
||||
const rejected = expect(first).rejects.toThrow("closed");
|
||||
await waitForImmediate();
|
||||
expect(runtime.latestSession().prompts).toHaveLength(0);
|
||||
await session.close();
|
||||
await rejected;
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user