mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
fix(server): retain restart-safe images with async processing
Integrate current main while preserving stable attachment storage, async materialization, bounded decoding and atomic publication. Adapt the restart regression and private-storage tests to the asynchronous API and isolate all materialization tests from the real home.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.2",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
@@ -107,11 +107,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.13.0",
|
||||
"@getpaseo/highlight": "0.13.0",
|
||||
"@getpaseo/plugin": "0.13.0",
|
||||
"@getpaseo/protocol": "0.13.0",
|
||||
"@getpaseo/relay": "0.13.0",
|
||||
"@getpaseo/client": "0.13.2",
|
||||
"@getpaseo/highlight": "0.13.2",
|
||||
"@getpaseo/plugin": "0.13.2",
|
||||
"@getpaseo/protocol": "0.13.2",
|
||||
"@getpaseo/relay": "0.13.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
|
||||
import {
|
||||
prepareAgentMessage,
|
||||
formatAgentMessage,
|
||||
@@ -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";
|
||||
@@ -10007,6 +10068,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 +11053,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 +11401,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 +11430,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 +11474,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);
|
||||
@@ -12083,3 +12191,176 @@ test("reads the last durable assistant response without loading a stored-only ch
|
||||
);
|
||||
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";
|
||||
@@ -81,7 +85,11 @@ import {
|
||||
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.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";
|
||||
@@ -331,6 +339,7 @@ export interface AgentManagerOptions {
|
||||
terminalManager?: TerminalManager | null;
|
||||
mcpBaseUrl?: string;
|
||||
mcpAuthToken?: string;
|
||||
pluginAgentTools?: PluginAgentToolsRuntime;
|
||||
paseoToolsEnabled?: boolean;
|
||||
paseoToolCatalogFactory?: PaseoToolCatalogFactory;
|
||||
resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined;
|
||||
@@ -554,18 +563,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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -758,10 +769,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>();
|
||||
@@ -779,6 +796,7 @@ export class AgentManager {
|
||||
|
||||
constructor(options: AgentManagerOptions) {
|
||||
this.pluginLifecycle = options.pluginLifecycle;
|
||||
this.pluginAgentTools = options.pluginAgentTools;
|
||||
this.timelineStore = options.timelineStore ?? new InMemoryAgentTimelineStore();
|
||||
this.idFactory = options.idFactory ?? (() => randomUUID());
|
||||
this.registry = options?.registry;
|
||||
@@ -883,6 +901,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);
|
||||
}
|
||||
@@ -1308,12 +1382,18 @@ 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,
|
||||
@@ -1430,6 +1510,8 @@ export class AgentManager {
|
||||
reason: "resume",
|
||||
purpose,
|
||||
workspaceId: options?.workspaceId ?? null,
|
||||
title: record?.title ?? mergedConfig.title ?? null,
|
||||
labels: record?.labels ?? options?.labels ?? {},
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
@@ -1439,7 +1521,7 @@ export class AgentManager {
|
||||
launchContext,
|
||||
currentResumeOptions,
|
||||
);
|
||||
await this.requireExternalMcpSupport(session, storedConfig);
|
||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
...options,
|
||||
persistence: handle,
|
||||
@@ -1487,7 +1569,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(
|
||||
@@ -1497,6 +1584,7 @@ export class AgentManager {
|
||||
},
|
||||
{ config: providerLaunchConfig, storedConfig, launchContext },
|
||||
);
|
||||
await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext);
|
||||
let handedToRegistration = false;
|
||||
try {
|
||||
const importedConfig = await this.normalizeConfig(
|
||||
@@ -1582,14 +1670,20 @@ export class AgentManager {
|
||||
storedConfig.cwd,
|
||||
paseoToolPolicy,
|
||||
undefined,
|
||||
{ reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId },
|
||||
{
|
||||
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;
|
||||
@@ -1608,7 +1702,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) {
|
||||
@@ -1646,6 +1740,7 @@ export class AgentManager {
|
||||
throw error;
|
||||
} finally {
|
||||
if (!handedToRegistration) {
|
||||
this.detachPluginTools(agentId);
|
||||
if (hadPreviousPaseoToolPolicy) {
|
||||
this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy);
|
||||
} else {
|
||||
@@ -1747,6 +1842,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) {
|
||||
@@ -1767,7 +1920,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(
|
||||
{
|
||||
@@ -1782,6 +1935,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();
|
||||
@@ -1794,6 +1948,7 @@ export class AgentManager {
|
||||
} catch (error) {
|
||||
persistError = error;
|
||||
}
|
||||
this.detachPluginTools(agentId);
|
||||
this.emitClosedAgent(closedAgent, { persist: false });
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -1807,6 +1962,7 @@ export class AgentManager {
|
||||
if (persistError !== undefined) {
|
||||
throw persistError;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private cancelRunningProviderSubagents(parentAgentId: string): void {
|
||||
@@ -2459,7 +2615,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.
|
||||
@@ -2470,6 +2638,7 @@ export class AgentManager {
|
||||
if (!handler) {
|
||||
return false;
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
if (options?.clientMessageId) {
|
||||
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted");
|
||||
this.emitState(agent);
|
||||
@@ -2489,7 +2658,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) {
|
||||
@@ -2500,7 +2669,7 @@ export class AgentManager {
|
||||
item: { type: "assistant_message", text: `[Error] ${text}` },
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2584,11 +2753,27 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
{
|
||||
@@ -2743,7 +2928,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(
|
||||
{
|
||||
@@ -3687,16 +3875,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: {
|
||||
@@ -3782,9 +3977,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,
|
||||
@@ -3847,6 +4042,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 });
|
||||
}
|
||||
@@ -4087,9 +4283,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
|
||||
@@ -4447,9 +4643,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);
|
||||
@@ -4523,7 +4719,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5333,10 +5529,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,
|
||||
@@ -5344,29 +5547,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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -5374,7 +5597,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> {
|
||||
|
||||
@@ -283,3 +283,33 @@ test("batches retain each sender's attachment context and image order", async ()
|
||||
)?.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);
|
||||
});
|
||||
|
||||
@@ -43,6 +43,8 @@ interface Dependencies {
|
||||
}
|
||||
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. */
|
||||
@@ -138,6 +140,7 @@ export function createAgentMessageDelivery(dependencies: Dependencies): AgentMes
|
||||
}
|
||||
|
||||
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"];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
AgentPromptContentBlock,
|
||||
@@ -175,9 +175,18 @@ 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;
|
||||
// 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) {
|
||||
|
||||
@@ -357,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" }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export type AgentRunController = Pick<
|
||||
| "getAgent"
|
||||
| "tryRunOutOfBand"
|
||||
| "hasInFlightRun"
|
||||
| "reloadStalePluginSession"
|
||||
| "replaceAgentRun"
|
||||
| "steerOrReplaceActiveTurn"
|
||||
| "streamAgent"
|
||||
@@ -110,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;
|
||||
|
||||
@@ -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>;
|
||||
/**
|
||||
@@ -694,6 +697,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[]>;
|
||||
|
||||
@@ -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,43 @@
|
||||
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
|
||||
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 +213,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 +250,149 @@ 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 });
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
|
||||
async (wholeTurns) => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("large");
|
||||
for (let index = 0; index < 100_000; index++) {
|
||||
store.append("large", { type: "user_message", text: String(index) });
|
||||
}
|
||||
const { result: tail, blockingMs } = await measureMainThreadWork(
|
||||
`timeline tail main-thread work (wholeTurns=${wholeTurns})`,
|
||||
() => {
|
||||
let page = store.fetch("large", { limit: 200, wholeTurns });
|
||||
for (let repeat = 1; repeat < 8; repeat++)
|
||||
page = store.fetch("large", { limit: 200, wholeTurns });
|
||||
return page;
|
||||
},
|
||||
);
|
||||
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 {
|
||||
|
||||
@@ -69,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"],
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -67,7 +68,7 @@ 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) {
|
||||
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);
|
||||
@@ -91,7 +92,7 @@ async function scenario(directory?: string, removalFailures = 0) {
|
||||
callerIsActive: async () => active,
|
||||
serverId: "origin",
|
||||
logger: createTestLogger(),
|
||||
retryDelayMs: 1,
|
||||
retryDelayMs,
|
||||
});
|
||||
services.push(service);
|
||||
await service.start();
|
||||
@@ -387,13 +388,13 @@ test("removed-host notice retries parent dispatch without observing again or del
|
||||
});
|
||||
|
||||
test("failed removal after dispatch acknowledgment retries only storage, preserving the accepted outcome", async () => {
|
||||
const state = await scenario(undefined, 1);
|
||||
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).toBe(0);
|
||||
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);
|
||||
@@ -412,3 +413,87 @@ test("shutdown during observation setup owns rejection of the already-canceled n
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface FinishRegistration {
|
||||
}
|
||||
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,
|
||||
@@ -72,6 +74,8 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
|
||||
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);
|
||||
@@ -300,6 +304,13 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
|
||||
}
|
||||
|
||||
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[]>();
|
||||
@@ -309,9 +320,11 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
|
||||
groups.set(key(record), records);
|
||||
}
|
||||
for (const records of groups.values()) attach(records, false);
|
||||
recovering = false;
|
||||
}),
|
||||
register: (input, pendingDispatch = false) =>
|
||||
serialize(async () => {
|
||||
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,
|
||||
@@ -337,13 +350,18 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
|
||||
},
|
||||
reject: () =>
|
||||
serialize(async () => {
|
||||
stop(watch, actualGroup);
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -270,6 +270,8 @@ function buildAgentManagerSpies() {
|
||||
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
|
||||
appendTimelineItem: vi.fn().mockResolvedValue(undefined),
|
||||
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
|
||||
getPluginTools: () => [],
|
||||
reloadStalePluginSession: async () => {},
|
||||
hasInFlightRun: vi.fn().mockReturnValue(false),
|
||||
steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }),
|
||||
tryRunOutOfBand: vi.fn().mockReturnValue(false),
|
||||
@@ -617,6 +619,10 @@ class FakeBrowserToolsBroker {
|
||||
}
|
||||
|
||||
class BoundaryAgentManagerFake {
|
||||
public getPluginTools(): [] {
|
||||
return [];
|
||||
}
|
||||
|
||||
private readonly agent = createManagedAgent({
|
||||
id: "agent-1",
|
||||
cwd: REPO_CWD,
|
||||
|
||||
@@ -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,3 +1,8 @@
|
||||
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 type {
|
||||
ProviderConnection,
|
||||
@@ -305,7 +310,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 },
|
||||
);
|
||||
@@ -1234,3 +1239,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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -113,9 +113,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 +570,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);
|
||||
}
|
||||
@@ -635,6 +652,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",
|
||||
@@ -1014,7 +1052,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(),
|
||||
@@ -1201,6 +1239,11 @@ class PluginAgentSession implements AgentSession {
|
||||
for (const event of this.history) yield event;
|
||||
}
|
||||
|
||||
get canStopRuntime(): (() => Promise<boolean>) | undefined {
|
||||
if (!this.bridge.negotiatedCapabilities.includes("session.can_stop_runtime")) return undefined;
|
||||
return () => this.bridge.canStopRuntime();
|
||||
}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,48 @@ function createQueryMock(events: unknown[]): Query {
|
||||
}
|
||||
|
||||
describe("Claude SDK env", () => {
|
||||
test.each(["node", "bun", process.execPath])(
|
||||
"identifies the spawned %s process as Paseo after clearing parent session env",
|
||||
async (command) => {
|
||||
let output = "";
|
||||
let exited = Promise.resolve<unknown[]>([]);
|
||||
const query = claudeQuery(
|
||||
{ prompt: "", options: {} },
|
||||
{
|
||||
runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } },
|
||||
launchEnv: { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" },
|
||||
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: "Paseo", 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,9 +1,14 @@
|
||||
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";
|
||||
@@ -32,7 +37,10 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
let configDir: string;
|
||||
let records: LogRecord[];
|
||||
|
||||
async function resume(queryFactory?: ClaudeQueryFactory): Promise<AgentSession> {
|
||||
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:
|
||||
@@ -42,10 +50,36 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
}),
|
||||
resolveBinary: async () => process.execPath,
|
||||
resolveVersion: async () => "2.1.220",
|
||||
rewindSdk,
|
||||
});
|
||||
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 {
|
||||
return path.join(claudeProjectDirSync(cwd, { configDir }), `${SESSION_ID}.jsonl`);
|
||||
}
|
||||
@@ -65,6 +99,286 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
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) => {
|
||||
@@ -300,21 +614,21 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("loads readable history when the temporary directory is unavailable", () => {
|
||||
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);
|
||||
withClaudeReplayHistory(file, (history) => {
|
||||
expect([...history.parentEntries]).toEqual([entry]);
|
||||
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",
|
||||
(target) => {
|
||||
async (target) => {
|
||||
const parent = transcriptPath();
|
||||
const sessionDirectory = parent.slice(0, -".jsonl".length);
|
||||
const child = path.join(sessionDirectory, "subagents", "agent-child.jsonl");
|
||||
@@ -334,14 +648,16 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
writeFileSync(parent, JSON.stringify(parentEntry));
|
||||
writeFileSync(child, JSON.stringify(childEntry));
|
||||
writeFileSync(workflow, JSON.stringify(childEntry));
|
||||
withClaudeReplayHistory(parent, (history) => {
|
||||
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([...history.parentEntries]).toEqual([parentEntry]);
|
||||
expect(await Array.fromAsync(history.parentEntries)).toEqual([parentEntry]);
|
||||
expect(history.subagents).toHaveLength(1);
|
||||
expect([...history.subagents[0]!.entries]).toEqual([childEntry]);
|
||||
expect([...history.workflowEntriesByRunId.get("wf_test")!]).toEqual([childEntry]);
|
||||
expect(await Array.fromAsync(history.subagents[0]!.entries)).toEqual([childEntry]);
|
||||
expect(await Array.fromAsync(history.workflowEntriesByRunId.get("wf_test")!)).toEqual([
|
||||
childEntry,
|
||||
]);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -32,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>;
|
||||
}
|
||||
|
||||
@@ -53,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",
|
||||
@@ -79,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: {
|
||||
@@ -124,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: {
|
||||
@@ -138,7 +138,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
|
||||
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
text: "Run npm test",
|
||||
@@ -146,7 +146,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("converts compact boundary metadata variants", () => {
|
||||
test("converts compact boundary metadata variants", async () => {
|
||||
const fixtures = [
|
||||
{
|
||||
entry: {
|
||||
@@ -175,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",
|
||||
@@ -186,7 +186,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("skips synthetic user entries", () => {
|
||||
test("skips synthetic user entries", async () => {
|
||||
const entry = {
|
||||
type: "user",
|
||||
isSynthetic: true,
|
||||
@@ -197,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,
|
||||
@@ -219,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: {
|
||||
@@ -246,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",
|
||||
@@ -305,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",
|
||||
@@ -333,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",
|
||||
@@ -357,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",
|
||||
@@ -372,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",
|
||||
@@ -396,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: {
|
||||
@@ -414,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);
|
||||
});
|
||||
});
|
||||
@@ -1208,7 +1208,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
input: Record<string, unknown>,
|
||||
options: Record<string, unknown>,
|
||||
): Promise<PermissionResult>;
|
||||
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
||||
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1248,7 +1248,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
),
|
||||
).resolves.toMatchObject({ behavior: "deny", interrupt: undefined });
|
||||
|
||||
internal.translateMessageToEvents({
|
||||
await internal.translateMessageToEvents({
|
||||
type: "command_lifecycle",
|
||||
command_uuid: steerUuid,
|
||||
state: "started",
|
||||
@@ -2395,7 +2395,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: [
|
||||
@@ -2409,7 +2409,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" }],
|
||||
@@ -3611,9 +3611,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",
|
||||
@@ -3799,7 +3799,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",
|
||||
@@ -3834,7 +3834,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.",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
||||
import { validateProviderOptions } from "../../provider-options.js";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -81,7 +83,7 @@ import {
|
||||
type ClaudeRewindSdk,
|
||||
} from "./rewind.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js";
|
||||
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPath } from "./project-dir.js";
|
||||
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
|
||||
import {
|
||||
isProviderImageMarkdown,
|
||||
@@ -1633,7 +1635,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
cwd: merged.cwd,
|
||||
};
|
||||
const claudeConfig = this.assertConfig(mergedConfig);
|
||||
return new ClaudeAgentSession(claudeConfig, {
|
||||
const session = new ClaudeAgentSession(claudeConfig, {
|
||||
defaults: this.defaults,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
handle,
|
||||
@@ -1644,6 +1646,8 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
resolveBinary: this.resolveBinary,
|
||||
rewindSdk: this.rewindSdk,
|
||||
});
|
||||
await session.ensureHistoryReady();
|
||||
return session;
|
||||
}
|
||||
|
||||
async getCatalogCacheKey(_options: FetchCatalogOptions): Promise<string> {
|
||||
@@ -2255,6 +2259,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private readonly queryFactory?: ClaudeQueryFactory;
|
||||
private readonly resolveBinary: () => Promise<string>;
|
||||
private query: Query | null = null;
|
||||
private readonly runtimeSafety = new ClaudeRuntimeSafety();
|
||||
private processingSdkMessages = 0;
|
||||
private readonly harnessEnvironment: Record<string, string>;
|
||||
private readonly usageSessionKey = randomUUID();
|
||||
private childProcess: ChildProcess | null = null;
|
||||
@@ -2326,6 +2332,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
{ type: "provider_subagent" }
|
||||
>[] = [];
|
||||
private historyPending = false;
|
||||
// Readiness never rejects; history/rebind failures are logged at their owning boundary.
|
||||
private historyReady: Promise<void> | null = null;
|
||||
private historySessionId: string | null = null;
|
||||
private replayingHistory = false;
|
||||
private turnState: TurnState = "idle";
|
||||
private nextTurnOrdinal = 1;
|
||||
@@ -2379,7 +2388,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
this.claudeSessionId = handle.sessionId;
|
||||
this.persistence = handle;
|
||||
this.loadPersistedHistory(handle.sessionId);
|
||||
this.historySessionId = handle.sessionId;
|
||||
} else {
|
||||
this.claudeSessionId = null;
|
||||
this.persistence = null;
|
||||
@@ -2459,6 +2468,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<{ turnId: string }> {
|
||||
await this.ensureHistoryReady();
|
||||
if (this.closed) {
|
||||
throw new Error("Claude session is closed");
|
||||
}
|
||||
@@ -2536,7 +2546,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.input.push(sdkMessage);
|
||||
setTimeout(() => {
|
||||
if (this.activeForegroundTurnId === turnId) {
|
||||
this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId);
|
||||
void this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId).catch(
|
||||
(error: unknown) => {
|
||||
this.logger.warn({ err: error }, "Failed to emit submitted Claude message");
|
||||
},
|
||||
);
|
||||
}
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
@@ -2629,6 +2643,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
await this.ensureHistoryReady();
|
||||
if (
|
||||
!this.historyPending ||
|
||||
(this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0)
|
||||
@@ -2640,7 +2655,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.persistedHistory = [];
|
||||
this.persistedProviderSubagentEvents = [];
|
||||
this.historyPending = false;
|
||||
let replayed = 0;
|
||||
for (const entry of history) {
|
||||
if (++replayed % 32 === 0) await setImmediate();
|
||||
yield {
|
||||
type: "timeline",
|
||||
item: entry.item,
|
||||
@@ -2931,6 +2948,18 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return this.persistence;
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
return (
|
||||
!this.activeForegroundTurnId &&
|
||||
!this.autonomousTurn &&
|
||||
!this.mainTurnInFlight &&
|
||||
this.unstartedMessageUuids.size === 0 &&
|
||||
this.pendingPermissions.size === 0 &&
|
||||
this.processingSdkMessages === 0 &&
|
||||
this.runtimeSafety.canStopRuntime()
|
||||
);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -2946,6 +2975,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
"provider.claude.session_close.start",
|
||||
);
|
||||
this.closed = true;
|
||||
await this.ensureHistoryReady();
|
||||
this.rejectAllPendingPermissions(new Error("Claude session closed"));
|
||||
this.cancelCurrentTurn?.();
|
||||
this.subscribers.clear();
|
||||
@@ -2986,7 +3016,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// (see `claude --help`), so the SDK's persistSession=false is silently dropped
|
||||
// in stream-json mode. Sweep the transcript ourselves so ephemeral runs
|
||||
// (metadata generator, branch-name generator) don't show up as resumable.
|
||||
const historyPath = this.resolveHistoryPath(this.claudeSessionId);
|
||||
const historyPath = await this.resolveHistoryPath(this.claudeSessionId);
|
||||
if (historyPath) {
|
||||
try {
|
||||
await promises.rm(historyPath, { force: true });
|
||||
@@ -3030,6 +3060,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||
await this.ensureHistoryReady();
|
||||
const target = this.resolveConversationRewindTarget(input.messageId);
|
||||
if (target.kind === "fresh-session") {
|
||||
this.startFreshConversationSession();
|
||||
@@ -3039,16 +3070,15 @@ class ClaudeAgentSession implements AgentSession {
|
||||
await revertClaudeConversation({
|
||||
sdk: this.rewindSdk,
|
||||
sessionId: currentSessionId,
|
||||
transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null,
|
||||
transcriptPath: currentSessionId ? await this.resolveHistoryPath(currentSessionId) : null,
|
||||
messageId: target.messageId,
|
||||
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
|
||||
setSessionId: (sessionId) => {
|
||||
this.rebindConversationSession(sessionId);
|
||||
},
|
||||
setSessionId: (sessionId) => this.rebindConversationSession(sessionId),
|
||||
});
|
||||
}
|
||||
|
||||
async revertFiles(input: { messageId: string }): Promise<void> {
|
||||
await this.ensureHistoryReady();
|
||||
const messageId = await this.resolveClaudeMessageId(input.messageId);
|
||||
await revertClaudeFiles({
|
||||
query: await this.ensureQuery(),
|
||||
@@ -3223,38 +3253,52 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private rebindConversationSession(sessionId: string): void {
|
||||
const oldSessionId = this.claudeSessionId;
|
||||
this.claudeSessionId = sessionId;
|
||||
this.pendingFreshSessionId = null;
|
||||
this.persistence = null;
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.queryRestartNeeded = true;
|
||||
this.persistedHistory = [];
|
||||
this.persistedProviderSubagentEvents = [];
|
||||
this.historyPending = false;
|
||||
this.userMessageIds = [];
|
||||
this.emittedUserMessageIds.clear();
|
||||
this.rewindTurnAnchors.length = 0;
|
||||
this.taskState.reset();
|
||||
this.loadPersistedHistory(sessionId);
|
||||
if (oldSessionId && oldSessionId !== sessionId) {
|
||||
this.dispatchEvents([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
|
||||
},
|
||||
{
|
||||
type: "thread_started",
|
||||
provider: "claude",
|
||||
sessionId,
|
||||
},
|
||||
]);
|
||||
}
|
||||
private rebindConversationSession(sessionId: string): Promise<void> {
|
||||
const previousReady = this.ensureHistoryReady();
|
||||
const rebind = (async () => {
|
||||
await previousReady;
|
||||
await this.retireQuery();
|
||||
if (this.closed) return;
|
||||
const oldSessionId = this.claudeSessionId;
|
||||
this.claudeSessionId = sessionId;
|
||||
this.pendingFreshSessionId = null;
|
||||
this.persistence = null;
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.queryRestartNeeded = true;
|
||||
this.persistedHistory = [];
|
||||
this.persistedProviderSubagentEvents = [];
|
||||
this.historyPending = false;
|
||||
this.userMessageIds = [];
|
||||
this.emittedUserMessageIds.clear();
|
||||
this.rewindTurnAnchors.length = 0;
|
||||
this.taskState.reset();
|
||||
this.historySessionId = sessionId;
|
||||
await this.loadPersistedHistory(sessionId);
|
||||
if (this.closed) return;
|
||||
if (oldSessionId && oldSessionId !== sessionId) {
|
||||
this.dispatchEvents([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
|
||||
},
|
||||
{
|
||||
type: "thread_started",
|
||||
provider: "claude",
|
||||
sessionId,
|
||||
},
|
||||
]);
|
||||
}
|
||||
})();
|
||||
this.historyReady = rebind.catch((error) => {
|
||||
this.logger.warn({ err: error, sessionId }, "Failed to rebind Claude session");
|
||||
});
|
||||
return rebind;
|
||||
}
|
||||
|
||||
private startFreshConversationSession(): void {
|
||||
this.historySessionId = null;
|
||||
this.historyReady = Promise.resolve();
|
||||
const sessionId = randomUUID();
|
||||
this.claudeSessionId = sessionId;
|
||||
this.pendingFreshSessionId = sessionId;
|
||||
@@ -3375,7 +3419,43 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return { kind: "fresh-session" };
|
||||
}
|
||||
|
||||
private async retireQuery(): Promise<void> {
|
||||
const oldQuery = this.query;
|
||||
if (!oldQuery) return;
|
||||
const oldInput = this.input;
|
||||
// Null out query/input BEFORE awaiting the old iterator's return so the
|
||||
// old pump sees this.query !== activeQuery and skips failActiveTurns.
|
||||
this.query = null;
|
||||
this.input = null;
|
||||
this.queryPumpPromise = null;
|
||||
this.queryRestartNeeded = false;
|
||||
// Ending the input retires the process on purpose. Detach first so its
|
||||
// exit is not reported as a crash.
|
||||
const retiredChild = this.childProcess;
|
||||
this.childProcess = null;
|
||||
if (retiredChild) this.failRunningRuntimeTasks();
|
||||
oldInput?.end();
|
||||
oldQuery.close?.();
|
||||
try {
|
||||
await oldQuery.return?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Tree-kill the old process tree now that the SDK has cleaned up.
|
||||
// If we skip this, MCP children of the previous claude process can
|
||||
// survive as orphans when the session spawns a replacement query.
|
||||
if (retiredChild) {
|
||||
await terminateWithTreeKill(retiredChild, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
}).catch(() => {
|
||||
/* process may already be dead */
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> {
|
||||
await this.ensureHistoryReady();
|
||||
// Controls use the running turn's query. Pending launch settings take effect
|
||||
// after that turn finishes, when its active query handle has been cleared.
|
||||
if (this.query && (!this.queryRestartNeeded || this.activeForegroundQuery === this.query)) {
|
||||
@@ -3383,37 +3463,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
if (this.queryRestartNeeded && this.query) {
|
||||
const oldQuery = this.query;
|
||||
const oldInput = this.input;
|
||||
// Null out query/input BEFORE awaiting the old iterator's return so the
|
||||
// old pump sees this.query !== activeQuery and skips failActiveTurns.
|
||||
this.query = null;
|
||||
this.input = null;
|
||||
this.queryPumpPromise = null;
|
||||
this.queryRestartNeeded = false;
|
||||
// Ending the input retires the process on purpose. Detach first so its
|
||||
// exit is not reported as a crash.
|
||||
const retiredChild = this.childProcess;
|
||||
this.childProcess = null;
|
||||
if (retiredChild) this.failRunningRuntimeTasks();
|
||||
oldInput?.end();
|
||||
oldQuery.close?.();
|
||||
try {
|
||||
await oldQuery.return?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Tree-kill the old process tree now that the SDK has cleaned up.
|
||||
// If we skip this, MCP children of the previous claude process can
|
||||
// survive as orphans when the session spawns a replacement query.
|
||||
if (retiredChild) {
|
||||
await terminateWithTreeKill(retiredChild, {
|
||||
gracefulTimeoutMs: 2_000,
|
||||
forceTimeoutMs: 2_000,
|
||||
}).catch(() => {
|
||||
/* process may already be dead */
|
||||
});
|
||||
}
|
||||
await this.retireQuery();
|
||||
}
|
||||
|
||||
// Preserve claudeSessionId across query recreation so buildOptions() passes
|
||||
@@ -3424,6 +3474,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const options = await this.buildOptions(launchMode);
|
||||
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
||||
this.input = input;
|
||||
this.runtimeSafety.reset();
|
||||
// A fresh Claude process has no turn of its own in flight.
|
||||
this.mainTurnInFlight = false;
|
||||
this.query = claudeQuery(
|
||||
@@ -4079,13 +4130,19 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
};
|
||||
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
|
||||
logRawMessage(message);
|
||||
consecutiveInterruptAbortRecoveries = 0;
|
||||
if (await this.handleMissingResumedConversation(message, activeQuery)) {
|
||||
return true;
|
||||
if (!this.ownsQuery(activeQuery)) return true;
|
||||
this.processingSdkMessages++;
|
||||
try {
|
||||
this.runtimeSafety.observe(message);
|
||||
logRawMessage(message);
|
||||
consecutiveInterruptAbortRecoveries = 0;
|
||||
if (await this.handleMissingResumedConversation(message, activeQuery)) return true;
|
||||
if (!this.ownsQuery(activeQuery)) return true;
|
||||
await this.routeSdkMessageFromPump(message);
|
||||
return false;
|
||||
} finally {
|
||||
this.processingSdkMessages--;
|
||||
}
|
||||
await this.routeSdkMessageFromPump(message);
|
||||
return false;
|
||||
};
|
||||
const drainActiveQuery = async (): Promise<boolean> => {
|
||||
for await (const message of activeQuery) {
|
||||
@@ -4201,6 +4258,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return isClaudeTurnInit(message) && !this.activeForegroundTurnId;
|
||||
}
|
||||
|
||||
private ownsQuery(query: Query): boolean {
|
||||
return !this.closed && this.query === query;
|
||||
}
|
||||
|
||||
private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> {
|
||||
if (this.consumeSessionStateChange(message)) {
|
||||
return;
|
||||
@@ -4275,10 +4336,17 @@ class ClaudeAgentSession implements AgentSession {
|
||||
messageIdHint: string | null,
|
||||
turnId: string | null,
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const messageEvents = this.translateMessageToEvents(message, {
|
||||
const query = this.query;
|
||||
const messageEvents = await this.translateMessageToEvents(message, {
|
||||
suppressAssistantText: true,
|
||||
suppressReasoning: true,
|
||||
});
|
||||
if (
|
||||
this.closed ||
|
||||
this.query !== query ||
|
||||
(this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null) !== turnId
|
||||
)
|
||||
return [];
|
||||
const assistantTimelineEvents = readClaudeParentToolUseId(message)
|
||||
? []
|
||||
: this.timelineAssembler
|
||||
@@ -4423,13 +4491,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return withdrewAll;
|
||||
}
|
||||
|
||||
private translateMessageToEvents(
|
||||
private async translateMessageToEvents(
|
||||
message: SDKMessage,
|
||||
options?: {
|
||||
suppressAssistantText?: boolean;
|
||||
suppressReasoning?: boolean;
|
||||
},
|
||||
): AgentStreamEvent[] {
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const parentToolUseId = readClaudeParentToolUseId(message);
|
||||
if (parentToolUseId) {
|
||||
return this.translateSidechainFrameToEvents(message, parentToolUseId);
|
||||
@@ -4477,11 +4545,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.appendSystemMessageEvents(message, events);
|
||||
break;
|
||||
case "user":
|
||||
this.appendUserMessageEvents(message, events);
|
||||
await this.appendUserMessageEvents(message, events);
|
||||
this.appendSidechainResultEvents(message, events);
|
||||
break;
|
||||
case "assistant": {
|
||||
const timelineItems = this.mapBlocksToTimeline(message.message.content, {
|
||||
const timelineItems = await this.mapBlocksToTimeline(message.message.content, {
|
||||
suppressAssistantText: options?.suppressAssistantText ?? false,
|
||||
suppressReasoning: options?.suppressReasoning ?? false,
|
||||
});
|
||||
@@ -4492,7 +4560,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
break;
|
||||
}
|
||||
case "stream_event":
|
||||
this.appendStreamEventEvents(message, events, options);
|
||||
await this.appendStreamEventEvents(message, events, options);
|
||||
break;
|
||||
case "result":
|
||||
this.appendResultEvents(message, events);
|
||||
@@ -4594,13 +4662,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private emitSubmittedUserMessage(
|
||||
private async emitSubmittedUserMessage(
|
||||
message: Extract<SDKMessage, { type: "user" }>,
|
||||
turnId: string,
|
||||
clientMessageId?: string,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
this.appendUserMessageEvents(message, events);
|
||||
await this.appendUserMessageEvents(message, events);
|
||||
if (this.activeForegroundTurnId !== turnId) return;
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -4730,10 +4799,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private appendUserMessageEvents(
|
||||
private async appendUserMessageEvents(
|
||||
message: Extract<SDKMessage, { type: "user" }>,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (isSyntheticUserEntry(message)) {
|
||||
return;
|
||||
}
|
||||
@@ -4772,7 +4841,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
this.appendUserContentArrayEvents(content, messageId, events);
|
||||
await this.appendUserContentArrayEvents(content, messageId, events);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4798,12 +4867,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
events.push({ type: "timeline", item, provider: "claude" });
|
||||
}
|
||||
|
||||
private appendUserContentArrayEvents(
|
||||
private async appendUserContentArrayEvents(
|
||||
content: ReadonlyArray<unknown>,
|
||||
messageId: string | undefined,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
const timelineItems = this.mapBlocksToTimeline(content, {
|
||||
): Promise<void> {
|
||||
const timelineItems = await this.mapBlocksToTimeline(content, {
|
||||
textMessageType: "user_message",
|
||||
});
|
||||
for (const item of timelineItems) {
|
||||
@@ -4819,16 +4888,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private appendStreamEventEvents(
|
||||
private async appendStreamEventEvents(
|
||||
message: Extract<SDKMessage, { type: "stream_event" }>,
|
||||
events: AgentStreamEvent[],
|
||||
options: { suppressAssistantText?: boolean; suppressReasoning?: boolean } | undefined,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const usageUpdatedEvent = this.contextUsage.buildStreamUsageEvent(message.event);
|
||||
if (usageUpdatedEvent) {
|
||||
events.push(usageUpdatedEvent);
|
||||
}
|
||||
const timelineItems = this.mapPartialEvent(message.event, {
|
||||
const timelineItems = await this.mapPartialEvent(message.event, {
|
||||
suppressAssistantText: options?.suppressAssistantText ?? false,
|
||||
suppressReasoning: options?.suppressReasoning ?? false,
|
||||
});
|
||||
@@ -5252,22 +5321,31 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private loadPersistedHistory(sessionId: string): void {
|
||||
ensureHistoryReady(): Promise<void> {
|
||||
if (!this.historyReady) {
|
||||
this.historyReady = this.historySessionId
|
||||
? this.loadPersistedHistory(this.historySessionId)
|
||||
: Promise.resolve();
|
||||
}
|
||||
return this.historyReady;
|
||||
}
|
||||
|
||||
private async loadPersistedHistory(sessionId: string): Promise<void> {
|
||||
let historyPath: string | null = null;
|
||||
this.replayingHistory = true;
|
||||
try {
|
||||
this.taskState.reset();
|
||||
historyPath = this.resolveHistoryPath(sessionId);
|
||||
if (!historyPath || !fs.existsSync(historyPath)) {
|
||||
historyPath = await this.resolveHistoryPath(sessionId);
|
||||
if (!historyPath || !(await transcriptExists(historyPath))) {
|
||||
this.logger.info(
|
||||
{ sessionId, cwd: this.config.cwd, historyPath },
|
||||
"No Claude transcript to load history from",
|
||||
);
|
||||
return;
|
||||
}
|
||||
withClaudeReplayHistory(historyPath, (history) => {
|
||||
const replay = this.ingestPersistedSidechains(history);
|
||||
this.ingestPersistedHistory(history.parentEntries, replay);
|
||||
await withClaudeReplayHistory(historyPath, async (history) => {
|
||||
const replay = await this.ingestPersistedSidechains(history);
|
||||
await this.ingestPersistedHistory(history.parentEntries, replay);
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
@@ -5279,13 +5357,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private ingestPersistedHistory(
|
||||
entries: Iterable<ClaudeHistoryEntry>,
|
||||
private async ingestPersistedHistory(
|
||||
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
|
||||
replay: ClaudeReplayOwnership,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const timeline: PersistedTimelineEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
this.ingestPersistedHistoryEntry(entry, timeline, replay);
|
||||
for await (const entry of entries) {
|
||||
await this.ingestPersistedHistoryEntry(entry, timeline, replay);
|
||||
}
|
||||
|
||||
if (timeline.length > 0) {
|
||||
@@ -5294,27 +5372,33 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private ingestPersistedSidechains(history: ClaudeReplayHistory): ClaudeReplayOwnership {
|
||||
const parentFacts = readClaudeReplayParentFacts(history.parentEntries);
|
||||
private async ingestPersistedSidechains(
|
||||
history: ClaudeReplayHistory,
|
||||
): Promise<ClaudeReplayOwnership> {
|
||||
const parentFacts = await readClaudeReplayParentFacts(history.parentEntries);
|
||||
|
||||
// Replay produces the same observations the live task protocol produces, then folds them
|
||||
// with the same function, so identity and status are derived once for both paths.
|
||||
const subagentReplay = observeReplaySubagents({
|
||||
subagents: history.subagents.map((subagent) => ({
|
||||
const subagents = [];
|
||||
for (const subagent of history.subagents) {
|
||||
subagents.push({
|
||||
...subagent,
|
||||
parentFacts: readClaudeReplayParentFacts(subagent.entries),
|
||||
})),
|
||||
parentFacts: await readClaudeReplayParentFacts(subagent.entries),
|
||||
});
|
||||
}
|
||||
const subagentReplay = await observeReplaySubagents({
|
||||
subagents,
|
||||
parent: parentFacts,
|
||||
convertEntry: (entry) => this.convertHistoryEntry(entry),
|
||||
});
|
||||
const observations = [
|
||||
...subagentReplay.observations,
|
||||
...observeReplayWorkflows({
|
||||
...(await observeReplayWorkflows({
|
||||
workflows: history.workflows,
|
||||
parentEntries: history.parentEntries,
|
||||
entriesByRunId: history.workflowEntriesByRunId,
|
||||
convertEntry: (entry) => this.convertHistoryEntry(entry),
|
||||
}),
|
||||
})),
|
||||
];
|
||||
const restoredProviderSubagentIds = new Set(
|
||||
observations
|
||||
@@ -5339,11 +5423,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return replay;
|
||||
}
|
||||
|
||||
private ingestPersistedHistoryEntry(
|
||||
private async ingestPersistedHistoryEntry(
|
||||
entry: ClaudeHistoryEntry,
|
||||
timeline: PersistedTimelineEntry[],
|
||||
replay: ClaudeReplayOwnership,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (entry.isSidechain) {
|
||||
return;
|
||||
}
|
||||
@@ -5357,7 +5441,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
? replay.toolOwners.get(notificationToolUseId)
|
||||
: undefined;
|
||||
if (notificationOwner) {
|
||||
for (const item of this.convertHistoryEntry(entry)) {
|
||||
for (const item of await this.convertHistoryEntry(entry)) {
|
||||
this.persistedProviderSubagentEvents.push({
|
||||
type: "provider_subagent",
|
||||
provider: "claude",
|
||||
@@ -5374,7 +5458,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const taskSnapshot = this.taskState.observe(entry);
|
||||
const items = [
|
||||
...(taskSnapshot ? [taskSnapshot] : []),
|
||||
...this.convertHistoryEntry(entry).map((item) =>
|
||||
...(await this.convertHistoryEntry(entry)).map((item) =>
|
||||
labelReplayedSubagentCard(item, replay.subagentToolCalls),
|
||||
),
|
||||
];
|
||||
@@ -5401,20 +5485,20 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveHistoryPath(sessionId: string): string | null {
|
||||
private async resolveHistoryPath(sessionId: string): Promise<string | null> {
|
||||
const cwd = this.config.cwd;
|
||||
if (!cwd) return null;
|
||||
return claudeTranscriptPathSync({
|
||||
return claudeTranscriptPath({
|
||||
cwd,
|
||||
sessionId,
|
||||
configDir: claudeConfigDir(this.harnessEnvironment),
|
||||
});
|
||||
}
|
||||
|
||||
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
|
||||
return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)).map(
|
||||
limitAgentTimelineItemContent,
|
||||
);
|
||||
private async convertHistoryEntry(entry: ClaudeHistoryEntry): Promise<AgentTimelineItem[]> {
|
||||
return (
|
||||
await convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content))
|
||||
).map(limitAgentTimelineItemContent);
|
||||
}
|
||||
|
||||
// Maps Claude content blocks into AgentTimelineItems.
|
||||
@@ -5429,14 +5513,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
//
|
||||
// NOTE: convertClaudeHistoryEntry uses extractUserMessageText directly instead of this function
|
||||
// for user entries. Both paths must produce equivalent user_message items.
|
||||
private mapBlocksToTimeline(
|
||||
private async mapBlocksToTimeline(
|
||||
content: string | ReadonlyArray<unknown>,
|
||||
options?: {
|
||||
textMessageType?: "assistant_message" | "user_message";
|
||||
suppressAssistantText?: boolean;
|
||||
suppressReasoning?: boolean;
|
||||
},
|
||||
): AgentTimelineItem[] {
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
const textMessageType = options?.textMessageType ?? "assistant_message";
|
||||
const suppressText =
|
||||
textMessageType === "assistant_message" && (options?.suppressAssistantText ?? false);
|
||||
@@ -5463,7 +5547,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) {
|
||||
continue;
|
||||
}
|
||||
this.mapBlockToTimeline(block, {
|
||||
await this.mapBlockToTimeline(block, {
|
||||
items,
|
||||
userTextParts,
|
||||
textMessageType,
|
||||
@@ -5508,7 +5592,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private mapBlockToTimeline(
|
||||
private async mapBlockToTimeline(
|
||||
block: ClaudeContentChunk,
|
||||
context: {
|
||||
items: AgentTimelineItem[];
|
||||
@@ -5517,7 +5601,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
suppressText: boolean;
|
||||
suppressReasoning: boolean;
|
||||
},
|
||||
): void {
|
||||
): Promise<void> {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
case "text_delta":
|
||||
@@ -5541,7 +5625,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
case "code_execution_tool_result":
|
||||
case "bash_code_execution_tool_result":
|
||||
case "text_editor_code_execution_tool_result":
|
||||
this.handleToolResult(block, context.items);
|
||||
await this.handleToolResult(block, context.items);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -5587,7 +5671,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
|
||||
private async handleToolResult(
|
||||
block: ClaudeContentChunk,
|
||||
items: AgentTimelineItem[],
|
||||
): Promise<void> {
|
||||
const entry =
|
||||
typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined;
|
||||
const blockToolName = typeof block.tool_name === "string" ? block.tool_name : undefined;
|
||||
@@ -5620,7 +5707,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.rememberSettledSubagentToolCall(settledToolCall, entry);
|
||||
|
||||
for (const image of images) {
|
||||
const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, {
|
||||
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
|
||||
materialize: materializeProviderImage,
|
||||
});
|
||||
if (imageItem) {
|
||||
@@ -5844,13 +5931,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return false;
|
||||
}
|
||||
|
||||
private mapPartialEvent(
|
||||
private async mapPartialEvent(
|
||||
event: SDKPartialAssistantMessage["event"],
|
||||
options?: {
|
||||
suppressAssistantText?: boolean;
|
||||
suppressReasoning?: boolean;
|
||||
},
|
||||
): AgentTimelineItem[] {
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
if (this.updatePartialEventToolState(event)) {
|
||||
return [];
|
||||
}
|
||||
@@ -5858,14 +5945,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
switch (event.type) {
|
||||
case "content_block_start":
|
||||
return isClaudeContentChunk(event.content_block)
|
||||
? this.mapBlocksToTimeline([event.content_block], {
|
||||
? await this.mapBlocksToTimeline([event.content_block], {
|
||||
suppressAssistantText: options?.suppressAssistantText,
|
||||
suppressReasoning: options?.suppressReasoning,
|
||||
})
|
||||
: [];
|
||||
case "content_block_delta":
|
||||
return isClaudeContentChunk(event.delta)
|
||||
? this.mapBlocksToTimeline([event.delta], {
|
||||
? await this.mapBlocksToTimeline([event.delta], {
|
||||
suppressAssistantText: options?.suppressAssistantText,
|
||||
suppressReasoning: options?.suppressReasoning,
|
||||
})
|
||||
@@ -6171,13 +6258,13 @@ function labelReplayedSubagentCard(
|
||||
* source consumes. The agentId scrape is kept only as a fallback for sessions recorded before
|
||||
* Claude Code wrote the meta sidecar.
|
||||
*/
|
||||
function readClaudeReplayParentFacts(
|
||||
entries: Iterable<ClaudeHistoryEntry>,
|
||||
): ClaudeReplayParentFacts {
|
||||
async function readClaudeReplayParentFacts(
|
||||
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
|
||||
): Promise<ClaudeReplayParentFacts> {
|
||||
const toolCalls = new Map<string, { title?: string; description?: string }>();
|
||||
const linksByAgentId = new Map<string, { toolCallId: string; failed: boolean }>();
|
||||
const outcomes = new Map<string, { failed: boolean }>();
|
||||
for (const entry of entries) {
|
||||
for await (const entry of entries) {
|
||||
const content = toObjectRecord(entry.message)?.content;
|
||||
if (!Array.isArray(content)) continue;
|
||||
for (const value of content) {
|
||||
@@ -6228,12 +6315,14 @@ interface ClaudeHistoryEntry {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function mapAssistantHistoryBlocksWithMessageId(
|
||||
async function mapAssistantHistoryBlocksWithMessageId(
|
||||
entry: ClaudeHistoryEntry,
|
||||
content: string | ClaudeContentChunk[],
|
||||
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
|
||||
): AgentTimelineItem[] {
|
||||
const items = mapBlocks(content);
|
||||
mapBlocks: (
|
||||
content: string | ClaudeContentChunk[],
|
||||
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
const items = await mapBlocks(content);
|
||||
const assistantMessageId =
|
||||
typeof entry.uuid === "string" && entry.uuid.length > 0 ? entry.uuid : null;
|
||||
if (!assistantMessageId) {
|
||||
@@ -6301,10 +6390,12 @@ function isProviderImageMessage(item: AgentTimelineItem): boolean {
|
||||
return item.type === "assistant_message" && isProviderImageMarkdown(item.text);
|
||||
}
|
||||
|
||||
export function convertClaudeHistoryEntry(
|
||||
export async function convertClaudeHistoryEntry(
|
||||
entry: ClaudeHistoryEntry,
|
||||
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
|
||||
): AgentTimelineItem[] {
|
||||
mapBlocks: (
|
||||
content: string | ClaudeContentChunk[],
|
||||
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
const preamble = convertClaudeHistoryEntryPreamble(entry);
|
||||
if ("shortCircuit" in preamble) {
|
||||
return preamble.shortCircuit;
|
||||
@@ -6343,7 +6434,7 @@ export function convertClaudeHistoryEntry(
|
||||
}
|
||||
|
||||
if (hasToolBlock && normalizedBlocks) {
|
||||
const mapped = mapBlocks(normalizedBlocks);
|
||||
const mapped = await mapBlocks(normalizedBlocks);
|
||||
if (entry.type === "user") {
|
||||
// tool_result handling (handleToolResult) emits image markdown as an assistant_message
|
||||
// alongside the tool_call. User-entry text blocks also map to assistant_message in this path
|
||||
@@ -6357,7 +6448,7 @@ export function convertClaudeHistoryEntry(
|
||||
}
|
||||
|
||||
if (entry.type === "assistant" && contentValue) {
|
||||
return mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
|
||||
return await mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
|
||||
}
|
||||
|
||||
return timeline;
|
||||
@@ -6709,7 +6800,7 @@ async function readClaudeRenamedSessionTitle(input: {
|
||||
}): Promise<string | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fsPromises.readFile(claudeTranscriptPathSync(input), "utf8");
|
||||
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
|
||||
} catch (error) {
|
||||
// A session with no transcript on disk has no rename to carry over. Every
|
||||
// other read failure is a real fault and stays explicit.
|
||||
@@ -6809,3 +6900,13 @@ function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle |
|
||||
state: record.state as ClaudeCommandLifecycle["state"],
|
||||
};
|
||||
}
|
||||
|
||||
async function transcriptExists(file: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(file);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 @@ function applyRuntimeSettingsToClaudeOptions(
|
||||
runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
});
|
||||
const providerEnv = createProviderEnv({
|
||||
baseEnv: spawnOptions.env,
|
||||
runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
});
|
||||
// Set our identity after sanitization clears the parent session's entrypoint.
|
||||
providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = "Paseo";
|
||||
const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay);
|
||||
const selfNodeCommand = isDefaultRuntime
|
||||
? buildSelfNodeCommand(resolved.args, providerEnv)
|
||||
: null;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+343
-262
@@ -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";
|
||||
|
||||
@@ -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, { failed: false }]]),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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(),
|
||||
};
|
||||
|
||||
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, failed: true }]]),
|
||||
outcomesByToolCallId: new Map(),
|
||||
},
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -130,7 +130,7 @@ function readUsage(entry: ClaudeReplayEntry): ClaudeSubagentUsage | null {
|
||||
export interface ClaudeReplaySubagentInput {
|
||||
agentId: string;
|
||||
meta: ClaudeSubagentMeta | null;
|
||||
entries: Iterable<ClaudeReplayEntry>;
|
||||
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
|
||||
/** Task declarations made by this subagent, used to attach its direct children. */
|
||||
parentFacts?: ClaudeReplayParentFacts;
|
||||
}
|
||||
@@ -250,13 +250,13 @@ 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);
|
||||
@@ -269,7 +269,7 @@ function observeSubagent(
|
||||
let childTerminalStatus: ProviderSubagentStatus | null = null;
|
||||
|
||||
// Consume each source once for presentation and timeline; keep no raw transcript records.
|
||||
for (const entry of subagent.entries) {
|
||||
for await (const entry of subagent.entries) {
|
||||
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
|
||||
if (!seenEntry) firstTimestamp = timestamp;
|
||||
seenEntry = true;
|
||||
@@ -280,7 +280,7 @@ function observeSubagent(
|
||||
childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null;
|
||||
}
|
||||
recordReplayToolOwners(toolOwners, entry, link.id);
|
||||
for (const item of convertEntry(entry)) {
|
||||
for (const item of await convertEntry(entry)) {
|
||||
timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) });
|
||||
}
|
||||
}
|
||||
@@ -320,11 +320,11 @@ function recordReplayToolOwners(
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -346,7 +346,7 @@ export function observeReplaySubagents(input: {
|
||||
const { ownerId, parent, link } = resolved;
|
||||
|
||||
// Only proven descendants may own notifications; ambient sidecars cannot claim them.
|
||||
for (const observation of observeSubagent(
|
||||
for (const observation of await observeSubagent(
|
||||
subagent,
|
||||
parent,
|
||||
input.convertEntry,
|
||||
|
||||
+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);
|
||||
});
|
||||
|
||||
+21
-16
@@ -45,14 +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: Iterable<ClaudeWorkflowParentEntry>;
|
||||
parentEntries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>;
|
||||
/** Repeatable entries in chronological order, supplied by the transcript reader. */
|
||||
entriesByRunId?: ReadonlyMap<string, Iterable<ClaudeReplayEntry>>;
|
||||
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
|
||||
}): SubagentObservation[] {
|
||||
const toolCallIdByRunId = readWorkflowLinks(input.parentEntries);
|
||||
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) {
|
||||
@@ -79,7 +82,7 @@ export function observeReplayWorkflows(input: {
|
||||
});
|
||||
}
|
||||
|
||||
for (const observation of observeWorkflowTimeline({
|
||||
for (const observation of await observeWorkflowTimeline({
|
||||
id,
|
||||
result: workflow.result,
|
||||
entries: input.entriesByRunId?.get(workflow.runId) ?? [],
|
||||
@@ -115,19 +118,19 @@ export function observeReplayWorkflows(input: {
|
||||
return observations;
|
||||
}
|
||||
|
||||
function observeWorkflowTimeline(input: {
|
||||
async function observeWorkflowTimeline(input: {
|
||||
id: string;
|
||||
result: unknown;
|
||||
entries: Iterable<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>();
|
||||
|
||||
for (const entry of input.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,11 +153,13 @@ function observeWorkflowTimeline(input: {
|
||||
return observations;
|
||||
}
|
||||
|
||||
function readWorkflowLinks(entries: Iterable<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) {
|
||||
@@ -165,7 +170,7 @@ function readWorkflowLinks(entries: Iterable<ClaudeWorkflowParentEntry>): Map<st
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -221,9 +221,11 @@ 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 function* assignTaskNotificationReplayIds<T>(entries: Iterable<T>): Generator<T> {
|
||||
export async function* assignTaskNotificationReplayIds<T>(
|
||||
entries: AsyncIterable<T>,
|
||||
): AsyncGenerator<T> {
|
||||
const occurrences = new Map<string, Map<string, number>>();
|
||||
for (const entry of entries) {
|
||||
for await (const entry of entries) {
|
||||
const record = TaskNotificationHistoryRecordSchema.safeParse(entry);
|
||||
if (!record.success) {
|
||||
yield entry;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js";
|
||||
import fs from "node:fs";
|
||||
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 {
|
||||
@@ -24,11 +25,11 @@ interface RecordLocation {
|
||||
}
|
||||
|
||||
/** Chunk in bytes so UTF-8 and multi-chunk records are decoded once, at a line boundary. */
|
||||
function* locatedRecords(
|
||||
async function* locatedRecords(
|
||||
file: string,
|
||||
fd: number,
|
||||
): Generator<{ entry: TranscriptEntry; location: RecordLocation }> {
|
||||
const stat = fs.fstatSync(fd);
|
||||
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}'`), {
|
||||
@@ -43,7 +44,7 @@ function* locatedRecords(
|
||||
let length = 0;
|
||||
while (remaining > 0) {
|
||||
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining));
|
||||
const count = fs.readSync(fd, buffer, 0, buffer.length, null);
|
||||
const { bytesRead: count } = await fd.read(buffer, 0, buffer.length, null);
|
||||
if (count === 0) break;
|
||||
remaining -= count;
|
||||
let start = 0;
|
||||
@@ -64,6 +65,8 @@ function* locatedRecords(
|
||||
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;
|
||||
@@ -85,20 +88,25 @@ function* locatedRecords(
|
||||
/** Indexed sources parse only their own records using descriptors owned by this replay. */
|
||||
function indexedRecords(
|
||||
locations: readonly RecordLocation[],
|
||||
descriptors: ReadonlyMap<string, number>,
|
||||
): Iterable<TranscriptEntry> {
|
||||
descriptors: ReadonlyMap<string, FileHandle>,
|
||||
): AsyncIterable<TranscriptEntry> {
|
||||
return {
|
||||
*[Symbol.iterator]() {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield* assignTaskNotificationReplayIds(readEntries());
|
||||
},
|
||||
};
|
||||
function* readEntries(): Generator<TranscriptEntry> {
|
||||
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 count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read);
|
||||
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;
|
||||
@@ -127,9 +135,9 @@ function parseRecord(line: string): TranscriptEntry | null {
|
||||
}
|
||||
}
|
||||
|
||||
function readOptionalFile(file: string): string | null {
|
||||
async function readOptionalFile(file: string): Promise<string | null> {
|
||||
try {
|
||||
return fs.readFileSync(file, "utf8");
|
||||
return await fs.readFile(file, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -138,31 +146,40 @@ function readOptionalFile(file: string): string | 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: Iterable<TranscriptEntry>;
|
||||
parentEntries: AsyncIterable<TranscriptEntry>;
|
||||
subagents: {
|
||||
agentId: string;
|
||||
meta: ClaudeSubagentMeta | null;
|
||||
entries: Iterable<TranscriptEntry>;
|
||||
entries: AsyncIterable<TranscriptEntry>;
|
||||
}[];
|
||||
workflows: ClaudeWorkflowRun[];
|
||||
workflowEntriesByRunId: Map<string, Iterable<TranscriptEntry>>;
|
||||
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 function withClaudeReplayHistory<T>(
|
||||
export async function withClaudeReplayHistory<T>(
|
||||
historyPath: string,
|
||||
consume: (history: ClaudeReplayHistory) => T,
|
||||
): T {
|
||||
const descriptors = new Map<string, number>();
|
||||
consume: (history: ClaudeReplayHistory) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const descriptors = new Map<string, FileHandle>();
|
||||
try {
|
||||
return consume(indexHistory(historyPath, descriptors));
|
||||
return await consume(await indexHistory(historyPath, descriptors));
|
||||
} finally {
|
||||
for (const fd of descriptors.values()) fs.closeSync(fd);
|
||||
await closeReplaySources(descriptors.values());
|
||||
}
|
||||
}
|
||||
|
||||
function indexHistory(historyPath: string, descriptors: Map<string, number>): ClaudeReplayHistory {
|
||||
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"),
|
||||
@@ -174,8 +191,7 @@ function indexHistory(historyPath: string, descriptors: Map<string, number>): Cl
|
||||
const directories = [sidechainDirectory];
|
||||
while (directories.length) {
|
||||
const directory = directories.pop()!;
|
||||
if (!fs.existsSync(directory)) continue;
|
||||
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||
for (const entry of await optionalDirectory(directory)) {
|
||||
const file = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
directories.push(file);
|
||||
@@ -194,23 +210,23 @@ function indexHistory(historyPath: string, descriptors: Map<string, number>): Cl
|
||||
}
|
||||
const agentId = /^agent-(.+)\.meta\.json$/.exec(entry.name)?.[1];
|
||||
if (!agentId) continue;
|
||||
const contents = readOptionalFile(file);
|
||||
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[]>();
|
||||
function scan(
|
||||
async function scan(
|
||||
file: string,
|
||||
visit: (entry: TranscriptEntry, location: RecordLocation) => void,
|
||||
): void {
|
||||
const fd = fs.openSync(file, "r");
|
||||
): Promise<void> {
|
||||
const fd = await fs.open(file, "r");
|
||||
descriptors.set(file, fd);
|
||||
for (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
|
||||
for await (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
|
||||
}
|
||||
for (const file of sidechainFiles) {
|
||||
scan(file, (entry, location) => {
|
||||
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) ?? [];
|
||||
@@ -218,11 +234,11 @@ function indexHistory(historyPath: string, descriptors: Map<string, number>): Cl
|
||||
locationsByAgentId.set(entry.agentId, locations);
|
||||
});
|
||||
}
|
||||
const workflowEntriesByRunId = new Map<string, Iterable<TranscriptEntry>>();
|
||||
const workflowEntriesByRunId = new Map<string, AsyncIterable<TranscriptEntry>>();
|
||||
for (const [runId, files] of workflowFiles) {
|
||||
const locations: RecordLocation[] = [];
|
||||
for (const file of files)
|
||||
scan(file, (entry, location) => {
|
||||
await scan(file, (entry, location) => {
|
||||
if (entry.type !== "user" || isToolResult(entry)) locations.push(location);
|
||||
});
|
||||
locations.sort((a, b) => {
|
||||
@@ -240,21 +256,28 @@ function indexHistory(historyPath: string, descriptors: Map<string, number>): Cl
|
||||
meta: metaByAgentId.get(agentId) ?? null,
|
||||
entries: indexedRecords(locations, descriptors),
|
||||
})),
|
||||
workflows: readWorkflows(sessionDirectory),
|
||||
workflows: await readWorkflows(sessionDirectory),
|
||||
workflowEntriesByRunId,
|
||||
};
|
||||
}
|
||||
|
||||
function readWorkflows(sessionDirectory: string): ClaudeWorkflowRun[] {
|
||||
async function readWorkflows(sessionDirectory: string): Promise<ClaudeWorkflowRun[]> {
|
||||
const workflows: ClaudeWorkflowRun[] = [];
|
||||
const workflowDirectory = path.join(sessionDirectory, "workflows");
|
||||
if (fs.existsSync(workflowDirectory)) {
|
||||
for (const entry of fs.readdirSync(workflowDirectory, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
const contents = readOptionalFile(path.join(workflowDirectory, entry.name));
|
||||
const workflow = contents === null ? null : parseClaudeWorkflowRun(contents);
|
||||
if (workflow) workflows.push(workflow);
|
||||
}
|
||||
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
@@ -1939,12 +1939,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 +1960,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 +1982,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 +1999,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 +2024,7 @@ export function threadItemToTimeline(
|
||||
}
|
||||
}
|
||||
|
||||
function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] {
|
||||
async function mcpToolResultImagesToTimeline(item: unknown): Promise<AgentTimelineItem[]> {
|
||||
const itemRecord = toObjectRecord(item);
|
||||
if (!itemRecord) {
|
||||
return [];
|
||||
@@ -2037,24 +2037,25 @@ 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)];
|
||||
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
|
||||
}
|
||||
|
||||
const CodexThreadReadResponseSchema = z
|
||||
@@ -2103,7 +2104,7 @@ async function loadCodexThreadHistoryTimeline(params: {
|
||||
historicalSubAgentActivity.agentThreadId,
|
||||
);
|
||||
if (existingIndex !== undefined) {
|
||||
const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd });
|
||||
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
|
||||
updateHistoricalSubAgentActivity(
|
||||
timeline,
|
||||
existingIndex,
|
||||
@@ -2116,7 +2117,7 @@ async function loadCodexThreadHistoryTimeline(params: {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const timelineItem of threadItemToTimelineEntries(item, { cwd: params.cwd })) {
|
||||
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
|
||||
const timestamp =
|
||||
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
|
||||
const settledTimelineItem =
|
||||
@@ -3290,10 +3291,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);
|
||||
@@ -4370,6 +4373,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");
|
||||
@@ -5029,6 +5033,34 @@ 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();
|
||||
@@ -5409,7 +5441,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);
|
||||
@@ -5439,13 +5485,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 });
|
||||
@@ -5456,7 +5505,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":
|
||||
@@ -5482,7 +5531,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;
|
||||
@@ -5527,10 +5576,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);
|
||||
@@ -5572,7 +5621,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) {
|
||||
@@ -5584,7 +5633,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
continue;
|
||||
}
|
||||
for (const parsed of pending) {
|
||||
this.dispatchSubAgentNotification(parsed, callId);
|
||||
await this.dispatchSubAgentNotification(parsed, callId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6550,9 +6599,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
|
||||
@@ -6562,20 +6615,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({
|
||||
@@ -6585,12 +6641,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(
|
||||
@@ -6598,7 +6653,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)) {
|
||||
@@ -6609,7 +6664,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);
|
||||
@@ -6638,7 +6693,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.pendingCommandOutputDeltas.delete(itemId);
|
||||
this.pendingFileChangeOutputDeltas.delete(itemId);
|
||||
}
|
||||
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
}
|
||||
|
||||
private consumeStreamedTextCompletion(
|
||||
@@ -6710,14 +6765,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);
|
||||
@@ -6739,7 +6794,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,
|
||||
});
|
||||
@@ -6758,7 +6813,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(
|
||||
@@ -6782,14 +6837,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,
|
||||
});
|
||||
|
||||
@@ -183,3 +183,29 @@ describe("Codex app-server transport", () => {
|
||||
codex.child.stdin.end();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex stdout byte admission", () => {
|
||||
test("fails pending requests and the session before joining an oversized line", 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('{"id":1,"result":"');
|
||||
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;
|
||||
@@ -167,41 +172,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 +188,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 +241,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"));
|
||||
@@ -313,6 +303,15 @@ export class CodexAppServerClient {
|
||||
}
|
||||
}
|
||||
|
||||
private handleStreamLimit(failure: ProviderStreamLimitError): void {
|
||||
// A rejected response/notification cannot be replayed safely. Fail the session,
|
||||
// including live turns with no pending RPC, rather than dropping a stream unit.
|
||||
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 +391,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 +399,8 @@ export class CodexAppServerClient {
|
||||
id: request.id,
|
||||
error: { message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
} finally {
|
||||
this.activeProviderRequests--;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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,60 @@
|
||||
// Provider-process stdout only; these are not app/daemon wire limits.
|
||||
export const JSONL_PROVIDER_LINE_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
export class ProviderStreamLimitError extends Error {
|
||||
readonly code = "PROVIDER_STREAM_LINE_TOO_LARGE";
|
||||
|
||||
constructor(readonly limitBytes: number) {
|
||||
super(
|
||||
`Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`,
|
||||
);
|
||||
this.name = "ProviderStreamLimitError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Admits raw UTF-8 bytes before retaining, decoding or joining a complete line. */
|
||||
export class ProviderLineReader {
|
||||
private parts: Buffer[] = [];
|
||||
private bytes = 0;
|
||||
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;
|
||||
const length = end - start;
|
||||
if (length > this.limitBytes - this.bytes) {
|
||||
this.close();
|
||||
this.onLimit(new ProviderStreamLimitError(this.limitBytes));
|
||||
return;
|
||||
}
|
||||
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 +62,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 +136,7 @@ function chunkBytes(data: string): Buffer | null {
|
||||
}
|
||||
|
||||
export class JsonlFrameDecoder {
|
||||
private lineBuffer = "";
|
||||
private readonly lines: ProviderLineReader;
|
||||
private assembly: ChunkAssembly | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -86,17 +144,18 @@ 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.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);
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,7 @@ interface LargeAgentStreamPayloadRequest {
|
||||
}
|
||||
|
||||
interface AgentStreamStressRequest {
|
||||
tools: boolean;
|
||||
count: number;
|
||||
coalesced: boolean;
|
||||
}
|
||||
@@ -461,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;
|
||||
}
|
||||
@@ -471,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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1452,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;
|
||||
});
|
||||
|
||||
@@ -317,9 +317,9 @@ function ompModelSupportsImageInput(model: OmpModel | null | undefined): boolean
|
||||
return model?.input?.includes("image") === true;
|
||||
}
|
||||
|
||||
function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string {
|
||||
async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise<string> {
|
||||
try {
|
||||
const materialized = materializeProviderImage({
|
||||
const materialized = await materializeProviderImage({
|
||||
data: image.data,
|
||||
mimeType: image.mimeType,
|
||||
});
|
||||
@@ -329,10 +329,10 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str
|
||||
}
|
||||
}
|
||||
|
||||
function convertPromptInput(
|
||||
async function convertPromptInput(
|
||||
prompt: AgentPromptInput,
|
||||
options: { model: OmpModel | null | undefined },
|
||||
): OmpPromptPayload {
|
||||
): Promise<OmpPromptPayload> {
|
||||
if (typeof prompt === "string") {
|
||||
return { text: prompt };
|
||||
}
|
||||
@@ -355,7 +355,7 @@ function convertPromptInput(
|
||||
mimeType: block.mimeType,
|
||||
});
|
||||
} else {
|
||||
textParts.push(renderTextOnlyImageHint(block));
|
||||
textParts.push(await renderTextOnlyImageHint(block));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -868,11 +868,9 @@ export class OmpAgentSession implements AgentSession {
|
||||
throw new Error("An OMP turn is already active");
|
||||
}
|
||||
|
||||
const payload = convertPromptInput(prompt, { model: this.state.model });
|
||||
const turnId = randomUUID();
|
||||
this.live = true;
|
||||
this.activeTurnId = turnId;
|
||||
this.rememberClientMessage(options?.clientMessageId ?? null, payload.text);
|
||||
this.activeAssistantMessageId = null;
|
||||
this.activeTurnTerminalAssistantMessage = null;
|
||||
this.activeTurnStarted = false;
|
||||
@@ -880,6 +878,10 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.activeTurnHasUserMessage = false;
|
||||
this.activePromptRequestId = null;
|
||||
this.clearNoTurnBuffers();
|
||||
const payload = await convertPromptInput(prompt, { model: this.state.model });
|
||||
if (this.closed) throw new Error("OMP session is closed");
|
||||
if (this.activeTurnId !== turnId) return { turnId };
|
||||
this.rememberClientMessage(options?.clientMessageId ?? null, payload.text);
|
||||
this.activeNoTurnPromptText = payload.text;
|
||||
this.usagePoller.startTurn();
|
||||
if (announceTurn) {
|
||||
@@ -946,7 +948,9 @@ export class OmpAgentSession implements AgentSession {
|
||||
): Promise<SteerResult> {
|
||||
if (this.closed || this.activeTurnId !== options.expectedTurnId)
|
||||
return { status: "unavailable" };
|
||||
const payload = convertPromptInput(prompt, { model: this.state.model });
|
||||
const payload = await convertPromptInput(prompt, { model: this.state.model });
|
||||
if (this.closed || this.activeTurnId !== options.expectedTurnId)
|
||||
return { status: "unavailable" };
|
||||
if (this.parseSlashCommandInput(payload.text)) return { status: "unavailable" };
|
||||
const submission = this.rememberClientMessage(options.clientMessageId ?? null, payload.text);
|
||||
try {
|
||||
@@ -1172,6 +1176,27 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.activeToolCalls.clear();
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (
|
||||
this.closed ||
|
||||
this.activeTurnId ||
|
||||
this.activeToolCalls.size ||
|
||||
this.pendingExtensionUiRequests.size ||
|
||||
this.pendingClientMessages.length ||
|
||||
this.pendingNoTurnOutputs.length ||
|
||||
this.pendingNoTurnCompletionAbort
|
||||
)
|
||||
return false;
|
||||
const state = await this.runtimeSession.getState();
|
||||
return (
|
||||
state.isSettled === true &&
|
||||
state.hasPendingAsyncWork === false &&
|
||||
!state.isStreaming &&
|
||||
!state.isCompacting &&
|
||||
state.queuedMessageCount === 0
|
||||
);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
@@ -8,7 +9,7 @@ import { streamOmpCoreHistory, type OmpCapturedUserMessageEntry } from "./messag
|
||||
import type { OmpAgentMessage } from "./rpc-types.js";
|
||||
import { FakeOmp } from "./test-utils/fake-omp.js";
|
||||
import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js";
|
||||
import { readOmpHistoryTodoState, streamOmpHistory } from "./history.js";
|
||||
import { readActiveOmpEntryChain, readOmpHistoryTodoState, streamOmpHistory } from "./history.js";
|
||||
|
||||
async function collectHistory(
|
||||
messages: OmpAgentMessage[],
|
||||
@@ -1245,3 +1246,53 @@ describe("OMP history mapper", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("indexes out-of-order branches, duplicate IDs and cycles before selecting history", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "omp-history-branches-"));
|
||||
const sessionFile = join(directory, "session.jsonl");
|
||||
const root = { id: "root", parentId: null };
|
||||
const earlyLeaf = {
|
||||
id: "early",
|
||||
parentId: "root",
|
||||
message: { role: "user", content: "你好🌍".repeat(15000) },
|
||||
};
|
||||
const branch = { id: "branch", parentId: "root" };
|
||||
const active = { id: "active", parentId: "branch" };
|
||||
const duplicate = {
|
||||
id: "branch",
|
||||
parentId: "root",
|
||||
message: { role: "user", content: "replacement" },
|
||||
};
|
||||
try {
|
||||
await writeFile(
|
||||
sessionFile,
|
||||
[earlyLeaf, active, root, branch, duplicate]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + '\n{"id":',
|
||||
);
|
||||
// The final record is a branch ancestor, not the active leaf. Latest duplicate wins
|
||||
// when following parents; the invalid tail contributes neither a leaf nor a parent.
|
||||
await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual([root, duplicate, active]);
|
||||
await expect(readActiveOmpEntryChain(sessionFile, "early")).resolves.toEqual([root, earlyLeaf]);
|
||||
await expect(readActiveOmpEntryChain(sessionFile, "missing")).resolves.toEqual([
|
||||
root,
|
||||
duplicate,
|
||||
active,
|
||||
]);
|
||||
const cycle = [
|
||||
{ id: "a", parentId: "b" },
|
||||
{ id: "b", parentId: "a" },
|
||||
];
|
||||
await writeFile(sessionFile, cycle.map((entry) => JSON.stringify(entry)).join("\n"));
|
||||
await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual(cycle);
|
||||
await writeFile(sessionFile, "null\n{}\ninvalid\n");
|
||||
await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual([]);
|
||||
await expect(readActiveOmpEntryChain(directory)).rejects.toMatchObject({ code: "EISDIR" });
|
||||
await expect(readActiveOmpEntryChain(join(directory, "missing"))).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
await expect(readOmpHistoryTodoState(join(directory, "missing"))).resolves.toBeNull();
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { measureMainThreadWork } from "../../../../test-utils/main-thread-work.js";
|
||||
import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
|
||||
import { mkdtemp, open, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { expect, test } from "vitest";
|
||||
import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../../../test-utils/daemon-client.js";
|
||||
import { readActiveOmpEntryChain } from "./history.js";
|
||||
|
||||
test("replays selected OMP history without blocking the event loop", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "omp-history-budget-"));
|
||||
const sessionFile = join(directory, "session.jsonl");
|
||||
const started = performance.now();
|
||||
try {
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
// This regression exercises file replay and daemon requests, not executable discovery.
|
||||
providerOverrides: Object.fromEntries(
|
||||
BUILTIN_PROVIDER_IDS.map((id) => [id, { enabled: false }]),
|
||||
),
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.13.2",
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
await client.getDaemonStatus();
|
||||
const file = await open(sessionFile, "w");
|
||||
// Structured message blocks expose document-sized parsing at a modest byte size.
|
||||
// Replay an earlier active entry so inactive payload retention cannot drive the probe.
|
||||
const text = Array.from({ length: 64 }, () => ({ type: "text", text: "x" }));
|
||||
let bytes = 0;
|
||||
let count = 0;
|
||||
try {
|
||||
while (bytes < 28 * 1024 * 1024) {
|
||||
const line =
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: `row-${count}`,
|
||||
parentId: count ? `row-${count - 1}` : null,
|
||||
message: { role: "user", content: text },
|
||||
}) + "\n";
|
||||
bytes += Buffer.byteLength(line);
|
||||
await file.write(line);
|
||||
count++;
|
||||
}
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
let repliesDuringReplay = 0;
|
||||
const { result: replayedEntries, blockingMs } = await measureMainThreadWork(
|
||||
"OMP history main-thread work",
|
||||
async () => {
|
||||
const replayState = { finished: false };
|
||||
const replay = readActiveOmpEntryChain(sessionFile, "row-0").finally(() => {
|
||||
replayState.finished = true;
|
||||
});
|
||||
// A real WebSocket RPC served by the isolated daemon on the same event loop.
|
||||
const requests = (async () => {
|
||||
while (!replayState.finished) {
|
||||
await client.getDaemonStatus();
|
||||
if (!replayState.finished) repliesDuringReplay++;
|
||||
await delay(10);
|
||||
}
|
||||
})();
|
||||
const [entries] = await Promise.all([replay, requests]);
|
||||
return entries;
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
bytes,
|
||||
count,
|
||||
blockingMs,
|
||||
repliesDuringReplay,
|
||||
generationAndLoadMs: performance.now() - started,
|
||||
}),
|
||||
);
|
||||
expect(replayedEntries).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
id: "row-0",
|
||||
parentId: null,
|
||||
message: { role: "user", content: text },
|
||||
},
|
||||
]);
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
expect(repliesDuringReplay).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await client.close().finally(() => daemon.close());
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { open, type FileHandle } from "node:fs/promises";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { basename, extname, join } from "node:path";
|
||||
import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
@@ -96,7 +97,9 @@ export async function* streamOmpHistory(input: {
|
||||
const messages: OmpAgentMessage[] = [];
|
||||
const replayEntries: OmpReplayEntry[] = [];
|
||||
const userEntries: OmpCapturedUserMessageEntry[] = [];
|
||||
let prepared = 0;
|
||||
for (const entry of entries) {
|
||||
if (++prepared % 32 === 0) await setImmediate();
|
||||
if (entry.type === "compaction") {
|
||||
replayEntries.push({ entry, item: mapCompactionEntry(entry) });
|
||||
continue;
|
||||
@@ -115,7 +118,9 @@ export async function* streamOmpHistory(input: {
|
||||
OMP_HISTORY_MAPPER_HOOKS,
|
||||
input.bridgedTools,
|
||||
);
|
||||
let replayed = 0;
|
||||
for (const replayEntry of replayEntries) {
|
||||
if (++replayed % 32 === 0) await setImmediate();
|
||||
const timestamp = normalizeProviderReplayTimestamp(replayEntry.entry.timestamp);
|
||||
const events: AgentStreamEvent[] =
|
||||
"item" in replayEntry
|
||||
@@ -329,34 +334,139 @@ function stripExtension(filePath: string): string {
|
||||
return extension ? filePath.slice(0, -extension.length) : filePath;
|
||||
}
|
||||
|
||||
export async function readActiveOmpEntryChain(
|
||||
sessionFile: string,
|
||||
activeEntryId?: string,
|
||||
): Promise<OmpSessionEntry[]> {
|
||||
const content = await readFile(sessionFile, "utf8");
|
||||
const entries = content.split("\n").flatMap((line) => {
|
||||
if (!line.trim()) return [];
|
||||
try {
|
||||
const value = JSON.parse(line) as OmpSessionEntry;
|
||||
return value && typeof value === "object" && typeof value.id === "string" ? [value] : [];
|
||||
} catch {
|
||||
return [];
|
||||
interface OmpEntryLocation {
|
||||
id: string;
|
||||
parentId?: string | null;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// Yield after each bounded IO chunk, including chunks containing only tiny or invalid records.
|
||||
// Keep bytes until a complete line so multi-byte UTF-8 characters survive chunk boundaries.
|
||||
async function* readOmpLines(
|
||||
file: FileHandle,
|
||||
size: number,
|
||||
): AsyncGenerator<{
|
||||
line: Buffer;
|
||||
offset: number;
|
||||
}> {
|
||||
const chunkSize = 64 * 1024;
|
||||
let fragments: Buffer[] = [];
|
||||
let lineOffset = 0;
|
||||
for (let position = 0; position < size; ) {
|
||||
const chunk = Buffer.allocUnsafe(Math.min(chunkSize, size - position));
|
||||
const { bytesRead } = await file.read(chunk, 0, chunk.length, position);
|
||||
if (!bytesRead) break;
|
||||
let start = 0;
|
||||
for (let index = 0; index < bytesRead; index++) {
|
||||
if (chunk[index] !== 10) continue;
|
||||
const tail = chunk.subarray(start, index);
|
||||
const line = fragments.length ? Buffer.concat([...fragments, tail]) : tail;
|
||||
yield { line, offset: lineOffset };
|
||||
fragments = [];
|
||||
start = index + 1;
|
||||
lineOffset = position + start;
|
||||
}
|
||||
});
|
||||
if (entries.length === 0) return [];
|
||||
const byId = new Map(entries.map((entry) => [entry.id!, entry]));
|
||||
const parentIds = new Set(entries.flatMap((entry) => (entry.parentId ? [entry.parentId] : [])));
|
||||
const leaves = entries.filter((entry) => !parentIds.has(entry.id!));
|
||||
let current: OmpSessionEntry | undefined =
|
||||
(activeEntryId ? byId.get(activeEntryId) : undefined) ?? leaves.at(-1) ?? entries.at(-1);
|
||||
const chain: OmpSessionEntry[] = [];
|
||||
if (start < bytesRead) fragments.push(chunk.subarray(start, bytesRead));
|
||||
position += bytesRead;
|
||||
await setImmediate();
|
||||
}
|
||||
if (fragments.length) yield { line: Buffer.concat(fragments), offset: lineOffset };
|
||||
}
|
||||
|
||||
function parseOmpEntry(line: Buffer): OmpSessionEntry | null {
|
||||
try {
|
||||
const value: unknown = JSON.parse(line.toString("utf8"));
|
||||
return value && typeof value === "object" && typeof Reflect.get(value, "id") === "string"
|
||||
? (value as OmpSessionEntry)
|
||||
: null;
|
||||
} catch {
|
||||
// OMP can leave a partial last record; malformed records have always been ignored.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectOmpChain(
|
||||
locations: readonly OmpEntryLocation[],
|
||||
byId: ReadonlyMap<string, OmpEntryLocation>,
|
||||
parentIds: ReadonlySet<string>,
|
||||
activeEntryId?: string,
|
||||
): Promise<OmpEntryLocation[]> {
|
||||
let current = activeEntryId ? byId.get(activeEntryId) : undefined;
|
||||
if (!current) {
|
||||
for (let index = locations.length - 1; index >= 0; index--) {
|
||||
const location = locations[index]!;
|
||||
if (!parentIds.has(location.id)) {
|
||||
current = location;
|
||||
break;
|
||||
}
|
||||
if (index % 256 === 0) await setImmediate();
|
||||
}
|
||||
current ??= locations.at(-1);
|
||||
}
|
||||
const chain: OmpEntryLocation[] = [];
|
||||
const seen = new Set<string>();
|
||||
while (current?.id && !seen.has(current.id)) {
|
||||
chain.push(current);
|
||||
seen.add(current.id);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
if (chain.length % 256 === 0) await setImmediate();
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
export async function readActiveOmpEntryChain(
|
||||
sessionFile: string,
|
||||
activeEntryId?: string,
|
||||
): Promise<OmpSessionEntry[]> {
|
||||
const file = await open(sessionFile, "r");
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
if (stat.isDirectory()) {
|
||||
throw Object.assign(
|
||||
new Error(`EISDIR: illegal operation on a directory, read '${sessionFile}'`),
|
||||
{
|
||||
code: "EISDIR",
|
||||
},
|
||||
);
|
||||
}
|
||||
// One descriptor and a fixed byte boundary exclude appends made while replay is yielding.
|
||||
// Retain graph metadata only: abandoned branches must not retain their message payloads.
|
||||
const locations: OmpEntryLocation[] = [];
|
||||
const byId = new Map<string, OmpEntryLocation>();
|
||||
const parentIds = new Set<string>();
|
||||
for await (const { line, offset } of readOmpLines(file, stat.size)) {
|
||||
const entry = parseOmpEntry(line);
|
||||
if (!entry) continue;
|
||||
const location = { id: entry.id!, parentId: entry.parentId, offset };
|
||||
locations.push(location);
|
||||
byId.set(location.id, location);
|
||||
if (location.parentId) parentIds.add(location.parentId);
|
||||
}
|
||||
const chain = await selectOmpChain(locations, byId, parentIds, activeEntryId);
|
||||
if (chain.length === 0) return [];
|
||||
const selected = new Map<number, number>();
|
||||
for (let index = 0; index < chain.length; index++) {
|
||||
selected.set(chain[index]!.offset, chain.length - index - 1);
|
||||
if (index % 256 === 0) await setImmediate();
|
||||
}
|
||||
const entries: OmpSessionEntry[] = [];
|
||||
for await (const { line, offset } of readOmpLines(file, stat.size)) {
|
||||
const index = selected.get(offset);
|
||||
if (index === undefined) continue;
|
||||
const entry = parseOmpEntry(line);
|
||||
// Truncation in place invalidates the indexed snapshot; never publish a partial chain.
|
||||
if (!entry || entry.id !== chain[chain.length - index - 1]!.id) {
|
||||
throw new Error(`OMP transcript changed during replay: ${sessionFile}`);
|
||||
}
|
||||
entries[index] = entry;
|
||||
selected.delete(offset);
|
||||
if (selected.size === 0) break;
|
||||
}
|
||||
if (selected.size) throw new Error(`OMP transcript changed during replay: ${sessionFile}`);
|
||||
return entries;
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
return chain.toReversed();
|
||||
}
|
||||
|
||||
function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null {
|
||||
|
||||
@@ -152,6 +152,8 @@ export const OmpSessionStateSchema = z
|
||||
thinkingLevel: OmpThinkingLevelSchema.optional(),
|
||||
isStreaming: z.boolean(),
|
||||
isCompacting: z.boolean(),
|
||||
hasPendingAsyncWork: z.boolean().optional(),
|
||||
isSettled: z.boolean().optional(),
|
||||
autoCompactionEnabled: z.boolean().optional(),
|
||||
fastModeEnabled: z.boolean().optional(),
|
||||
fastModeActive: z.boolean().optional(),
|
||||
|
||||
@@ -7010,3 +7010,21 @@ describe("OpenCode session permission rules", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("OpenCode v1 runtime safety retains terminals and busy sessions on its shared server", async () => {
|
||||
const { parent, openCode } = await createParentSession("runtime-safety");
|
||||
try {
|
||||
expect(await parent.canStopRuntime!()).toBe(true);
|
||||
openCode.ptyListResponse = { data: [{ id: "shell", status: "running" }] };
|
||||
expect(await parent.canStopRuntime!()).toBe(false);
|
||||
openCode.ptyListResponse = { data: [] };
|
||||
openCode.sessionStatusResponse = { data: { "other-native-session": { type: "busy" } } };
|
||||
expect(await parent.canStopRuntime!()).toBe(false);
|
||||
openCode.sessionStatusResponse = { data: {} };
|
||||
expect(await parent.canStopRuntime!()).toBe(true);
|
||||
openCode.ptyListResponse = { error: "Inventory unavailable" };
|
||||
await expect(parent.canStopRuntime!()).rejects.toThrow("Unable to inspect OpenCode terminals");
|
||||
} finally {
|
||||
await parent.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5055,6 +5055,43 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (
|
||||
this.closed ||
|
||||
this.serverExited ||
|
||||
this.reconnection ||
|
||||
this.turnState.status !== "idle" ||
|
||||
this.runningToolCalls.size ||
|
||||
this.pendingPermissions.size ||
|
||||
this.pendingSteerSubmissions.length
|
||||
)
|
||||
return false;
|
||||
const request = { signal: AbortSignal.timeout(10_000) };
|
||||
const family = new Map([[this.sessionId, this.config.cwd]]);
|
||||
const inspected = new Set<string>();
|
||||
for (const [sessionId, directory] of family) {
|
||||
if (!inspected.has(directory)) {
|
||||
const statuses = await this.client.session.status({ directory }, request);
|
||||
if (statuses.error || !statuses.data)
|
||||
throw new Error("Unable to inspect OpenCode executions");
|
||||
if (Object.values(statuses.data).some((status) => status.type !== "idle")) return false;
|
||||
const terminals = await this.client.pty.list({ directory }, request);
|
||||
if (terminals.error || !terminals.data)
|
||||
throw new Error("Unable to inspect OpenCode terminals");
|
||||
if (terminals.data.some((terminal) => terminal.status === "running")) return false;
|
||||
inspected.add(directory);
|
||||
}
|
||||
const response = await this.client.session.children(
|
||||
{ sessionID: sessionId, directory },
|
||||
request,
|
||||
);
|
||||
const children = readOpenCodeChildSessionInfosFromResponse(response);
|
||||
if (!children) throw new Error("Unable to inspect OpenCode child sessions");
|
||||
for (const child of children) family.set(child.id, child.directory ?? directory);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
this.closed = true;
|
||||
|
||||
@@ -56,6 +56,7 @@ test("bridge preserves tool failure messages and classifies invalid requests as
|
||||
getDaemonTcpHost: null,
|
||||
serviceProxyPublicBaseUrl: null,
|
||||
resolveScriptHealth: null,
|
||||
getGlobalServicePorts: async () => undefined,
|
||||
logger,
|
||||
emit: () => {},
|
||||
publishStatusUpdate: () => {},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { collectTimelineSnapshot } from "./test-utils/v2-timeline.js";
|
||||
import type { SessionMessageAssistant, SessionMessageUser } from "@opencode/client";
|
||||
import { V2Timeline } from "./v2/timeline.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -1560,7 +1561,7 @@ describe("translateOpenCodeEvent", () => {
|
||||
});
|
||||
|
||||
describe("OpenCode v2 history reconciliation", () => {
|
||||
it("keeps client message identity on live echoes but omits it from history", () => {
|
||||
it("keeps client message identity on live echoes but omits it from history", async () => {
|
||||
const user: SessionMessageUser = {
|
||||
id: "native-message",
|
||||
type: "user",
|
||||
@@ -1568,13 +1569,13 @@ describe("OpenCode v2 history reconciliation", () => {
|
||||
time: { created: 1 },
|
||||
metadata: { paseoClientMessageId: "client-message" },
|
||||
};
|
||||
expect(new V2Timeline().messages([user])).toMatchObject([
|
||||
expect(await collectTimelineSnapshot(new V2Timeline(), [user])).toMatchObject([
|
||||
{ item: { clientMessageId: "client-message", messageId: "native-message" } },
|
||||
]);
|
||||
expect(new V2Timeline(false).messages([user])).toMatchObject([
|
||||
expect(await collectTimelineSnapshot(new V2Timeline(false), [user])).toMatchObject([
|
||||
{ item: { type: "user_message", messageId: "native-message" } },
|
||||
]);
|
||||
const [event] = new V2Timeline(false).messages([user]);
|
||||
const [event] = await collectTimelineSnapshot(new V2Timeline(false), [user]);
|
||||
expect(event && "item" in event && event.item).not.toHaveProperty("clientMessageId");
|
||||
});
|
||||
function assistant(text: string): SessionMessageAssistant {
|
||||
@@ -1587,31 +1588,35 @@ describe("OpenCode v2 history reconciliation", () => {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
}
|
||||
it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", () => {
|
||||
it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", async () => {
|
||||
const timeline = new V2Timeline();
|
||||
const text = (messages: ReturnType<V2Timeline["messages"]>) =>
|
||||
const text = (messages: Awaited<ReturnType<typeof collectTimelineSnapshot>>) =>
|
||||
messages.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message"
|
||||
? [event.item.text]
|
||||
: [],
|
||||
);
|
||||
expect(text(timeline.messages([assistant("hello")]))).toEqual(["hello"]);
|
||||
expect(timeline.messages([assistant("hel")])).toEqual([]);
|
||||
expect(text(timeline.messages([assistant("hello world")]))).toEqual([" world"]);
|
||||
expect(timeline.messages([assistant("hello world")])).toEqual([]);
|
||||
expect(text(await collectTimelineSnapshot(timeline, [assistant("hello")]))).toEqual(["hello"]);
|
||||
expect(await collectTimelineSnapshot(timeline, [assistant("hel")])).toEqual([]);
|
||||
expect(text(await collectTimelineSnapshot(timeline, [assistant("hello world")]))).toEqual([
|
||||
" world",
|
||||
]);
|
||||
expect(await collectTimelineSnapshot(timeline, [assistant("hello world")])).toEqual([]);
|
||||
});
|
||||
it("keeps reasoning separate from assistant output", () => {
|
||||
it("keeps reasoning separate from assistant output", async () => {
|
||||
const timeline = new V2Timeline();
|
||||
const message = assistant("answer");
|
||||
message.content.unshift({ type: "reasoning", text: "thinking" });
|
||||
expect(
|
||||
timeline.messages([message]).map((event) => (event.type === "timeline" ? event.item : null)),
|
||||
(await collectTimelineSnapshot(timeline, [message])).map((event) =>
|
||||
event.type === "timeline" ? event.item : null,
|
||||
),
|
||||
).toEqual([
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{ type: "assistant_message", text: "answer", messageId: "message" },
|
||||
]);
|
||||
});
|
||||
it("emits tool state transitions once and keeps their call identity", () => {
|
||||
it("emits tool state transitions once and keeps their call identity", async () => {
|
||||
const timeline = new V2Timeline();
|
||||
const message = assistant("");
|
||||
message.content = [
|
||||
@@ -1623,7 +1628,7 @@ describe("OpenCode v2 history reconciliation", () => {
|
||||
state: { status: "running", input: { command: "pwd" }, metadata: {} },
|
||||
},
|
||||
];
|
||||
expect(timeline.messages([message])).toMatchObject([
|
||||
expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([
|
||||
{ item: { type: "tool_call", callId: "call", status: "running" } },
|
||||
]);
|
||||
message.content = [
|
||||
@@ -1639,9 +1644,9 @@ describe("OpenCode v2 history reconciliation", () => {
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(timeline.messages([message])).toMatchObject([
|
||||
expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([
|
||||
{ item: { type: "tool_call", callId: "call", status: "completed" } },
|
||||
]);
|
||||
expect(timeline.messages([message])).toEqual([]);
|
||||
expect(await collectTimelineSnapshot(timeline, [message])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+2
@@ -170,6 +170,7 @@ export class TestOpenCodeClient {
|
||||
null;
|
||||
sessionPromptAsyncEvents: unknown[] = [idleEvent()];
|
||||
sessionPromptAsyncResponse: OpenCodeResponse = {};
|
||||
ptyListResponse: OpenCodeResponse = { data: [] };
|
||||
sessionStatusResponse: OpenCodeResponse = { data: {} };
|
||||
sessionStatusImplementation:
|
||||
| ((parameters: unknown, options: unknown) => Promise<OpenCodeResponse>)
|
||||
@@ -195,6 +196,7 @@ export class TestOpenCodeClient {
|
||||
|
||||
asSdkClient(): OpencodeClient {
|
||||
return {
|
||||
pty: { list: async () => this.ptyListResponse },
|
||||
app: {
|
||||
agents: async (parameters: unknown, options: unknown) => {
|
||||
this.calls.appAgents.push(parameters);
|
||||
|
||||
@@ -90,6 +90,9 @@ export class V2Harness {
|
||||
|
||||
readonly api: V2Api = {
|
||||
server: { info: unexpected },
|
||||
shell: {
|
||||
list: async () => ({ location: { directory: this.info.location.directory }, data: [] }),
|
||||
},
|
||||
plugin: {
|
||||
list: async () => ({
|
||||
location: { directory: this.info.location.directory },
|
||||
@@ -131,6 +134,7 @@ export class V2Harness {
|
||||
},
|
||||
permission: { list: async () => [], reply: unexpected },
|
||||
session: {
|
||||
inbox: { list: async () => [] },
|
||||
form: { list: async () => [], reply: unexpected, cancel: unexpected },
|
||||
create: async (input = {}) => {
|
||||
this.creates.push(input);
|
||||
|
||||
+13
-10
@@ -1,3 +1,4 @@
|
||||
import { collectTimelineSnapshot } from "./v2-timeline.js";
|
||||
import { TimelineProjection } from "../../../timeline-projection.js";
|
||||
import { V2Timeline } from "../v2/timeline.js";
|
||||
|
||||
@@ -21,16 +22,18 @@ for (let index = 0; index < chunks; index++) {
|
||||
const event =
|
||||
mode === "delta"
|
||||
? timeline.delta({ ...part, delta })
|
||||
: timeline.messages([
|
||||
{
|
||||
id: "answer",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
time: { created: 2 },
|
||||
content: [{ type: "reasoning", text }],
|
||||
},
|
||||
])[0];
|
||||
: (
|
||||
await collectTimelineSnapshot(timeline, [
|
||||
{
|
||||
id: "answer",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
time: { created: 2 },
|
||||
content: [{ type: "reasoning", text }],
|
||||
},
|
||||
])
|
||||
)[0];
|
||||
if (event?.type !== "timeline") throw new Error("Expected a timeline event");
|
||||
projection.append({
|
||||
seq: index + 1,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SessionMessageInfo } from "@opencode/client";
|
||||
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
|
||||
import type { V2Timeline } from "../v2/timeline.js";
|
||||
|
||||
// Snapshot assertions without a live stream can collect the publisher's events.
|
||||
export async function collectTimelineSnapshot(
|
||||
timeline: V2Timeline,
|
||||
messages: SessionMessageInfo[],
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
await timeline.messages(messages, (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
return events;
|
||||
}
|
||||
@@ -1074,3 +1074,110 @@ describe("OpenCode v2 session lifecycle", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("OpenCode v2 runtime safety retains shells, queued reports and shared executions", async () => {
|
||||
const harness = new V2Harness();
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
harness.api.shell.list = async () => ({
|
||||
location: { directory: "/tmp/project" },
|
||||
data: [
|
||||
{
|
||||
id: "quiet-shell",
|
||||
status: "running",
|
||||
command: "sleep 7200",
|
||||
cwd: "/tmp/project",
|
||||
shell: "sh",
|
||||
file: "/tmp/out",
|
||||
metadata: {},
|
||||
time: { started: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
harness.api.shell.list = async () => ({ location: { directory: "/tmp/project" }, data: [] });
|
||||
harness.api.session.inbox.list = async () => [
|
||||
{
|
||||
id: "report",
|
||||
sessionID: session.id!,
|
||||
type: "synthetic",
|
||||
delivery: "queue",
|
||||
time: { created: 1 },
|
||||
payload: { text: "Background child finished" },
|
||||
},
|
||||
];
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
harness.api.session.inbox.list = async () => [];
|
||||
harness.api.session.active = async () => ({
|
||||
"unrelated-autonomous-session": { type: "running" },
|
||||
});
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
harness.api.session.active = async () => ({});
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
harness.api.shell.list = async () => {
|
||||
throw new Error("No shell inventory");
|
||||
};
|
||||
await expect(session.canStopRuntime!()).rejects.toThrow("No shell inventory");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode v2 runtime safety follows descendant locations before releasing the last lease", async () => {
|
||||
const harness = new V2Harness();
|
||||
const child = {
|
||||
...harness.info,
|
||||
id: "child",
|
||||
parentID: harness.info.id,
|
||||
location: { directory: "/tmp/child-worktree" },
|
||||
};
|
||||
const locations: string[] = [];
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
// A completed native child can still own a quiet shell in another location.
|
||||
harness.api.session.list = async (input) => ({
|
||||
data: input.parentID === session.id ? [child] : [],
|
||||
cursor: {},
|
||||
});
|
||||
let running = true;
|
||||
harness.api.shell.list = async (input) => {
|
||||
const directory = input?.location?.directory ?? "";
|
||||
locations.push(directory);
|
||||
return {
|
||||
location: { directory },
|
||||
data:
|
||||
directory === child.location.directory && running
|
||||
? [
|
||||
{
|
||||
id: "child-shell",
|
||||
status: "running",
|
||||
command: "sleep 7200",
|
||||
cwd: directory,
|
||||
shell: "sh",
|
||||
file: "/tmp/out",
|
||||
metadata: {},
|
||||
time: { started: 1 },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
};
|
||||
try {
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
expect(locations).toEqual(["/tmp/project", "/tmp/child-worktree"]);
|
||||
running = false;
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
await session.close();
|
||||
expect(harness.releases).toBe(1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { OpenCodeClient } from "@opencode/client";
|
||||
|
||||
export interface V2Api {
|
||||
server: OpenCodeClient["server"];
|
||||
shell: Pick<OpenCodeClient["shell"], "list">;
|
||||
session: Pick<
|
||||
OpenCodeClient["session"],
|
||||
| "create"
|
||||
@@ -20,6 +21,7 @@ export interface V2Api {
|
||||
| "revert"
|
||||
| "log"
|
||||
> & {
|
||||
inbox: Pick<OpenCodeClient["session"]["inbox"], "list">;
|
||||
form: Pick<OpenCodeClient["session"]["form"], "list" | "reply" | "cancel">;
|
||||
};
|
||||
plugin: Pick<OpenCodeClient["plugin"], "list">;
|
||||
|
||||
@@ -92,13 +92,13 @@ export class SessionChildren {
|
||||
this.childStates.set(info.id, signature);
|
||||
this.options.emit({ type: "provider_subagent", provider: "opencode", event: presentation });
|
||||
}
|
||||
for (const event of timeline.messages(await messages(this.options.client(), info.id))) {
|
||||
await timeline.messages(await messages(this.options.client(), info.id), (event) => {
|
||||
if (event.type === "timeline")
|
||||
this.options.emit({
|
||||
type: "provider_subagent",
|
||||
provider: "opencode",
|
||||
event: { type: "timeline", id: info.id, item: event.item, timestamp: event.timestamp },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
await this.configureConnection();
|
||||
const location = { directory: this.config.cwd };
|
||||
this.modes = modesFromV2((await this.client.agent.list({ location })).data);
|
||||
this.timeline.messages(await messages(this.client, this.id));
|
||||
await this.timeline.messages(await messages(this.client, this.id), () => {});
|
||||
await this.startStream();
|
||||
await this.children.reconcile(this.id);
|
||||
}
|
||||
@@ -261,7 +261,11 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
async *streamHistory() {
|
||||
await this.reconnectIfExited();
|
||||
const history = new V2Timeline(false);
|
||||
yield* history.messages(await messages(this.client, this.id));
|
||||
const events: AgentStreamEvent[] = [];
|
||||
await history.messages(await messages(this.client, this.id), (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
yield* events;
|
||||
}
|
||||
async getRuntimeInfo() {
|
||||
await this.reconnectIfExited();
|
||||
@@ -417,7 +421,7 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
]);
|
||||
this.info = info;
|
||||
this.history = history;
|
||||
for (const event of this.timeline.messages(history)) this.emitTimeline(event);
|
||||
await this.timeline.messages(history, (event) => this.emitTimeline(event));
|
||||
await this.permissions.reconcile(this.id);
|
||||
}
|
||||
private async reconcile() {
|
||||
@@ -516,6 +520,36 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
await delay(100, undefined, { signal }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (this.closed || this.exited || this.reconnecting || this.turns.id || this.dirty)
|
||||
return false;
|
||||
const request = { signal: AbortSignal.timeout(10_000) };
|
||||
// Active executions are server-wide. Releasing the last lease must not stop an
|
||||
// autonomous execution merely because it belongs to another session or location.
|
||||
if (Object.keys(await this.client.session.active(request)).length > 0) return false;
|
||||
const family = new Map([[this.info.id, this.info]]);
|
||||
const directories = new Set<string>();
|
||||
for (const info of family.values()) {
|
||||
if ((await this.client.session.inbox.list({ sessionID: info.id }, request)).length)
|
||||
return false;
|
||||
directories.add(info.location.directory);
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await this.client.session.list(
|
||||
{ ...(cursor ? { cursor } : { parentID: info.id }), limit: 100 },
|
||||
request,
|
||||
);
|
||||
for (const child of page.data) family.set(child.id, child);
|
||||
cursor = page.cursor.next ?? undefined;
|
||||
} while (cursor);
|
||||
}
|
||||
for (const directory of directories) {
|
||||
const shells = await this.client.shell.list({ location: { directory } }, request);
|
||||
if (shells.data.some((shell) => shell.status === "running")) return false;
|
||||
}
|
||||
return !this.turns.id && !this.dirty;
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js";
|
||||
import { V2Timeline } from "./timeline.js";
|
||||
import type { SessionMessageAssistant } from "@opencode/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
@@ -80,7 +81,7 @@ describe("OpenCode v2 token streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("deduplicates snapshots before and after deltas using per-type ordinals", () => {
|
||||
test("deduplicates snapshots before and after deltas using per-type ordinals", async () => {
|
||||
const timeline = new V2Timeline();
|
||||
const text = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const;
|
||||
const reasoning = { ...text, type: "reasoning" } as const;
|
||||
@@ -99,24 +100,28 @@ describe("OpenCode v2 token streaming", () => {
|
||||
},
|
||||
{ type: "text", text: "Hello" },
|
||||
]);
|
||||
expect(timeline.messages([snapshot])).toMatchObject([{ item: { text: "lo" } }]);
|
||||
expect(await collectTimelineSnapshot(timeline, [snapshot])).toMatchObject([
|
||||
{ item: { text: "lo" } },
|
||||
]);
|
||||
expect(timeline.delta({ ...text, delta: "lo" })).toBeNull();
|
||||
expect(timeline.messages([snapshot])).toEqual([]);
|
||||
expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]);
|
||||
expect(timeline.delta({ ...text, delta: "!" })).toMatchObject({ item: { text: "!" } });
|
||||
expect(timeline.messages([snapshot])).toEqual([]);
|
||||
expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]);
|
||||
});
|
||||
|
||||
test("recovers missed fragments from snapshots after reconnect", () => {
|
||||
test("recovers missed fragments from snapshots after reconnect", async () => {
|
||||
const timeline = new V2Timeline();
|
||||
const part = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const;
|
||||
timeline.startPart(part);
|
||||
expect(timeline.delta({ ...part, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } });
|
||||
timeline.resetStreams();
|
||||
expect(timeline.delta({ ...part, delta: "!" })).toBeNull();
|
||||
expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toMatchObject([
|
||||
{ item: { text: "lo!" } },
|
||||
]);
|
||||
expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toEqual([]);
|
||||
expect(
|
||||
await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]),
|
||||
).toMatchObject([{ item: { text: "lo!" } }]);
|
||||
expect(
|
||||
await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("withholds streamed prose during a structured-output turn", async () => {
|
||||
@@ -203,3 +208,62 @@ describe("OpenCode v2 token streaming", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["text", "reasoning"] as const)(
|
||||
"publishes snapshot %s before a live suffix during image materialization",
|
||||
async (type) => {
|
||||
const timeline = new V2Timeline();
|
||||
const part = { assistantMessageID: "answer", type, ordinal: 0 };
|
||||
timeline.startPart(part);
|
||||
const emitted: AgentStreamEvent[] = [];
|
||||
let settled = false;
|
||||
const snapshot = timeline
|
||||
.messages(
|
||||
[
|
||||
assistant([
|
||||
{ type, text: "Hello" },
|
||||
{
|
||||
type: "tool",
|
||||
id: "screenshot",
|
||||
name: "paseo_browser_screenshot",
|
||||
executed: false,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {},
|
||||
content: [
|
||||
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png" },
|
||||
],
|
||||
metadata: {},
|
||||
},
|
||||
time: { created: 2, ran: 2, completed: 2 },
|
||||
},
|
||||
]),
|
||||
],
|
||||
(event) => {
|
||||
emitted.push(event);
|
||||
},
|
||||
)
|
||||
.then(() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
const delta = timeline.delta({ ...part, delta: "Hello world" });
|
||||
if (delta) emitted.push(delta);
|
||||
await snapshot;
|
||||
const text = emitted.flatMap((event) => {
|
||||
if (event.type !== "timeline") return [];
|
||||
if (
|
||||
type === "text" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
!event.item.text.includes("![Image]")
|
||||
)
|
||||
return [event.item.text];
|
||||
if (type === "reasoning" && event.item.type === "reasoning") return [event.item.text];
|
||||
return [];
|
||||
});
|
||||
expect(text).toEqual(["Hello", " world"]);
|
||||
expect(text.join("")).toBe("Hello world");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js";
|
||||
import type { SessionMessageAssistant } from "@opencode/client";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -15,7 +16,7 @@ function assistant(content: SessionMessageAssistant["content"]): SessionMessageA
|
||||
};
|
||||
}
|
||||
|
||||
test("a completed edit carries the replaced and replacement text", () => {
|
||||
test("a completed edit carries the replaced and replacement text", async () => {
|
||||
// Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`.
|
||||
const edit = assistant([
|
||||
{
|
||||
@@ -45,7 +46,7 @@ test("a completed edit carries the replaced and replacement text", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(new V2Timeline().messages([edit])).toMatchObject([
|
||||
expect(await collectTimelineSnapshot(new V2Timeline(), [edit])).toMatchObject([
|
||||
{
|
||||
item: {
|
||||
type: "tool_call",
|
||||
@@ -57,7 +58,7 @@ test("a completed edit carries the replaced and replacement text", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("a tool's image output is shown as an image, not as base64 in the tool output", () => {
|
||||
test("a tool's image output is shown as an image, not as base64 in the tool output", async () => {
|
||||
const png =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||
const screenshot = assistant([
|
||||
@@ -80,7 +81,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp
|
||||
]);
|
||||
const timeline = new V2Timeline();
|
||||
|
||||
const events = timeline.messages([screenshot]);
|
||||
const events = await collectTimelineSnapshot(timeline, [screenshot]);
|
||||
|
||||
expect(JSON.stringify(events)).not.toContain(png);
|
||||
expect(events.map((event) => event.type === "timeline" && event.item)).toMatchObject([
|
||||
@@ -95,7 +96,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp
|
||||
text: expect.stringMatching(/^\n\n!\[Image\]\(file:\/\/.*paseo-attachments.*\.png\)\n\n$/),
|
||||
},
|
||||
]);
|
||||
expect(timeline.messages([screenshot])).toEqual([]);
|
||||
expect(await collectTimelineSnapshot(timeline, [screenshot])).toEqual([]);
|
||||
});
|
||||
|
||||
test("a surrogate pair split across deltas streams back intact", () => {
|
||||
|
||||
@@ -55,18 +55,32 @@ export class V2Timeline {
|
||||
return { type: "timeline", provider: "opencode", item, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
messages(messages: SessionMessageInfo[]): AgentStreamEvent[] {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
private pendingMessages: Promise<unknown> = Promise.resolve();
|
||||
|
||||
// Publish each cursor advance in the same synchronous step. Buffering snapshot
|
||||
// events until image IO finishes lets live deltas overtake already committed text.
|
||||
messages(
|
||||
messages: SessionMessageInfo[],
|
||||
publish: (event: AgentStreamEvent) => void,
|
||||
): Promise<void> {
|
||||
const result = this.pendingMessages.then(() => this.publishMessages(messages, publish));
|
||||
this.pendingMessages = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
private async publishMessages(
|
||||
messages: SessionMessageInfo[],
|
||||
publish: (event: AgentStreamEvent) => void,
|
||||
): Promise<void> {
|
||||
const state: TimelineState = { structured: false, accepted: false };
|
||||
for (const message of messages) {
|
||||
const timestamp = new Date(message.time.created).toISOString();
|
||||
const push = (item: AgentTimelineItem) =>
|
||||
events.push({ type: "timeline", provider: "opencode", item, timestamp });
|
||||
publish({ type: "timeline", provider: "opencode", item, timestamp });
|
||||
if (message.type === "user") this.userMessage(message, state, push);
|
||||
else if (message.type === "assistant") this.assistantMessage(message, state, push);
|
||||
else if (message.type === "assistant") await this.assistantMessage(message, state, push);
|
||||
else if (message.type === "compaction") this.compactionMessage(message, push);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private userMessage(
|
||||
@@ -89,7 +103,7 @@ export class V2Timeline {
|
||||
});
|
||||
}
|
||||
|
||||
private assistantMessage(
|
||||
private async assistantMessage(
|
||||
message: Extract<SessionMessageInfo, { type: "assistant" }>,
|
||||
state: TimelineState,
|
||||
push: (item: AgentTimelineItem) => void,
|
||||
@@ -99,16 +113,16 @@ export class V2Timeline {
|
||||
// paths address the same cursor entry.
|
||||
let textOrdinal = 0;
|
||||
let reasoningOrdinal = 0;
|
||||
message.content.forEach((part, partIndex) => {
|
||||
for (const [partIndex, part] of message.content.entries()) {
|
||||
if (part.type === "tool" && part.name === STRUCTURED_OUTPUT_TOOL) {
|
||||
this.structuredOutputPart(message.id, partIndex, part, state, push);
|
||||
} else if (part.type === "text" || part.type === "reasoning") {
|
||||
const ordinal = part.type === "text" ? textOrdinal++ : reasoningOrdinal++;
|
||||
this.textPart(message.id, part, ordinal, state, push);
|
||||
} else {
|
||||
this.toolPart(message.id, partIndex, part, push);
|
||||
await this.toolPart(message.id, partIndex, part, push);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private structuredOutputPart(
|
||||
@@ -156,7 +170,7 @@ export class V2Timeline {
|
||||
else push({ type: "reasoning", text: suffix });
|
||||
}
|
||||
|
||||
private toolPart(
|
||||
private async toolPart(
|
||||
messageID: string,
|
||||
partIndex: number,
|
||||
part: Extract<
|
||||
@@ -174,16 +188,16 @@ export class V2Timeline {
|
||||
if (item) push(item);
|
||||
// Images follow their tool call as markdown, as Claude and Codex emit them,
|
||||
// so base64 never reaches the tool output.
|
||||
images.forEach((image, index) => {
|
||||
for (const [index, image] of images.entries()) {
|
||||
const imageKey = `${key}:image:${index}`;
|
||||
if (this.content.has(imageKey)) return;
|
||||
if (this.content.has(imageKey)) continue;
|
||||
this.content.set(imageKey, "");
|
||||
const imageItem = renderProviderImageOutputAsAssistantMarkdown(
|
||||
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(
|
||||
{ url: image.uri, mimeType: image.mime },
|
||||
{ materialize: materializeProviderImage },
|
||||
);
|
||||
if (imageItem) push(imageItem);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private compactionMessage(
|
||||
|
||||
@@ -3063,6 +3063,81 @@ describe("PiRpcAgentClient", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ outcome: "successful", error: null },
|
||||
{ outcome: "failed", error: new Error("summarizer failed") },
|
||||
])("waits for $outcome manual compaction before sending a new prompt", async ({ error }) => {
|
||||
const { pi, session, events } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
const fakeSession = pi.latestSession();
|
||||
fakeSession.compactError = error;
|
||||
const compactionFinished = Promise.withResolvers<void>();
|
||||
fakeSession.compactCompletion = compactionFinished.promise;
|
||||
const handler = session.tryHandleOutOfBand("/compact");
|
||||
const compaction = handler!.run({ emit: () => {} });
|
||||
|
||||
const { turnId } = await session.startTurn("continue after compaction");
|
||||
await flushTurnScheduling();
|
||||
expect(fakeSession.compactRequests).toEqual([{}]);
|
||||
expect(fakeSession.prompts).toEqual([]);
|
||||
|
||||
compactionFinished.resolve();
|
||||
await compaction;
|
||||
await flushTurnScheduling();
|
||||
expect(fakeSession.prompts).toEqual([{ message: "continue after compaction", imageCount: 0 }]);
|
||||
|
||||
fakeSession.finishTurn();
|
||||
await events.nextTurnCompletion();
|
||||
expect(events.turnLifecycleEvents()).toContainEqual({ type: "turn_completed", turnId });
|
||||
expect(events.eventTypes()).not.toContain("turn_failed");
|
||||
});
|
||||
|
||||
test.each(["interrupt", "close"] as const)(
|
||||
"does not send a prompt waiting on compaction after %s",
|
||||
async (action) => {
|
||||
const { pi, session } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
const fakeSession = pi.latestSession();
|
||||
const compactionFinished = Promise.withResolvers<void>();
|
||||
fakeSession.compactCompletion = compactionFinished.promise;
|
||||
const handler = session.tryHandleOutOfBand("/compact");
|
||||
const compaction = handler!.run({ emit: () => {} });
|
||||
|
||||
await session.startTurn("do not send this prompt");
|
||||
await session[action]();
|
||||
compactionFinished.resolve();
|
||||
await compaction;
|
||||
await flushTurnScheduling();
|
||||
|
||||
expect(fakeSession.prompts).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
test("does not send a prompt when compaction finishes during interruption", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
const fakeSession = pi.latestSession();
|
||||
const compactionFinished = Promise.withResolvers<void>();
|
||||
fakeSession.compactCompletion = compactionFinished.promise;
|
||||
const clearQueueFinished = Promise.withResolvers<void>();
|
||||
fakeSession.clearQueueCompletion = clearQueueFinished.promise;
|
||||
const handler = session.tryHandleOutOfBand("/compact");
|
||||
const compaction = handler!.run({ emit: () => {} });
|
||||
const { turnId } = await session.startTurn("do not send this prompt");
|
||||
|
||||
const interruption = session.interrupt();
|
||||
compactionFinished.resolve();
|
||||
await compaction;
|
||||
await flushTurnScheduling();
|
||||
expect(fakeSession.controlRequests).toEqual(["clear_queue"]);
|
||||
expect(fakeSession.prompts).toEqual([]);
|
||||
|
||||
clearQueueFinished.resolve();
|
||||
await interruption;
|
||||
expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_canceled", turnId }]);
|
||||
expect(fakeSession.prompts).toEqual([]);
|
||||
});
|
||||
|
||||
test("closes Pi compact loading marker when RPC rejects after compaction starts", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
@@ -3488,3 +3563,83 @@ describe("transformPiModels", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("pi runtime safety protects queued replies and runtime-owned activity", async () => {
|
||||
const runtime = new FakePi();
|
||||
const session = await createClient(runtime).createSession(createConfig());
|
||||
onTestFinished(() => session.close());
|
||||
const native = runtime.latestSession();
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
native.state.pendingMessageCount = 1;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.pendingMessageCount = 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("reserves the Pi turn while an image prompt is materialized", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
const first = session.startTurn([
|
||||
{ type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" },
|
||||
]);
|
||||
await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active");
|
||||
await first;
|
||||
expect(pi.latestSession().prompts).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("interrupting Pi during image conversion cancels the admitted turn and permits a follow-up", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
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, 51).toString("base64"),
|
||||
mimeType: "image/png",
|
||||
},
|
||||
])
|
||||
.then((result) => {
|
||||
admitted = true;
|
||||
return result;
|
||||
});
|
||||
await waitForImmediate();
|
||||
expect(admitted).toBe(false);
|
||||
expect(pi.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(pi.latestSession().prompts).toEqual([
|
||||
{ message: "follow-up after image interrupt", imageCount: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("closing Pi during image conversion rejects its pending start", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const first = session.startTurn([
|
||||
{
|
||||
type: "image",
|
||||
data: Buffer.alloc(16 * 1024 * 1024, 52).toString("base64"),
|
||||
mimeType: "image/png",
|
||||
},
|
||||
]);
|
||||
const rejected = expect(first).rejects.toThrow("closed");
|
||||
await waitForImmediate();
|
||||
expect(pi.latestSession().prompts).toHaveLength(0);
|
||||
await session.close();
|
||||
await rejected;
|
||||
});
|
||||
|
||||
@@ -367,9 +367,9 @@ function piModelSupportsImageInput(model: PiModel | null | undefined): boolean {
|
||||
return model?.input?.includes("image") === true;
|
||||
}
|
||||
|
||||
function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string {
|
||||
async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise<string> {
|
||||
try {
|
||||
const materialized = materializeProviderImage({
|
||||
const materialized = await materializeProviderImage({
|
||||
data: image.data,
|
||||
mimeType: image.mimeType,
|
||||
});
|
||||
@@ -379,10 +379,10 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str
|
||||
}
|
||||
}
|
||||
|
||||
function convertPromptInput(
|
||||
async function convertPromptInput(
|
||||
prompt: AgentPromptInput,
|
||||
options: { model: PiModel | null | undefined },
|
||||
): PiPromptPayload {
|
||||
): Promise<PiPromptPayload> {
|
||||
if (typeof prompt === "string") {
|
||||
return { text: prompt };
|
||||
}
|
||||
@@ -405,7 +405,7 @@ function convertPromptInput(
|
||||
mimeType: block.mimeType,
|
||||
});
|
||||
} else {
|
||||
textParts.push(renderTextOnlyImageHint(block));
|
||||
textParts.push(await renderTextOnlyImageHint(block));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1189,6 +1189,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
private readonly contextUserEntries: PiCapturedEntry[] = [];
|
||||
private readonly treeUserEntriesById = new Map<string, PiCapturedEntry>();
|
||||
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
|
||||
private compactionPromise: Promise<void> | null = null;
|
||||
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
|
||||
private outOfBandCompactionStarted = false;
|
||||
private outOfBandCompactionCompleted = false;
|
||||
@@ -1279,7 +1280,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
throw new Error("A Pi turn is already active");
|
||||
}
|
||||
|
||||
const payload = convertPromptInput(prompt, { model: this.state.model });
|
||||
const turnId = randomUUID();
|
||||
this.activeTurnId = turnId;
|
||||
this.usagePoller.startTurn();
|
||||
@@ -1291,11 +1291,23 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.activePromptRequestId = null;
|
||||
this.pendingSteerSubmissions.length = 0;
|
||||
this.clearNoTurnBuffers();
|
||||
const payload = await convertPromptInput(prompt, { model: this.state.model });
|
||||
if (this.closed) throw new Error("Pi session is closed");
|
||||
if (this.activeTurnId !== turnId) return { turnId };
|
||||
this.activeNoTurnPromptText = payload.text;
|
||||
const shouldProbeForNoTurnPrompt = this.parseSlashCommandInput(payload.text) !== null;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (this.compactionPromise) {
|
||||
// Pi RPC rejects prompts until the manual compaction request settles.
|
||||
await this.compactionPromise.catch(() => undefined);
|
||||
const turnCanceled =
|
||||
this.closed || this.activeTurnId !== turnId || this.interruptingTurn?.turnId === turnId;
|
||||
if (turnCanceled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ack = await this.runtimeSession.prompt(payload.text, payload.images);
|
||||
this.activePromptRequestId = ack.requestId ?? null;
|
||||
const correlatedResult = ack.requestId
|
||||
@@ -1353,7 +1365,9 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
if (this.closed || this.activeTurnId !== options.expectedTurnId) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
const payload = convertPromptInput(prompt, { model: this.state.model });
|
||||
const payload = await convertPromptInput(prompt, { model: this.state.model });
|
||||
if (this.closed || this.activeTurnId !== options.expectedTurnId)
|
||||
return { status: "unavailable" };
|
||||
// Pi rejects steer RPCs that are extension commands, so slash inputs keep the
|
||||
// interrupt-and-replace fallback where they can run directly.
|
||||
if (this.parseSlashCommandInput(payload.text)) {
|
||||
@@ -1581,6 +1595,22 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
return await resultPromise;
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (
|
||||
this.closed ||
|
||||
this.activeTurnId ||
|
||||
this.pendingExtensionUiRequests.size ||
|
||||
this.pendingExtensionResults.size ||
|
||||
this.pendingExtensionHydrations.size ||
|
||||
this.pendingSteerSubmissions.length ||
|
||||
this.pendingNoTurnOutputs.length ||
|
||||
this.interruptingTurn
|
||||
)
|
||||
return false;
|
||||
const state = await this.runtimeSession.getState();
|
||||
return !state.isStreaming && !state.isCompacting && state.pendingMessageCount === 0;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
@@ -1788,7 +1818,8 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.outOfBandCompactionStarted = false;
|
||||
this.outOfBandCompactionCompleted = false;
|
||||
try {
|
||||
await this.runtimeSession.compact(customInstructions);
|
||||
this.compactionPromise = this.runtimeSession.compact(customInstructions);
|
||||
await this.compactionPromise;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
@@ -1814,6 +1845,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
this.compactionPromise = null;
|
||||
if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) {
|
||||
this.outOfBandCompactionEmit = null;
|
||||
this.outOfBandCompactionStarted = false;
|
||||
|
||||
@@ -108,6 +108,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
steerError: Error | null = null;
|
||||
readonly controlRequests: string[] = [];
|
||||
clearQueueError: Error | null = null;
|
||||
clearQueueCompletion: Promise<void> | null = null;
|
||||
readonly compactRequests: Array<{ customInstructions?: string }> = [];
|
||||
readonly setAutoCompactionRequests: boolean[] = [];
|
||||
readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = [];
|
||||
@@ -140,6 +141,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
subagentSubscriptionError: Error | null = null;
|
||||
setSessionNameError: Error | null = null;
|
||||
compactError: Error | null = null;
|
||||
compactCompletion: Promise<void> | null = null;
|
||||
emitCompactEnd = true;
|
||||
getStateError: Error | null = null;
|
||||
getSessionStatsError: Error | null = null;
|
||||
@@ -233,6 +235,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
|
||||
async clearQueue(): Promise<void> {
|
||||
this.controlRequests.push("clear_queue");
|
||||
await this.clearQueueCompletion;
|
||||
if (this.clearQueueError) {
|
||||
throw this.clearQueueError;
|
||||
}
|
||||
@@ -241,6 +244,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
async compact(customInstructions?: string): Promise<void> {
|
||||
this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions });
|
||||
this.emit({ type: "compaction_start", reason: "manual" });
|
||||
await this.compactCompletion;
|
||||
if (this.emitCompactEnd) {
|
||||
this.emit({ type: "compaction_end", reason: "manual" });
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ describe.skipIf(process.platform === "win32")("materializeProviderImage", () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("writes image attachments under a private directory in the paseo home", () => {
|
||||
const materialized = materializeProviderImage({
|
||||
test("writes image attachments under a private directory in the paseo home", async () => {
|
||||
const materialized = await materializeProviderImage({
|
||||
data: "YWJjMTIz",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync, utimesSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
utimesSync,
|
||||
statSync,
|
||||
readFileSync,
|
||||
} from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import MarkdownIt from "markdown-it";
|
||||
@@ -6,6 +14,7 @@ import { projectTimelineRows } from "../timeline-projection.js";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
MAX_PROVIDER_IMAGE_BYTES,
|
||||
isProviderImageMarkdown,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
} from "./provider-image-output.js";
|
||||
@@ -31,8 +40,8 @@ function attachmentsDir(): string {
|
||||
return path.join(testHome, "paseo-attachments");
|
||||
}
|
||||
|
||||
function renderImageMarkdown(imagePath: string): string {
|
||||
const item = renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
|
||||
async function renderImageMarkdown(imagePath: string): Promise<string> {
|
||||
const item = await renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
|
||||
if (!item || item.type !== "assistant_message") {
|
||||
throw new Error("Expected provider image output to render as assistant markdown.");
|
||||
}
|
||||
@@ -59,8 +68,8 @@ describe("isProviderImageMarkdown", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("emits Windows file paths as file URIs", () => {
|
||||
const markdown = renderImageMarkdown(
|
||||
test("emits Windows file paths as file URIs", async () => {
|
||||
const markdown = await renderImageMarkdown(
|
||||
`C:\\Users\\me\\AppData\\Local\\Temp\\paseo-attachments\\${HASH}.png`,
|
||||
);
|
||||
|
||||
@@ -70,22 +79,24 @@ describe("isProviderImageMarkdown", () => {
|
||||
expect(isProviderImageMarkdown(markdown)).toBe(true);
|
||||
});
|
||||
|
||||
test("emits POSIX file paths with spaces as valid file URI markdown", () => {
|
||||
const markdown = renderImageMarkdown("/home/user/Projects/Project With Spaces/screenshot.png");
|
||||
test("emits POSIX file paths with spaces as valid file URI markdown", async () => {
|
||||
const markdown = await renderImageMarkdown(
|
||||
"/home/user/Projects/Project With Spaces/screenshot.png",
|
||||
);
|
||||
|
||||
expect(markdown).toBe(
|
||||
"\n\n\n\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("encodes URI-significant characters in POSIX file paths", () => {
|
||||
const markdown = renderImageMarkdown("/tmp/screenshot#1?draft.png");
|
||||
test("encodes URI-significant characters in POSIX file paths", async () => {
|
||||
const markdown = await renderImageMarkdown("/tmp/screenshot#1?draft.png");
|
||||
|
||||
expect(markdown).toBe("\n\n\n\n");
|
||||
});
|
||||
|
||||
test("preserves double-leading slashes in POSIX file paths", () => {
|
||||
const markdown = renderImageMarkdown("//tmp/screenshot#1.png");
|
||||
test("preserves double-leading slashes in POSIX file paths", async () => {
|
||||
const markdown = await renderImageMarkdown("//tmp/screenshot#1.png");
|
||||
|
||||
expect(markdown).toBe("\n\n\n\n");
|
||||
});
|
||||
@@ -97,8 +108,8 @@ describe("isProviderImageMarkdown", () => {
|
||||
"\\\\?\\UNC\\server\\share\\shot?draft.png",
|
||||
"file://server/share/shot%3Fdraft.png",
|
||||
],
|
||||
])("encodes %s image paths as file URIs", (_label, imagePath, expectedSource) => {
|
||||
expect(renderImageMarkdown(imagePath)).toBe(`\n\n\n\n`);
|
||||
])("encodes %s image paths as file URIs", async (_label, imagePath, expectedSource) => {
|
||||
expect(await renderImageMarkdown(imagePath)).toBe(`\n\n\n\n`);
|
||||
});
|
||||
|
||||
test("rejects user-authored markdown that is not a materialized attachment", () => {
|
||||
@@ -110,27 +121,27 @@ describe("isProviderImageMarkdown", () => {
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
useTempPaseoHome();
|
||||
vi.resetModules();
|
||||
({ materializeProviderImage } = await import("./provider-image-output.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPaseoHome === undefined) {
|
||||
delete process.env.PASEO_HOME;
|
||||
} else {
|
||||
process.env.PASEO_HOME = originalPaseoHome;
|
||||
}
|
||||
if (testHome) {
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
testHome = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("materializeProviderImage", () => {
|
||||
beforeEach(async () => {
|
||||
useTempPaseoHome();
|
||||
vi.resetModules();
|
||||
({ materializeProviderImage } = await import("./provider-image-output.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPaseoHome === undefined) {
|
||||
delete process.env.PASEO_HOME;
|
||||
} else {
|
||||
process.env.PASEO_HOME = originalPaseoHome;
|
||||
}
|
||||
if (testHome) {
|
||||
rmSync(testHome, { recursive: true, force: true });
|
||||
testHome = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("recreates the private directory if the cached directory is removed", () => {
|
||||
const first = materializeProviderImage({
|
||||
test("recreates the private directory if the cached directory is removed", async () => {
|
||||
const first = await materializeProviderImage({
|
||||
data: "YWJjMTIz",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
@@ -138,7 +149,7 @@ describe("materializeProviderImage", () => {
|
||||
|
||||
rmSync(path.dirname(first.path), { recursive: true, force: true });
|
||||
|
||||
const second = materializeProviderImage({
|
||||
const second = await materializeProviderImage({
|
||||
data: "ZGVmNDU2",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
@@ -146,8 +157,8 @@ describe("materializeProviderImage", () => {
|
||||
expect(existsSync(second.path)).toBe(true);
|
||||
});
|
||||
|
||||
test("stores images in a stable directory under the paseo home", () => {
|
||||
const result = materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
test("stores images in a stable directory under the paseo home", async () => {
|
||||
const result = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
|
||||
// A fixed directory, not a per-process mkdtemp one — the old shape is what
|
||||
// let the OS reap these images out from under the app after ~3 days.
|
||||
@@ -158,19 +169,22 @@ describe("materializeProviderImage", () => {
|
||||
});
|
||||
|
||||
test("is idempotent for the same bytes across daemon restarts", async () => {
|
||||
const first = materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
const first = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
|
||||
// Reload the module while retaining the files from the previous instance.
|
||||
vi.resetModules();
|
||||
const restarted = await import("./provider-image-output.js");
|
||||
const second = restarted.materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
const second = await restarted.materializeProviderImage({
|
||||
data: "YWJjMTIz",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
|
||||
expect(second.path).toBe(first.path);
|
||||
expect(readdirSync(attachmentsDir())).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("emits markdown that isProviderImageMarkdown still recognizes", () => {
|
||||
const item = renderProviderImageOutputAsAssistantMarkdown(
|
||||
test("emits markdown that isProviderImageMarkdown still recognizes", async () => {
|
||||
const item = await renderProviderImageOutputAsAssistantMarkdown(
|
||||
{ data: "YWJjMTIz", mimeType: "image/png" },
|
||||
{ materialize: materializeProviderImage },
|
||||
);
|
||||
@@ -186,22 +200,22 @@ describe("materializeProviderImage", () => {
|
||||
// longer fuse, because mtime cannot tell a referenced image from an abandoned
|
||||
// one and reading a transcript never touches the file.
|
||||
test("keeps images no matter how old they are", async () => {
|
||||
const image = materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
const image = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
const longAgoSeconds = Date.now() / 1000 - 365 * 24 * 60 * 60;
|
||||
utimesSync(image.path, longAgoSeconds, longAgoSeconds);
|
||||
|
||||
// Reloading the module must not sweep images referenced by older transcripts.
|
||||
vi.resetModules();
|
||||
const restarted = await import("./provider-image-output.js");
|
||||
restarted.materializeProviderImage({ data: "Z2hpNzg5", mimeType: "image/png" });
|
||||
await restarted.materializeProviderImage({ data: "Z2hpNzg5", mimeType: "image/png" });
|
||||
|
||||
expect(existsSync(image.path)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider image paragraph boundaries", () => {
|
||||
test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", (ending) => {
|
||||
const image = renderProviderImageOutputAsAssistantMarkdown({
|
||||
test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", async (ending) => {
|
||||
const image = await renderProviderImageOutputAsAssistantMarkdown({
|
||||
url: "https://example.com/chart.png",
|
||||
});
|
||||
if (!image) throw new Error("Expected image output");
|
||||
@@ -235,10 +249,10 @@ describe("provider image paragraph boundaries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("materialized image output retains paragraph boundaries and attachment recognition", () => {
|
||||
const item = renderProviderImageOutputAsAssistantMarkdown(
|
||||
test("materialized image output retains paragraph boundaries and attachment recognition", async () => {
|
||||
const item = await renderProviderImageOutputAsAssistantMarkdown(
|
||||
{ data: "YWJjMTIz", mimeType: "image/png" },
|
||||
{ materialize: () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) },
|
||||
{ materialize: async () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) },
|
||||
);
|
||||
expect(item).toEqual({
|
||||
type: "assistant_message",
|
||||
@@ -247,3 +261,76 @@ test("materialized image output retains paragraph boundaries and attachment reco
|
||||
if (!item || item.type !== "assistant_message") throw new Error("Expected image output");
|
||||
expect(isProviderImageMarkdown(item.text)).toBe(true);
|
||||
});
|
||||
|
||||
test("repeated content is reused without rewriting the attachment", async () => {
|
||||
const image = {
|
||||
data: Buffer.from("dedupe regression").toString("base64"),
|
||||
mimeType: "image/png",
|
||||
};
|
||||
const first = await materializeProviderImage(image);
|
||||
const before = statSync(first.path, { bigint: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const second = await materializeProviderImage(image);
|
||||
expect(second.path).toBe(first.path);
|
||||
expect(statSync(second.path, { bigint: true }).mtimeNs).toBe(before.mtimeNs);
|
||||
});
|
||||
|
||||
test("64 MiB materialization leaves the event loop responsive and preserves bytes", async () => {
|
||||
const bytes = Buffer.alloc(64 * 1024 * 1024, 37);
|
||||
const data = bytes.toString("base64");
|
||||
let previous = performance.now();
|
||||
let longest = 0;
|
||||
const probe = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longest = Math.max(longest, now - previous);
|
||||
previous = now;
|
||||
}, 1);
|
||||
try {
|
||||
const result = await materializeProviderImage({ data, mimeType: "image/png" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
console.log(`64 MiB attachment longest loop gap: ${longest.toFixed(2)} ms`);
|
||||
expect(longest).toBeLessThan(50);
|
||||
expect(readFileSync(result.path).equals(bytes)).toBe(true);
|
||||
rmSync(result.path, { force: true });
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves base64 decoding across whitespace and chunk boundaries", async () => {
|
||||
const bytes = Buffer.alloc(512 * 1024, 123);
|
||||
const base64 = bytes.toString("base64");
|
||||
const data = base64.match(/.{1,77}/g)!.join("\n");
|
||||
const result = await materializeProviderImage({ data, mimeType: "image/png" });
|
||||
expect(readFileSync(result.path).equals(bytes)).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects oversized materialization before work and leaves the queue usable", async () => {
|
||||
const data = "A".repeat(Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4 + 260);
|
||||
await expect(materializeProviderImage({ data, mimeType: "image/png" })).rejects.toThrow(
|
||||
"exceeds",
|
||||
);
|
||||
const omitted = await renderProviderImageOutputAsAssistantMarkdown(
|
||||
{ data, mimeType: "image/png" },
|
||||
{ materialize: materializeProviderImage },
|
||||
);
|
||||
expect(omitted).toEqual({
|
||||
type: "assistant_message",
|
||||
text: "Image output was omitted because it was not available as a file path or URL.",
|
||||
});
|
||||
const image = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
expect(readFileSync(image.path).toString()).toBe("abc123");
|
||||
});
|
||||
|
||||
test("simultaneous requests for the same content share the published file", async () => {
|
||||
const image = {
|
||||
data: Buffer.from("concurrent dedupe fixture").toString("base64"),
|
||||
mimeType: "image/png",
|
||||
};
|
||||
const results = await Promise.all([
|
||||
materializeProviderImage(image),
|
||||
materializeProviderImage(image),
|
||||
]);
|
||||
expect(results[0]).toEqual(results[1]);
|
||||
expect(readFileSync(results[0].path).toString()).toBe("concurrent dedupe fixture");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import * as fsSync from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
@@ -25,31 +26,25 @@ const MATERIALIZED_IMAGE_FILE_MODE = 0o600;
|
||||
// Persist images across process lifetimes. Age-based cleanup would delete files
|
||||
// still referenced by transcripts; any future cleanup must account for references.
|
||||
let materializedImageAttachmentDir: string | null = null;
|
||||
// Serialize materializations: one decoder and one file operation at a time, including
|
||||
// concurrent history replay and live turns. A rejected operation must not poison the queue.
|
||||
let pendingMaterialization: Promise<unknown> = Promise.resolve();
|
||||
|
||||
function canReuseMaterializedImageAttachmentDir(dir: string): boolean {
|
||||
try {
|
||||
const stats = fsSync.lstatSync(dir);
|
||||
if (!stats.isDirectory()) {
|
||||
return false;
|
||||
async function getMaterializedImageAttachmentDir(): Promise<string> {
|
||||
if (materializedImageAttachmentDir) {
|
||||
try {
|
||||
const stats = await fs.lstat(materializedImageAttachmentDir);
|
||||
if (stats.isDirectory()) {
|
||||
await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
return materializedImageAttachmentDir;
|
||||
}
|
||||
} catch {
|
||||
/* Attachment storage may have been removed between turns. */
|
||||
}
|
||||
fsSync.chmodSync(dir, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getMaterializedImageAttachmentDir(): string {
|
||||
if (
|
||||
materializedImageAttachmentDir &&
|
||||
canReuseMaterializedImageAttachmentDir(materializedImageAttachmentDir)
|
||||
) {
|
||||
return materializedImageAttachmentDir;
|
||||
}
|
||||
|
||||
const dir = path.join(resolvePaseoHome(), PROVIDER_IMAGE_ATTACHMENT_DIR);
|
||||
fsSync.mkdirSync(dir, { recursive: true, mode: PRIVATE_ATTACHMENT_DIR_MODE });
|
||||
fsSync.chmodSync(dir, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
await fs.mkdir(dir, { recursive: true, mode: PRIVATE_ATTACHMENT_DIR_MODE });
|
||||
await fs.chmod(dir, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
materializedImageAttachmentDir = dir;
|
||||
return dir;
|
||||
}
|
||||
@@ -73,30 +68,93 @@ function getImageExtension(mimeType: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImageData(mimeType: string, data: string): { mimeType: string; data: string } {
|
||||
if (data.startsWith("data:")) {
|
||||
const match = data.match(/^data:([^;]+);base64,(.*)$/);
|
||||
if (match) {
|
||||
return { mimeType: match[1], data: match[2] };
|
||||
}
|
||||
}
|
||||
return { mimeType, data };
|
||||
}
|
||||
// 128 MiB admits the measured 64 MiB fixture with headroom while bounding disk
|
||||
// and decoding per image. This is materialization admission, not a wire schema limit.
|
||||
export const MAX_PROVIDER_IMAGE_BYTES = 128 * 1024 * 1024;
|
||||
const BASE64_CHUNK_CHARACTERS = 256 * 1024; // Multiple of four; decode units stay aligned.
|
||||
|
||||
// Content-addressed filenames deduplicate repeated image bytes across restarts.
|
||||
export function materializeProviderImage(image: {
|
||||
data: string;
|
||||
mimeType: string | null;
|
||||
}): MaterializedProviderImage {
|
||||
const attachmentsDir = getMaterializedImageAttachmentDir();
|
||||
const normalized = normalizeImageData(image.mimeType ?? "image/png", image.data);
|
||||
const bytes = Buffer.from(normalized.data, "base64");
|
||||
const extension = getImageExtension(normalized.mimeType);
|
||||
const hash = createHash("sha256").update(bytes).digest("hex");
|
||||
const filePath = path.join(attachmentsDir, `${hash}.${extension}`);
|
||||
fsSync.writeFileSync(filePath, bytes, { mode: MATERIALIZED_IMAGE_FILE_MODE });
|
||||
fsSync.chmodSync(filePath, MATERIALIZED_IMAGE_FILE_MODE);
|
||||
return { path: filePath };
|
||||
}): Promise<MaterializedProviderImage> {
|
||||
const result = pendingMaterialization.then(() => materializeImage(image));
|
||||
pendingMaterialization = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function materializeImage(image: {
|
||||
data: string;
|
||||
mimeType: string | null;
|
||||
}): Promise<MaterializedProviderImage> {
|
||||
const maxEncodedCharacters = Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4;
|
||||
// Check length before inspecting/copying the payload, including a bounded data URI header.
|
||||
if (image.data.length > maxEncodedCharacters + 256) {
|
||||
throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`);
|
||||
}
|
||||
let data = image.data;
|
||||
let mimeType = image.mimeType ?? "image/png";
|
||||
if (data.startsWith("data:")) {
|
||||
const separator = data.indexOf(";base64,");
|
||||
if (separator >= 0 && separator < 256) {
|
||||
mimeType = data.slice(5, separator);
|
||||
data = data.slice(separator + 8);
|
||||
}
|
||||
}
|
||||
if (data.length > maxEncodedCharacters) {
|
||||
throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`);
|
||||
}
|
||||
const hash = createHash("sha256");
|
||||
for await (const bytes of decodeImage(data)) hash.update(bytes);
|
||||
const dir = await getMaterializedImageAttachmentDir();
|
||||
const filePath = path.join(dir, `${hash.digest("hex")}.${getImageExtension(mimeType)}`);
|
||||
try {
|
||||
await fs.stat(filePath);
|
||||
return { path: filePath };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
const staging = await fs.mkdtemp(path.join(dir, ".pending-"));
|
||||
const stagingPath = path.join(staging, "image");
|
||||
try {
|
||||
await fs.chmod(staging, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
const file = await fs.open(stagingPath, "wx", MATERIALIZED_IMAGE_FILE_MODE);
|
||||
try {
|
||||
await file.chmod(MATERIALIZED_IMAGE_FILE_MODE);
|
||||
// Decode again only for a new hash. This bounds retained memory to one chunk
|
||||
// and performs no writes at all for repeated content, including history replay.
|
||||
for await (const bytes of decodeImage(data)) await file.writeFile(bytes);
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
await fs.rename(stagingPath, filePath);
|
||||
return { path: filePath };
|
||||
} finally {
|
||||
await fs.rm(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function* decodeImage(data: string): AsyncGenerator<Buffer> {
|
||||
let carry = "";
|
||||
let decodedBytes = 0;
|
||||
for (let offset = 0; offset < data.length; offset += BASE64_CHUNK_CHARACTERS) {
|
||||
// Buffer.from ignores non-base64 characters. Carry incomplete quartets so
|
||||
// line-wrapped payloads decode identically across chunk boundaries.
|
||||
const chunk =
|
||||
carry +
|
||||
data.slice(offset, offset + BASE64_CHUNK_CHARACTERS).replace(/[^A-Za-z0-9+/_=-]/g, "");
|
||||
const finalChunk = offset + BASE64_CHUNK_CHARACTERS >= data.length || chunk.includes("=");
|
||||
const end = finalChunk ? chunk.length : chunk.length - (chunk.length % 4);
|
||||
const bytes = Buffer.from(chunk.slice(0, end), "base64");
|
||||
carry = chunk.slice(end);
|
||||
decodedBytes += bytes.length;
|
||||
if (decodedBytes > MAX_PROVIDER_IMAGE_BYTES)
|
||||
throw new Error("Image attachment exceeds size limit");
|
||||
yield bytes;
|
||||
await setImmediate();
|
||||
if (finalChunk) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Recognizes markdown rendered for a materialized provider image: its source is a content-hashed
|
||||
@@ -112,7 +170,10 @@ export function isProviderImageMarkdown(text: string): boolean {
|
||||
}
|
||||
|
||||
interface RenderProviderImageOutputOptions {
|
||||
materialize?: (image: { data: string; mimeType: string | null }) => MaterializedProviderImage;
|
||||
materialize?: (image: {
|
||||
data: string;
|
||||
mimeType: string | null;
|
||||
}) => Promise<MaterializedProviderImage>;
|
||||
}
|
||||
|
||||
function nonEmptyString(value: string | null | undefined): string | null {
|
||||
@@ -169,10 +230,10 @@ function escapeMarkdownImageSource(value: string): string {
|
||||
return markdownImageSource(value).replace(/\\/g, "\\\\").replace(/\)/g, "\\)");
|
||||
}
|
||||
|
||||
export function renderProviderImageOutputAsAssistantMarkdown(
|
||||
export async function renderProviderImageOutputAsAssistantMarkdown(
|
||||
image: ProviderImageOutput,
|
||||
options: RenderProviderImageOutputOptions = {},
|
||||
): AgentTimelineItem | null {
|
||||
): Promise<AgentTimelineItem | null> {
|
||||
const source = nonEmptyString(image.path) ?? nonEmptyString(image.url);
|
||||
if (source && !isDataImageSource(source)) {
|
||||
const altText = escapeMarkdownImageAlt(nonEmptyString(image.altText) ?? "Image");
|
||||
@@ -191,7 +252,7 @@ export function renderProviderImageOutputAsAssistantMarkdown(
|
||||
let materialized: MaterializedProviderImage | null = null;
|
||||
try {
|
||||
materialized = options.materialize
|
||||
? options.materialize({
|
||||
? await options.materialize({
|
||||
data,
|
||||
mimeType: nonEmptyString(image.mimeType),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js";
|
||||
|
||||
const PASEO_MCP_SERVER_NAME = "paseo";
|
||||
export const PASEO_MCP_SERVER_NAME = "paseo";
|
||||
const PASEO_MCP_PATHNAME = "/mcp/agents";
|
||||
|
||||
export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import pino from "pino";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
import { STTManager } from "./stt-manager.js";
|
||||
import { PersistedConfigSchema } from "../persisted-config.js";
|
||||
import { loadPersistedConfig } from "../persisted-config.js";
|
||||
import { resolveSpeechConfig } from "../speech/speech-config-resolver.js";
|
||||
import type {
|
||||
SpeechToTextProvider,
|
||||
@@ -97,12 +100,18 @@ class SequencedFakeStt implements SpeechToTextProvider {
|
||||
|
||||
describe("STTManager", () => {
|
||||
function resolveVoiceLanguage(params: { env?: NodeJS.ProcessEnv; persisted?: unknown }): string {
|
||||
const result = resolveSpeechConfig({
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
env: params.env ?? ({} as NodeJS.ProcessEnv),
|
||||
persisted: PersistedConfigSchema.parse(params.persisted ?? {}),
|
||||
});
|
||||
return result.speech.sttLanguages.voice;
|
||||
const home = mkdtempSync(path.join(tmpdir(), "stt-language-"));
|
||||
try {
|
||||
writeFileSync(path.join(home, "config.json"), JSON.stringify(params.persisted ?? {}));
|
||||
const result = resolveSpeechConfig({
|
||||
paseoHome: home,
|
||||
env: params.env ?? {},
|
||||
persisted: loadPersistedConfig(home),
|
||||
});
|
||||
return result.speech.sttLanguages.voice;
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function transcribeWithResolvedVoiceLanguage(params: {
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// Frozen pre-index page selection: differential oracle for retained projection tests.
|
||||
import type { AgentTimelineRow } from "../agent-timeline-store-types.js";
|
||||
import {
|
||||
TimelineProjection,
|
||||
projectTimelineRows,
|
||||
type TimelineProjectionEntry,
|
||||
type ProjectedTimelineRow,
|
||||
type TimelineLimitDirection,
|
||||
type ProjectedTimelinePageSelection,
|
||||
} from "../timeline-projection.js";
|
||||
|
||||
function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): TimelineProjectionEntry[] {
|
||||
return rows.map((row) => {
|
||||
if ("seqStart" in row) return { ...(row as ProjectedTimelineRow) };
|
||||
return {
|
||||
item: row.item,
|
||||
timestamp: row.timestamp,
|
||||
...(row.turnId ? { turnId: row.turnId } : {}),
|
||||
...(row.providerMessageId ? { providerMessageId: row.providerMessageId } : {}),
|
||||
seqStart: row.seq,
|
||||
seqEnd: row.seq,
|
||||
sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }],
|
||||
collapsed: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getTimelineBounds(
|
||||
rows: readonly AgentTimelineRow[],
|
||||
): { minSeq: number; maxSeq: number } | null {
|
||||
const first = rows[0];
|
||||
const last = rows[rows.length - 1];
|
||||
if (!first || !last) {
|
||||
return null;
|
||||
}
|
||||
const entries = makeCanonicalEntries(rows);
|
||||
return {
|
||||
minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity),
|
||||
maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0),
|
||||
};
|
||||
}
|
||||
|
||||
function firstSourceSeqInRange(
|
||||
entry: TimelineProjectionEntry,
|
||||
startSeq: number,
|
||||
endSeq: number,
|
||||
): number | null {
|
||||
for (const range of entry.sourceSeqRanges) {
|
||||
const firstSeq = Math.max(range.startSeq, startSeq);
|
||||
if (firstSeq <= Math.min(range.endSeq, endSeq)) return firstSeq;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ProjectedEntryCandidate {
|
||||
entry: TimelineProjectionEntry;
|
||||
index: number;
|
||||
firstSourceSeq: number;
|
||||
}
|
||||
|
||||
function selectProjectedEntriesAfter(input: {
|
||||
entries: readonly TimelineProjectionEntry[];
|
||||
startSeq: number;
|
||||
maxSeq: number;
|
||||
limit: number;
|
||||
}): { entries: TimelineProjectionEntry[]; endSeq: number | null } {
|
||||
const eligible = input.entries
|
||||
.map((entry, index) => ({
|
||||
entry,
|
||||
index,
|
||||
firstSourceSeq: firstSourceSeqInRange(entry, input.startSeq, input.maxSeq),
|
||||
}))
|
||||
.filter((candidate): candidate is ProjectedEntryCandidate => candidate.firstSourceSeq !== null)
|
||||
.sort((left, right) => left.firstSourceSeq - right.firstSourceSeq || left.index - right.index);
|
||||
const selected = input.limit === 0 ? eligible : eligible.slice(0, input.limit);
|
||||
const selectedEntries = selected
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map((candidate) => candidate.entry);
|
||||
if (selectedEntries.length === 0) return { entries: [], endSeq: null };
|
||||
|
||||
const selectedRanges = selectedEntries
|
||||
.flatMap((entry) => entry.sourceSeqRanges)
|
||||
.sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq);
|
||||
// Wide projected entries can include future, discontiguous source ranges. The
|
||||
// page cursor advances only through source rows covered without a gap.
|
||||
let endSeq = input.startSeq - 1;
|
||||
for (const range of selectedRanges) {
|
||||
if (range.endSeq <= endSeq) continue;
|
||||
if (range.startSeq > endSeq + 1) break;
|
||||
endSeq = Math.min(range.endSeq, input.maxSeq);
|
||||
}
|
||||
|
||||
return {
|
||||
entries: selectedEntries,
|
||||
endSeq: endSeq >= input.startSeq ? endSeq : null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectedEntriesBefore(input: {
|
||||
entries: readonly TimelineProjectionEntry[];
|
||||
endSeq: number;
|
||||
limit: number;
|
||||
}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } {
|
||||
// Older history follows projected display order. Lifecycle updates can move an
|
||||
// entry's seqEnd without moving its display anchor, so seqStart keeps the full
|
||||
// projected item on exactly one backward page.
|
||||
const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq);
|
||||
const selected =
|
||||
input.limit === 0 || input.limit >= eligible.length
|
||||
? eligible
|
||||
: eligible.slice(eligible.length - input.limit);
|
||||
|
||||
return {
|
||||
entries: selected,
|
||||
startSeq: selected[0]?.seqStart ?? null,
|
||||
hasOlder: selected.length < eligible.length,
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectedEntriesTail(
|
||||
projectedAll: TimelineProjectionEntry[],
|
||||
limit: number,
|
||||
bounds: { minSeq: number; maxSeq: number },
|
||||
): ProjectedTimelinePageSelection {
|
||||
let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit);
|
||||
// Include earlier display anchors with updates in the selected source window,
|
||||
// so a tail certifies contiguous coverage even with interleaved tool updates.
|
||||
for (let i = start - 1; i >= 0; i--) {
|
||||
if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i;
|
||||
}
|
||||
const selected = projectedAll.slice(start);
|
||||
return {
|
||||
entries: selected,
|
||||
startSeq: selected[0]?.seqStart ?? null,
|
||||
endSeq: bounds.maxSeq,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectReferenceProjectedTimelinePage(input: {
|
||||
rows: readonly AgentTimelineRow[];
|
||||
bounds?: { minSeq: number; maxSeq: number };
|
||||
direction: TimelineLimitDirection;
|
||||
cursorSeq?: number;
|
||||
limit?: number;
|
||||
}): ProjectedTimelinePageSelection {
|
||||
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
|
||||
const bounds = input.bounds ?? getTimelineBounds(input.rows);
|
||||
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
|
||||
if (!bounds) {
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (projectedAll.length === 0) {
|
||||
if (input.direction === "after") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: cursorSeq >= bounds.minSeq,
|
||||
hasNewer: cursorSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
if (input.direction === "before") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: cursorSeq > bounds.minSeq,
|
||||
hasNewer: cursorSeq <= bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.direction === "tail") {
|
||||
return selectProjectedEntriesTail(projectedAll, limit, bounds);
|
||||
}
|
||||
|
||||
if (input.direction === "after") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
|
||||
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
|
||||
const selected = selectProjectedEntriesAfter({
|
||||
entries: projectedAll,
|
||||
startSeq,
|
||||
maxSeq: bounds.maxSeq,
|
||||
limit,
|
||||
});
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.endSeq === null ? null : startSeq,
|
||||
endSeq: selected.endSeq,
|
||||
hasOlder: startSeq > bounds.minSeq,
|
||||
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
|
||||
if (endSeq < bounds.minSeq) {
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit });
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.startSeq,
|
||||
endSeq,
|
||||
hasOlder: selected.hasOlder,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
// Existing selection fixtures exercise the retained owner through its public API.
|
||||
export function selectProjectedTimelinePage(
|
||||
input: Parameters<typeof selectReferenceProjectedTimelinePage>[0] & { wholeTurns?: boolean },
|
||||
) {
|
||||
const projection = new TimelineProjection(input.rows);
|
||||
const bounds = input.bounds ?? getTimelineBounds(input.rows) ?? { minSeq: 0, maxSeq: 0 };
|
||||
const page = projection.selectPage({ ...input, bounds });
|
||||
return {
|
||||
...page,
|
||||
entries: page.entries.map((row) => {
|
||||
const { seq: _seq, ...entry } = row as ProjectedTimelineRow;
|
||||
return entry;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { selectProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentTimelineRow } from "./agent-manager.js";
|
||||
import {
|
||||
projectTimelineRows,
|
||||
selectProjectedTimelinePage,
|
||||
selectTimelineWindowByProjectedLimit,
|
||||
} from "./timeline-projection.js";
|
||||
|
||||
@@ -534,6 +534,154 @@ describe("selectProjectedTimelinePage", () => {
|
||||
};
|
||||
}
|
||||
|
||||
test("whole-turn pages extend past the soft target and keep prompt jumps whole", () => {
|
||||
const rows: AgentTimelineRow[] = Array.from({ length: 125 }, (_, index) => ({
|
||||
seq: index + 1,
|
||||
timestamp: new Date(1000 + index).toISOString(),
|
||||
turnId: ["short", "long", "latest"][Number(index >= 3) + Number(index >= 123)],
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: `tool-${index}`,
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
},
|
||||
}));
|
||||
const tail = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "tail",
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(tail.entries.map((entry) => entry.seqStart)).toEqual(
|
||||
rows.slice(3).map((row) => row.seq),
|
||||
);
|
||||
expect(tail).toMatchObject({ startSeq: 4, endSeq: 125, hasOlder: true, hasNewer: false });
|
||||
const before = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 124,
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(before.entries.map((entry) => entry.seqStart)).toEqual(
|
||||
rows.slice(3, 123).map((row) => row.seq),
|
||||
);
|
||||
expect(before.endSeq).toBe(123);
|
||||
const older = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 4,
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(older.entries.map((entry) => entry.seqStart)).toEqual([1, 2, 3]);
|
||||
expect(older.hasOlder).toBe(false);
|
||||
const jump = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 70,
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(jump.entries.map((entry) => entry.seqStart)).toEqual(
|
||||
rows.slice(3, 123).map((row) => row.seq),
|
||||
);
|
||||
expect(jump).toMatchObject({ startSeq: 4, endSeq: 123, hasOlder: true, hasNewer: true });
|
||||
const nearPreviousPrompt = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 10,
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(nearPreviousPrompt).toMatchObject({ startSeq: 1, endSeq: 123 });
|
||||
expect(
|
||||
selectProjectedTimelinePage({ rows, direction: "tail", limit: 40 }).entries,
|
||||
).toHaveLength(40);
|
||||
});
|
||||
|
||||
test("whole-turn pages share short runs and use the same deterministic legacy triggers", () => {
|
||||
const rows: AgentTimelineRow[] = Array.from({ length: 24 }, (_, index) => ({
|
||||
seq: index + 1,
|
||||
timestamp: new Date(1000 + index).toISOString(),
|
||||
item:
|
||||
index % 2 === 0
|
||||
? { type: "user_message", text: `prompt-${index}` }
|
||||
: { type: "assistant_message", text: `reply-${index}` },
|
||||
}));
|
||||
const page = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "tail",
|
||||
limit: 7,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(page.entries.map((entry) => entry.seqStart)).toEqual([17, 18, 19, 20, 21, 22, 23, 24]);
|
||||
expect(
|
||||
selectProjectedTimelinePage({ rows, direction: "tail", limit: 0, wholeTurns: true }).entries,
|
||||
).toHaveLength(24);
|
||||
rows.push({
|
||||
...toolRow(25, "completed"),
|
||||
item: { ...toolRow(25, "completed").item, name: "heartbeat" } as AgentTimelineRow["item"],
|
||||
});
|
||||
rows.push({
|
||||
seq: 26,
|
||||
timestamp: new Date().toISOString(),
|
||||
item: { type: "assistant_message", text: "awake" },
|
||||
});
|
||||
expect(
|
||||
selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "tail",
|
||||
limit: 1,
|
||||
wholeTurns: true,
|
||||
}).entries.map((entry) => entry.seqStart),
|
||||
).toEqual([25, 26]);
|
||||
});
|
||||
|
||||
test("whole-turn tails close interleaved source coverage without changing forward catch-up", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: new Date(1).toISOString(),
|
||||
turnId: "first",
|
||||
item: { type: "user_message", text: "go" },
|
||||
},
|
||||
{ ...toolRow(2, "running"), turnId: "first" },
|
||||
{
|
||||
seq: 3,
|
||||
timestamp: new Date(3).toISOString(),
|
||||
turnId: "second",
|
||||
item: { type: "user_message", text: "next" },
|
||||
},
|
||||
{
|
||||
seq: 4,
|
||||
timestamp: new Date(4).toISOString(),
|
||||
turnId: "second",
|
||||
item: { type: "assistant_message", text: "done" },
|
||||
},
|
||||
{ ...toolRow(5, "completed"), turnId: "first" },
|
||||
];
|
||||
expect(
|
||||
selectProjectedTimelinePage({ rows, direction: "tail", limit: 1, wholeTurns: true }),
|
||||
).toMatchObject({
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
});
|
||||
expect(
|
||||
selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "after",
|
||||
cursorSeq: 2,
|
||||
limit: 1,
|
||||
wholeTurns: true,
|
||||
}),
|
||||
).toEqual(selectProjectedTimelinePage({ rows, direction: "after", cursorSeq: 2, limit: 1 }));
|
||||
});
|
||||
|
||||
test("tail page returns full projected items instead of tool lifecycle deltas", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{ seq: 1, timestamp: "2026-02-13T00:00:00.000Z", item: { type: "user_message", text: "go" } },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn";
|
||||
import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js";
|
||||
import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
|
||||
|
||||
@@ -295,12 +296,44 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
|
||||
/** A retained item. `seq` is its latest source event, not its display anchor. */
|
||||
export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry;
|
||||
|
||||
interface ProjectedTimelinePageOptions {
|
||||
wholeTurns?: boolean;
|
||||
bounds: { minSeq: number; maxSeq: number };
|
||||
direction: TimelineLimitDirection;
|
||||
cursorSeq?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Owns projected state; source events are consumed, never retained. */
|
||||
export class TimelineProjection {
|
||||
private readonly rows: ProjectedTimelineRow[] = [];
|
||||
private readonly identities = new Map<string, number>();
|
||||
private lastAssistantRowIndex: number | null = null;
|
||||
// Disjoint source-coverage spans in display order. A tail starting inside a
|
||||
// span must include its first anchor to cover interleaved lifecycle updates.
|
||||
private readonly spans: { start: number; endSeq: number }[] = [];
|
||||
|
||||
private readonly sourceRanges: { startSeq: number; endSeq: number; rowIndex: number }[] = [];
|
||||
|
||||
constructor(rows: readonly AgentTimelineRow[] = []) {
|
||||
for (const row of rows) this.appendProjected(row);
|
||||
for (const [rowIndex, row] of this.rows.entries()) {
|
||||
for (const range of row.sourceSeqRanges) this.sourceRanges.push({ ...range, rowIndex });
|
||||
}
|
||||
this.sourceRanges.sort((a, b) => a.startSeq - b.startSeq);
|
||||
}
|
||||
|
||||
append(row: AgentTimelineRow): void {
|
||||
const rowIndex = this.appendProjected(row);
|
||||
const last = this.sourceRanges.at(-1);
|
||||
if (last && last.rowIndex === rowIndex && last.endSeq + 1 === row.seq) {
|
||||
last.endSeq = row.seq;
|
||||
} else {
|
||||
this.sourceRanges.push({ startSeq: row.seq, endSeq: row.seq, rowIndex });
|
||||
}
|
||||
}
|
||||
|
||||
private appendProjected(row: AgentTimelineRow): number {
|
||||
const entry = makeCanonicalEntries([row])[0];
|
||||
const identity = timelineItemIdentity(row.item);
|
||||
const index = identity === null ? undefined : this.identities.get(identity);
|
||||
@@ -308,7 +341,8 @@ export class TimelineProjection {
|
||||
const merged = existing ? mergeIdentityEntries(existing, entry) : null;
|
||||
if (merged && index !== undefined) {
|
||||
this.rows[index] = { ...merged, seq: merged.seqEnd };
|
||||
return;
|
||||
this.extendSpan(index);
|
||||
return index;
|
||||
}
|
||||
const previous = this.rows.at(-1);
|
||||
const adjacent = previous
|
||||
@@ -317,16 +351,181 @@ export class TimelineProjection {
|
||||
if (previous && adjacent.length === 1) {
|
||||
const mergedText = adjacent[0];
|
||||
this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd };
|
||||
return;
|
||||
this.extendSpan(this.rows.length - 1);
|
||||
return this.rows.length - 1;
|
||||
}
|
||||
if (identity !== null) this.identities.set(identity, this.rows.length);
|
||||
if (entry.item.type === "assistant_message") this.lastAssistantRowIndex = this.rows.length;
|
||||
this.rows.push({ ...entry, seq: entry.seqEnd });
|
||||
const last = this.spans.at(-1);
|
||||
if (last && last.endSeq >= entry.seqStart) {
|
||||
last.endSeq = Math.max(last.endSeq, entry.seqEnd);
|
||||
} else {
|
||||
this.spans.push({ start: this.rows.length - 1, endSeq: entry.seqEnd });
|
||||
}
|
||||
return this.rows.length - 1;
|
||||
}
|
||||
|
||||
private spanIndex(rowIndex: number): number {
|
||||
let low = 0;
|
||||
let high = this.spans.length;
|
||||
while (low + 1 < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (this.spans[middle].start <= rowIndex) low = middle;
|
||||
else high = middle;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
private extendSpan(rowIndex: number): void {
|
||||
const index = this.spanIndex(rowIndex);
|
||||
const span = this.spans[index];
|
||||
span.endSeq = Math.max(span.endSeq, this.rows[rowIndex].seqEnd);
|
||||
let end = index + 1;
|
||||
while (end < this.spans.length && this.rows[this.spans[end].start].seqStart <= span.endSeq) {
|
||||
span.endSeq = Math.max(span.endSeq, this.spans[end].endSeq);
|
||||
end++;
|
||||
}
|
||||
this.spans.splice(index + 1, end - index - 1);
|
||||
}
|
||||
|
||||
getRows(): ProjectedTimelineRow[] {
|
||||
return this.rows.map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
selectPage(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
if (this.rows.length === 0) {
|
||||
const cursor =
|
||||
input.cursorSeq ??
|
||||
(input.direction === "before" ? input.bounds.maxSeq + 1 : input.bounds.minSeq - 1);
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder:
|
||||
input.direction === "after"
|
||||
? cursor >= input.bounds.minSeq
|
||||
: input.direction === "before" && cursor > input.bounds.minSeq,
|
||||
hasNewer:
|
||||
input.direction === "after"
|
||||
? cursor < input.bounds.maxSeq
|
||||
: input.direction === "before" && cursor <= input.bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
switch (input.direction) {
|
||||
case "tail":
|
||||
return this.selectTail(input);
|
||||
case "after":
|
||||
return this.selectAfter(input);
|
||||
case "before":
|
||||
return this.selectBefore(input);
|
||||
}
|
||||
}
|
||||
|
||||
private selectTail(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
const limit = Math.max(0, Math.floor(input.limit ?? 0));
|
||||
const requestedStart = limit === 0 ? 0 : Math.max(0, this.rows.length - limit);
|
||||
const coveredStart = this.spans[this.spanIndex(requestedStart)].start;
|
||||
const start = input.wholeTurns ? this.wholeTurnWindowStart(coveredStart) : coveredStart;
|
||||
return {
|
||||
entries: this.rows.slice(start),
|
||||
startSeq: this.rows[start].seqStart,
|
||||
endSeq: input.bounds.maxSeq,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
private wholeTurnWindowStart(requestedStart: number): number {
|
||||
let start = requestedStart;
|
||||
for (;;) {
|
||||
while (start > 0 && continuesProjectedTurn(this.rows[start - 1], this.rows[start])) start--;
|
||||
// Source coverage can pull in an earlier tool and therefore its entire run.
|
||||
const coveredStart = this.spans[this.spanIndex(start)].start;
|
||||
if (coveredStart === start) return start;
|
||||
start = coveredStart;
|
||||
}
|
||||
}
|
||||
|
||||
private selectAfter(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
const startSeq = Math.max(
|
||||
input.bounds.minSeq,
|
||||
(input.cursorSeq ?? input.bounds.minSeq - 1) + 1,
|
||||
);
|
||||
let low = 0;
|
||||
let high = this.sourceRanges.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (this.sourceRanges[middle].endSeq < startSeq) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const indexes = new Set<number>();
|
||||
const limit = Math.max(0, Math.floor(input.limit ?? 0));
|
||||
for (let i = low; i < this.sourceRanges.length; i++) {
|
||||
const range = this.sourceRanges[i];
|
||||
if (range.startSeq > input.bounds.maxSeq) break;
|
||||
indexes.add(range.rowIndex);
|
||||
if (limit > 0 && indexes.size === limit) break;
|
||||
}
|
||||
const entries = [...indexes].sort((a, b) => a - b).map((index) => this.rows[index]);
|
||||
const selected = selectProjectedEntriesAfter({
|
||||
entries,
|
||||
startSeq,
|
||||
maxSeq: input.bounds.maxSeq,
|
||||
limit,
|
||||
});
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.endSeq === null ? null : startSeq,
|
||||
endSeq: selected.endSeq,
|
||||
hasOlder: startSeq > input.bounds.minSeq,
|
||||
hasNewer: selected.endSeq !== null && selected.endSeq < input.bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
private selectBefore(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
const endSeq = Math.min(input.bounds.maxSeq, (input.cursorSeq ?? input.bounds.maxSeq + 1) - 1);
|
||||
if (endSeq >= input.bounds.minSeq) {
|
||||
let low = 0;
|
||||
let high = this.rows.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (this.rows[middle].seqStart <= endSeq) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const limit = Math.max(0, Math.floor(input.limit ?? 0));
|
||||
const requestedStart = limit === 0 ? 0 : Math.max(0, low - limit);
|
||||
const start = input.wholeTurns ? this.wholeTurnWindowStart(requestedStart) : requestedStart;
|
||||
// Choose the window before extending the right edge, so a prompt jump
|
||||
// cannot lose its target when the next turn is arbitrarily large.
|
||||
if (input.wholeTurns) {
|
||||
while (low < this.rows.length && continuesProjectedTurn(this.rows[low - 1], this.rows[low]))
|
||||
low++;
|
||||
}
|
||||
let selectedEnd = endSeq;
|
||||
if (input.wholeTurns)
|
||||
selectedEnd = low === this.rows.length ? input.bounds.maxSeq : this.rows[low].seqStart - 1;
|
||||
return {
|
||||
entries: this.rows.slice(start, low),
|
||||
startSeq: this.rows[start]?.seqStart ?? null,
|
||||
endSeq: selectedEnd,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: selectedEnd < input.bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true };
|
||||
}
|
||||
|
||||
getLastItem(seq: number): AgentTimelineItem | null {
|
||||
const last = this.sourceRanges.at(-1);
|
||||
return last?.endSeq === seq ? this.rows[last.rowIndex].item : null;
|
||||
}
|
||||
|
||||
getLastAssistantMessage(): string | null {
|
||||
const row = this.lastAssistantRowIndex === null ? null : this.rows[this.lastAssistantRowIndex];
|
||||
return row?.item.type === "assistant_message" ? row.item.text : null;
|
||||
}
|
||||
|
||||
enrichSubmittedUserMessage(
|
||||
clientMessageId: string,
|
||||
providerMessageId: string,
|
||||
@@ -444,21 +643,6 @@ export function selectTimelineWindowByProjectedLimit(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function getTimelineBounds(
|
||||
rows: readonly AgentTimelineRow[],
|
||||
): { minSeq: number; maxSeq: number } | null {
|
||||
const first = rows[0];
|
||||
const last = rows[rows.length - 1];
|
||||
if (!first || !last) {
|
||||
return null;
|
||||
}
|
||||
const entries = makeCanonicalEntries(rows);
|
||||
return {
|
||||
minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity),
|
||||
maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0),
|
||||
};
|
||||
}
|
||||
|
||||
function firstSourceSeqInRange(
|
||||
entry: TimelineProjectionEntry,
|
||||
startSeq: number,
|
||||
@@ -515,140 +699,11 @@ function selectProjectedEntriesAfter(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectedEntriesBefore(input: {
|
||||
entries: readonly TimelineProjectionEntry[];
|
||||
endSeq: number;
|
||||
limit: number;
|
||||
}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } {
|
||||
// Older history follows projected display order. Lifecycle updates can move an
|
||||
// entry's seqEnd without moving its display anchor, so seqStart keeps the full
|
||||
// projected item on exactly one backward page.
|
||||
const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq);
|
||||
const selected =
|
||||
input.limit === 0 || input.limit >= eligible.length
|
||||
? eligible
|
||||
: eligible.slice(eligible.length - input.limit);
|
||||
|
||||
return {
|
||||
entries: selected,
|
||||
startSeq: selected[0]?.seqStart ?? null,
|
||||
hasOlder: selected.length < eligible.length,
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectedEntriesTail(
|
||||
projectedAll: TimelineProjectionEntry[],
|
||||
limit: number,
|
||||
bounds: { minSeq: number; maxSeq: number },
|
||||
): ProjectedTimelinePageSelection {
|
||||
let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit);
|
||||
// Include earlier display anchors with updates in the selected source window,
|
||||
// so a tail certifies contiguous coverage even with interleaved tool updates.
|
||||
for (let i = start - 1; i >= 0; i--) {
|
||||
if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i;
|
||||
}
|
||||
const selected = projectedAll.slice(start);
|
||||
return {
|
||||
entries: selected,
|
||||
startSeq: selected[0]?.seqStart ?? null,
|
||||
endSeq: bounds.maxSeq,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectProjectedTimelinePage(input: {
|
||||
rows: readonly AgentTimelineRow[];
|
||||
bounds?: { minSeq: number; maxSeq: number };
|
||||
direction: TimelineLimitDirection;
|
||||
cursorSeq?: number;
|
||||
limit?: number;
|
||||
}): ProjectedTimelinePageSelection {
|
||||
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
|
||||
const bounds = input.bounds ?? getTimelineBounds(input.rows);
|
||||
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
|
||||
if (!bounds) {
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (projectedAll.length === 0) {
|
||||
if (input.direction === "after") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: cursorSeq >= bounds.minSeq,
|
||||
hasNewer: cursorSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
if (input.direction === "before") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: cursorSeq > bounds.minSeq,
|
||||
hasNewer: cursorSeq <= bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.direction === "tail") {
|
||||
return selectProjectedEntriesTail(projectedAll, limit, bounds);
|
||||
}
|
||||
|
||||
if (input.direction === "after") {
|
||||
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
|
||||
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
|
||||
const selected = selectProjectedEntriesAfter({
|
||||
entries: projectedAll,
|
||||
startSeq,
|
||||
maxSeq: bounds.maxSeq,
|
||||
limit,
|
||||
});
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.endSeq === null ? null : startSeq,
|
||||
endSeq: selected.endSeq,
|
||||
hasOlder: startSeq > bounds.minSeq,
|
||||
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
|
||||
const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
|
||||
if (endSeq < bounds.minSeq) {
|
||||
return {
|
||||
entries: [],
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
hasOlder: false,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit });
|
||||
return {
|
||||
entries: selected.entries,
|
||||
startSeq: selected.startSeq,
|
||||
endSeq,
|
||||
hasOlder: selected.hasOlder,
|
||||
hasNewer: endSeq < bounds.maxSeq,
|
||||
};
|
||||
function continuesProjectedTurn(
|
||||
previous: TimelineProjectionEntry,
|
||||
next: TimelineProjectionEntry,
|
||||
): boolean {
|
||||
return continuesTimelineTurn(previous.turnId, next.turnId, isTimelineTurnTrigger(next.item));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { registerPluginTools } from "./plugin-tools.js";
|
||||
import type { FinishSubscriptions, FinishRegistration } from "../finish-subscriptions/index.js";
|
||||
import type { AgentMessageDelivery } from "../agent-messages/index.js";
|
||||
import {
|
||||
@@ -704,6 +705,7 @@ function createToolCatalog(
|
||||
}
|
||||
const localHostOperations = new Map<string, HostOperation>();
|
||||
const tools = new Map<string, PaseoToolDefinition>();
|
||||
const builtinNames = new Set<string>();
|
||||
const hostTools = new Map([
|
||||
["create_agent", "createAgent"],
|
||||
["create_workspace", "createWorkspace"],
|
||||
@@ -738,6 +740,7 @@ function createToolCatalog(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool handlers are schema-validated at registration boundaries.
|
||||
handler: (input: any, context: PaseoToolExecutionContext) => Promise<PaseoToolResult>,
|
||||
) => {
|
||||
builtinNames.add(name);
|
||||
if (!isPaseoToolEnabled(options.paseoToolPolicy, name)) {
|
||||
return;
|
||||
}
|
||||
@@ -3272,6 +3275,14 @@ function createToolCatalog(
|
||||
},
|
||||
);
|
||||
|
||||
registerPluginTools({
|
||||
agentManager,
|
||||
callerAgentId,
|
||||
scope,
|
||||
tools,
|
||||
builtinNames,
|
||||
policy: options.paseoToolPolicy,
|
||||
});
|
||||
return toCatalog();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config";
|
||||
import type { AgentManager } from "../agent-manager.js";
|
||||
import { isPaseoToolEnabled } from "../paseo-tool-policy.js";
|
||||
import type { PaseoToolDefinition } from "./types.js";
|
||||
import { addModelVisibleStructuredContent } from "./paseo-tool-serialization.js";
|
||||
|
||||
export function registerPluginTools(options: {
|
||||
agentManager: AgentManager;
|
||||
callerAgentId?: string;
|
||||
scope: "agent" | "top-level";
|
||||
tools: Map<string, PaseoToolDefinition>;
|
||||
builtinNames: Set<string>;
|
||||
policy?: ProviderPaseoToolsPolicy;
|
||||
}): void {
|
||||
const { agentManager, callerAgentId, scope, tools, builtinNames, policy } = options;
|
||||
if (scope !== "agent" || !callerAgentId) return;
|
||||
for (const tool of agentManager.getPluginTools(callerAgentId)) {
|
||||
if (builtinNames.has(tool.name)) {
|
||||
throw new Error(
|
||||
`Agent tool ${tool.name} from plugin ${tool.pluginId} collides with Paseo tool ${tool.name}`,
|
||||
);
|
||||
}
|
||||
if (!isPaseoToolEnabled(policy, tool.name)) continue;
|
||||
tools.set(tool.name, {
|
||||
name: tool.name,
|
||||
title: tool.title,
|
||||
description: tool.description,
|
||||
inputSchema: z.fromJSONSchema(tool.inputSchema),
|
||||
async handler(input, { signal }) {
|
||||
const result = await agentManager.executePluginTool(tool, callerAgentId, input, signal);
|
||||
return addModelVisibleStructuredContent({
|
||||
content: result.text === undefined ? [] : [{ type: "text", text: result.text }],
|
||||
structuredContent: result.structuredContent,
|
||||
isError: result.isError,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import { writeFileAtomic, writeJsonFileAtomic } from "./atomic-file.js";
|
||||
|
||||
let directory: string;
|
||||
beforeEach(async () => {
|
||||
directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-atomic-file-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each(["EPERM", "EACCES", "EBUSY"])(
|
||||
"commits a completed receipt after transient Windows %s replace failures",
|
||||
async (code) => {
|
||||
const file = path.join(directory, "receipt.json");
|
||||
await writeJsonFileAtomic(file, { state: "pending" });
|
||||
let failures = 3;
|
||||
let elapsed = 0;
|
||||
await writeJsonFileAtomic(
|
||||
file,
|
||||
{ state: "completed" },
|
||||
{
|
||||
platform: "win32",
|
||||
now: () => elapsed,
|
||||
sleep: async (milliseconds) => {
|
||||
elapsed += milliseconds;
|
||||
},
|
||||
rename: async (source, destination) => {
|
||||
if (failures-- > 0) {
|
||||
expect(JSON.parse(await fs.readFile(destination, "utf8"))).toEqual({
|
||||
state: "pending",
|
||||
});
|
||||
expect(JSON.parse(await fs.readFile(source, "utf8"))).toEqual({ state: "completed" });
|
||||
throw Object.assign(new Error("destination held open"), { code });
|
||||
}
|
||||
await fs.rename(source, destination);
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "completed" });
|
||||
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
|
||||
expect(elapsed).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["EPERM", "EACCES", "EBUSY"])(
|
||||
"preserves the previous file and removes the temp file when Windows %s persists",
|
||||
async (code) => {
|
||||
const file = path.join(directory, "receipt.json");
|
||||
await writeJsonFileAtomic(file, { state: "pending" });
|
||||
const error = Object.assign(new Error("destination held open"), { code });
|
||||
const waits: number[] = [];
|
||||
let elapsed = 0;
|
||||
const attempts: number[] = [];
|
||||
await expect(
|
||||
writeJsonFileAtomic(
|
||||
file,
|
||||
{ state: "completed" },
|
||||
{
|
||||
platform: "win32",
|
||||
now: () => elapsed,
|
||||
sleep: async (milliseconds) => {
|
||||
waits.push(milliseconds);
|
||||
elapsed += milliseconds;
|
||||
},
|
||||
rename: async () => {
|
||||
attempts.push(elapsed);
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toBe(error);
|
||||
expect(elapsed).toBe(1000);
|
||||
expect(waits.slice(0, 5)).toEqual([10, 20, 40, 80, 100]);
|
||||
expect(Math.max(...waits)).toBe(100);
|
||||
expect(attempts.every((time) => time < 1000)).toBe(true);
|
||||
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
|
||||
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["linux", "EPERM"],
|
||||
["darwin", "EACCES"],
|
||||
["linux", "EBUSY"],
|
||||
["win32", "ENOENT"],
|
||||
["win32", "ENOSPC"],
|
||||
["win32", "EXDEV"],
|
||||
] as const)("does not retry %s %s failures", async (platform, code) => {
|
||||
const file = path.join(directory, "receipt.json");
|
||||
await writeJsonFileAtomic(file, { state: "pending" });
|
||||
const error = Object.assign(new Error("rename failed"), { code });
|
||||
let attempts = 0;
|
||||
const waits: number[] = [];
|
||||
await expect(
|
||||
writeFileAtomic(file, "replacement", {
|
||||
platform,
|
||||
now: () => 0,
|
||||
sleep: async (milliseconds) => {
|
||||
waits.push(milliseconds);
|
||||
},
|
||||
rename: async () => {
|
||||
attempts++;
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
).rejects.toBe(error);
|
||||
expect(attempts).toBe(1);
|
||||
expect(waits).toEqual([]);
|
||||
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
|
||||
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
|
||||
});
|
||||
|
||||
it("does not retry after a rename consumes the Windows retry budget", async () => {
|
||||
const file = path.join(directory, "receipt.json");
|
||||
const error = Object.assign(new Error("slow rename failed"), { code: "EPERM" });
|
||||
let elapsed = 0;
|
||||
const waits: number[] = [];
|
||||
await expect(
|
||||
writeFileAtomic(file, "replacement", {
|
||||
platform: "win32",
|
||||
now: () => elapsed,
|
||||
sleep: async (milliseconds) => {
|
||||
waits.push(milliseconds);
|
||||
},
|
||||
rename: async () => {
|
||||
elapsed += 1000;
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
).rejects.toBe(error);
|
||||
expect(waits).toEqual([]);
|
||||
expect(await fs.readdir(directory)).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates parent directories and replaces binary and JSON files through the default runtime", async () => {
|
||||
const file = path.join(directory, "nested", "file");
|
||||
const bytes = Buffer.from([0, 255, 128, 1]);
|
||||
await writeFileAtomic(file, bytes);
|
||||
expect(await fs.readFile(file)).toEqual(bytes);
|
||||
await writeJsonFileAtomic(file, { state: "completed" });
|
||||
expect(await fs.readFile(file, "utf8")).toBe('{\n "state": "completed"\n}');
|
||||
expect(await fs.readdir(path.dirname(file))).toEqual(["file"]);
|
||||
});
|
||||
@@ -1,10 +1,26 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
interface AtomicFileRuntime {
|
||||
platform: NodeJS.Platform;
|
||||
rename: (source: string, destination: string) => Promise<void>;
|
||||
now: () => number;
|
||||
sleep: (milliseconds: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const runtime: AtomicFileRuntime = {
|
||||
platform: process.platform,
|
||||
rename: fs.rename,
|
||||
now: () => performance.now(),
|
||||
sleep,
|
||||
};
|
||||
|
||||
export async function writeFileAtomic(
|
||||
filePath: string,
|
||||
data: string | NodeJS.ArrayBufferView,
|
||||
operations: AtomicFileRuntime = runtime,
|
||||
): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = path.join(
|
||||
@@ -13,13 +29,48 @@ export async function writeFileAtomic(
|
||||
);
|
||||
try {
|
||||
await fs.writeFile(tempPath, data, "utf8");
|
||||
await fs.rename(tempPath, filePath);
|
||||
await replaceFile(tempPath, filePath, operations);
|
||||
} catch (error) {
|
||||
await fs.rm(tempPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJsonFileAtomic(filePath: string, value: unknown): Promise<void> {
|
||||
await writeFileAtomic(filePath, JSON.stringify(value, null, 2));
|
||||
async function replaceFile(
|
||||
source: string,
|
||||
destination: string,
|
||||
operations: AtomicFileRuntime,
|
||||
): Promise<void> {
|
||||
// Windows readers/scanners can briefly deny replacement. Yield with backoff,
|
||||
// but keep interactive persistence failures bounded to a one-second retry budget.
|
||||
const deadline = operations.now() + 1000;
|
||||
let delay = 10;
|
||||
for (;;) {
|
||||
try {
|
||||
await operations.rename(source, destination);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (
|
||||
operations.platform !== "win32" ||
|
||||
!(error instanceof Error) ||
|
||||
!("code" in error) ||
|
||||
!["EPERM", "EACCES", "EBUSY"].includes(String(error.code))
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const remaining = deadline - operations.now();
|
||||
if (remaining <= 0) throw error;
|
||||
await operations.sleep(Math.min(delay, remaining));
|
||||
if (operations.now() >= deadline) throw error;
|
||||
delay = Math.min(delay * 2, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJsonFileAtomic(
|
||||
filePath: string,
|
||||
value: unknown,
|
||||
operations: AtomicFileRuntime = runtime,
|
||||
): Promise<void> {
|
||||
await writeFileAtomic(filePath, JSON.stringify(value, null, 2), operations);
|
||||
}
|
||||
|
||||
@@ -182,6 +182,9 @@ const INBOUND_PERMISSION = {
|
||||
set_agent_model_request: "workspace.write",
|
||||
set_agent_thinking_request: "workspace.write",
|
||||
set_daemon_config_request: "daemon.manage",
|
||||
"speech.local_models.list.request": "daemon.read",
|
||||
"speech.local_models.download.request": "daemon.manage",
|
||||
"speech.local_models.delete.request": "daemon.manage",
|
||||
set_voice_mode: "workspace.write",
|
||||
shutdown_server_request: "daemon.manage",
|
||||
start_workspace_script_request: "workspace.write",
|
||||
@@ -417,6 +420,9 @@ const OUTBOUND_PERMISSION = {
|
||||
set_agent_model_response: "workspace.write",
|
||||
set_agent_thinking_response: "workspace.write",
|
||||
set_daemon_config_response: "daemon.manage",
|
||||
"speech.local_models.list.response": "daemon.read",
|
||||
"speech.local_models.download.response": "daemon.manage",
|
||||
"speech.local_models.delete.response": "daemon.manage",
|
||||
set_voice_mode_response: "workspace.write",
|
||||
start_workspace_script_response: "workspace.write",
|
||||
stash_list_response: "workspace.read",
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test, onTestFinished } from "vitest";
|
||||
import { MutableDaemonConfigSchema } from "@getpaseo/protocol/messages";
|
||||
import { DaemonConfigStore } from "./daemon-config-store.js";
|
||||
import { loadPersistedConfig } from "./persisted-config.js";
|
||||
import {
|
||||
createIdleRuntimeCleanup,
|
||||
type RuntimeCleanupCandidate,
|
||||
} from "./agent/idle-runtime-cleanup/index.js";
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
|
||||
import { fanOutReconciledWorkspaceUpdates } from "./bootstrap.js";
|
||||
import { fanOutReconciledWorkspaceUpdates, connectIdleRuntimeCleanupConfig } from "./bootstrap.js";
|
||||
|
||||
test("reconciliation emits workspace updates when observer sync fails", async () => {
|
||||
const emittedWorkspaceIds: string[][] = [];
|
||||
@@ -54,3 +65,132 @@ test("reconciliation isolates workspace update failures between sessions", async
|
||||
expect(emittedWorkspaceIds).toEqual([["ws-reclassified"]]);
|
||||
expect(warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Exercise bootstrap's actual live-config binding with the real collector and store.
|
||||
|
||||
function liveCleanupHost() {
|
||||
const home = mkdtempSync(path.join(tmpdir(), "paseo-live-cleanup-"));
|
||||
writeFileSync(path.join(home, "config.json"), JSON.stringify({ version: 1 }));
|
||||
const store = new DaemonConfigStore(
|
||||
home,
|
||||
MutableDaemonConfigSchema.parse({
|
||||
relay: { enabled: true },
|
||||
mcp: { injectIntoAgents: false },
|
||||
idleRuntimeCleanup: true,
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
reloadSource: {
|
||||
resolve: (persisted) => ({
|
||||
mutable: MutableDaemonConfigSchema.parse({
|
||||
relay: { enabled: true },
|
||||
mcp: { injectIntoAgents: false },
|
||||
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
|
||||
}),
|
||||
overrideControlledPaths: [],
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
let minutes = 61;
|
||||
let closed = false;
|
||||
let protectedCaller = false;
|
||||
const runtime: RuntimeCleanupCandidate = {
|
||||
identity: {},
|
||||
agentId: "agent",
|
||||
provider: "test",
|
||||
sessionId: "session",
|
||||
parentAgentId: undefined,
|
||||
lastActivityAt: 0,
|
||||
isEligible: () => !closed,
|
||||
closeIfEligible: async (eligible) => {
|
||||
if (!eligible()) return false;
|
||||
closed = true;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled: true,
|
||||
now: () => minutes * 60_000,
|
||||
runtimes: () => [runtime],
|
||||
isProtected: () => protectedCaller,
|
||||
logger: createTestLogger(),
|
||||
schedule: () => () => {},
|
||||
});
|
||||
const unsubscribe = connectIdleRuntimeCleanupConfig(store, cleanup);
|
||||
onTestFinished(async () => {
|
||||
unsubscribe();
|
||||
await cleanup.close();
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
return {
|
||||
home,
|
||||
store,
|
||||
cleanup,
|
||||
unsubscribe,
|
||||
protect: (value: boolean) => {
|
||||
protectedCaller = value;
|
||||
},
|
||||
sweep: (at = minutes) => {
|
||||
minutes = at;
|
||||
return cleanup.sweep();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("bootstrap's host toggle prevents collection live, persists, and leaves other hosts enabled", async () => {
|
||||
const host = liveCleanupHost();
|
||||
const other = liveCleanupHost();
|
||||
await host.store.patch({ idleRuntimeCleanup: false });
|
||||
expect(loadPersistedConfig(host.home).agents?.idleRuntimeCleanup).toBe(false);
|
||||
expect((await host.sweep()).collected).toEqual([]);
|
||||
expect((await other.sweep()).collected).toHaveLength(1);
|
||||
await host.store.patch({ idleRuntimeCleanup: true });
|
||||
host.protect(true);
|
||||
expect((await host.sweep()).collected).toEqual([]);
|
||||
host.protect(false);
|
||||
expect((await host.sweep(62)).collected).toEqual([]);
|
||||
expect((await host.sweep(122)).collected).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("bootstrap's config-file reload binding disables and enables eligible collection live", async () => {
|
||||
const host = liveCleanupHost();
|
||||
for (const enabled of [false, true]) {
|
||||
writeFileSync(
|
||||
path.join(host.home, "config.json"),
|
||||
JSON.stringify({ version: 1, agents: { idleRuntimeCleanup: enabled } }),
|
||||
);
|
||||
expect(await host.store.reload()).toEqual({
|
||||
appliedPaths: ["agents.idleRuntimeCleanup"],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
});
|
||||
expect((await host.sweep()).collected).toHaveLength(enabled ? 1 : 0);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"failed config apply restores prior idle cleanup behavior (previous: %s)",
|
||||
async (previous) => {
|
||||
const host = liveCleanupHost();
|
||||
await host.store.patch({ idleRuntimeCleanup: previous });
|
||||
const persistedBefore = loadPersistedConfig(host.home);
|
||||
const refuse = host.store.onApply(() => {
|
||||
throw new Error("Another live owner refused");
|
||||
});
|
||||
await expect(host.store.patch({ idleRuntimeCleanup: !previous })).rejects.toThrow(
|
||||
"Another live owner refused",
|
||||
);
|
||||
expect(host.store.get().idleRuntimeCleanup).toBe(previous);
|
||||
expect(loadPersistedConfig(host.home)).toEqual(persistedBefore);
|
||||
expect((await host.sweep()).collected).toHaveLength(previous ? 1 : 0);
|
||||
refuse();
|
||||
},
|
||||
);
|
||||
|
||||
test("disposing bootstrap's binding stops configuration delivery", async () => {
|
||||
const host = liveCleanupHost();
|
||||
host.unsubscribe();
|
||||
await host.store.patch({ idleRuntimeCleanup: false });
|
||||
expect((await host.sweep()).collected).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createIdleRuntimeCleanup } from "./agent/idle-runtime-cleanup/index.js";
|
||||
import {
|
||||
createFinishSubscriptions,
|
||||
createFinishObservation,
|
||||
@@ -137,6 +138,7 @@ import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/confi
|
||||
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
|
||||
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
||||
import { createSpeechService } from "./speech/speech-runtime.js";
|
||||
import { createSpeechSettingsOwner } from "./speech/speech-settings.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
||||
@@ -159,7 +161,11 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import { ScheduleService } from "./schedule/service.js";
|
||||
import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js";
|
||||
import { createOrchestrationSkills } from "./orchestration-skills/index.js";
|
||||
import { resolveConfigFromPersisted, type CliConfigOverrides } from "./config.js";
|
||||
import {
|
||||
resolveReloadableConfig,
|
||||
type ResolvedDaemonSettings,
|
||||
type CliConfigOverrides,
|
||||
} from "./config.js";
|
||||
import { resolvePaseoToolPolicy } from "./agent/paseo-tool-policy.js";
|
||||
import { BrowserToolsBroker } from "./browser-tools/broker.js";
|
||||
import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js";
|
||||
@@ -194,7 +200,7 @@ import type {
|
||||
AgentProviderRuntimeSettingsMap,
|
||||
ProviderOverride,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
|
||||
import { type PersistedConfig } from "./persisted-config.js";
|
||||
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
|
||||
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
|
||||
import { ScriptHealthMonitor } from "./script-health-monitor.js";
|
||||
@@ -378,6 +384,8 @@ export interface PaseoSpeechSttLanguages {
|
||||
|
||||
export interface PaseoSpeechConfig {
|
||||
providers: RequestedSpeechProviders;
|
||||
// Where local models live, whether or not a feature currently uses the local provider.
|
||||
localModelsDir: string;
|
||||
sttLanguages?: PaseoSpeechSttLanguages;
|
||||
local?: PaseoLocalSpeechConfig;
|
||||
}
|
||||
@@ -453,6 +461,7 @@ export interface PaseoDaemonConfig {
|
||||
dictationFinalTimeoutMs?: number;
|
||||
downloadTokenTtlMs?: number;
|
||||
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
|
||||
idleRuntimeCleanup?: boolean;
|
||||
providerCatalogRefreshTimeoutMs?: number;
|
||||
metadataGeneration?: {
|
||||
providers?: Array<{
|
||||
@@ -546,14 +555,18 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri
|
||||
return config.trustedProxies ?? ["loopback"];
|
||||
}
|
||||
|
||||
function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfig["relay"] {
|
||||
function createInitialRelayConfig(config: ResolvedDaemonSettings): MutableDaemonConfig["relay"] {
|
||||
return {
|
||||
enabled: config.relayEnabled ?? true,
|
||||
requireProtectedSecurity: config.relayRequireProtectedSecurity ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig {
|
||||
function resolveReloadEnv(config: PaseoDaemonConfig): NodeJS.ProcessEnv {
|
||||
return config.configReload?.env ?? process.env;
|
||||
}
|
||||
|
||||
function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): MutableDaemonConfig {
|
||||
const providers = config.providerOverrides ?? {};
|
||||
|
||||
const initialConfig: MutableDaemonConfig = {
|
||||
@@ -577,6 +590,7 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
|
||||
},
|
||||
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
|
||||
enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false,
|
||||
idleRuntimeCleanup: config.idleRuntimeCleanup !== false,
|
||||
appendSystemPrompt: config.appendSystemPrompt ?? "",
|
||||
pluginsEnabled: config.pluginsEnabled ?? false,
|
||||
plugins: config.plugins ?? {},
|
||||
@@ -612,19 +626,30 @@ export async function createPaseoDaemon(
|
||||
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`;
|
||||
const daemonVersion = config.daemonVersion ?? resolveDaemonVersion(import.meta.url);
|
||||
const initialMutableConfig = createInitialMutableDaemonConfig(config);
|
||||
const speechService = createSpeechService({
|
||||
logger,
|
||||
openaiConfig: config.openai,
|
||||
speechConfig: config.speech,
|
||||
});
|
||||
logger.info({ elapsed: elapsed() }, "Speech service created");
|
||||
const daemonConfigStore = new DaemonConfigStore(config.paseoHome, initialMutableConfig, logger, {
|
||||
relayEnabledMutable: config.relayEnabledMutable ?? true,
|
||||
startupPersisted: config.configReload?.startupPersisted,
|
||||
speech: createSpeechSettingsOwner({
|
||||
paseoHome: config.paseoHome,
|
||||
env: resolveReloadEnv(config),
|
||||
speechService,
|
||||
}),
|
||||
reloadSource: {
|
||||
resolve: (persisted) => {
|
||||
const reloaded = resolveConfigFromPersisted(config.paseoHome, persisted, {
|
||||
env: config.configReload?.env ?? process.env,
|
||||
const reloaded = resolveReloadableConfig(persisted, {
|
||||
env: resolveReloadEnv(config),
|
||||
cli: config.configReload?.cli,
|
||||
relayEnabledFallback: config.configReload?.relayEnabledFallback,
|
||||
});
|
||||
return {
|
||||
mutable: createInitialMutableDaemonConfig(reloaded),
|
||||
overrideControlledPaths: reloaded.configReload?.overrideControlledPaths ?? [],
|
||||
overrideControlledPaths: reloaded.overrideControlledPaths,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -974,6 +999,7 @@ export async function createPaseoDaemon(
|
||||
const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState();
|
||||
const agentManager = new AgentManager({
|
||||
pluginLifecycle: pluginRuntime,
|
||||
pluginAgentTools: pluginRuntime,
|
||||
clients: initialAgentManagerState.clients,
|
||||
providerDefinitions: initialAgentManagerState.providerDefinitions,
|
||||
registry: agentStorage,
|
||||
@@ -994,6 +1020,10 @@ export async function createPaseoDaemon(
|
||||
const unsubscribePluginProviders =
|
||||
pluginRuntime.subscribeProviderRegistrations(syncPluginProviders);
|
||||
|
||||
const unsubscribePluginSessionChanges = pluginRuntime.subscribeSessionOpenChanges(() => {
|
||||
agentManager.invalidatePluginSessions();
|
||||
});
|
||||
|
||||
const detachAgentStoragePersistence = attachAgentStoragePersistence(
|
||||
logger,
|
||||
agentManager,
|
||||
@@ -1398,6 +1428,7 @@ export async function createPaseoDaemon(
|
||||
archiveWorkspace: archiveScheduleWorkspaceExternal,
|
||||
});
|
||||
await scheduleService.start();
|
||||
|
||||
agentManager.setAgentArchivedCallback(async (agentId) => {
|
||||
try {
|
||||
await scheduleService.completeForAgent(agentId);
|
||||
@@ -1445,6 +1476,20 @@ export async function createPaseoDaemon(
|
||||
}),
|
||||
});
|
||||
|
||||
const idleRuntimeCleanup = createIdleRuntimeCleanup({
|
||||
enabled: daemonConfigStore.get().idleRuntimeCleanup !== false,
|
||||
runtimes: () => agentManager.getRuntimeCleanupCandidates(),
|
||||
isProtected: (agentId) =>
|
||||
finishSubscriptions.hasPendingForCaller(agentId) ||
|
||||
(wsServer?.agentMessages.hasPending(agentId) ?? false),
|
||||
logger,
|
||||
});
|
||||
|
||||
const unsubscribeIdleRuntimeCleanup = connectIdleRuntimeCleanupConfig(
|
||||
daemonConfigStore,
|
||||
idleRuntimeCleanup,
|
||||
);
|
||||
|
||||
const createAgentToolHostDependencies = (
|
||||
runtime: PaseoToolRuntimeContext,
|
||||
): PaseoToolHostDependencies => ({
|
||||
@@ -1500,7 +1545,7 @@ export async function createPaseoDaemon(
|
||||
spawnWorkspaceScript,
|
||||
assertAutomationAllowed: (workspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, workspaceId),
|
||||
globalServicePorts: loadPersistedConfig(config.paseoHome).worktrees?.servicePorts,
|
||||
getGlobalServicePorts: () => daemonConfigStore.readServicePorts(),
|
||||
}),
|
||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||
@@ -1659,13 +1704,6 @@ export async function createPaseoDaemon(
|
||||
logger.info({ route: agentMcpRoute, enabled: mcpEnabled }, "Agent MCP route mounted");
|
||||
}
|
||||
|
||||
const speechService = createSpeechService({
|
||||
logger,
|
||||
openaiConfig: config.openai,
|
||||
speechConfig: config.speech,
|
||||
});
|
||||
logger.info({ elapsed: elapsed() }, "Speech service created");
|
||||
|
||||
logger.info({ elapsed: elapsed() }, "Bootstrap complete, ready to start listening");
|
||||
|
||||
const start = async () => {
|
||||
@@ -1885,6 +1923,7 @@ export async function createPaseoDaemon(
|
||||
localCredential = null;
|
||||
await deleteLocalCredential(config.paseoHome);
|
||||
unsubscribePluginProviders();
|
||||
unsubscribePluginSessionChanges();
|
||||
await pluginRuntime.stopAllPlugins().catch(() => undefined);
|
||||
await serviceProxy.stopStandalone().catch(() => undefined);
|
||||
await agentProviderRuntime.shutdown().catch(() => undefined);
|
||||
@@ -1904,9 +1943,12 @@ export async function createPaseoDaemon(
|
||||
// that is still open. Plugins themselves are stopped once every session
|
||||
// they serve has been closed, further down.
|
||||
unsubscribePluginProviders();
|
||||
unsubscribePluginSessionChanges();
|
||||
await hubRelationships.stop();
|
||||
workspaceReconciliation.dispose();
|
||||
scriptHealthMonitor.stop();
|
||||
unsubscribeIdleRuntimeCleanup();
|
||||
await idleRuntimeCleanup.close();
|
||||
// Freeze both ingress and registration before taking the agent closure snapshot.
|
||||
wsServer?.prepareForShutdown();
|
||||
await finishSubscriptions.close();
|
||||
@@ -1997,3 +2039,11 @@ async function closeAllAgents(logger: Logger, agentManager: AgentManager): Promi
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function connectIdleRuntimeCleanupConfig(
|
||||
store: DaemonConfigStore,
|
||||
cleanup: Pick<ReturnType<typeof createIdleRuntimeCleanup>, "setEnabled">,
|
||||
): () => void {
|
||||
cleanup.setEnabled(store.get().idleRuntimeCleanup !== false);
|
||||
return store.onFieldChange("idleRuntimeCleanup", (value) => cleanup.setEnabled(value !== false));
|
||||
}
|
||||
|
||||
@@ -132,3 +132,22 @@ test.each(["base", "uncommitted"] as const)(
|
||||
expect(await read("next", "next-after")).toEqual({ diff: "next-after" });
|
||||
},
|
||||
);
|
||||
|
||||
test("superseding a diff cancels its highlighting while identical readers share work", async () => {
|
||||
const cache = new CheckoutDiffCache(() => 0);
|
||||
const release = Promise.withResolvers<{ diff: string }>();
|
||||
let activeSignal: AbortSignal | undefined;
|
||||
const load = (signal: AbortSignal) => {
|
||||
activeSignal = signal;
|
||||
return release.promise;
|
||||
};
|
||||
const first = cache.read({ cwd: "repo", compare: { mode: "base" }, load });
|
||||
await Promise.resolve();
|
||||
expect(activeSignal).toBeDefined();
|
||||
expect(activeSignal!.aborted).toBe(false);
|
||||
expect(cache.read({ cwd: "repo", compare: { mode: "base" }, load })).toBe(first);
|
||||
cache.invalidate("repo", "base");
|
||||
expect(activeSignal!.aborted).toBe(true);
|
||||
release.resolve({ diff: "old snapshot" });
|
||||
await first;
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { CheckoutDiffCompare, CheckoutDiffResult } from "../utils/checkout-
|
||||
interface ActiveRead {
|
||||
promise: Promise<CheckoutDiffResult>;
|
||||
invalidated: boolean;
|
||||
controller: AbortController;
|
||||
}
|
||||
|
||||
interface CachedDiff {
|
||||
@@ -17,7 +18,7 @@ interface CheckoutDiffReadInput {
|
||||
compare: CheckoutDiffCompare;
|
||||
force?: boolean;
|
||||
reason?: string;
|
||||
load: () => Promise<CheckoutDiffResult>;
|
||||
load: (signal: AbortSignal) => Promise<CheckoutDiffResult>;
|
||||
}
|
||||
|
||||
/** Completed payloads are bounded; active work survives invalidation and eviction. */
|
||||
@@ -51,6 +52,7 @@ export class CheckoutDiffCache {
|
||||
if (pending) {
|
||||
if (force) {
|
||||
pending.invalidated = true;
|
||||
pending.controller.abort();
|
||||
this.values.delete(key);
|
||||
}
|
||||
if (!pending.invalidated) return pending.promise;
|
||||
@@ -66,8 +68,9 @@ export class CheckoutDiffCache {
|
||||
// Defer load until ownership is installed, including synchronous invalidations.
|
||||
const read: ActiveRead = {
|
||||
invalidated: false,
|
||||
controller: new AbortController(),
|
||||
promise: Promise.resolve()
|
||||
.then(load)
|
||||
.then(() => load(read.controller.signal))
|
||||
.then((value) => {
|
||||
// Never repopulate the cache with a snapshot invalidated during its build.
|
||||
if (!read.invalidated)
|
||||
@@ -86,7 +89,10 @@ export class CheckoutDiffCache {
|
||||
if (cachedCwd !== cwd || (cachedMode !== mode && cachedMode !== "all")) continue;
|
||||
this.values.delete(key);
|
||||
const pending = this.active.get(key);
|
||||
if (pending) pending.invalidated = true;
|
||||
if (pending) {
|
||||
pending.invalidated = true;
|
||||
pending.controller.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { measureMainThreadWork } from "../test-utils/main-thread-work.js";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { loadConfig, resolveBundledWebUiDistDir, resolveConfigFromPersisted } from "./config.js";
|
||||
import {
|
||||
loadConfig,
|
||||
resolveBundledWebUiDistDir,
|
||||
resolveConfigFromPersisted,
|
||||
resolveReloadableConfig,
|
||||
} from "./config.js";
|
||||
import { loadPersistedConfig } from "./persisted-config.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
@@ -14,6 +20,26 @@ describe("server config", () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
test("resolves reloadable settings without resolving the startup password", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-auth-reload-"));
|
||||
roots.push(paseoHome);
|
||||
const env = { PASEO_PASSWORD: "synthetic-audit-password" };
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
const startup = resolveConfigFromPersisted(paseoHome, persisted, { env });
|
||||
const { result: reloaded, blockingMs } = await measureMainThreadWork(
|
||||
"config resolve main-thread work",
|
||||
() =>
|
||||
resolveReloadableConfig(
|
||||
{ ...persisted, daemon: { ...persisted.daemon, browserTools: { enabled: true } } },
|
||||
{ env },
|
||||
),
|
||||
);
|
||||
expect(reloaded).not.toHaveProperty("auth");
|
||||
expect(reloaded.browserToolsEnabled).toBe(true);
|
||||
expect(startup.auth?.password).toBeTypeOf("string");
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
});
|
||||
|
||||
test("records when the daemon is managed by Paseo Desktop", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-desktop-managed-"));
|
||||
roots.push(paseoHome);
|
||||
@@ -27,6 +53,17 @@ describe("server config", () => {
|
||||
expect(standaloneConfig.desktopManaged).toBe(false);
|
||||
});
|
||||
|
||||
test("idle runtime cleanup defaults on and supports a host-wide opt-out", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-idle-cleanup-"));
|
||||
roots.push(paseoHome);
|
||||
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true);
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
JSON.stringify({ agents: { idleRuntimeCleanup: false } }),
|
||||
);
|
||||
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false);
|
||||
});
|
||||
|
||||
test("loads the provider catalog refresh timeout", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-provider-timeout-"));
|
||||
roots.push(paseoHome);
|
||||
@@ -104,9 +141,13 @@ describe("server config", () => {
|
||||
name: "OpenAI speech providers",
|
||||
providers: { dictation: "openai", voiceStt: "openai", voiceTts: "openai" },
|
||||
expected: [
|
||||
"features.dictation.stt.apiKey",
|
||||
"features.dictation.stt.confidenceThreshold",
|
||||
"features.dictation.stt.model",
|
||||
"features.voiceMode.stt.apiKey",
|
||||
"features.voiceMode.stt.confidenceThreshold",
|
||||
"features.voiceMode.stt.model",
|
||||
"features.voiceMode.tts.apiKey",
|
||||
"features.voiceMode.tts.model",
|
||||
"features.voiceMode.tts.voice",
|
||||
],
|
||||
@@ -115,8 +156,9 @@ describe("server config", () => {
|
||||
name: "mixed local and OpenAI speech providers",
|
||||
providers: { dictation: "local", voiceStt: "openai", voiceTts: "local" },
|
||||
expected: [
|
||||
"features.dictation.stt.confidenceThreshold",
|
||||
"features.dictation.stt.model",
|
||||
"features.voiceMode.stt.apiKey",
|
||||
"features.voiceMode.stt.confidenceThreshold",
|
||||
"features.voiceMode.stt.model",
|
||||
"features.voiceMode.tts.model",
|
||||
],
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
|
||||
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
|
||||
import { hashDaemonPassword } from "./auth.js";
|
||||
import { resolveSpeechConfig } from "./speech/speech-config-resolver.js";
|
||||
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
||||
import { readSpeechSettings } from "./speech/speech-settings.js";
|
||||
import { mergeHostnames, parseHostnamesEnv, type HostnamesConfig } from "./hostnames.js";
|
||||
import { resolveGitProcessPolicy } from "../utils/git-process-scheduler.js";
|
||||
|
||||
@@ -568,31 +568,43 @@ interface ResolveConfigFromPersistedOptions {
|
||||
relayEnabledFallback?: boolean;
|
||||
}
|
||||
|
||||
// Startup values, including authentication, never enter runtime settings resolution.
|
||||
export type ResolvedDaemonSettings = Pick<
|
||||
PaseoDaemonConfig,
|
||||
| "corsAllowedOrigins"
|
||||
| "hostnames"
|
||||
| "trustedProxies"
|
||||
| "mcpEnabled"
|
||||
| "mcpInjectIntoAgents"
|
||||
| "browserToolsEnabled"
|
||||
| "git"
|
||||
| "autoArchiveAfterMerge"
|
||||
| "enableTerminalAgentHooks"
|
||||
| "appendSystemPrompt"
|
||||
| "terminalProfiles"
|
||||
| "agentProfiles"
|
||||
| "skillSelection"
|
||||
| "pluginsEnabled"
|
||||
| "plugins"
|
||||
| "relayEnabled"
|
||||
| "relayRequireProtectedSecurity"
|
||||
| "appBaseUrl"
|
||||
| "idleRuntimeCleanup"
|
||||
| "providerCatalogRefreshTimeoutMs"
|
||||
| "metadataGeneration"
|
||||
| "providerOverrides"
|
||||
>;
|
||||
|
||||
export function resolveConfigFromPersisted(
|
||||
paseoHome: string,
|
||||
persisted: PersistedConfig,
|
||||
options?: ResolveConfigFromPersistedOptions,
|
||||
): PaseoDaemonConfig {
|
||||
const resolvedOptions = options ?? {};
|
||||
const env = configurationEnvironment(resolvedOptions.env ?? process.env);
|
||||
const cli = resolvedOptions.cli;
|
||||
const env = configurationEnvironment(options?.env ?? process.env);
|
||||
const cli = options?.cli;
|
||||
const relayEnabledFallback =
|
||||
resolvedOptions.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined;
|
||||
|
||||
const listen = resolveListenAddress(env, cli, persisted);
|
||||
const {
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
browserToolsEnabled,
|
||||
autoArchiveAfterMerge,
|
||||
appendSystemPrompt,
|
||||
terminalProfiles,
|
||||
agentProfiles,
|
||||
hostnames,
|
||||
trustedProxies,
|
||||
appBaseUrl,
|
||||
} = resolveStaticLoadConfigSettings(env, cli, persisted);
|
||||
|
||||
options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined;
|
||||
const { overrideControlledPaths, ...settings } = resolveReloadableConfig(persisted, options);
|
||||
const relay = resolveRelayConfig({
|
||||
env,
|
||||
persisted,
|
||||
@@ -600,42 +612,14 @@ export function resolveConfigFromPersisted(
|
||||
cliRelayUseTls: cli?.relayUseTls,
|
||||
enabledFallback: relayEnabledFallback,
|
||||
});
|
||||
const serviceProxy = resolveServiceProxyConfig(env, persisted);
|
||||
const webUi = resolveWebUiConfig(paseoHome, env, cli, persisted);
|
||||
|
||||
const { openai, speech } = resolveSpeechConfig({
|
||||
paseoHome,
|
||||
env,
|
||||
persisted,
|
||||
});
|
||||
|
||||
const { openai, speech } = resolveSpeechConfig({ paseoHome, env, persisted });
|
||||
const voiceLlm = resolveVoiceLlmConfig(env, persisted);
|
||||
const providerOverrides = extractProviderOverrides(
|
||||
persisted.agents?.providers as Record<string, unknown> | undefined,
|
||||
);
|
||||
|
||||
const overrideControlledPaths = resolveOverrideControlledPaths(env, cli, speech.providers);
|
||||
|
||||
return {
|
||||
listen,
|
||||
...settings,
|
||||
listen: resolveListenAddress(env, cli, persisted),
|
||||
paseoHome,
|
||||
desktopManaged: env.PASEO_DESKTOP_MANAGED === "1",
|
||||
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
|
||||
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
|
||||
hostnames,
|
||||
trustedProxies,
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
browserToolsEnabled,
|
||||
git: resolveGitProcessConfig(env, persisted),
|
||||
autoArchiveAfterMerge,
|
||||
enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false,
|
||||
appendSystemPrompt,
|
||||
terminalProfiles,
|
||||
agentProfiles,
|
||||
skillSelection: persisted.agents?.skills?.selection,
|
||||
pluginsEnabled: persisted.pluginsEnabled ?? false,
|
||||
plugins: persisted.plugins,
|
||||
pluginRegistries: persisted.pluginRegistries,
|
||||
pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY,
|
||||
mcpDebug: env.MCP_DEBUG === "1",
|
||||
@@ -643,26 +627,20 @@ export function resolveConfigFromPersisted(
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
staticDir: "public",
|
||||
agentClients: {},
|
||||
relayEnabled: relay.enabled,
|
||||
relayEnabledMutable: relay.enabledMutable,
|
||||
relayRequireProtectedSecurity: relay.requireProtectedSecurity,
|
||||
relayEndpoint: relay.endpoint,
|
||||
relayPublicEndpoint: relay.publicEndpoint,
|
||||
relayUseTls: relay.useTls,
|
||||
relayPublicUseTls: relay.publicUseTls,
|
||||
serviceProxy,
|
||||
webUi,
|
||||
appBaseUrl,
|
||||
serviceProxy: resolveServiceProxyConfig(env, persisted),
|
||||
webUi: resolveWebUiConfig(paseoHome, env, cli, persisted),
|
||||
auth: resolveAuthConfig(env, persisted),
|
||||
openai,
|
||||
speech,
|
||||
voiceLlmProvider: voiceLlm.provider,
|
||||
voiceLlmProviderExplicit: voiceLlm.providerExplicit,
|
||||
voiceLlmModel: voiceLlm.model,
|
||||
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
|
||||
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
|
||||
metadataGeneration: persisted.agents?.metadataGeneration,
|
||||
providerOverrides,
|
||||
agentProviderSettings: extractAgentProviderSettings(settings.providerOverrides),
|
||||
log: resolveLogConfigFromEnv(env, persisted),
|
||||
configReload: {
|
||||
env: { ...env },
|
||||
@@ -674,6 +652,40 @@ export function resolveConfigFromPersisted(
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveReloadableConfig(
|
||||
persisted: PersistedConfig,
|
||||
options?: ResolveConfigFromPersistedOptions,
|
||||
): ResolvedDaemonSettings & { overrideControlledPaths: string[] } {
|
||||
const env = configurationEnvironment(options?.env ?? process.env);
|
||||
const cli = options?.cli;
|
||||
const relay = resolveRelayConfig({
|
||||
env,
|
||||
persisted,
|
||||
cliRelayEnabled: cli?.relayEnabled,
|
||||
cliRelayUseTls: cli?.relayUseTls,
|
||||
enabledFallback:
|
||||
options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined,
|
||||
});
|
||||
return {
|
||||
...resolveStaticLoadConfigSettings(env, cli, persisted),
|
||||
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
|
||||
git: resolveGitProcessConfig(env, persisted),
|
||||
enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false,
|
||||
skillSelection: persisted.agents?.skills?.selection,
|
||||
pluginsEnabled: persisted.pluginsEnabled ?? false,
|
||||
plugins: persisted.plugins,
|
||||
relayEnabled: relay.enabled,
|
||||
relayRequireProtectedSecurity: relay.requireProtectedSecurity,
|
||||
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
|
||||
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
|
||||
metadataGeneration: persisted.agents?.metadataGeneration,
|
||||
providerOverrides: extractProviderOverrides(
|
||||
persisted.agents?.providers as Record<string, unknown> | undefined,
|
||||
),
|
||||
overrideControlledPaths: resolveOverrideControlledPaths(env, cli, persisted),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadConfig(
|
||||
paseoHome: string,
|
||||
options?: Omit<ResolveConfigFromPersistedOptions, "relayEnabledFallback">,
|
||||
@@ -691,13 +703,13 @@ function parsePositiveGitOverride(value: string | undefined): boolean {
|
||||
function resolveOverrideControlledPaths(
|
||||
env: NodeJS.ProcessEnv,
|
||||
cli: CliConfigOverrides | undefined,
|
||||
speechProviders: RequestedSpeechProviders,
|
||||
persisted: PersistedConfig,
|
||||
): string[] {
|
||||
return Array.from(
|
||||
new Set([
|
||||
...resolveDaemonOverrideControlledPaths(env, cli),
|
||||
...resolveLogOverrideControlledPaths(env),
|
||||
...resolveSpeechOverrideControlledPaths(env, speechProviders),
|
||||
...resolveSpeechOverrideControlledPaths(env, persisted),
|
||||
]),
|
||||
).sort();
|
||||
}
|
||||
@@ -806,67 +818,12 @@ function resolveLogOverrideControlledPaths(env: NodeJS.ProcessEnv): string[] {
|
||||
return paths;
|
||||
}
|
||||
|
||||
function isEnabledSpeechProvider(
|
||||
provider: RequestedSpeechProviders[keyof RequestedSpeechProviders],
|
||||
expected: "local" | "openai",
|
||||
): boolean {
|
||||
return provider.enabled !== false && provider.provider === expected;
|
||||
}
|
||||
|
||||
function resolveSpeechOverrideControlledPaths(
|
||||
env: NodeJS.ProcessEnv,
|
||||
providers: RequestedSpeechProviders,
|
||||
persisted: PersistedConfig,
|
||||
): string[] {
|
||||
const paths: string[] = [];
|
||||
const add = (envName: string, ...configPaths: string[]) => {
|
||||
if (env[envName] !== undefined) paths.push(...configPaths);
|
||||
};
|
||||
|
||||
add("PASEO_DICTATION_ENABLED", "features.dictation.enabled");
|
||||
add("PASEO_DICTATION_STT_PROVIDER", "features.dictation.stt.provider");
|
||||
if (
|
||||
env.PASEO_DICTATION_LOCAL_STT_MODEL !== undefined &&
|
||||
isEnabledSpeechProvider(providers.dictationStt, "local")
|
||||
) {
|
||||
paths.push("features.dictation.stt.model");
|
||||
}
|
||||
add("PASEO_DICTATION_LANGUAGE", "features.dictation.stt.language");
|
||||
add("PASEO_VOICE_MODE_ENABLED", "features.voiceMode.enabled");
|
||||
add("PASEO_VOICE_LLM_PROVIDER", "features.voiceMode.llm.provider");
|
||||
add("PASEO_VOICE_STT_PROVIDER", "features.voiceMode.stt.provider");
|
||||
if (
|
||||
env.PASEO_VOICE_LOCAL_STT_MODEL !== undefined &&
|
||||
isEnabledSpeechProvider(providers.voiceStt, "local")
|
||||
) {
|
||||
paths.push("features.voiceMode.stt.model");
|
||||
}
|
||||
add("PASEO_VOICE_LANGUAGE", "features.voiceMode.stt.language");
|
||||
add("PASEO_VOICE_TURN_DETECTION_PROVIDER", "features.voiceMode.turnDetection.provider");
|
||||
add("PASEO_VOICE_TTS_PROVIDER", "features.voiceMode.tts.provider");
|
||||
if (
|
||||
env.PASEO_VOICE_LOCAL_TTS_MODEL !== undefined &&
|
||||
isEnabledSpeechProvider(providers.voiceTts, "local")
|
||||
) {
|
||||
paths.push("features.voiceMode.tts.model");
|
||||
}
|
||||
add("PASEO_VOICE_LOCAL_TTS_SPEAKER_ID", "features.voiceMode.tts.speakerId");
|
||||
add("PASEO_VOICE_LOCAL_TTS_SPEED", "features.voiceMode.tts.speed");
|
||||
add("PASEO_LOCAL_MODELS_DIR", "providers.local.modelsDir");
|
||||
const openAiDictationStt = isEnabledSpeechProvider(providers.dictationStt, "openai");
|
||||
const openAiVoiceStt = isEnabledSpeechProvider(providers.voiceStt, "openai");
|
||||
if (env.STT_CONFIDENCE_THRESHOLD !== undefined && (openAiDictationStt || openAiVoiceStt)) {
|
||||
paths.push("features.dictation.stt.confidenceThreshold");
|
||||
}
|
||||
if (env.STT_MODEL !== undefined) {
|
||||
if (openAiDictationStt) paths.push("features.dictation.stt.model");
|
||||
if (openAiVoiceStt) paths.push("features.voiceMode.stt.model");
|
||||
}
|
||||
if (isEnabledSpeechProvider(providers.voiceTts, "openai")) {
|
||||
add("TTS_MODEL", "features.voiceMode.tts.model");
|
||||
add("TTS_VOICE", "features.voiceMode.tts.voice");
|
||||
}
|
||||
if (env.PASEO_DICTATION_LANGUAGE !== undefined && env.PASEO_VOICE_LANGUAGE === undefined) {
|
||||
paths.push("features.voiceMode.stt.language");
|
||||
}
|
||||
const paths = Object.keys(readSpeechSettings(persisted, env).envOverrides);
|
||||
if (env.PASEO_VOICE_LLM_PROVIDER !== undefined) paths.push("features.voiceMode.llm.provider");
|
||||
if (env.PASEO_LOCAL_MODELS_DIR !== undefined) paths.push("providers.local.modelsDir");
|
||||
return paths;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import pino from "pino";
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
@@ -9,6 +10,47 @@ import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo
|
||||
import type { HostDirectory } from "@getpaseo/protocol/hosts";
|
||||
|
||||
const cleanup: (() => Promise<unknown>)[] = [];
|
||||
|
||||
// These asynchronous failures otherwise disappear into the fixture's silent logger.
|
||||
// Only completion errors and filesystem metadata belong in CI, never prompts or credentials.
|
||||
function completionLogger() {
|
||||
return pino(
|
||||
{
|
||||
level: "warn",
|
||||
serializers: {
|
||||
err: (error) => ({
|
||||
type: error.name,
|
||||
code: error.code,
|
||||
syscall: error.syscall,
|
||||
path: typeof error.path === "string" ? basename(error.path) : undefined,
|
||||
dest: typeof error.dest === "string" ? basename(error.dest) : undefined,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
write(line) {
|
||||
const record = JSON.parse(line);
|
||||
if (
|
||||
record.context !== "Failed to dispatch queued agent message" &&
|
||||
![
|
||||
"Agent message batch dispatch failed",
|
||||
"Retrying finish subscription",
|
||||
"Finish subscription failed",
|
||||
].includes(record.msg)
|
||||
)
|
||||
return;
|
||||
console.error("Completion diagnostic", {
|
||||
time: record.time,
|
||||
message: record.msg,
|
||||
agentId: record.agentId,
|
||||
messageIds: record.messageIds,
|
||||
subscription: record.subscription,
|
||||
error: record.err,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
afterEach(async () => {
|
||||
for (const close of cleanup.splice(0).toReversed()) await close();
|
||||
});
|
||||
@@ -1502,7 +1544,7 @@ test(
|
||||
);
|
||||
|
||||
test(
|
||||
"remote agent reports, local CLI sends and automatic completion share the recipient batch",
|
||||
"remote agent reports, local CLI sends and automatic completion reach the same recipient",
|
||||
{ timeout: 30000 },
|
||||
async () => {
|
||||
const parentPrompts: string[] = [];
|
||||
@@ -1512,6 +1554,7 @@ test(
|
||||
finishChild.resolve();
|
||||
});
|
||||
const a = await daemon({
|
||||
logger: completionLogger(),
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn: (prompt, config) => {
|
||||
if (config.title === "Batch parent")
|
||||
@@ -1590,7 +1633,10 @@ test(
|
||||
});
|
||||
expect(report.isError).not.toBe(true);
|
||||
expect(report.structuredContent).toMatchObject({ deliveryStatus: "queued", serverId: aId });
|
||||
expect(parentPrompts).toEqual([]);
|
||||
// A CLI process can take longer than the two-second quiet window to start.
|
||||
// Exercise each delivery across windows; delivery.test.ts owns batching with a clock.
|
||||
await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1);
|
||||
expect(parentPrompts[0]).toContain("REMOTE_REPORT <ready>");
|
||||
|
||||
const cli = await promisify(execFile)(
|
||||
process.execPath,
|
||||
@@ -1613,7 +1659,8 @@ test(
|
||||
},
|
||||
);
|
||||
expect(JSON.parse(cli.stdout)).toMatchObject({ status: "queued" });
|
||||
expect(parentPrompts).toEqual([]);
|
||||
await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2);
|
||||
expect(parentPrompts[1]).toContain("LOCAL_CLI_REPORT");
|
||||
const receiptStates = async () => {
|
||||
const directory = join(a.paseoHome, "agent-requests");
|
||||
const receipts = await Promise.all(
|
||||
@@ -1628,14 +1675,16 @@ test(
|
||||
.map((receipt) => receipt.state)
|
||||
.sort();
|
||||
};
|
||||
expect(await receiptStates()).toEqual(["pending", "pending"]);
|
||||
finishChild.resolve();
|
||||
await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1);
|
||||
expect(parentPrompts[0]).toContain("REMOTE_REPORT <ready>");
|
||||
expect(parentPrompts[0]).toContain("LOCAL_CLI_REPORT");
|
||||
expect(parentPrompts[0]).toContain('event="finished"');
|
||||
expect(parentPrompts[0]).toContain(`source-agent-id="${child.id}"`);
|
||||
await expect.poll(receiptStates, { timeout: 10000 }).toEqual(["completed", "completed"]);
|
||||
await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(3);
|
||||
expect(parentPrompts[2]).toContain('event="finished"');
|
||||
expect(parentPrompts[2]).toContain(`source-agent-id="${child.id}"`);
|
||||
await expect
|
||||
.poll(receiptStates, {
|
||||
timeout: 10000,
|
||||
message: "Parent accepted the batch; waiting for durable receipt commits",
|
||||
})
|
||||
.toEqual(["completed", "completed"]);
|
||||
const timeline = await origin.client.fetchAgentTimeline(parent.id);
|
||||
const messages = timeline.entries.flatMap((row) =>
|
||||
row.item.type === "tool_call" && row.item.agentMessage ? [row.item.agentMessage] : [],
|
||||
@@ -1822,6 +1871,7 @@ test(
|
||||
});
|
||||
const a = await daemon({
|
||||
mcpEnabled: true,
|
||||
logger: completionLogger(),
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn: (prompt) => {
|
||||
if (String(prompt).includes('kind="agent-notification"'))
|
||||
@@ -1883,8 +1933,14 @@ test(
|
||||
await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(2);
|
||||
expect(parentPrompts[1]).toContain('event="finished"');
|
||||
await expect
|
||||
.poll(async () =>
|
||||
JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")),
|
||||
// Provider start precedes dispatch acknowledgment and the durable removal commit.
|
||||
.poll(
|
||||
async () =>
|
||||
JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")),
|
||||
{
|
||||
timeout: 10000,
|
||||
message: "Parent accepted completion; waiting for durable intent removal",
|
||||
},
|
||||
)
|
||||
.toEqual([]);
|
||||
},
|
||||
@@ -1957,6 +2013,7 @@ test.each(["removed", "disabled"] as const)(
|
||||
cleanup.push(async () => finish.resolve());
|
||||
const a = await daemon({
|
||||
mcpEnabled: true,
|
||||
logger: completionLogger(),
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn: (prompt) => {
|
||||
if (String(prompt).includes('kind="agent-notification"'))
|
||||
@@ -2015,8 +2072,10 @@ test.each(["removed", "disabled"] as const)(
|
||||
expect(parentPrompts[0]).toContain("is unknown.");
|
||||
expect(parentPrompts[0]).not.toContain("finished.");
|
||||
await expect
|
||||
.poll(async () =>
|
||||
JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")),
|
||||
.poll(
|
||||
async () =>
|
||||
JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toEqual([]);
|
||||
if (availability === "removed")
|
||||
|
||||
@@ -1318,9 +1318,9 @@ test("emits disabled voice capability reasons on fresh daemon startup", async ()
|
||||
expect(voice).toBeTruthy();
|
||||
|
||||
expect(voice?.dictation.enabled).toBe(false);
|
||||
expect(voice?.dictation.reason).toBe("Dictation is disabled in daemon config.");
|
||||
expect(voice?.dictation.reason).toBe("Dictation is off. Turn it on in Settings > Voice.");
|
||||
expect(voice?.voice.enabled).toBe(false);
|
||||
expect(voice?.voice.reason).toBe("Realtime voice is disabled in daemon config.");
|
||||
expect(voice?.voice.reason).toBe("Voice mode is off. Turn it on in Settings > Voice.");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await isolatedCtx.cleanup();
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { loadPersistedConfig } from "./persisted-config.js";
|
||||
import { readSpeechSettings } from "./speech/speech-settings.js";
|
||||
import type { PersistedConfig } from "./persisted-config.js";
|
||||
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
|
||||
|
||||
@@ -26,6 +28,7 @@ function reloadableConfig(
|
||||
metadataGeneration: { providers: agents.metadataGeneration?.providers ?? [] },
|
||||
autoArchiveAfterMerge: daemon.autoArchiveAfterMerge ?? false,
|
||||
enableTerminalAgentHooks: daemon.enableTerminalAgentHooks ?? false,
|
||||
idleRuntimeCleanup: agents.idleRuntimeCleanup !== false,
|
||||
appendSystemPrompt: daemon.appendSystemPrompt ?? "",
|
||||
terminalProfiles: daemon.terminalProfiles,
|
||||
agentProfiles: daemon.agentProfiles,
|
||||
@@ -98,7 +101,7 @@ describe("DaemonConfigStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("patch persists relay state and emits its field change", () => {
|
||||
test("patch persists relay state and emits its field change", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
@@ -114,13 +117,13 @@ describe("DaemonConfigStore", () => {
|
||||
const changes: unknown[] = [];
|
||||
store.onFieldChange("relay.enabled", (value) => changes.push(value));
|
||||
|
||||
store.patch({ relay: { enabled: true } });
|
||||
await store.patch({ relay: { enabled: true } });
|
||||
|
||||
expect(changes).toEqual([true]);
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("patch persists the relay protected security requirement independently of relay enablement", () => {
|
||||
test("patch persists the relay protected security requirement independently of relay enablement", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(
|
||||
@@ -142,14 +145,14 @@ describe("DaemonConfigStore", () => {
|
||||
const changes: unknown[] = [];
|
||||
store.onFieldChange("relay.requireProtectedSecurity", (value) => changes.push(value));
|
||||
|
||||
store.patch({ relay: { requireProtectedSecurity: true } });
|
||||
await store.patch({ relay: { requireProtectedSecurity: true } });
|
||||
|
||||
expect(changes).toEqual([true]);
|
||||
expect(store.get().relay).toEqual({ enabled: true, requireProtectedSecurity: true });
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.requireProtectedSecurity).toBe(true);
|
||||
});
|
||||
|
||||
test("patch round-trips agent profiles through the strictly-parsed persisted config", () => {
|
||||
test("patch round-trips agent profiles through the strictly-parsed persisted config", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
@@ -163,7 +166,7 @@ describe("DaemonConfigStore", () => {
|
||||
appendSystemPrompt: "",
|
||||
});
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
agentProfiles: [
|
||||
{
|
||||
id: "profile_ui",
|
||||
@@ -195,7 +198,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(store.get().agentProfiles).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("patch replaces the whole agent profile list rather than merging entries", () => {
|
||||
test("patch replaces the whole agent profile list rather than merging entries", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
@@ -213,13 +216,13 @@ describe("DaemonConfigStore", () => {
|
||||
],
|
||||
});
|
||||
|
||||
store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] });
|
||||
await store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] });
|
||||
|
||||
expect(store.get().agentProfiles).toEqual([{ id: "a", name: "Keep", provider: "claude" }]);
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("rolls back config when a field transition fails", () => {
|
||||
test("rolls back config when a field transition fails", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
@@ -238,14 +241,14 @@ describe("DaemonConfigStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
|
||||
await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow(
|
||||
"Relay transport failed to start",
|
||||
);
|
||||
expect(store.get().relay?.enabled).toBe(false);
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("rolls back live owners when a later transactional owner fails", () => {
|
||||
test("rolls back live owners when a later transactional owner fails", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
@@ -269,7 +272,7 @@ describe("DaemonConfigStore", () => {
|
||||
throw new Error("Provider refresh failed");
|
||||
});
|
||||
|
||||
expect(() => store.patch({ browserTools: { enabled: true } })).toThrow(
|
||||
await expect(store.patch({ browserTools: { enabled: true } })).rejects.toThrow(
|
||||
"Provider refresh failed",
|
||||
);
|
||||
expect(browserToolsEnabled).toBe(false);
|
||||
@@ -277,7 +280,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.browserTools?.enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects relay patches when a launch override owns the setting", () => {
|
||||
test("rejects relay patches when a launch override owns the setting", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(
|
||||
@@ -296,12 +299,12 @@ describe("DaemonConfigStore", () => {
|
||||
{ relayEnabledMutable: false },
|
||||
);
|
||||
|
||||
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
|
||||
await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow(
|
||||
"Relay is controlled by a daemon launch override",
|
||||
);
|
||||
});
|
||||
|
||||
test("unrelated patches do not persist a one-launch relay override", () => {
|
||||
test("unrelated patches do not persist a one-launch relay override", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
@@ -328,12 +331,12 @@ describe("DaemonConfigStore", () => {
|
||||
{ relayEnabledMutable: false },
|
||||
);
|
||||
|
||||
store.patch({ browserTools: { enabled: true } });
|
||||
await store.patch({ browserTools: { enabled: true } });
|
||||
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("unrelated patches persist only requested file intent", () => {
|
||||
test("unrelated patches persist only requested file intent", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const before = loadPersistedConfig(paseoHome);
|
||||
@@ -359,7 +362,7 @@ describe("DaemonConfigStore", () => {
|
||||
{ relayEnabledMutable: false },
|
||||
);
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
appendSystemPrompt: "Only this field",
|
||||
// Reload-only runtime state is accepted as unknown wire data for forward
|
||||
// compatibility but is not part of the patch capability.
|
||||
@@ -373,7 +376,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists provider enabled flags into config.json", () => {
|
||||
test("patch persists provider enabled flags into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -414,7 +417,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
providers: {
|
||||
gemini: { enabled: false },
|
||||
},
|
||||
@@ -429,7 +432,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists provider Paseo-tool policy without changing availability", () => {
|
||||
test("patch persists provider Paseo-tool policy without changing availability", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
writeFileSync(
|
||||
@@ -446,14 +449,14 @@ describe("DaemonConfigStore", () => {
|
||||
appendSystemPrompt: "",
|
||||
});
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
providers: {
|
||||
claude: {
|
||||
paseoTools: { enabled: true, disabledTools: ["list_agents"] },
|
||||
},
|
||||
},
|
||||
});
|
||||
store.patch({
|
||||
await store.patch({
|
||||
providers: {
|
||||
claude: {
|
||||
paseoTools: { disabledTools: ["create_agent"] },
|
||||
@@ -471,7 +474,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch removes provider entries from config.json", () => {
|
||||
test("patch removes provider entries from config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -516,7 +519,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
const next = store.patch({ removeProviders: ["gemini"] });
|
||||
const next = await store.patch({ removeProviders: ["gemini"] });
|
||||
|
||||
expect(next.providers.gemini).toBeUndefined();
|
||||
expect(next.providers.claude).toEqual({ enabled: false });
|
||||
@@ -525,7 +528,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(persisted.agents?.providers?.claude).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
test("patch removes the providers object when the last provider is deleted", () => {
|
||||
test("patch removes the providers object when the last provider is deleted", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -564,13 +567,13 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({ removeProviders: ["gemini"] });
|
||||
await store.patch({ removeProviders: ["gemini"] });
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.agents?.providers).toBeUndefined();
|
||||
});
|
||||
|
||||
test("patch removes deleted providers from metadata generation", () => {
|
||||
test("patch removes deleted providers from metadata generation", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -626,7 +629,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
const next = store.patch({ removeProviders: ["gemini"] });
|
||||
const next = await store.patch({ removeProviders: ["gemini"] });
|
||||
|
||||
expect(next.metadataGeneration.providers).toEqual([{ provider: "claude", model: "haiku" }]);
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
@@ -635,7 +638,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists provider removal when in-memory config is already clean", () => {
|
||||
test("patch persists provider removal when in-memory config is already clean", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -677,7 +680,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
const next = store.patch({ removeProviders: ["gemini"] });
|
||||
const next = await store.patch({ removeProviders: ["gemini"] });
|
||||
|
||||
expect(next.providers.gemini).toBeUndefined();
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
@@ -685,7 +688,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] });
|
||||
});
|
||||
|
||||
test("patch persists append system prompt into config.json", () => {
|
||||
test("patch persists append system prompt into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -703,7 +706,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
appendSystemPrompt: "Prefer terse replies.",
|
||||
});
|
||||
|
||||
@@ -711,7 +714,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
|
||||
});
|
||||
|
||||
test("patch persists browser tools opt-in into config.json", () => {
|
||||
test("patch persists browser tools opt-in into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -728,13 +731,13 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({ browserTools: { enabled: true } });
|
||||
await store.patch({ browserTools: { enabled: true } });
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.daemon?.browserTools).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
test("patch persists provider additional models into config.json", () => {
|
||||
test("patch persists provider additional models into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -752,7 +755,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
providers: {
|
||||
claude: {
|
||||
additionalModels: [
|
||||
@@ -776,7 +779,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists daemon append system prompt into config.json", () => {
|
||||
test("patch persists daemon append system prompt into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -794,7 +797,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
appendSystemPrompt: "Prefer terse replies.",
|
||||
});
|
||||
|
||||
@@ -802,7 +805,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
|
||||
});
|
||||
|
||||
test("patch persists enable terminal agent hooks into config.json", () => {
|
||||
test("patch persists enable terminal agent hooks into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -819,13 +822,13 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({ enableTerminalAgentHooks: true });
|
||||
await store.patch({ enableTerminalAgentHooks: true });
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.daemon?.enableTerminalAgentHooks).toBe(true);
|
||||
});
|
||||
|
||||
test("patch persists metadata generation providers into config.json", () => {
|
||||
test("patch persists metadata generation providers into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -843,7 +846,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
metadataGeneration: {
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
@@ -861,7 +864,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists clearing metadata generation providers into config.json", () => {
|
||||
test("patch persists clearing metadata generation providers into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -896,13 +899,50 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({ metadataGeneration: { providers: [] } });
|
||||
await store.patch({ metadataGeneration: { providers: [] } });
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] });
|
||||
});
|
||||
|
||||
test("patch persists custom ACP provider overrides into config.json", () => {
|
||||
test("patch persists the host idle runtime cleanup opt-out for the next daemon start", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
agents: { metadataGeneration: { providers: [{ provider: "claude" }] } },
|
||||
})}\n`,
|
||||
);
|
||||
const store = new DaemonConfigStore(
|
||||
paseoHome,
|
||||
{
|
||||
mcp: { injectIntoAgents: false },
|
||||
browserTools: { enabled: false },
|
||||
providers: {},
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
idleRuntimeCleanup: true,
|
||||
appendSystemPrompt: "",
|
||||
metadataGeneration: { providers: [{ provider: "claude" }] },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect((await store.patch({ idleRuntimeCleanup: false })).idleRuntimeCleanup).toBe(false);
|
||||
|
||||
expect(loadPersistedConfig(paseoHome).agents).toEqual({
|
||||
metadataGeneration: { providers: [{ provider: "claude" }] },
|
||||
idleRuntimeCleanup: false,
|
||||
});
|
||||
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false);
|
||||
|
||||
await store.patch({ idleRuntimeCleanup: true });
|
||||
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true);
|
||||
});
|
||||
|
||||
test("patch persists custom ACP provider overrides into config.json", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -920,7 +960,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
await store.patch({
|
||||
providers: {
|
||||
"paseo-e2e-acp": {
|
||||
extends: "acp",
|
||||
@@ -988,7 +1028,49 @@ describe("DaemonConfigStore reload", () => {
|
||||
writeFileSync(path.join(paseoHome, "config.json"), `${JSON.stringify(config, null, 2)}\n`);
|
||||
}
|
||||
|
||||
test("applies mutable edits and reports startup-only edits", () => {
|
||||
test("applies overlapping plugin mutations to the latest committed sources", async () => {
|
||||
const { store } = createReloadableStore({
|
||||
initialPersisted: {
|
||||
plugins: {
|
||||
a: { source: "directory", path: "/a", enabled: false },
|
||||
b: { source: "directory", path: "/b", enabled: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const enable = store.patch((current) => ({
|
||||
plugins: {
|
||||
...current.plugins,
|
||||
a: { ...current.plugins.a!, enabled: true },
|
||||
},
|
||||
}));
|
||||
const disable = store.patch((current) => ({
|
||||
plugins: {
|
||||
...current.plugins,
|
||||
b: { ...current.plugins.b!, enabled: false },
|
||||
},
|
||||
}));
|
||||
await Promise.all([enable, disable]);
|
||||
expect(store.get().plugins).toEqual({
|
||||
a: { source: "directory", path: "/a", enabled: true },
|
||||
b: { source: "directory", path: "/b", enabled: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("serializes overlapping patches before committing their combined state", async () => {
|
||||
const { paseoHome, store } = createReloadableStore();
|
||||
const first = store.patch({ appendSystemPrompt: "first" });
|
||||
const second = store.patch({ browserTools: { enabled: true } });
|
||||
expect(store.get().appendSystemPrompt).toBe("");
|
||||
await Promise.all([first, second]);
|
||||
expect(store.get().appendSystemPrompt).toBe("first");
|
||||
expect(store.get().browserTools.enabled).toBe(true);
|
||||
expect(loadPersistedConfig(paseoHome).daemon).toMatchObject({
|
||||
appendSystemPrompt: "first",
|
||||
browserTools: { enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("applies mutable edits and reports startup-only edits", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore();
|
||||
writeConfig(paseoHome, {
|
||||
...persisted,
|
||||
@@ -1000,7 +1082,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: [
|
||||
"daemon.browserTools.enabled",
|
||||
"daemon.git.maxProcessConcurrency",
|
||||
@@ -1013,7 +1095,30 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().git).toEqual({ maxProcessesPerSecond: 12, maxProcessConcurrency: 3 });
|
||||
});
|
||||
|
||||
test("applies the global plugin switch in both directions", () => {
|
||||
test("reloads idle runtime cleanup in both directions without requiring a restart", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore();
|
||||
const changes: unknown[] = [];
|
||||
store.onFieldChange("idleRuntimeCleanup", (value) => changes.push(value));
|
||||
for (const enabled of [false, true]) {
|
||||
writeConfig(paseoHome, { ...persisted, agents: { idleRuntimeCleanup: enabled } });
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: ["agents.idleRuntimeCleanup"],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
});
|
||||
expect(store.get().idleRuntimeCleanup).toBe(enabled);
|
||||
}
|
||||
expect(changes).toEqual([false, true]);
|
||||
await store.patch({ idleRuntimeCleanup: false });
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
});
|
||||
expect(store.get().idleRuntimeCleanup).toBe(false);
|
||||
});
|
||||
|
||||
test("applies the global plugin switch in both directions", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore({
|
||||
initialPersisted: { version: 1, pluginsEnabled: false },
|
||||
});
|
||||
@@ -1021,7 +1126,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
store.onFieldChange("pluginsEnabled", (value) => changes.push(value));
|
||||
|
||||
writeConfig(paseoHome, { ...persisted, pluginsEnabled: true });
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: ["pluginsEnabled"],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
@@ -1029,7 +1134,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().pluginsEnabled).toBe(true);
|
||||
|
||||
writeConfig(paseoHome, { ...persisted, pluginsEnabled: false });
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: ["pluginsEnabled"],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
@@ -1038,7 +1143,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(changes).toEqual([true, false]);
|
||||
});
|
||||
|
||||
test("classifies every leaf when a parent subtree is added", () => {
|
||||
test("classifies every leaf when a parent subtree is added", async () => {
|
||||
const { paseoHome, store } = createReloadableStore({
|
||||
initialPersisted: { version: 1 },
|
||||
});
|
||||
@@ -1053,14 +1158,14 @@ describe("DaemonConfigStore reload", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: ["daemon.relay.enabled"],
|
||||
restartRequiredPaths: ["daemon.relay.endpoint", "daemon.relay.useTls"],
|
||||
overrideControlledPaths: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("classifies every leaf when the daemon subtree is removed", () => {
|
||||
test("classifies every leaf when the daemon subtree is removed", async () => {
|
||||
const { paseoHome, store } = createReloadableStore({
|
||||
initialPersisted: {
|
||||
version: 1,
|
||||
@@ -1081,7 +1186,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
});
|
||||
writeConfig(paseoHome, { version: 1 });
|
||||
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: ["daemon.browserTools.enabled"],
|
||||
restartRequiredPaths: [
|
||||
"daemon.listen",
|
||||
@@ -1095,7 +1200,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().relay?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps overridden leaves separate from restart-required siblings", () => {
|
||||
test("keeps overridden leaves separate from restart-required siblings", async () => {
|
||||
const { paseoHome, store } = createReloadableStore({
|
||||
initialPersisted: { version: 1 },
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
@@ -1107,25 +1212,25 @@ describe("DaemonConfigStore reload", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: ["daemon.relay.endpoint"],
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid JSON and invalid schema apply nothing", () => {
|
||||
test("invalid JSON and invalid schema apply nothing", async () => {
|
||||
const { paseoHome, store } = createReloadableStore();
|
||||
writeFileSync(path.join(paseoHome, "config.json"), "{ nope\n");
|
||||
expect(() => store.reload()).toThrow("Invalid JSON");
|
||||
await expect(store.reload()).rejects.toThrow("Invalid JSON");
|
||||
expect(store.get().browserTools.enabled).toBe(false);
|
||||
|
||||
writeConfig(paseoHome, { daemon: { browserTools: { enabled: "yes" } } });
|
||||
expect(() => store.reload()).toThrow("Invalid config");
|
||||
await expect(store.reload()).rejects.toThrow("Invalid config");
|
||||
expect(store.get().browserTools.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("removing providers and optional profiles clears live state", () => {
|
||||
test("removing providers and optional profiles clears live state", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore();
|
||||
writeConfig(paseoHome, {
|
||||
...persisted,
|
||||
@@ -1140,10 +1245,10 @@ describe("DaemonConfigStore reload", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
store.reload();
|
||||
await store.reload();
|
||||
|
||||
writeConfig(paseoHome, persisted);
|
||||
const result = store.reload();
|
||||
const result = await store.reload();
|
||||
|
||||
expect(result.appliedPaths).toEqual([
|
||||
"agents.providers",
|
||||
@@ -1155,7 +1260,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().agentProfiles).toBeUndefined();
|
||||
});
|
||||
|
||||
test("reports a launch-controlled edit without changing live state", () => {
|
||||
test("reports a launch-controlled edit without changing live state", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore({
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
});
|
||||
@@ -1165,7 +1270,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
daemon: { ...persisted.daemon, relay: { enabled: !initialRelay } },
|
||||
});
|
||||
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
@@ -1173,7 +1278,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().relay?.enabled).toBe(initialRelay);
|
||||
});
|
||||
|
||||
test("an unrelated patch does not mark a manual override-owned edit as applied", () => {
|
||||
test("an unrelated patch does not mark a manual override-owned edit as applied", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore({
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
});
|
||||
@@ -1181,16 +1286,16 @@ describe("DaemonConfigStore reload", () => {
|
||||
...persisted,
|
||||
daemon: { ...persisted.daemon, relay: { enabled: true } },
|
||||
});
|
||||
store.patch({ appendSystemPrompt: "patched elsewhere" });
|
||||
await store.patch({ appendSystemPrompt: "patched elsewhere" });
|
||||
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
});
|
||||
});
|
||||
|
||||
test("reports startup-only launch overrides instead of restart warnings", () => {
|
||||
test("reports startup-only launch overrides instead of restart warnings", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore({
|
||||
overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"],
|
||||
});
|
||||
@@ -1206,19 +1311,255 @@ describe("DaemonConfigStore reload", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"],
|
||||
});
|
||||
});
|
||||
|
||||
test("a no-op reload returns empty path lists", () => {
|
||||
test("a no-op reload returns empty path lists", async () => {
|
||||
const { store } = createReloadableStore();
|
||||
expect(store.reload()).toEqual({
|
||||
expect(await store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DaemonConfigStore speech settings", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createSpeechStore(initialPersisted?: unknown, env: NodeJS.ProcessEnv = {}) {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-speech-"));
|
||||
tempDirs.push(paseoHome);
|
||||
if (initialPersisted) {
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify(initialPersisted, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
const applied: PersistedConfig[] = [];
|
||||
const speech = {
|
||||
read: (next: PersistedConfig) => readSpeechSettings(next, env),
|
||||
apply: (next: PersistedConfig) => applied.push(next),
|
||||
};
|
||||
const store = new DaemonConfigStore(paseoHome, reloadableConfig(persisted), undefined, {
|
||||
speech,
|
||||
reloadSource: {
|
||||
resolve: (next) => ({ mutable: reloadableConfig(next), overrideControlledPaths: [] }),
|
||||
},
|
||||
});
|
||||
return { paseoHome, store, applied, persisted };
|
||||
}
|
||||
|
||||
test("reads, patches, and clears local STT language without losing it on other saves", async () => {
|
||||
const { paseoHome, store } = createSpeechStore({
|
||||
features: { dictation: { stt: { language: "fr" } } },
|
||||
});
|
||||
expect(store.get().features?.dictation.stt).toEqual({ provider: "local", language: "fr" });
|
||||
await store.patch({ features: { voiceMode: { enabled: true } } });
|
||||
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({
|
||||
provider: "local",
|
||||
language: "fr",
|
||||
});
|
||||
const patched = await store.patch({
|
||||
features: { dictation: { stt: { provider: "local", language: "de" } } },
|
||||
});
|
||||
expect(patched.features?.dictation.stt).toEqual({ provider: "local", language: "de" });
|
||||
await store.patch({ features: { dictation: { stt: { language: null } } } });
|
||||
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ provider: "local" });
|
||||
});
|
||||
|
||||
test("reports language env overrides for both local STT sites", async () => {
|
||||
const { store } = createSpeechStore(
|
||||
{},
|
||||
{ PASEO_DICTATION_LANGUAGE: "fr", PASEO_VOICE_LANGUAGE: "de" },
|
||||
);
|
||||
expect(store.get().envOverrides).toMatchObject({
|
||||
"features.dictation.stt.language": "PASEO_DICTATION_LANGUAGE",
|
||||
"features.voiceMode.stt.language": "PASEO_VOICE_LANGUAGE",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps voice mode off until it is turned on", async () => {
|
||||
const { paseoHome, store } = createSpeechStore();
|
||||
|
||||
expect(store.get().features?.dictation.enabled).toBe(true);
|
||||
expect(store.get().features?.voiceMode.enabled).toBe(false);
|
||||
|
||||
const result = await store.patch({ features: { voiceMode: { enabled: true } } });
|
||||
|
||||
expect(result.features?.voiceMode.enabled).toBe(true);
|
||||
expect(loadPersistedConfig(paseoHome).features?.voiceMode?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("stores the OpenAI key without ever returning it", async () => {
|
||||
const { paseoHome, store, applied } = createSpeechStore();
|
||||
|
||||
const result = await store.patch({
|
||||
features: { dictation: { stt: { provider: "openai", apiKey: " sk-test " } } },
|
||||
});
|
||||
|
||||
expect(result.features?.dictation.stt).toEqual({ provider: "openai", hasApiKey: true });
|
||||
expect(JSON.stringify(result)).not.toContain("sk-test");
|
||||
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toMatchObject({
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("applies a replaced key even though the visible settings do not change", async () => {
|
||||
const { store, applied } = createSpeechStore({
|
||||
version: 1,
|
||||
features: { dictation: { stt: { provider: "openai", apiKey: "sk-old" } } },
|
||||
});
|
||||
|
||||
await store.patch({ features: { dictation: { stt: { apiKey: "sk-new" } } } });
|
||||
|
||||
expect(applied.map((next) => next.features?.dictation?.stt)).toEqual([
|
||||
{ provider: "openai", apiKey: "sk-new" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("switching provider clears the previous provider's model", async () => {
|
||||
const { paseoHome, store } = createSpeechStore({
|
||||
version: 1,
|
||||
features: { dictation: { stt: { provider: "local", model: "parakeet-tdt-0.6b-v3-int8" } } },
|
||||
});
|
||||
|
||||
const result = await store.patch({ features: { dictation: { stt: { provider: "openai" } } } });
|
||||
|
||||
expect(result.features?.dictation.stt).toEqual({ provider: "openai", hasApiKey: false });
|
||||
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({
|
||||
provider: "openai",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a local model id outside the catalog without persisting it", async () => {
|
||||
const { paseoHome, store, applied } = createSpeechStore();
|
||||
|
||||
await expect(
|
||||
store.patch({ features: { voiceMode: { tts: { provider: "local", model: "nope" } } } }),
|
||||
).rejects.toThrow("Unknown local voice model: nope");
|
||||
expect(loadPersistedConfig(paseoHome).features?.voiceMode).toBeUndefined();
|
||||
expect(applied).toEqual([]);
|
||||
});
|
||||
|
||||
test("reload applies hand-edited speech settings without a restart", async () => {
|
||||
const { paseoHome, store, applied, persisted } = createSpeechStore();
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({ ...persisted, features: { voiceMode: { enabled: false } } }, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const result = await store.reload();
|
||||
|
||||
expect(result.appliedPaths).toContain("features.voiceMode");
|
||||
expect(result.restartRequiredPaths).toEqual([]);
|
||||
expect(store.get().features?.voiceMode.enabled).toBe(false);
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
test("merges same-provider fields and replaces the whole variant when provider changes", async () => {
|
||||
const { paseoHome, store } = createSpeechStore();
|
||||
await store.patch({
|
||||
features: {
|
||||
voiceMode: {
|
||||
stt: { provider: "openai", apiKey: "secret", baseUrl: "https://site/v1", model: "first" },
|
||||
},
|
||||
},
|
||||
});
|
||||
await store.patch({ features: { voiceMode: { stt: { model: "second", baseUrl: null } } } });
|
||||
expect(loadPersistedConfig(paseoHome).features?.voiceMode?.stt).toEqual({
|
||||
provider: "openai",
|
||||
apiKey: "secret",
|
||||
model: "second",
|
||||
});
|
||||
await store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "third" } } } });
|
||||
expect(store.get().features?.voiceMode.stt).toEqual({
|
||||
provider: "openai",
|
||||
model: "third",
|
||||
hasApiKey: true,
|
||||
});
|
||||
await store.patch({ features: { voiceMode: { stt: { apiKey: "new-secret" } } } });
|
||||
await store.patch({ features: { voiceMode: { stt: { provider: "local" } } } });
|
||||
await store.patch({ features: { voiceMode: { stt: { provider: "openai" } } } });
|
||||
expect(store.get().features?.voiceMode.stt).toEqual({ provider: "openai", hasApiKey: false });
|
||||
});
|
||||
|
||||
test("keeps a saved OpenAI key when a full variant patch omits the write-only key", async () => {
|
||||
const { paseoHome, store } = createSpeechStore({
|
||||
features: { voiceMode: { stt: { provider: "openai", apiKey: "saved-secret" } } },
|
||||
});
|
||||
const result = await store.patch({
|
||||
features: { voiceMode: { stt: { provider: "openai", model: "whisper-1" } } },
|
||||
});
|
||||
expect(result.features?.voiceMode.stt).toEqual({
|
||||
provider: "openai",
|
||||
model: "whisper-1",
|
||||
hasApiKey: true,
|
||||
});
|
||||
expect(loadPersistedConfig(paseoHome).features?.voiceMode?.stt).toEqual({
|
||||
provider: "openai",
|
||||
apiKey: "saved-secret",
|
||||
model: "whisper-1",
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("saved-secret");
|
||||
});
|
||||
|
||||
test("loads a legacy host, redacts its key and writes the normalized config after any save", async () => {
|
||||
const { paseoHome, store } = createSpeechStore({
|
||||
providers: { openai: { apiKey: "legacy-secret", stt: { baseUrl: "https://old/v1" } } },
|
||||
features: { voiceMode: { enabled: true, stt: { provider: "openai" } } },
|
||||
});
|
||||
expect(store.get().features?.voiceMode.stt).toEqual({
|
||||
provider: "openai",
|
||||
baseUrl: "https://old/v1",
|
||||
hasApiKey: true,
|
||||
});
|
||||
expect(JSON.stringify(store.get())).not.toContain("legacy-secret");
|
||||
await store.patch({ autoArchiveAfterMerge: true });
|
||||
const disk = JSON.parse(readFileSync(path.join(paseoHome, "config.json"), "utf8"));
|
||||
expect(disk.providers).not.toHaveProperty("openai");
|
||||
expect(disk.features.voiceMode.stt.apiKey).toBe("legacy-secret");
|
||||
});
|
||||
|
||||
test("reports env overrides and removes fallback provenance after saving a key", async () => {
|
||||
const { store } = createSpeechStore(
|
||||
{ features: { voiceMode: { stt: { provider: "openai", model: "configured" } } } },
|
||||
{
|
||||
PASEO_VOICE_STT_PROVIDER: "local",
|
||||
OPENAI_API_KEY: "env-secret",
|
||||
},
|
||||
);
|
||||
expect(store.get().features?.voiceMode.stt.provider).toBe("local");
|
||||
expect(store.get().envOverrides).toEqual({
|
||||
"features.voiceMode.stt.provider": "PASEO_VOICE_STT_PROVIDER",
|
||||
});
|
||||
const other = createSpeechStore(
|
||||
{ features: { voiceMode: { stt: { provider: "openai" } } } },
|
||||
{
|
||||
OPENAI_API_KEY: "env-secret",
|
||||
OPENAI_STT_BASE_URL: "https://env/v1",
|
||||
STT_MODEL: "override",
|
||||
},
|
||||
).store;
|
||||
expect(other.get().envOverrides).toEqual({
|
||||
"features.voiceMode.stt.apiKey": "OPENAI_API_KEY",
|
||||
"features.voiceMode.stt.baseUrl": "OPENAI_STT_BASE_URL",
|
||||
"features.voiceMode.stt.model": "STT_MODEL",
|
||||
});
|
||||
expect(other.get().features?.voiceMode.stt.hasApiKey).toBe(false);
|
||||
await other.patch({
|
||||
features: { voiceMode: { stt: { apiKey: "saved-secret", baseUrl: "https://saved/v1" } } },
|
||||
});
|
||||
expect(other.get().envOverrides).toEqual({ "features.voiceMode.stt.model": "STT_MODEL" });
|
||||
expect(JSON.stringify(other.get())).not.toContain("secret");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
loadPersistedConfig,
|
||||
savePersistedConfig,
|
||||
savePersistedConfigAsync,
|
||||
loadPersistedConfigAsync,
|
||||
type PersistedConfig,
|
||||
} from "./persisted-config.js";
|
||||
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
MutableDaemonConfigPatchSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
|
||||
import type { SpeechSettingsPatch } from "@getpaseo/protocol/speech/rpc-schemas";
|
||||
import { mergeSpeechSettingsPatch, type SpeechSettingsOwner } from "./speech/speech-settings.js";
|
||||
|
||||
export type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages";
|
||||
|
||||
@@ -25,14 +28,18 @@ interface SupportedMutableConfigPatch {
|
||||
metadataGeneration?: MutableDaemonConfig["metadataGeneration"];
|
||||
autoArchiveAfterMerge?: boolean;
|
||||
enableTerminalAgentHooks?: boolean;
|
||||
idleRuntimeCleanup?: boolean;
|
||||
appendSystemPrompt?: string;
|
||||
terminalProfiles?: MutableDaemonConfig["terminalProfiles"];
|
||||
agentProfiles?: MutableDaemonConfig["agentProfiles"];
|
||||
skills?: MutableDaemonConfig["skills"];
|
||||
pluginsEnabled?: boolean;
|
||||
plugins?: MutableDaemonConfig["plugins"];
|
||||
features?: SpeechSettingsPatch;
|
||||
}
|
||||
|
||||
const SPEECH_PERSISTED_PATHS = ["features.dictation", "features.voiceMode"];
|
||||
|
||||
interface LoggerLike {
|
||||
child(bindings: Record<string, unknown>): LoggerLike;
|
||||
info(...args: unknown[]): void;
|
||||
@@ -185,6 +192,7 @@ const RELOADABLE_PATHS = [
|
||||
"daemon.terminalProfiles",
|
||||
"daemon.agentProfiles",
|
||||
"app.baseUrl",
|
||||
"agents.idleRuntimeCleanup",
|
||||
"agents.providers",
|
||||
"agents.catalogRefreshTimeoutMs",
|
||||
"agents.metadataGeneration",
|
||||
@@ -209,6 +217,7 @@ const PERSISTED_TO_MUTABLE_PATH = new Map<string, string>([
|
||||
["daemon.terminalProfiles", "terminalProfiles"],
|
||||
["daemon.agentProfiles", "agentProfiles"],
|
||||
["app.baseUrl", "app.baseUrl"],
|
||||
["agents.idleRuntimeCleanup", "idleRuntimeCleanup"],
|
||||
["agents.providers", "providers"],
|
||||
["agents.catalogRefreshTimeoutMs", "catalogRefreshTimeoutMs"],
|
||||
["agents.metadataGeneration", "metadataGeneration"],
|
||||
@@ -283,6 +292,9 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut
|
||||
...(patch.enableTerminalAgentHooks !== undefined
|
||||
? { enableTerminalAgentHooks: patch.enableTerminalAgentHooks }
|
||||
: {}),
|
||||
...(patch.idleRuntimeCleanup !== undefined
|
||||
? { idleRuntimeCleanup: patch.idleRuntimeCleanup }
|
||||
: {}),
|
||||
...(patch.appendSystemPrompt !== undefined
|
||||
? { appendSystemPrompt: patch.appendSystemPrompt }
|
||||
: {}),
|
||||
@@ -290,6 +302,7 @@ function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMut
|
||||
...(patch.agentProfiles !== undefined ? { agentProfiles: patch.agentProfiles } : {}),
|
||||
...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}),
|
||||
...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}),
|
||||
...(patch.features !== undefined ? { features: patch.features } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -323,6 +336,7 @@ export function applyMutableProviderConfigToOverrides(
|
||||
}
|
||||
|
||||
export class DaemonConfigStore {
|
||||
private mutations: Promise<unknown> = Promise.resolve();
|
||||
private current: MutableDaemonConfig;
|
||||
private readonly paseoHome: string;
|
||||
private readonly logger: LoggerLike | undefined;
|
||||
@@ -332,6 +346,7 @@ export class DaemonConfigStore {
|
||||
private readonly relayEnabledMutable: boolean;
|
||||
private readonly reloadSource: DaemonConfigReloadSource | undefined;
|
||||
private readonly startupPersisted: PersistedConfig;
|
||||
private readonly speechOwner: SpeechSettingsOwner | undefined;
|
||||
private lastKnownPersisted: PersistedConfig;
|
||||
|
||||
constructor(
|
||||
@@ -342,42 +357,60 @@ export class DaemonConfigStore {
|
||||
relayEnabledMutable?: boolean;
|
||||
reloadSource?: DaemonConfigReloadSource;
|
||||
startupPersisted?: PersistedConfig;
|
||||
speech?: SpeechSettingsOwner;
|
||||
} = {},
|
||||
) {
|
||||
this.paseoHome = paseoHome;
|
||||
this.logger = getLogger(logger);
|
||||
this.relayEnabledMutable = options.relayEnabledMutable ?? true;
|
||||
this.reloadSource = options.reloadSource;
|
||||
this.speechOwner = options.speech;
|
||||
this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger);
|
||||
this.lastKnownPersisted = this.startupPersisted;
|
||||
this.current = MutableDaemonConfigSchema.parse({
|
||||
...initial,
|
||||
relay: initial.relay ?? { enabled: true },
|
||||
...this.readSpeech(this.startupPersisted),
|
||||
});
|
||||
this.relayEnabledMutable = options.relayEnabledMutable ?? true;
|
||||
this.reloadSource = options.reloadSource;
|
||||
this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger);
|
||||
this.lastKnownPersisted = this.startupPersisted;
|
||||
}
|
||||
|
||||
public get(): MutableDaemonConfig {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
|
||||
const parsedPatch = pickSupportedPatchFields(MutableDaemonConfigPatchSchema.parse(partial));
|
||||
return this.applySupportedPatch(parsedPatch);
|
||||
public patch(
|
||||
partial:
|
||||
| MutableDaemonConfigPatch
|
||||
| ((current: MutableDaemonConfig) => MutableDaemonConfigPatch),
|
||||
): Promise<MutableDaemonConfig> {
|
||||
return this.enqueue(() =>
|
||||
this.applySupportedPatch(
|
||||
pickSupportedPatchFields(
|
||||
MutableDaemonConfigPatchSchema.parse(
|
||||
typeof partial === "function" ? partial(this.current) : partial,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public setAgentSkillSelection(selection: AgentSkillSelection): MutableDaemonConfig {
|
||||
return this.applySupportedPatch({ skills: { selection } });
|
||||
public setAgentSkillSelection(selection: AgentSkillSelection): Promise<MutableDaemonConfig> {
|
||||
return this.enqueue(() => this.applySupportedPatch({ skills: { selection } }));
|
||||
}
|
||||
|
||||
private applySupportedPatch(parsedPatch: SupportedMutableConfigPatch): MutableDaemonConfig {
|
||||
private async applySupportedPatch(
|
||||
parsedPatch: SupportedMutableConfigPatch,
|
||||
): Promise<MutableDaemonConfig> {
|
||||
if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) {
|
||||
throw new Error(
|
||||
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
|
||||
);
|
||||
}
|
||||
const { removeProviders = [], ...configPatch } = parsedPatch;
|
||||
const { removeProviders = [], ...patch } = parsedPatch;
|
||||
const { features: speechPatch, ...configPatch } = patch;
|
||||
const removedProviders = Array.from(new Set(removeProviders));
|
||||
const merged = deepMerge(this.current, configPatch);
|
||||
if (speechPatch !== undefined) Object.assign(merged, this.readPatchedSpeech(speechPatch));
|
||||
if (parsedPatch.skills?.selection !== undefined) {
|
||||
merged.skills = { selection: parsedPatch.skills.selection };
|
||||
}
|
||||
@@ -391,42 +424,68 @@ export class DaemonConfigStore {
|
||||
|
||||
const configChanged = !isEqualValue(this.current, next);
|
||||
|
||||
if (!configChanged && removedProviders.length === 0) {
|
||||
// A replaced API key leaves the speech view unchanged, so a speech patch always persists.
|
||||
if (!configChanged && removedProviders.length === 0 && speechPatch === undefined) {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
const { previous: persistedBeforePatch, knownNext } = this.persistConfig(
|
||||
configPatch,
|
||||
const { previous: persistedBeforePatch, knownNext } = await this.persistConfig(
|
||||
patch,
|
||||
removedProviders,
|
||||
);
|
||||
if (!configChanged) {
|
||||
this.lastKnownPersisted = knownNext;
|
||||
return this.current;
|
||||
}
|
||||
|
||||
try {
|
||||
this.applyReplacement(next, { removedProviders });
|
||||
this.lastKnownPersisted = knownNext;
|
||||
} catch (error) {
|
||||
savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger);
|
||||
throw error;
|
||||
const speechChanged = this.speechPersistedChanged(this.lastKnownPersisted, knownNext);
|
||||
if (configChanged) {
|
||||
try {
|
||||
this.applyReplacement(next, { removedProviders });
|
||||
} catch (error) {
|
||||
await savePersistedConfigAsync(this.paseoHome, persistedBeforePatch, this.logger);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
this.lastKnownPersisted = knownNext;
|
||||
if (speechChanged) this.speechOwner?.apply(knownNext);
|
||||
|
||||
return this.current;
|
||||
}
|
||||
|
||||
public reload(): DaemonConfigReloadResult {
|
||||
private readSpeech(
|
||||
persisted: PersistedConfig,
|
||||
): Pick<MutableDaemonConfig, "features" | "envOverrides"> {
|
||||
return this.speechOwner ? this.speechOwner.read(persisted) : {};
|
||||
}
|
||||
|
||||
private readPatchedSpeech(
|
||||
speechPatch: SpeechSettingsPatch,
|
||||
): Pick<MutableDaemonConfig, "features" | "envOverrides"> {
|
||||
if (!this.speechOwner) {
|
||||
throw new Error("Speech settings are unavailable for this daemon instance");
|
||||
}
|
||||
return this.speechOwner.read(mergeSpeechSettingsPatch(this.lastKnownPersisted, speechPatch));
|
||||
}
|
||||
|
||||
private speechPersistedChanged(previous: PersistedConfig, next: PersistedConfig): boolean {
|
||||
return diffPaths(previous, next).some((path) =>
|
||||
SPEECH_PERSISTED_PATHS.some((owner) => pathBelongsTo(path, owner)),
|
||||
);
|
||||
}
|
||||
|
||||
public reload(): Promise<DaemonConfigReloadResult> {
|
||||
return this.enqueue(() => this.reloadPersisted());
|
||||
}
|
||||
|
||||
private async reloadPersisted(): Promise<DaemonConfigReloadResult> {
|
||||
if (!this.reloadSource) {
|
||||
throw new Error("Daemon config reload is unavailable for this daemon instance");
|
||||
}
|
||||
|
||||
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
|
||||
const persisted = await loadPersistedConfigAsync(this.paseoHome, this.logger);
|
||||
const resolved = this.reloadSource.resolve(persisted);
|
||||
// Plugin source changes require the plugin lifecycle operation or a daemon
|
||||
// restart. The global switch is independently reloadable.
|
||||
const desired = MutableDaemonConfigSchema.parse({
|
||||
...resolved.mutable,
|
||||
plugins: this.current.plugins,
|
||||
...this.readSpeech(persisted),
|
||||
});
|
||||
const changedSinceLastApply = diffPaths(this.lastKnownPersisted, persisted);
|
||||
const overrideControlledPaths = compactOwnedPaths(
|
||||
@@ -435,7 +494,7 @@ export class DaemonConfigStore {
|
||||
),
|
||||
resolved.overrideControlledPaths,
|
||||
);
|
||||
const appliedPaths = RELOADABLE_PATHS.filter((persistedPath) => {
|
||||
const appliedPaths: string[] = RELOADABLE_PATHS.filter((persistedPath) => {
|
||||
if (resolved.overrideControlledPaths.some((owner) => pathBelongsTo(persistedPath, owner))) {
|
||||
return false;
|
||||
}
|
||||
@@ -452,6 +511,8 @@ export class DaemonConfigStore {
|
||||
diffPaths(this.startupPersisted, persisted).filter((path) => {
|
||||
if (path === "$schema" || path === "version") return false;
|
||||
if (RELOADABLE_PATHS.some((owner) => pathBelongsTo(path, owner))) return false;
|
||||
if (this.speechOwner && SPEECH_PERSISTED_PATHS.some((owner) => pathBelongsTo(path, owner)))
|
||||
return false;
|
||||
return !resolved.overrideControlledPaths.some((owner) => pathBelongsTo(path, owner));
|
||||
}),
|
||||
[],
|
||||
@@ -460,8 +521,17 @@ export class DaemonConfigStore {
|
||||
const removedProviders = Object.keys(this.current.providers).filter(
|
||||
(provider) => !(provider in desired.providers),
|
||||
);
|
||||
const speechChanged = this.speechPersistedChanged(this.lastKnownPersisted, persisted);
|
||||
this.applyReplacement(desired, { removedProviders });
|
||||
this.lastKnownPersisted = persisted;
|
||||
if (speechChanged && this.speechOwner) {
|
||||
this.speechOwner.apply(persisted);
|
||||
appliedPaths.push(
|
||||
...SPEECH_PERSISTED_PATHS.filter((owner) =>
|
||||
changedSinceLastApply.some((path) => pathBelongsTo(path, owner)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
appliedPaths: [...appliedPaths].sort(),
|
||||
@@ -570,11 +640,24 @@ export class DaemonConfigStore {
|
||||
};
|
||||
}
|
||||
|
||||
private persistConfig(
|
||||
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.mutations.then(operation);
|
||||
this.mutations = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
public readServicePorts(): Promise<NonNullable<PersistedConfig["worktrees"]>["servicePorts"]> {
|
||||
return this.enqueue(
|
||||
async () =>
|
||||
(await loadPersistedConfigAsync(this.paseoHome, this.logger)).worktrees?.servicePorts,
|
||||
);
|
||||
}
|
||||
|
||||
private async persistConfig(
|
||||
patch: Omit<SupportedMutableConfigPatch, "removeProviders">,
|
||||
removeProviders: readonly string[],
|
||||
): { previous: PersistedConfig; knownNext: PersistedConfig } {
|
||||
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
|
||||
): Promise<{ previous: PersistedConfig; knownNext: PersistedConfig }> {
|
||||
const persisted = await loadPersistedConfigAsync(this.paseoHome, this.logger);
|
||||
const merge = (source: PersistedConfig) =>
|
||||
mergeMutablePatchIntoPersistedConfig({
|
||||
persisted: source,
|
||||
@@ -584,7 +667,7 @@ export class DaemonConfigStore {
|
||||
});
|
||||
const nextPersisted = merge(persisted);
|
||||
const knownNext = merge(this.lastKnownPersisted);
|
||||
savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
|
||||
await savePersistedConfigAsync(this.paseoHome, nextPersisted, this.logger);
|
||||
return { previous: persisted, knownNext };
|
||||
}
|
||||
}
|
||||
@@ -598,8 +681,11 @@ function mergeMutablePatchIntoPersistedConfig(params: {
|
||||
const { persisted, patch, removeProviders, persistRelayEnabled } = params;
|
||||
const daemon = mergeMutableDaemonPatch(persisted.daemon, patch, persistRelayEnabled);
|
||||
const agents = mergeMutableAgentPatch(persisted.agents, patch, removeProviders);
|
||||
const withSpeech = patch.features
|
||||
? mergeSpeechSettingsPatch(persisted, patch.features)
|
||||
: persisted;
|
||||
return {
|
||||
...persisted,
|
||||
...withSpeech,
|
||||
...(patch.pluginsEnabled !== undefined ? { pluginsEnabled: patch.pluginsEnabled } : {}),
|
||||
...(patch.plugins !== undefined ? { plugins: patch.plugins } : {}),
|
||||
...(daemon ? { daemon } : { daemon: undefined }),
|
||||
@@ -616,6 +702,7 @@ function mergeMutableAgentPatch(
|
||||
patch.providers === undefined &&
|
||||
patch.metadataGeneration === undefined &&
|
||||
patch.skills === undefined &&
|
||||
patch.idleRuntimeCleanup === undefined &&
|
||||
removeProviders.length === 0
|
||||
) {
|
||||
return persistedAgents;
|
||||
@@ -648,6 +735,10 @@ function mergeMutableAgentPatch(
|
||||
next["skills"] = { selection: patch.skills.selection };
|
||||
}
|
||||
|
||||
if (patch.idleRuntimeCleanup !== undefined) {
|
||||
next["idleRuntimeCleanup"] = patch.idleRuntimeCleanup;
|
||||
}
|
||||
|
||||
return Object.keys(next).length > 0 ? (next as PersistedConfig["agents"]) : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
@@ -223,3 +224,76 @@ test("All changes subscriptions refresh on file edits and commits", async () =>
|
||||
await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("large source diffs stay renderable for reads, subscriptions, and commit files", async () => {
|
||||
const ctx = await createDaemonTestContext();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-budget-"));
|
||||
const runGit = promisify(execFile);
|
||||
try {
|
||||
git(cwd, "init", "-b", "main");
|
||||
git(cwd, "config", "user.name", "Test");
|
||||
git(cwd, "config", "user.email", "test@example.com");
|
||||
git(cwd, "config", "core.autocrlf", "false");
|
||||
const original = "const value = Math.max(42, 7);\n".repeat(Math.ceil((5 * 1024 * 1024) / 30));
|
||||
writeFileSync(join(cwd, "generated.ts"), original);
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
|
||||
await writeFile(join(cwd, "generated.ts"), original.replace("42", "43"));
|
||||
const pending = ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
const rpcStarted = performance.now();
|
||||
await ctx.client.fetchAgents({});
|
||||
const rpcMs = performance.now() - rpcStarted;
|
||||
const initial = await pending;
|
||||
expect(initial.error).toBeNull();
|
||||
const check = (file: (typeof initial.files)[number] | null | undefined, value: string) => {
|
||||
expect(file).toMatchObject({ path: "generated.ts", additions: 1, deletions: 1 });
|
||||
const lines = file!.hunks.flatMap((hunk) => hunk.lines);
|
||||
expect(lines.filter((line) => line.type === "add").map((line) => line.content)).toEqual([
|
||||
`const value = Math.max(${value}, 7);`,
|
||||
]);
|
||||
expect(lines.find((line) => line.type === "add")?.tokens).toContainEqual({
|
||||
text: value,
|
||||
style: "number",
|
||||
});
|
||||
};
|
||||
check(initial.files[0], "43");
|
||||
const subscription = ctx.client.observeCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
try {
|
||||
let latest = await subscription.ready;
|
||||
check(latest.files[0], "43");
|
||||
subscription.subscribe({
|
||||
snapshot: (snapshot) => {
|
||||
latest = snapshot;
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type === "checkout_diff_update") latest = message.payload;
|
||||
},
|
||||
});
|
||||
await writeFile(join(cwd, "generated.ts"), original.replace("42", "44"));
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
latest.files[0]?.hunks.flatMap((hunk) => hunk.lines).find((line) => line.type === "add")
|
||||
?.content,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe("const value = Math.max(44, 7);");
|
||||
check(latest.files[0], "44");
|
||||
} finally {
|
||||
await subscription.release();
|
||||
}
|
||||
await runGit("git", ["add", "."], { cwd });
|
||||
await runGit("git", ["-c", "commit.gpgsign=false", "commit", "-m", "edit"], { cwd });
|
||||
const { stdout } = await runGit("git", ["rev-parse", "HEAD"], { cwd });
|
||||
const commit = await ctx.client.getCommitFileDiff(cwd, stdout.trim(), "generated.ts");
|
||||
check(commit.file, "44");
|
||||
console.info({
|
||||
sourceBytes: Buffer.byteLength(original),
|
||||
cheapRpcMs: rpcMs,
|
||||
isolatedDaemonPort: ctx.daemon.port,
|
||||
});
|
||||
} finally {
|
||||
await ctx.cleanup();
|
||||
await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -87,3 +87,53 @@ test("logs --follow preserves the same assistant text as one-shot logs", async (
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("omitted after limit pages forward while explicit zero preserves complete CLI history", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
|
||||
const initial = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
|
||||
const cursor = { epoch: initial.epoch, seq: initial.window.maxSeq };
|
||||
for (let index = 0; index < 451; index++) {
|
||||
await daemon.daemon.agentManager.appendTimelineItem(agent.id, {
|
||||
type: "user_message",
|
||||
text: String(index),
|
||||
});
|
||||
}
|
||||
const first = await client.fetchAgentTimeline(agent.id, { direction: "after", cursor });
|
||||
expect(first.entries).toHaveLength(200);
|
||||
expect(first.hasNewer).toBe(true);
|
||||
expect(first.endCursor).toEqual({ epoch: cursor.epoch, seq: cursor.seq + 200 });
|
||||
const second = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor: first.endCursor!,
|
||||
});
|
||||
const third = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor: second.endCursor!,
|
||||
});
|
||||
expect(second.entries).toHaveLength(200);
|
||||
expect(third.entries).toHaveLength(51);
|
||||
expect(third.hasNewer).toBe(false);
|
||||
expect(
|
||||
[...first.entries, ...second.entries, ...third.entries].map((entry) => entry.item),
|
||||
).toEqual(
|
||||
Array.from({ length: 451 }, (_, index) => ({ type: "user_message", text: String(index) })),
|
||||
);
|
||||
const allAfter = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor,
|
||||
limit: 0,
|
||||
});
|
||||
const allTail = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
|
||||
expect(allAfter.entries).toHaveLength(451);
|
||||
expect(allAfter.hasNewer).toBe(false);
|
||||
expect(allTail.entries).toHaveLength(initial.entries.length + 451);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -219,7 +219,7 @@ test(
|
||||
);
|
||||
|
||||
test(
|
||||
"real Pi daemon executes manual compact out-of-band instead of prompt text",
|
||||
"real Pi daemon accepts a prompt after manual compact",
|
||||
async () => {
|
||||
const cwd = tmpCwd("pi-manual-compact-");
|
||||
|
||||
@@ -234,20 +234,24 @@ test(
|
||||
cwd,
|
||||
title: "pi-manual-compact",
|
||||
provider: "pi",
|
||||
model: PI_COMPACTION_TEST_MODEL,
|
||||
model: PI_REAL_TEST_MODEL,
|
||||
});
|
||||
|
||||
await client.sendMessage(agent.id, "Reply exactly: compact-ready");
|
||||
const finish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.status, JSON.stringify(await fetchCanonicalTimeline(client, agent.id))).toBe(
|
||||
"idle",
|
||||
);
|
||||
|
||||
await client.sendMessage(agent.id, "/compact summarize this e2e run");
|
||||
|
||||
await waitForTimelineItem(
|
||||
client,
|
||||
agent.id,
|
||||
(item) => item.type === "compaction" && item.status === "completed",
|
||||
);
|
||||
await client.sendMessage(agent.id, "Reply exactly: after-compaction");
|
||||
const followUpFinish = await client.waitForFinish(agent.id, PI_TEST_TIMEOUT_MS);
|
||||
expect(followUpFinish.status).toBe("idle");
|
||||
|
||||
const items = await fetchCanonicalTimeline(client, agent.id);
|
||||
expect(items).toEqual(
|
||||
@@ -263,9 +267,12 @@ test(
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
items.some((item) => item.type === "user_message" && item.text.includes("/compact")),
|
||||
).toBe(false);
|
||||
expect(items).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
text: "after-compaction",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
items
|
||||
.filter((item) => item.type === "assistant_message")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user