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

This commit is contained in:
paseo-bot[bot]
2026-11-08 06:37:04 +00:00
1387 changed files with 87894 additions and 20485 deletions
+211
View File
@@ -0,0 +1,211 @@
Copyright (c) 2025-present Mohamed Boudra
Portions of this software are licensed as follows:
* Third-party components incorporated into Paseo remain licensed under
the licenses provided by their respective copyright holders.
* Except for those third-party components, Paseo is licensed under the
Apache License, Version 2.0, reproduced below.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+13 -9
View File
@@ -1,7 +1,8 @@
{
"name": "@getpaseo/server",
"version": "0.14.0",
"version": "0.15.3",
"description": "Paseo backend server",
"license": "Apache-2.0",
"files": [
"dist/server",
"dist/src",
@@ -89,7 +90,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/daemon-e2e/terminal-worker-lifecycle.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/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.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: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/create-agent-mode.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/creation-status.e2e.test.ts src/server/daemon-e2e/persisted-creation-caller.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.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/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.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",
@@ -101,22 +102,25 @@
"test:e2e:real": "npm run test:integration:real",
"test:e2e:local": "npm run test:integration:local",
"test:e2e:relay:live": "cross-env RUN_LIVE_RELAY_E2E=1 vitest run src/server/daemon-e2e/live-relay.real.e2e.test.ts --maxWorkers=1",
"test:e2e:ui": "vitest --ui e2e.test.ts"
"test:e2e:ui": "vitest --ui e2e.test.ts",
"measure:diff-subscriptions": "tsx scripts/measure-diff-subscriptions.ts"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0",
"@getpaseo/highlight": "0.14.0",
"@getpaseo/plugin": "0.14.0",
"@getpaseo/protocol": "0.14.0",
"@getpaseo/relay": "0.14.0",
"@getpaseo/client": "0.15.3",
"@getpaseo/highlight": "0.15.3",
"@getpaseo/plugin": "0.15.3",
"@getpaseo/protocol": "0.15.3",
"@getpaseo/relay": "0.15.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
"@opencode/client": "2.0.10",
"@xterm/headless": "^6.0.0",
"@xterm/addon-serialize": "0.14.0",
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
"@xterm/headless": "6.1.0-beta.334",
"ai": "5.0.78",
"ajv": "^8.20.0",
"bcryptjs": "^3.0.3",
@@ -1,4 +1,4 @@
import { mkdir } from "node:fs/promises";
import { copyFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -27,4 +27,10 @@ for (const directory of ["", "v2/"]) {
target: "es2022",
outfile: outputPath,
});
if (directory === "v2/") {
await copyFile(
new URL("../src/server/agent/providers/opencode/v2/state.mjs", import.meta.url),
path.join(path.dirname(outputPath), "state.mjs"),
);
}
}
@@ -0,0 +1,111 @@
import { performance } from "node:perf_hooks";
import { mkdir, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import {
encodeDiffFrame,
DiffOpcode,
type DiffFrame,
} from "@getpaseo/protocol/binary-frames/index";
import { DiffSubscriptions } from "../src/server/diff/index.js";
import { createFileObserver } from "../src/server/file-observer/index.js";
import { runGitCommand } from "../src/utils/run-git-command.js";
import { parseAndHighlightDiff } from "../src/server/utils/diff-highlighter.js";
import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound";
const cwd = resolve(import.meta.dirname, "../../..");
const artifacts = resolve(cwd, ".artifacts/diff-subscription");
await mkdir(artifacts, { recursive: true });
const pinnedHead = "3ed57e9dd";
const observer = createFileObserver();
const engine = new DiffSubscriptions({
async requestWorkingTreeWatch(root, changed) {
const watch = await observer.subscribe(root, () => changed(), {
ignore: [resolve(root, ".git")],
});
return {
repoRoot: root,
unsubscribe: async () => {
await watch.unsubscribe();
},
};
},
});
try {
for (const count of [1, 30]) {
const [base, head] = await Promise.all([
runGitCommand(["rev-parse", `${pinnedHead}~${count}`], { cwd }),
runGitCommand(["rev-parse", pinnedHead], { cwd }),
]);
const started = performance.now();
const frames: DiffFrame[] = [];
const encoded: Uint8Array[] = [];
const subscription = await engine.subscribe(
{
cwd,
base: { kind: "rev", rev: base.stdout.trim() },
head: { kind: "rev", rev: head.stdout.trim() },
},
async (frame) => {
frames.push(frame);
encoded.push(encodeDiffFrame(frame));
},
);
await subscription.start();
const coldMs = performance.now() - started;
await subscription.release();
const raw = await runGitCommand(["diff", base.stdout.trim(), head.stdout.trim()], {
cwd,
maxOutputBytes: 50 * 1024 * 1024,
});
const contentAt = async (sha: string, path: string) => {
const result = await runGitCommand(["show", `${sha}:${path}`], {
cwd,
acceptExitCodes: [0, 128],
});
return result.exitCode === 0 ? result.stdout : null;
};
const legacyFiles = await parseAndHighlightDiff(raw.stdout, cwd, {
getOldFileContent: (file) => contentAt(base.stdout.trim(), file.path),
getNewFileContent: (file) => contentAt(head.stdout.trim(), file.path),
});
const legacy = JSON.stringify({
type: "session",
message: {
type: "checkout_diff_update",
payload: { cwd, subscriptionId: "benchmark", files: legacyFiles, error: null },
},
});
const parseStart = performance.now();
const validation = validateWSOutboundMessage(JSON.parse(legacy));
if (!validation.success) throw validation.error;
const jsonParseValidationMs = performance.now() - parseStart;
const begin = frames.find((frame) => frame.opcode === DiffOpcode.Begin);
const end = frames.findLast((frame) => frame.opcode === DiffOpcode.End);
if (!begin || begin.opcode !== DiffOpcode.Begin || !end || end.opcode !== DiffOpcode.End)
throw new Error("Revision was not delimited");
if (end.error) throw new Error(end.error.message);
const metrics = {
pinnedHead: head.stdout.trim(),
count,
files: begin.entries.length,
rawPatchBytes: Buffer.byteLength(raw.stdout),
encodedBytes: encoded.reduce((sum, bytes) => sum + bytes.length, 0),
legacyJsonBytes: Buffer.byteLength(legacy),
coldMs,
jsonParseValidationMs,
omitted: end.omitted,
};
console.log(JSON.stringify(metrics));
await writeFile(
resolve(artifacts, `corpus-${count}.json`),
JSON.stringify({
metrics,
legacy,
frames: encoded.map((bytes) => Buffer.from(bytes).toString("base64")),
}),
);
}
} finally {
await engine.close();
await observer.close();
}
@@ -247,7 +247,7 @@ second line'`,
]);
});
it("keeps nested sub-agent logs beside the sub-agent that produced them", () => {
it("lists each sub-agent as its launch marker", () => {
const timeline: AgentTimelineItem[] = [
{ type: "assistant_message", text: "Before first child." },
toolCallItem({
@@ -257,7 +257,7 @@ second line'`,
type: "sub_agent",
subAgentType: "Child one",
description: "First investigation",
log: "[Assistant] First child result.",
log: "",
},
}),
{ type: "assistant_message", text: "Between children." },
@@ -268,7 +268,7 @@ second line'`,
type: "sub_agent",
subAgentType: "Child two",
description: "Second investigation",
log: "[Assistant] Second child result.",
log: "",
},
}),
{ type: "assistant_message", text: "After second child." },
@@ -279,10 +279,8 @@ second line'`,
expect(result.split("\n")).toEqual([
"[Assistant] Before first child.",
"[Child one] First investigation",
"[Assistant] First child result.",
"[Assistant] Between children.",
"[Child two] Second investigation",
"[Assistant] Second child result.",
"[Assistant] After second child.",
]);
});
@@ -456,6 +454,34 @@ second line'`,
).toThrow("Fork from a later completed response");
});
it("forks from a checkpoint when a plugin card was updated after it", () => {
const card = (seq: number, turns: number) =>
row(seq, {
type: "plugin",
id: "memory-receipt",
pluginId: "memorise",
kind: "memory-receipt",
version: 1,
data: { turns },
});
const result = buildAgentForkContextAttachment({
boundaryMessageId: "assistant-1",
rows: [
row(1, { type: "user_message", text: "First question", messageId: "user-1" }),
card(2, 1),
row(3, { type: "assistant_message", text: "First answer.", messageId: "assistant-1" }),
row(4, { type: "user_message", text: "Second question", messageId: "user-2" }),
row(5, { type: "assistant_message", text: "Second answer.", messageId: "assistant-2" }),
card(6, 2),
],
});
expect(result.attachment.text).toContain("[User] First question");
expect(result.attachment.text).toContain("[Assistant] First answer.");
expect(result.attachment.text).not.toContain("Second question");
expect(result.attachment.text).not.toContain("memory-receipt");
});
it("selects a synthetic assistant error by its timeline cursor", () => {
const result = buildAgentForkContextAttachment({
cursorBoundary: {
@@ -13,6 +13,12 @@ const MAX_TOOL_SUMMARY_CHARS = 200;
const MAX_FORK_CONTEXT_CHARS = 100_000;
const MAX_FORK_METADATA_CHARS = 1_000;
const FORK_HISTORY_OMITTED = "[Earlier chat history omitted]\n";
// A fork copies only these kinds, so only they can make a checkpoint stale.
const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [
"user_message",
"assistant_message",
"tool_call",
];
interface ActivityCuratorOptions {
maxItems?: number;
@@ -164,9 +170,6 @@ function curateProjectedActivityEntries(
break;
}
entries.push(formatToolCallEntry(item, options));
if (item.detail.type === "sub_agent" && item.detail.log.trim()) {
entries.push(activityEntry(item.detail.log.trim()));
}
break;
}
case "todo":
@@ -272,7 +275,14 @@ function selectForkContextRows(input: {
);
}
const boundarySeq = projectedRows[boundaryIndex].seqEnd;
if (projectedRows.some((row) => row.seqStart <= boundarySeq && row.seqEnd > boundarySeq)) {
if (
projectedRows.some(
(row) =>
FORK_CONTEXT_KINDS.includes(row.item.type) &&
row.seqStart <= boundarySeq &&
row.seqEnd > boundarySeq,
)
) {
throw new Error(
"This checkpoint changed after it was created. Fork from a later completed response instead.",
);
@@ -352,7 +362,7 @@ export function buildAgentForkContextAttachment(input: {
const entries = curateProjectedActivityEntries(selected.items, {
maxItems: 0,
labelAssistantMessages: true,
includeKinds: ["user_message", "assistant_message", "tool_call"],
includeKinds: FORK_CONTEXT_KINDS,
includeExternalToolInput: false,
});
const metadata = { agentTitle: input.agentTitle, cwd: input.cwd };
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
@@ -17,6 +17,7 @@ import type {
AgentSessionConfig,
} from "./agent-sdk-types.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import { createDaemonTestContext } from "../test-utils/daemon-test-context.js";
test("loads archived records for history and active records with the interactive default", async () => {
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-purpose-"));
@@ -142,60 +143,247 @@ test("resuming a stored agent keeps its unread flag and its last-activity time",
}
});
test("loads an archived agent's history after its working directory is removed", async () => {
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-missing-cwd-"));
const worktree = path.join(root, "managed-worktree");
await mkdir(worktree, { recursive: true });
const logger = createTestLogger();
const storage = new AgentStorage(path.join(root, "agents"), logger);
const manager = new AgentManager({
clients: createTestAgentClients(),
registry: storage,
logger,
});
const agentId = "00000000-0000-4000-8000-000000000501";
try {
const agent = await manager.createAgent({ provider: "codex", cwd: worktree }, agentId, {
workspaceId: "workspace-worktree",
});
await startAgentRun(manager, agent.id, "what did you change", logger, {});
// Dispatching a run does not finish it: the provider appends the reply to its
// history afterwards, and the turn is finalized only once that append lands.
// Archive after the turn is finalized so the transcript this test reads back is
// already on disk when the worktree goes away.
const finished = await manager.waitForAgentEvent(agent.id);
expect(finished.status).toBe("idle");
await manager.archiveAgent(agent.id);
await manager.closeAgent(agent.id);
await manager.flush();
await storage.flush();
// Archiving the workspace removes the worktree it owned. The agent's history is
// persisted and reading it must not depend on that directory still being there.
await rm(worktree, { recursive: true, force: true });
const loaded = await ensureAgentLoaded(agentId, {
agentManager: manager,
agentStorage: storage,
test.each([false, true])(
"loads history after its working directory is removed (archived: %s)",
async (archived) => {
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-missing-cwd-"));
const worktree = path.join(root, "managed-worktree");
await mkdir(worktree, { recursive: true });
const logger = createTestLogger();
const storage = new AgentStorage(path.join(root, "agents"), logger);
const manager = new AgentManager({
clients: createTestAgentClients(),
registry: storage,
logger,
});
expect(loaded.id).toBe(agentId);
// The transcript is replayed from the provider's persisted history, so the reply the
// agent gave before the worktree went away is still readable.
const replies = manager
.getTimeline(agentId)
.filter((item) => item.type === "assistant_message");
expect(replies.length).toBeGreaterThan(0);
expect(replies.every((item) => item.type === "assistant_message" && item.text.length > 0)).toBe(
true,
const agentId = "00000000-0000-4000-8000-000000000501";
try {
const agent = await manager.createAgent({ provider: "codex", cwd: worktree }, agentId, {
workspaceId: "workspace-worktree",
});
await startAgentRun(manager, agent.id, "what did you change", logger, {});
// Dispatching a run does not finish it: the provider appends the reply to its
// history afterwards, and the turn is finalized only once that append lands.
// Archive after the turn is finalized so the transcript this test reads back is
// already on disk when the worktree goes away.
const finished = await manager.waitForAgentEvent(agent.id);
expect(finished.status).toBe("idle");
if (archived) await manager.archiveAgent(agent.id);
await manager.closeAgent(agent.id);
await manager.flush();
await storage.flush();
// Archiving the workspace removes the worktree it owned. The agent's history is
// persisted and reading it must not depend on that directory still being there.
await rm(worktree, { recursive: true, force: true });
const loaded = await ensureAgentLoaded(agentId, {
agentManager: manager,
agentStorage: storage,
logger,
purpose: "history",
});
expect(loaded.id).toBe(agentId);
// The transcript is replayed from the provider's persisted history, so the reply the
// agent gave before the worktree went away is still readable.
const replies = manager
.getTimeline(agentId)
.filter((item) => item.type === "assistant_message");
expect(replies.length).toBeGreaterThan(0);
expect(
replies.every((item) => item.type === "assistant_message" && item.text.length > 0),
).toBe(true);
} finally {
await manager.closeAgent(agentId).catch(() => undefined);
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
await rm(root, { recursive: true, force: true });
}
},
);
test("timeline requests read unarchived stored history with a deleted cwd", async () => {
const ctx = await createDaemonTestContext();
const cwd = await mkdtemp(path.join(tmpdir(), "timeline-deleted-cwd-"));
try {
const agent = await ctx.client.createAgent({ provider: "codex", cwd });
await ctx.client.sendMessage(agent.id, "remember this conversation");
await ctx.client.waitForFinish(agent.id, 15000);
const before = await ctx.client.fetchAgentTimeline(agent.id);
await ctx.daemon.daemon.agentManager.closeAgent(agent.id);
await ctx.daemon.daemon.agentManager.flush();
await rm(cwd, { recursive: true, force: true });
const after = await ctx.client.fetchAgentTimeline(agent.id);
expect(after.entries).toEqual(before.entries);
expect(after.entries.length).toBeGreaterThan(0);
await expect(ctx.client.sendMessage(agent.id, "continue")).rejects.toThrow(
"Working directory does not exist",
);
await expect(ctx.client.createAgent({ provider: "codex", cwd })).rejects.toThrow(
"Working directory does not exist",
);
await mkdir(cwd);
await ctx.client.sendMessage(agent.id, "continue after restoring directory");
expect((await ctx.client.waitForFinish(agent.id, 15000)).status).toBe("idle");
} finally {
await manager.closeAgent(agentId).catch(() => undefined);
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
await rm(root, { recursive: true, force: true });
await ctx.cleanup();
await rm(cwd, { recursive: true, force: true });
}
});
}, 30000);
test("timeline reads keep runnable unarchived agents interactive for the next prompt", async () => {
const clients = createTestAgentClients();
const base = clients.codex!;
const purposes: Array<AgentResumeSessionOptions | undefined> = [];
const client: AgentClient = {
provider: base.provider,
capabilities: base.capabilities,
createSession: (config, context) => base.createSession(config, context),
resumeSession: (handle, overrides, context, options) => {
purposes.push(options);
return base.resumeSession(handle, overrides, context, options);
},
fetchCatalog: (options) => base.fetchCatalog(options),
isAvailable: () => base.isAvailable(),
};
const ctx = await createDaemonTestContext({ agentClients: { codex: client } });
const cwd = await mkdtemp(path.join(tmpdir(), "timeline-interactive-"));
try {
const agent = await ctx.client.createAgent({ provider: "codex", cwd });
await ctx.daemon.daemon.agentManager.closeAgent(agent.id);
await ctx.client.fetchAgentTimeline(agent.id);
expect(purposes).toEqual([{ purpose: "interactive" }]);
await ctx.client.sendMessage(agent.id, "continue after opening history");
expect((await ctx.client.waitForFinish(agent.id, 15000)).status).toBe("idle");
const timeline = await ctx.client.fetchAgentTimeline(agent.id);
expect(timeline.entries.length).toBeGreaterThan(0);
await ctx.daemon.daemon.agentManager.closeAgent(agent.id);
await rm(cwd, { recursive: true, force: true });
await ctx.client.fetchAgentTimeline(agent.id);
await mkdir(cwd);
const manager = ctx.daemon.daemon.agentManager;
await Promise.all([
manager.tryRunOutOfBand(agent.id, "first prompt"),
manager.tryRunOutOfBand(agent.id, "second prompt"),
]);
expect(purposes).toEqual([
{ purpose: "interactive" },
{ purpose: "history" },
undefined, // Reload uses the provider's interactive default.
]);
} finally {
await ctx.cleanup();
await rm(cwd, { recursive: true, force: true });
}
}, 30000);
test.each(["mode", "thinking", "rewind"])(
"restored OMP history accepts %s before starting an interactive turn",
async (setting) => {
const { OmpAgentClient } = await import("./providers/omp/agent.js");
const { FakeOmp } = await import("./providers/omp/test-utils/fake-omp.js");
const { sendPreparedPromptToAgent } = await import("./agent-prompt.js");
const root = await mkdtemp(path.join(tmpdir(), "history-mode-"));
const cwd = path.join(root, "removed-worktree");
const sessionFile = path.join(root, "session.jsonl");
await writeFile(
sessionFile,
[
{ type: "session", id: "root", parentId: null },
{
type: "message",
id: "user-1",
parentId: "root",
message: { role: "user", content: "saved question" },
},
]
.map((entry) => JSON.stringify(entry))
.join("\n") + "\n",
);
const logger = createTestLogger();
const storage = new AgentStorage(path.join(root, "agents"), logger);
const runtime = new FakeOmp();
const manager = new AgentManager({
clients: {
omp: new OmpAgentClient({
logger,
runtime,
runtimeSettings: { command: { mode: "replace", argv: [process.execPath] } },
}),
},
registry: storage,
logger,
});
const agentId = "00000000-0000-4000-8000-000000000601";
const now = new Date().toISOString();
try {
await storage.upsert({
id: agentId,
provider: "omp",
cwd,
createdAt: now,
updatedAt: now,
labels: {},
lastStatus: "closed",
archivedAt: null,
persistence: {
provider: "omp",
sessionId: "history-mode",
nativeHandle: sessionFile,
metadata: { cwd },
},
});
await ensureAgentLoaded(agentId, {
agentManager: manager,
agentStorage: storage,
logger,
purpose: "history",
});
expect(
runtime.recordedLaunches.filter((launch) => launch.session === sessionFile),
).toHaveLength(0);
await mkdir(cwd);
if (setting === "rewind") {
await manager.rewind(agentId, "user-1", "conversation");
expect(runtime.latestSession().branchRequests).toEqual(["user-1"]);
}
if (setting === "thinking") {
await manager.setAgentThinkingOption(agentId, "high");
expect(manager.getAgent(agentId)?.config.thinkingOptionId).toBe("high");
expect(runtime.latestSession().setThinkingLevelRequests).toContain("high");
}
await sendPreparedPromptToAgent({
agentManager: manager,
agentStorage: storage,
agentId,
logger,
prompt: "continue",
sessionMode: "full",
});
expect(
runtime.recordedLaunches.filter((launch) => launch.session === sessionFile),
).toHaveLength(1);
expect(manager.getAgent(agentId)?.currentModeId).toBe("full");
expect(runtime.latestSession().prompts).toEqual([
expect.objectContaining({ message: "continue" }),
]);
runtime.latestSession().finishTurn();
await manager.waitForAgentEvent(agentId);
await manager.archiveAgent(agentId);
await manager.closeAgent(agentId);
await ensureAgentLoaded(agentId, { agentManager: manager, agentStorage: storage, logger });
const launchesBeforeModeChange = runtime.recordedLaunches.length;
await expect(manager.setAgentMode(agentId, "full")).rejects.toThrow("Agent is archived");
expect(runtime.recordedLaunches).toHaveLength(launchesBeforeModeChange);
} finally {
await manager.closeAgent(agentId).catch(() => undefined);
await manager.flush();
await storage.flush();
await rm(root, { recursive: true, force: true });
}
},
30000,
);
@@ -41,6 +41,7 @@ export interface EnsureAgentLoadedDeps {
agentStorage: AgentStorage;
validProviders?: Iterable<AgentProvider>;
broadcastTimeline?: boolean;
purpose?: "history" | "interactive";
logger: Logger;
}
@@ -118,7 +119,7 @@ export async function ensureAgentLoaded(
buildConfigOverrides(record),
agentId,
{ ...extractTimestamps(record), attention: extractAttention(record) },
record.archivedAt ? { purpose: "history" } : undefined,
deps.purpose ? { purpose: deps.purpose } : undefined,
);
deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence");
} else {
@@ -135,6 +136,7 @@ export async function ensureAgentLoaded(
labels: record.labels,
workspaceId: record.workspaceId,
owner: record.owner,
background: record.background,
env: record.launchEnv,
});
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager, type AgentManagerEvent } from "./agent-manager.js";
import { AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS } from "./agent-stream-coalescer.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
import { projectTimelineRows } from "./timeline-projection.js";
import type {
@@ -31,7 +32,6 @@ import type {
const COALESCE_WINDOW_MS = AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS;
const BEFORE_COALESCE_WINDOW_MS = Math.max(COALESCE_WINDOW_MS - 1, 0);
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: false,
@@ -400,10 +400,7 @@ describe("target coalesced behavior", () => {
try {
const { agentId, session } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output }));
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
await waitForSessionEventQueue();
@@ -425,10 +422,7 @@ describe("target coalesced behavior", () => {
try {
const { agentId } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output }));
await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output }));
@@ -449,10 +443,7 @@ describe("target coalesced behavior", () => {
try {
const { agentId, session } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output }));
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
await harness.manager.hydrateTimelineFromProvider(agentId, { force: true });
@@ -470,10 +461,7 @@ describe("target coalesced behavior", () => {
try {
const { agentId } = await createManagedSession(harness);
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "completed",
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
});
const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output }));
await harness.manager.emitLiveTimelineItem(
agentId,
@@ -495,10 +483,9 @@ describe("target coalesced behavior", () => {
try {
const { agentId, session } = await createManagedSession(harness);
const content = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
const expectedItem = toolCall({
status: "failed",
error: { content: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH) },
});
const expectedItem = limitAgentTimelineItemContent(
toolCall({ status: "failed", error: { content } }),
);
session.pushEvent(timelineEvent(toolCall({ status: "failed", error: { content } })));
await waitForSessionEventQueue();
File diff suppressed because it is too large Load Diff
+270 -50
View File
@@ -98,6 +98,7 @@ import {
type ProviderSubagentDescriptor,
type ProviderSubagentStoreEvent,
} from "./provider-subagents/store.js";
import { AgentBackgroundWorkStore, type AgentBackgroundWorkItem } from "./background-work/index.js";
import { withTimeout } from "../../utils/promise-timeout.js";
import { extractAttention } from "../persistence-hooks.js";
@@ -171,6 +172,29 @@ async function assertUsableWorkingDirectory(cwd: string): Promise<void> {
}
}
interface StopAgentBackgroundWorkOptions {
agentId: string;
itemId: string;
stopToken: string;
}
async function resolveResumePurpose(
record: StoredAgentRecord | null,
requested: AgentResumePurpose | undefined,
cwd: string,
): Promise<AgentResumePurpose> {
if (!record) return requested ?? "interactive";
if (record.archivedAt) return "history";
if (requested !== "history") return "interactive";
try {
await stat(cwd);
return "interactive";
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "history";
throw error;
}
}
interface PreparedSessionConfig {
storedConfig: AgentSessionConfig;
launchConfig: AgentSessionConfig;
@@ -238,6 +262,7 @@ export type AgentManagerEvent =
| { type: "agent_state"; agent: ManagedAgent }
| { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean }
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
| { type: "background_work"; agentId: string; items: AgentBackgroundWorkItem[] }
| { type: "timeline_replacement"; agentId: string; epoch: string }
| {
type: "agent_stream";
@@ -324,6 +349,8 @@ export interface CreateAgentOptions {
// undefined is an explicit decision: the agent never appears in the sidebar.
workspaceId: string | undefined;
owner?: AgentOwner;
/** Background agents stay out of discovery, tabs, tracks and attention. Fixed at creation. */
background?: boolean;
}
export interface AgentManagerOptions {
@@ -387,6 +414,18 @@ export type AttentionState =
attentionTimestamp: Date;
};
/** Private helpers and background agents report to their creator, never to the user. */
function raisesAttention(agent: Pick<ManagedAgent, "internal" | "background">): boolean {
return !agent.internal && !agent.background;
}
/** The attention a read agent gets back when marked unread; null while it is still working. */
function unreadAttentionReason(status: AgentLifecycleStatus): "finished" | "error" | null {
if (status === "idle" || status === "closed") return "finished";
if (status === "error") return "error";
return null;
}
function resolveInitialAttention(input: AttentionState | undefined): AttentionState {
if (input == null || !input.requiresAttention) {
return { requiresAttention: false };
@@ -452,6 +491,11 @@ interface ManagedAgentBase {
* Internal agents are hidden from listings and don't trigger notifications.
*/
internal?: boolean;
/**
* Background agents are durable but hidden from default discovery and never raise attention.
* Fixed at creation.
*/
background?: boolean;
/**
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
*/
@@ -620,6 +664,36 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri
return Object.assign(new Error(message), { name: "AbortError" });
}
// PASEO_WORKSPACE_ID lets an agent rename or archive its own workspace; several workspaces can
// share one cwd, so the agent can't work it out from PASEO_AGENT_CWD.
function agentIdentityEnv({
agentId,
cwd,
opening,
}: {
agentId: string;
cwd: string;
opening: { workspaceId?: string | null } | undefined;
}): Record<string, string> {
const workspaceId = opening?.workspaceId;
return {
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
...(workspaceId ? { PASEO_WORKSPACE_ID: workspaceId } : {}),
};
}
// Launch env and the registered agent must agree, or the next snapshot erases the stored workspace.
function resolveResumeWorkspaceId({
options,
record,
}: {
options: { workspaceId?: string } | undefined;
record: Pick<StoredAgentRecord, "workspaceId"> | null;
}): string | undefined {
return options?.workspaceId ?? record?.workspaceId;
}
function validateAgentId(agentId: string, source: string): string {
const result = AgentIdSchema.safeParse(agentId);
if (!result.success) {
@@ -758,6 +832,7 @@ export class AgentManager {
private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore: InMemoryAgentTimelineStore;
private readonly providerSubagents = new ProviderSubagentStore();
private readonly backgroundWork = new AgentBackgroundWorkStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
private readonly sessionEventTails = new Map<string, Promise<void>>();
private readonly steerEventBarriers = new Map<string, SteerEventBarrier>();
@@ -771,6 +846,7 @@ export class AgentManager {
private readonly backgroundTasks = new Set<Promise<void>>();
private readonly agentRegistrationTasks = new Set<Promise<void>>();
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
private readonly historySessions = new WeakSet<AgentSession>();
private readonly reloadedSessionCloses = new WeakMap<AgentSession, Promise<void>>();
private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>();
private readonly collectingRuntimes = new Set<string>();
@@ -1317,6 +1393,38 @@ export class AgentManager {
return this.providerSubagents.list(parentAgentId);
}
listAgentBackgroundWork(agentId: string): AgentBackgroundWorkItem[] {
this.requirePublicAgent(agentId);
return this.backgroundWork.list(agentId);
}
async stopAgentBackgroundWork({
agentId,
itemId,
stopToken,
}: StopAgentBackgroundWorkOptions): Promise<void> {
this.requirePublicAgent(agentId);
// Inventory events share the session queue; drain events already received before
// authorizing the token, including a reconnect's ownership revocation.
await this.sessionEventTails.get(agentId)?.catch(() => undefined);
const agent = this.requirePublicAgent(agentId);
if (!agent.session || !agent.session.stopBackgroundWork)
throw new Error("Background work cannot be stopped");
const item = this.backgroundWork.list(agentId).find((entry) => entry.id === itemId);
if (!item?.canStop || item.stopToken !== stopToken)
throw new Error("Background work is no longer available to stop");
await agent.session.stopBackgroundWork(itemId);
}
getAgentBackgroundWorkSummary(agentId: string): { count: number } | undefined {
return this.backgroundWork.summary(agentId);
}
private clearBackgroundWork(agentId: string): void {
const items = this.backgroundWork.clear(agentId);
if (items) this.dispatch({ type: "background_work", agentId, items });
}
listProviderSubagentActivity(): ProviderSubagentDescriptor[] {
const publicParentIds = new Set(
Array.from(this.agents.values())
@@ -1402,6 +1510,7 @@ export class AgentManager {
initialTitle: options.initialTitle,
workspaceId: options.workspaceId,
owner: options.owner,
background: options.background,
launchEnv: options.env ? { ...options.env } : undefined,
historyPrimed: true,
});
@@ -1434,6 +1543,7 @@ export class AgentManager {
labels?: Record<string, string>;
workspaceId?: string;
owner?: AgentOwner;
background?: boolean;
attention?: AttentionState;
},
resumeOptions?: AgentResumeSessionOptions,
@@ -1466,6 +1576,7 @@ export class AgentManager {
labels?: Record<string, string>;
workspaceId?: string;
owner?: AgentOwner;
background?: boolean;
attention?: AttentionState;
},
resumeOptions?: AgentResumeSessionOptions,
@@ -1486,12 +1597,11 @@ export class AgentManager {
// settled before the config is prepared, because a history load reads an archived
// agent whose working directory may be gone.
const record = this.registry ? await this.registry.get(resolvedAgentId) : null;
const currentResumeOptions = record
? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) }
: resumeOptions;
const purpose = currentResumeOptions?.purpose ?? "interactive";
const purpose = await resolveResumePurpose(record, resumeOptions?.purpose, mergedConfig.cwd);
const currentResumeOptions = { purpose } satisfies AgentResumeSessionOptions;
const launchEnv = record?.launchEnv;
const workspaceId = resolveResumeWorkspaceId({ options, record });
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
mergedConfig,
resolvedAgentId,
@@ -1514,7 +1624,7 @@ export class AgentManager {
{
reason: "resume",
purpose,
workspaceId: options?.workspaceId ?? null,
workspaceId,
title: record?.title ?? mergedConfig.title ?? null,
labels: record?.labels ?? options?.labels ?? {},
},
@@ -1526,9 +1636,11 @@ export class AgentManager {
launchContext,
currentResumeOptions,
);
if (purpose === "history") this.historySessions.add(session);
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, {
...options,
workspaceId,
launchEnv,
persistence: handle,
restoring: true,
@@ -1567,6 +1679,7 @@ export class AgentManager {
cwd: input.cwd,
},
resolvedAgentId,
{ resolveDefaultModel: false },
);
this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy);
const launchContext = await this.buildLaunchContext(
@@ -1593,8 +1706,13 @@ export class AgentManager {
await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext);
let handedToRegistration = false;
try {
const runtimeInfo = await imported.session.getRuntimeInfo();
const importedConfig = await this.normalizeConfig(
stripInternalPaseoMcpServer(imported.config),
stripInternalPaseoMcpServer({
...imported.config,
model: runtimeInfo.model ?? imported.config.model,
}),
{ resolveDefaultModel: false },
);
const timelineRows = buildImportedTimelineRows(imported.timeline);
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
@@ -1701,6 +1819,7 @@ export class AgentManager {
await this.closeReloadedSession(existing.session, agentId);
await this.drainSessionEvents(agentId);
this.cancelRunningProviderSubagents(agentId);
this.clearBackgroundWork(agentId);
closedExisting = this.prepareAgentForClosure(existing, "agent reloaded");
await this.persistSnapshot(closedExisting);
this.assertAcceptingAgentRegistrations();
@@ -1727,6 +1846,7 @@ export class AgentManager {
labels: existing.labels,
workspaceId: existing.workspaceId,
owner: existing.owner,
background: existing.background,
launchEnv: existing.launchEnv,
createdAt: existing.createdAt,
updatedAt: existing.updatedAt,
@@ -1743,6 +1863,7 @@ export class AgentManager {
} else if (this.agents.get(agentId) === existing) {
existing.lifecycle = "error";
existing.lastError = error instanceof Error ? error.message : String(error);
this.raiseErrorAttention(existing);
this.emitState(existing);
}
throw error;
@@ -1947,7 +2068,10 @@ export class AgentManager {
// Retain ownership until shutdown succeeds. A failed close may still own a
// native writer, so publishing a resumable closed snapshot would orphan it.
await agent.session.close();
await this.drainSessionEvents(agentId);
this.cancelRunningProviderSubagents(agentId);
// close() drops the session's subscribers before the provider could report an empty list.
this.clearBackgroundWork(agentId);
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
let persistError: unknown;
@@ -2048,7 +2172,7 @@ export class AgentManager {
return;
}
const parentAgentId = parent.id;
const records = await registry.list();
const records = await registry.listActive();
for (const record of records) {
if (record.archivedAt) {
continue;
@@ -2070,7 +2194,10 @@ export class AgentManager {
return;
}
if (shouldDetachFromArchivedParent(parent, currentChild)) {
await this.detachAgentUnlocked(currentChild.id);
const detached = await this.detachAgentUnlocked(currentChild.id);
if (!detached.live) {
this.dispatchStoredAgentState(detached.record);
}
} else if (this.agents.has(currentChild.id)) {
await this.archiveAgentUnlocked(currentChild.id);
} else {
@@ -2156,7 +2283,7 @@ export class AgentManager {
session: null,
capabilities: STORED_AGENT_CAPABILITIES,
config: buildStoredAgentConfig(record),
runtimeInfo: undefined,
runtimeInfo: record.runtimeInfo,
lifecycle: "closed",
createdAt: new Date(record.createdAt),
updatedAt,
@@ -2180,13 +2307,14 @@ export class AgentManager {
lastError: record.lastError ?? undefined,
attention,
internal: record.internal,
background: record.background,
labels: record.labels,
},
});
}
async setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
const agent = this.requireSessionAgent(agentId);
const agent = await this.ensureInteractiveSession(agentId);
const notice = (await agent.session.setMode(modeId)) ?? null;
await this.drainSessionEvents(agentId);
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
@@ -2202,18 +2330,20 @@ export class AgentManager {
}
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
const agent = this.requireSessionAgent(agentId);
const agent = await this.ensureInteractiveSession(agentId);
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
if (agent.session.setModel) {
await agent.session.setModel(normalizedModelId);
}
const selection = await agent.session.setModel?.(normalizedModelId);
await this.drainSessionEvents(agentId);
agent.config.model = normalizedModelId ?? undefined;
// A reset clears the preference even when the provider retains its active model.
let effectiveModelId = normalizedModelId;
if (normalizedModelId !== null && selection) effectiveModelId = selection.model;
agent.config.model = effectiveModelId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
agent.runtimeInfo = { ...agent.runtimeInfo, model: effectiveModelId };
}
this.refreshSessionPersistence(agent);
this.touchUpdatedAt(agent);
@@ -2224,7 +2354,7 @@ export class AgentManager {
agentId: string,
thinkingOptionId: string | null,
): Promise<AgentProviderNotice | null> {
const agent = this.requireSessionAgent(agentId);
const agent = await this.ensureInteractiveSession(agentId);
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
@@ -2255,7 +2385,7 @@ export class AgentManager {
}
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
const agent = this.requireAgent(agentId);
const agent = await this.ensureInteractiveSession(agentId);
if (!agent.session.setFeature) {
throw new Error("Agent session does not support setting features");
@@ -2398,16 +2528,16 @@ export class AgentManager {
async markAgentUnread(agentId: string): Promise<void> {
const liveAgent = this.agents.get(agentId);
if (liveAgent) {
const isFinished = liveAgent.lifecycle === "idle";
const attentionReason = unreadAttentionReason(liveAgent.lifecycle);
const hasPendingPermissions = liveAgent.pendingPermissions.size > 0;
const canMarkUnread =
isFinished && !liveAgent.attention.requiresAttention && !hasPendingPermissions;
attentionReason && !liveAgent.attention.requiresAttention && !hasPendingPermissions;
if (!canMarkUnread) {
throw new Error(`Agent is no longer finished and read: ${agentId}`);
throw new Error(`Agent is no longer settled and read: ${agentId}`);
}
liveAgent.attention = {
requiresAttention: true,
attentionReason: "finished",
attentionReason,
attentionTimestamp: new Date(),
};
await this.persistSnapshot(liveAgent);
@@ -2417,18 +2547,18 @@ export class AgentManager {
const registry = this.requireRegistry();
const record = await registry.get(agentId);
const hasFinishedStatus = record?.lastStatus === "idle" || record?.lastStatus === "closed";
const attentionReason = record?.lastStatus ? unreadAttentionReason(record.lastStatus) : null;
const canMarkUnread =
record && !record.internal && !record.archivedAt && !record.requiresAttention;
if (!canMarkUnread || !hasFinishedStatus) {
throw new Error(`Agent is no longer finished and read: ${agentId}`);
if (!canMarkUnread || !attentionReason) {
throw new Error(`Agent is no longer settled and read: ${agentId}`);
}
const updatedAt = this.nextStoredUpdatedAt(record);
const nextRecord: StoredAgentRecord = {
...record,
updatedAt,
requiresAttention: true,
attentionReason: "finished",
attentionReason,
attentionTimestamp: updatedAt,
};
await registry.upsert(nextRecord);
@@ -2497,23 +2627,28 @@ export class AgentManager {
if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId);
await this.syncNativeArchiveState(record.provider, record.persistence, "restore");
await registry.upsert({
const nextRecord: StoredAgentRecord = {
...record,
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
archivedAt: null,
updatedAt: new Date().toISOString(),
});
};
await registry.upsert(nextRecord);
if (this.getAgent(agentId)) {
this.notifyAgentState(agentId);
} else if (!nextRecord.internal) {
// Same publication archiveSnapshot makes, so a stored-only agent reappears for every client.
this.dispatchStoredAgentState(nextRecord);
}
return true;
}
async unarchiveSnapshotByHandle(handle: AgentPersistenceHandle): Promise<void> {
const registry = this.requireRegistry();
const records = await registry.list();
// Restoring an archived provider session is an explicit recovery operation.
const records = await registry.listAll();
const matched = records.find(
(record) =>
record.persistence?.provider === handle.provider &&
@@ -2636,7 +2771,7 @@ export class AgentManager {
logger: this.logger,
});
}
const agent = this.requireSessionAgent(agentId);
const agent = await this.ensureInteractiveSession(agentId);
// Providers parse slash commands from the raw text, so a command another agent sent
// must reach them without its sender envelope.
const agentMessage = typeof prompt === "string" ? parseAgentMessage(prompt) : null;
@@ -2765,6 +2900,34 @@ export class AgentManager {
}
}
private async ensureInteractiveSession(agentId: string): Promise<ActiveManagedAgent> {
const agent = this.requireSessionAgent(agentId);
if (this.historySessions.has(agent.session)) {
await this.trackAgentRegistrationOperation(
this.runLifecycleMutation(agentId, async () => {
const record = await this.registry?.get(agentId);
if (record?.archivedAt) throw new Error(`Agent is archived: ${agentId}`);
const current = this.requireSessionAgent(agentId);
if (this.historySessions.has(current.session)) {
// Recheck inside the lane: another prompt may already have upgraded it.
// Reload validates the directory before releasing readable history.
await this.reloadAgentSessionInternal(agentId);
}
}),
);
}
return this.requireSessionAgent(agentId);
}
private async *streamAfterHistoryLoad(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions,
): AsyncGenerator<AgentStreamEvent> {
await this.ensureInteractiveSession(agentId);
yield* this.streamAgent(agentId, prompt, options);
}
private async *streamAfterRuntimeCollection(
agentId: string,
prompt: AgentPromptInput,
@@ -2787,6 +2950,8 @@ export class AgentManager {
if (this.collectingRuntimes.has(agentId) || (!this.agents.has(agentId) && this.registry))
return this.streamAfterRuntimeCollection(agentId, prompt, options);
const existingAgent = this.requireSessionAgent(agentId);
if (this.historySessions.has(existingAgent.session))
return this.streamAfterHistoryLoad(agentId, prompt, options);
this.logger.trace(
{
agentId,
@@ -2840,6 +3005,7 @@ export class AgentManager {
this.runs.settleForegroundStart(pendingRun, { status: "started", turnId });
agent.activeForegroundTurnId = turnId;
this.openActiveTurn(agent, turnId, turnStartedAt);
agent.attention = { requiresAttention: false };
agent.lifecycle = "running";
this.touchUpdatedAt(agent);
// AgentManager owns the accepted-turn boundary. Publish liveness before the canonical
@@ -3029,7 +3195,13 @@ export class AgentManager {
expectedTurnId,
});
if (admission.status === "accepted") {
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId);
await this.recordAcceptedSteer(
agent,
prompt,
options?.clientMessageId,
expectedTurnId,
admission.providerMessageId,
);
}
return admission;
});
@@ -3062,7 +3234,13 @@ export class AgentManager {
expectedTurnId,
});
if (admission.status === "accepted") {
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId);
await this.recordAcceptedSteer(
agent,
prompt,
options?.clientMessageId,
expectedTurnId,
admission.providerMessageId,
);
}
return admission;
})
@@ -3167,6 +3345,7 @@ export class AgentManager {
prompt: AgentPromptInput,
clientMessageId: string | undefined,
expectedTurnId: string,
providerMessageId: string | undefined,
): Promise<void> {
if (!clientMessageId) {
return;
@@ -3174,6 +3353,7 @@ export class AgentManager {
this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", {
messageId: clientMessageId,
turnId: expectedTurnId,
...(providerMessageId ? { providerMessageId } : {}),
});
this.emitState(agent);
}
@@ -3458,7 +3638,12 @@ export class AgentManager {
}
async rewind(agentId: string, messageId: string, mode: RewindMode): Promise<void> {
const agent = this.requireSessionAgent(agentId);
let agent = this.requireSessionAgent(agentId);
if (this.historySessions.has(agent.session)) {
agent = await this.ensureInteractiveSession(agentId);
}
// An interactive session must acquire its rewind lock without yielding.
const submittedRow = this.timelineStore
.getRows(agentId)
.find(
@@ -3505,6 +3690,10 @@ export class AgentManager {
"agent.rewind.complete",
);
} catch (error) {
// Native rewind can commit a fork before history refresh fails. Recovery
// must resume that latest handle rather than the abandoned conversation.
this.refreshSessionPersistence(agent);
await this.persistSnapshot(agent);
this.logger.warn(
{ err: error, agentId, provider: agent.provider, messageId, mode },
"agent.rewind.failed",
@@ -3787,6 +3976,7 @@ export class AgentManager {
publishWhenReady?: boolean;
workspaceId?: string;
owner?: AgentOwner;
background?: boolean;
launchEnv?: Record<string, string>;
},
): Promise<ManagedAgent> {
@@ -3816,6 +4006,7 @@ export class AgentManager {
config,
now,
durableTimelineHasRows,
background: options?.background === true,
options,
});
@@ -3960,6 +4151,7 @@ export class AgentManager {
config: AgentSessionConfig;
now: Date;
durableTimelineHasRows: boolean;
background: boolean;
options:
| {
createdAt?: Date;
@@ -4014,6 +4206,7 @@ export class AgentManager {
lastError: options?.lastError,
attention: resolveInitialAttention(options?.attention),
internal: config.internal ?? false,
background: params.background,
labels: options?.labels ?? {},
} as ActiveManagedAgent;
}
@@ -4073,6 +4266,7 @@ export class AgentManager {
for (const event of this.providerSubagents.deleteParent(agentId)) {
this.dispatch({ type: "provider_subagent", event });
}
this.clearBackgroundWork(agentId);
}
private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void {
@@ -4182,6 +4376,14 @@ export class AgentManager {
this.dispatch({ type: "provider_subagent", event: update });
return;
}
if (event.type === "background_work") {
const items =
event.items === null
? this.backgroundWork.clear(agent.id)
: this.backgroundWork.apply(agent.id, event.items);
if (items) this.dispatch({ type: "background_work", agentId: agent.id, items });
return;
}
const turnId = getAgentStreamEventTurnId(event);
const matchingWaiters = this.runs.getMatchingWaiters(agent, turnId);
this.logger.trace(
@@ -4934,6 +5136,8 @@ export class AgentManager {
if (eventTurnId) {
this.openActiveTurn(agent, eventTurnId, new Date());
}
agent.lastError = undefined;
agent.attention = { requiresAttention: false };
agent.lifecycle = "running";
this.emitState(agent);
}
@@ -4945,7 +5149,7 @@ export class AgentManager {
const hadPendingPermissions = agent.pendingPermissions.size > 0;
agent.pendingPermissions.set(event.request.id, event.request);
this.refreshSessionPersistence(agent);
if (!hadPendingPermissions && !agent.internal) {
if (!hadPendingPermissions && raisesAttention(agent)) {
this.broadcastAgentAttention(agent, "permission");
}
this.emitState(agent);
@@ -5200,17 +5404,19 @@ export class AgentManager {
// Track the new status
this.previousStatuses.set(agent.id, currentStatus);
// Skip attention tracking for internal agents
if (agent.internal) {
if (!raisesAttention(agent)) {
return;
}
if (previousStatus !== "error" && currentStatus === "error") {
this.raiseErrorAttention(agent);
return;
}
// Skip if already requires attention
if (agent.attention.requiresAttention) {
return;
}
// Check if agent transitioned from running to idle (finished)
if (previousStatus === "running" && currentStatus === "idle") {
agent.attention = {
requiresAttention: true,
@@ -5218,19 +5424,25 @@ export class AgentManager {
attentionTimestamp: new Date(),
};
this.broadcastAgentAttention(agent, "finished");
return;
}
}
// Check if agent entered error state
if (previousStatus !== "error" && currentStatus === "error") {
agent.attention = {
requiresAttention: true,
attentionReason: "error",
attentionTimestamp: new Date(),
};
this.broadcastAgentAttention(agent, "error");
// The failed state is only shown while error attention is unread, so every new failure raises
// it, including one that keeps the lifecycle at "error". An error outranks an unread finish,
// which would otherwise make the failure look green.
private raiseErrorAttention(agent: ManagedAgent): void {
if (!raisesAttention(agent)) {
return;
}
if (agent.attention.requiresAttention && agent.attention.attentionReason !== "finished") {
return;
}
agent.attention = {
requiresAttention: true,
attentionReason: "error",
attentionTimestamp: new Date(),
};
this.broadcastAgentAttention(agent, "error");
}
private enqueueBackgroundPersist(agent: ManagedAgent): void {
@@ -5391,6 +5603,13 @@ export class AgentManager {
) {
continue;
}
if (
subscriber.agentId &&
event.type === "background_work" &&
subscriber.agentId !== event.agentId
) {
continue;
}
if (
subscriber.agentId &&
event.type === "agent_state" &&
@@ -5424,6 +5643,7 @@ export class AgentManager {
if (event.type === "agent_state") return event.agent.internal === true;
if (event.type === "agent_removed") return event.internal;
if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true;
if (event.type === "background_work") return this.agents.get(event.agentId)?.internal === true;
if (event.type !== "provider_subagent") return false;
const parentAgentId =
event.event.type === "upsert"
@@ -5511,11 +5731,12 @@ export class AgentManager {
private async prepareSessionConfig(
config: AgentSessionConfig,
agentId: string,
options: { env?: Record<string, string>; purpose?: AgentResumePurpose } = {},
options: NormalizeConfigOptions = {},
): Promise<PreparedSessionConfig> {
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), {
env: options.env,
purpose: options.purpose,
resolveDefaultModel: options.resolveDefaultModel,
});
const paseoToolPolicy = this.paseoToolsEnabled
? this.resolvePaseoToolPolicy(storedConfig.provider)
@@ -5594,8 +5815,7 @@ export class AgentManager {
mcpServerOwners,
env: {
...env,
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
...agentIdentityEnv({ agentId, cwd, opening }),
},
};
const useNativeTools =
@@ -4,6 +4,7 @@ import { AGENT_LIFECYCLE_STATUSES } from "./agent-manager.js";
import {
buildStoredAgentPayload,
toAgentPayload,
toAgentListItemPayload,
toRecentProviderSessionDescriptorPayload,
toStoredAgentRecord,
type ManagedAgent,
@@ -539,3 +540,11 @@ describe("toRecentProviderSessionDescriptorPayload", () => {
});
});
});
it("omits unknown background work and preserves known summary in list items", () => {
const snapshot = toAgentPayload(createManagedAgent());
expect(toAgentListItemPayload(snapshot)).not.toHaveProperty("backgroundWork");
expect(toAgentListItemPayload({ ...snapshot, backgroundWork: { count: 2 } })).toMatchObject({
backgroundWork: { count: 2 },
});
});
@@ -106,6 +106,7 @@ export function toStoredAgentRecord(
? agent.attention.attentionTimestamp.toISOString()
: null,
internal: options?.internal,
...(agent.background ? { background: true } : {}),
owner: agent.owner,
} satisfies StoredAgentRecord;
}
@@ -149,7 +150,7 @@ export function toAgentPayload(
title: options?.title ?? null,
labels: agent.labels,
archivedAt: options?.archivedAt ?? null,
...(agent.internal ? { internal: true } : {}),
...visibilityFlags(agent),
};
const usage = sanitizeUsage(agent.lastUsage);
@@ -206,6 +207,17 @@ function buildStoredPersistenceHandle(
return toAgentPersistenceHandle(validProviders, record.persistence);
}
/** The wire carries `internal` and `background` only when set. */
function visibilityFlags(agent: {
internal?: boolean;
background?: boolean;
}): Pick<AgentSnapshotPayload, "internal" | "background"> {
return {
...(agent.internal ? { internal: true } : {}),
...(agent.background ? { background: true } : {}),
};
}
export function buildStoredAgentPayload(
record: StoredAgentRecord,
validProviders: Iterable<AgentProvider>,
@@ -258,7 +270,7 @@ export function buildStoredAgentPayload(
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ?? null,
archivedAt: record.archivedAt ?? null,
...(record.internal ? { internal: true } : {}),
...visibilityFlags(record),
labels: normalizeLabels(record.labels),
...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}),
...(providerAvailable ? {} : { providerUnavailable: true }),
@@ -275,6 +287,7 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt
thinkingOptionId: agent.thinkingOptionId,
effectiveThinkingOptionId: agent.effectiveThinkingOptionId,
status: agent.status,
...(agent.backgroundWork ? { backgroundWork: agent.backgroundWork } : {}),
cwd: agent.cwd,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
@@ -208,7 +208,11 @@ export interface AgentSteerOptions extends AgentRunOptions {
clearPendingPermissions?: boolean;
}
export type SteerResult = { status: "accepted" } | { status: "unavailable" };
// providerMessageId names the steered message in the provider's own history, when the provider
// mints one. Rewinding to the message needs it.
export type SteerResult =
| { status: "accepted"; providerMessageId?: string }
| { status: "unavailable" };
export interface SteerActiveTurnOptions extends AgentSteerOptions {
expectedTurnId: string;
@@ -313,7 +317,10 @@ export type ToolCallDetail =
subAgentType?: string;
description?: string;
childSessionId?: string;
log: string;
// COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. Apps before v0.14.1
// require `log`; every producer sends it empty and carries the same tag. Optional here because
// protocol timeline items, where `log` is optional, flow into this type.
log?: string;
actions?: Array<{
index: number;
toolName: string;
@@ -461,6 +468,12 @@ export type AgentStreamEvent =
type: "provider_subagent";
provider: AgentProvider;
event: import("./provider-subagents/store.js").ProviderSubagentInputEvent;
}
| {
/** Full replacement of the agent's live background work (shells, monitors…). */
type: "background_work";
provider: AgentProvider;
items: import("./background-work/index.js").AgentBackgroundWorkInput[] | null;
};
export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined {
@@ -700,6 +713,7 @@ export interface AgentSession {
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void>;
/** Null until a new provider process could resume this session; see docs/providers.md. */
describePersistence(): AgentPersistenceHandle | null;
/**
* Resolve once every foreground turn that predates this call can no longer run or become active.
@@ -707,6 +721,8 @@ export interface AgentSession {
* still uncertain.
*/
interrupt(): Promise<void>;
/** Stop one currently owned background item and wait for provider confirmation. */
stopBackgroundWork?(id: string): Promise<void>;
/** True only when releasing this runtime loses no background work or pending replies.
* An optional additional veto: missing methods use daemon baseline eligibility;
* implemented checks that reject or return false keep the runtime resident. */
@@ -714,7 +730,9 @@ export interface AgentSession {
/** Release live runtime resources without archiving or deleting the durable native session. */
close(): Promise<void>;
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
/** A returned model is the effective selection committed by this mutation.
* Returning void means the requested selection was accepted. No status read is required. */
setModel?(modelId: string | null): Promise<void | { model: string | null }>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>;
revertConversation?(input: { messageId: string }): Promise<void>;
@@ -3,6 +3,8 @@ import os from "node:os";
import path from "node:path";
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
import { promises as fs } from "node:fs";
import { Session as InspectorSession } from "node:inspector/promises";
import type { HeapProfiler } from "node:inspector";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentStorage } from "./agent-storage.js";
@@ -148,6 +150,101 @@ describe("AgentStorage", () => {
rmSync(tmpDir, { recursive: true, force: true });
});
test("workspace and active queries follow updates, removal, and reload", async () => {
const base = {
provider: "codex",
cwd: tmpDir,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
labels: {},
};
await storage.upsert({
...base,
id: "a",
workspaceId: "one",
lastUserMessageAt: base.createdAt,
});
await storage.upsert({ ...base, id: "b", workspaceId: "two", archivedAt: base.createdAt });
expect((await storage.listActive()).map((r) => r.id)).toEqual(["a"]);
await storage.upsert({
...base,
id: "a",
workspaceId: "two",
lastUserMessageAt: base.updatedAt,
});
expect(await storage.listByWorkspaces(["one"])).toEqual([]);
expect((await storage.listByWorkspaces(["two"])).map((r) => r.id)).toEqual(["a"]);
expect(
(await storage.listByWorkspaces(["two"], { includeArchived: true })).map((r) => r.id).sort(),
).toEqual(["a", "b"]);
expect(await storage.lastUserMessageAtByWorkspace(["one", "two"])).toEqual(
new Map([["two", base.createdAt]]),
);
await storage.flush();
const reloaded = new AgentStorage(storagePath, createTestLogger());
expect((await reloaded.listActive()).map((r) => r.id)).toEqual(["a"]);
expect(
await reloaded.lastUserMessageAtByWorkspace(["two"], { excludeAgentIds: ["a"] }),
).toEqual(new Map());
await reloaded.remove("a");
expect(await reloaded.listActive()).toEqual([]);
expect(await reloaded.lastUserMessageAtByWorkspace(["two"])).toEqual(new Map());
});
test("single-agent lookups keep allocations bounded with a large stored history", async () => {
await fs.mkdir(storagePath, { recursive: true });
const record = {
id: "history-0",
provider: "codex",
cwd: tmpDir,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
labels: {},
lastStatus: "idle",
};
for (let offset = 0; offset < 2048; offset += 64) {
await Promise.all(
Array.from({ length: 64 }, (_, index) => {
const id = `history-${offset + index}`;
return fs.writeFile(
path.join(storagePath, `${id}.json`),
JSON.stringify({ ...record, id }),
);
}),
);
}
await storage.initialize();
expect(await storage.listAll()).toHaveLength(2048);
const expected = await storage.get(record.id);
expect(expected).toMatchObject(record);
const inspector = new InspectorSession();
inspector.connect();
let allocatedBytes = 0;
let matches = 0;
try {
await inspector.post("HeapProfiler.startSampling", {
samplingInterval: 16384,
includeObjectsCollectedByMajorGC: true,
includeObjectsCollectedByMinorGC: true,
});
for (let index = 0; index < 1000; index++) {
if ((await storage.get(record.id)) === expected) matches++;
}
const { profile } = await inspector.post("HeapProfiler.stopSampling");
const sum = (node: HeapProfiler.SamplingHeapProfileNode): number =>
node.selfSize + node.children.reduce((bytes, child) => bytes + sum(child), 0);
allocatedBytes = sum(profile.head);
} finally {
inspector.disconnect();
}
expect(matches).toBe(1000);
// Copying this history on every lookup allocates >16 MiB. Allow generous
// sampling/runtime overhead without relying on the host's execution speed.
expect(allocatedBytes).toBeLessThan(4 * 1024 * 1024);
});
test("applySnapshot persists configs and snapshot metadata", async () => {
await storage.applySnapshot(
createManagedAgent({
@@ -172,7 +269,7 @@ describe("AgentStorage", () => {
}),
);
const records = await storage.list();
const records = await storage.listAll();
expect(records).toHaveLength(1);
const [record] = records;
expect(record.provider).toBe("claude");
@@ -189,8 +286,8 @@ describe("AgentStorage", () => {
expect(record.lastModeId).toBe("coding");
expect(record.lastStatus).toBe("idle");
const reloaded = new AgentStorage(storagePath, logger);
const [persisted] = await reloaded.list();
const reloaded = new AgentStorage(storagePath, createTestLogger());
const [persisted] = await reloaded.listAll();
expect(persisted.cwd).toBe("/tmp/project");
expect(persisted.config?.providerOptions).toEqual({ allowedTools: ["Read"] });
});
@@ -210,7 +307,7 @@ describe("AgentStorage", () => {
const record = await storage.get("agent-feature-values");
expect(record?.config?.featureValues).toEqual({ fast_mode: true });
const reloaded = new AgentStorage(storagePath, logger);
const reloaded = new AgentStorage(storagePath, createTestLogger());
const persisted = await reloaded.get("agent-feature-values");
expect(persisted?.config?.featureValues).toEqual({ fast_mode: true });
expect(buildSessionConfig(persisted!).featureValues).toEqual({ fast_mode: true });
@@ -223,7 +320,7 @@ describe("AgentStorage", () => {
}),
);
const reloaded = new AgentStorage(storagePath, logger);
const reloaded = new AgentStorage(storagePath, createTestLogger());
const persisted = await reloaded.get("agent-no-feature-values");
expect(persisted?.config?.featureValues).toBeUndefined();
expect(buildSessionConfig(persisted!).featureValues).toBeUndefined();
@@ -312,7 +409,7 @@ describe("AgentStorage", () => {
const current = await storage.get("agent-2");
expect(current?.title).toBe("Fix Login Bug");
const reloaded = new AgentStorage(storagePath, logger);
const reloaded = new AgentStorage(storagePath, createTestLogger());
const persisted = await reloaded.get("agent-2");
expect(persisted?.title).toBe("Fix Login Bug");
});
@@ -415,7 +512,7 @@ describe("AgentStorage", () => {
);
// Registry should return all agents - filtering is done at the manager level
const records = await storage.list();
const records = await storage.listAll();
expect(records).toHaveLength(2);
});
@@ -486,12 +583,12 @@ describe("AgentStorage", () => {
);
// Reload the registry from disk
const reloaded = new AgentStorage(storagePath, logger);
const reloaded = new AgentStorage(storagePath, createTestLogger());
const record = await reloaded.get("internal-agent");
expect(record?.internal).toBe(true);
// Registry returns all agents - filtering happens at manager level
const records = await reloaded.list();
const records = await reloaded.listAll();
expect(records).toHaveLength(1);
expect(records[0]?.internal).toBe(true);
});
@@ -543,8 +640,8 @@ describe("AgentStorage", () => {
);
// Force a reload so the registry has to discover from disk (and may choose either copy).
const reloaded = new AgentStorage(storagePath, logger);
const before = await reloaded.list();
const reloaded = new AgentStorage(storagePath, createTestLogger());
const before = await reloaded.listAll();
expect(before.map((r) => r.id)).toContain(agentId);
await reloaded.remove(agentId);
@@ -571,8 +668,8 @@ describe("AgentStorage", () => {
expect(await hasAnyRecordFile()).toBe(false);
const afterReload = new AgentStorage(storagePath, logger);
const after = await afterReload.list();
const afterReload = new AgentStorage(storagePath, createTestLogger());
const after = await afterReload.listAll();
expect(after.some((r) => r.id === agentId)).toBe(false);
});
});
+135 -17
View File
@@ -75,6 +75,7 @@ const STORED_AGENT_SCHEMA = z.object({
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
attentionTimestamp: z.string().nullable().optional(),
internal: z.boolean().optional(),
background: z.boolean().optional(),
archivedAt: z.string().nullable().optional(),
owner: AgentOwnerSchema.optional(),
});
@@ -104,9 +105,14 @@ export class AgentStorage {
private deleting: Set<string> = new Set();
private daemonAgentIdsByExecution: Map<string, string> = new Map();
private daemonExecutionKeysByAgentId: Map<string, string> = new Map();
private readonly idsByWorkspace = new Map<string, Set<string>>();
private readonly workspaceById = new Map<string, string>();
private readonly activeIds = new Set<string>();
private readonly promptTimeByAgent = new Map<string, string>();
private readonly promptTimes = new Map<string, string>();
private loaded = false;
private baseDir: string;
private loadPromise: Promise<StoredAgentRecord[]> | null = null;
private loadPromise: Promise<void> | null = null;
private logger: Logger;
constructor(baseDir: string, logger: Logger) {
@@ -118,11 +124,119 @@ export class AgentStorage {
await this.load();
}
async list(): Promise<StoredAgentRecord[]> {
async listAll(): Promise<StoredAgentRecord[]> {
await this.load();
return Array.from(this.cache.values());
}
async listActive(options?: { includeArchived?: boolean }): Promise<StoredAgentRecord[]> {
await this.load();
return Array.from(
options?.includeArchived ? this.cache.keys() : this.activeIds,
(id) => this.cache.get(id)!,
);
}
async listByWorkspaces(
workspaceIds: Iterable<string>,
options?: { includeArchived?: boolean },
): Promise<StoredAgentRecord[]> {
await this.load();
const records: StoredAgentRecord[] = [];
for (const workspaceId of new Set(workspaceIds)) {
for (const id of this.idsByWorkspace.get(workspaceId) ?? []) {
const record = this.cache.get(id)!;
if (options?.includeArchived || !record.archivedAt) records.push(record);
}
}
return records;
}
async lastUserMessageAtByWorkspace(
workspaceIds?: Iterable<string>,
options?: { excludeAgentIds?: Iterable<string> },
): Promise<ReadonlyMap<string, string>> {
await this.load();
const times = new Map<string, string>();
const excluded = new Set(options?.excludeAgentIds);
const affected = new Set(Array.from(excluded, (id) => this.workspaceById.get(id)));
for (const id of workspaceIds ?? this.promptTimes.keys()) {
let at = this.promptTimes.get(id);
if (affected.has(id)) {
at = undefined;
for (const agentId of this.idsByWorkspace.get(id) ?? []) {
const record = this.cache.get(agentId)!;
if (
!excluded.has(agentId) &&
!record.internal &&
!record.background &&
record.lastUserMessageAt &&
record.lastUserMessageAt > (at ?? "")
) {
at = record.lastUserMessageAt;
}
}
}
if (at) times.set(id, at);
}
return times;
}
private indexWorkspace(record: StoredAgentRecord): void {
const previous = this.workspaceById.get(record.id);
if (previous) this.idsByWorkspace.get(previous)?.delete(record.id);
this.workspaceById.delete(record.id);
this.activeIds.delete(record.id);
if (!record.archivedAt) this.activeIds.add(record.id);
if (record.workspaceId) {
const ids = this.idsByWorkspace.get(record.workspaceId) ?? new Set<string>();
ids.add(record.id);
this.idsByWorkspace.set(record.workspaceId, ids);
this.workspaceById.set(record.id, record.workspaceId);
}
this.indexPromptTime(record, previous);
}
private indexPromptTime(record: StoredAgentRecord, previous: string | undefined): void {
const previousTime = this.promptTimeByAgent.get(record.id);
const nextTime =
!record.internal && !record.background ? (record.lastUserMessageAt ?? undefined) : undefined;
if (nextTime) this.promptTimeByAgent.set(record.id, nextTime);
else this.promptTimeByAgent.delete(record.id);
if (previous === record.workspaceId && previousTime === nextTime) return;
if (
previous &&
previousTime &&
this.promptTimes.get(previous) === previousTime &&
(previous !== record.workspaceId || !nextTime || nextTime < previousTime)
) {
this.refreshPromptTime(previous);
}
if (
record.workspaceId &&
nextTime &&
nextTime > (this.promptTimes.get(record.workspaceId) ?? "")
) {
this.promptTimes.set(record.workspaceId, nextTime);
}
}
private refreshPromptTime(workspaceId: string): void {
this.promptTimes.delete(workspaceId);
let latest = "";
for (const id of this.idsByWorkspace.get(workspaceId) ?? []) {
const record = this.cache.get(id)!;
if (
!record.internal &&
!record.background &&
record.lastUserMessageAt &&
record.lastUserMessageAt > latest
)
latest = record.lastUserMessageAt;
}
if (latest) this.promptTimes.set(workspaceId, latest);
}
async get(agentId: string): Promise<StoredAgentRecord | null> {
await this.load();
return this.cache.get(agentId) ?? null;
@@ -142,8 +256,7 @@ export class AgentStorage {
}
async listByWorkspace(workspaceId: string): Promise<StoredAgentRecord[]> {
await this.load();
return Array.from(this.cache.values()).filter((record) => record.workspaceId === workspaceId);
return this.listByWorkspaces([workspaceId], { includeArchived: true });
}
async findByDaemonExecution(owner: DaemonAgentOwner): Promise<StoredAgentRecord | null> {
@@ -233,6 +346,14 @@ export class AgentStorage {
}),
);
const workspaceId = this.workspaceById.get(agentId);
this.workspaceById.delete(agentId);
this.promptTimeByAgent.delete(agentId);
this.activeIds.delete(agentId);
if (workspaceId) {
this.idsByWorkspace.get(workspaceId)?.delete(agentId);
this.refreshPromptTime(workspaceId);
}
this.cache.delete(agentId);
this.removeOwnerIndex(agentId);
this.pathById.delete(agentId);
@@ -261,6 +382,8 @@ export class AgentStorage {
if (existing && existing.archivedAt !== undefined) {
record.archivedAt = existing.archivedAt;
}
// Background is fixed at creation; a runtime opened without it cannot clear it.
if (existing?.background) record.background = true;
record.resumeOnStartup =
existing?.resumeOnStartup && agent.lifecycle !== "idle" && agent.lifecycle !== "error";
return record;
@@ -283,9 +406,9 @@ export class AgentStorage {
await Promise.allSettled(writes);
}
private async load(): Promise<StoredAgentRecord[]> {
private async load(): Promise<void> {
if (this.loaded) {
return Array.from(this.cache.values());
return;
}
if (!this.loadPromise) {
@@ -295,7 +418,7 @@ export class AgentStorage {
return this.loadPromise;
}
private async doLoad(): Promise<StoredAgentRecord[]> {
private async doLoad(): Promise<void> {
this.cache.clear();
this.pathById.clear();
this.pathsById.clear();
@@ -303,28 +426,25 @@ export class AgentStorage {
this.daemonExecutionKeysByAgentId.clear();
try {
const records = await this.scanDisk();
await this.scanDisk();
this.loaded = true;
return records;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
this.loaded = true;
return [];
return;
}
this.logger.error({ err: error }, "Failed to load agents");
this.loaded = true;
return [];
}
}
private async scanDisk(): Promise<StoredAgentRecord[]> {
const records: StoredAgentRecord[] = [];
private async scanDisk(): Promise<void> {
let entries: Dirent[] = [];
try {
entries = await fs.readdir(this.baseDir, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
return;
}
throw error;
}
@@ -361,14 +481,11 @@ export class AgentStorage {
for (const item of loaded) {
if (!item) continue;
const { record, filePath } = item;
records.push(record);
this.cache.set(record.id, record);
this.indexOwner(record);
this.pathById.set(record.id, filePath);
this.addIndexedPath(record.id, filePath);
}
return records;
}
private async readRecordFile(filePath: string): Promise<StoredAgentRecord | null> {
@@ -405,6 +522,7 @@ export class AgentStorage {
}
private indexOwner(record: StoredAgentRecord): void {
this.indexWorkspace(record);
this.removeOwnerIndex(record.id);
if (record.owner?.kind === "daemon") {
const key = daemonExecutionKey(record.owner);
@@ -1,12 +1,30 @@
import { AgentTimelineItemPayloadSchema } from "@getpaseo/protocol/messages";
import { describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
function serializedBytes(item: AgentTimelineItem): number {
return Buffer.byteLength(JSON.stringify(item), "utf8");
}
describe("agent timeline content", () => {
test("returns an item within budget unchanged", () => {
const item: AgentTimelineItem = {
type: "tool_call",
callId: "small",
name: "Bash",
status: "completed",
error: null,
detail: { type: "shell", command: "ls", output: "x".repeat(60 * 1024) },
};
expect(limitAgentTimelineItemContent(item)).toBe(item);
});
test.each([false, true])(
"preserves the exact UTF-16 prefix for oversized shell output (failed=%s)",
"cuts oversized shell output and keeps short fields whole (failed=%s)",
(failed) => {
const text = "x".repeat(64 * 1024 - 1) + "🙂";
const text = "x".repeat(1024 * 1024) + "🙂";
const item = limitAgentTimelineItemContent({
type: "tool_call",
callId: "large-shell",
@@ -15,45 +33,116 @@ describe("agent timeline content", () => {
detail: { type: "shell", command: "echo large", output: text },
error: failed ? { content: text } : null,
});
expect(item).toEqual({
type: "tool_call",
expect(item).toMatchObject({
callId: "large-shell",
name: "Bash",
status: failed ? "failed" : "completed",
detail: { type: "shell", command: "echo large", output: text.slice(0, 64 * 1024) },
error: failed ? { content: text.slice(0, 64 * 1024) } : null,
detail: { type: "shell", command: "echo large" },
});
if (item.type !== "tool_call" || item.detail.type !== "shell") throw new Error("shape");
expect(item.detail.output).toMatch(/^x+…\[truncated \d{3}\.\d KB\]$/);
expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024);
},
);
test("marks truncated plain-text tool content within the content budget", () => {
const oversizedInput = "x".repeat(64 * 1024 + 1);
test("replaces an oversized data URL in an unknown tool result with a marker", () => {
const url = `data:image/jpeg;base64,${"A".repeat(14 * 1024 * 1024)}`;
const item = limitAgentTimelineItemContent({
type: "tool_call",
callId: "terminal-session-4242",
name: "terminal",
callId: "screenshot",
name: "node_repl.js",
status: "completed",
error: null,
detail: {
type: "plain_text",
text: oversizedInput,
icon: "square_terminal",
type: "unknown",
input: { index: 1 },
output: { content: [{ type: "image", url }], note: "captured" },
},
});
expect(limitAgentTimelineItemContent(item)).toEqual(item);
expect(item).toEqual({
type: "tool_call",
callId: "terminal-session-4242",
name: "terminal",
callId: "screenshot",
name: "node_repl.js",
status: "completed",
error: null,
detail: {
type: "plain_text",
text: "x".repeat(64 * 1024 - "\n\n[Content truncated]".length) + "\n\n[Content truncated]",
icon: "square_terminal",
type: "unknown",
input: { index: 1 },
output: { content: [{ type: "image", url: "…[truncated 14.0 MB]" }], note: "captured" },
},
});
});
test("gives non-tool items a 256 KiB budget", () => {
const text = "a".repeat(10 * 1024 * 1024);
const item = limitAgentTimelineItemContent({ type: "assistant_message", text });
if (item.type !== "assistant_message") throw new Error("shape");
expect(item.text).toMatch(/^a+…\[truncated 9\.\d MB\]$/);
expect(item.text.length).toBeGreaterThan(200 * 1024);
expect(serializedBytes(item)).toBeLessThanOrEqual(256 * 1024);
});
test("bounds an item whose structure alone exceeds the budget", () => {
const item = limitAgentTimelineItemContent({
type: "tool_call",
callId: "many",
name: "query",
status: "completed",
error: null,
detail: { type: "unknown", input: null, output: Array.from({ length: 1_000_000 }, () => 1) },
});
if (item.type !== "tool_call" || item.detail.type !== "unknown") throw new Error("shape");
const output = item.detail.output as unknown[];
expect(output.length).toBeLessThan(1_000_000);
expect(output.every((value) => value === 1)).toBe(true);
expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024);
});
test("keeps short strings whole and limits idempotently when an item has many strings", () => {
const callId = "3f2b8c1e-9a4d-4e6f-8b2a-1c3d5e7f9a0b";
const item = limitAgentTimelineItemContent({
type: "tool_call",
callId,
name: "mcp__search__files",
status: "completed",
error: null,
detail: {
type: "unknown",
input: { query: "useWorkspaceSomethingVeryDescriptive" },
output: Array.from(
{ length: 10_000 },
(_, index) => `${String(index).padStart(5, "0")}${"p".repeat(28)}`,
),
},
});
if (item.type !== "tool_call" || item.detail.type !== "unknown") throw new Error("shape");
expect(item.callId).toBe(callId);
expect(item.detail.input).toEqual({ query: "useWorkspaceSomethingVeryDescriptive" });
const output = item.detail.output as string[];
expect(output.length).toBeLessThan(10_000);
expect(output.every((path) => path.length === 33)).toBe(true);
expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024);
expect(limitAgentTimelineItemContent(item)).toBe(item);
});
test("keeps a long typed array valid against the wire schema", () => {
const item = limitAgentTimelineItemContent({
type: "todo",
items: Array.from({ length: 20_000 }, (_, index) => ({
text: `Task ${index}`,
completed: false,
})),
});
if (item.type !== "todo") throw new Error("shape");
expect(item.items.length).toBeLessThan(20_000);
expect(AgentTimelineItemPayloadSchema.safeParse(item).success).toBe(true);
expect(serializedBytes(item)).toBeLessThanOrEqual(256 * 1024);
});
test("is stable on an already-limited item", () => {
const item = limitAgentTimelineItemContent({
type: "reasoning",
text: "r".repeat(1024 * 1024),
});
expect(limitAgentTimelineItemContent(item)).toBe(item);
});
});
@@ -1,9 +1,19 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
export const PLUGIN_TIMELINE_DATA_MAX_BYTES = 64 * 1024;
// One timeline fetch sends its entries in one WebSocket message, so every item must stay small.
// Budgets count UTF-16 units plus a fixed cost per node; neither is exact JSON bytes. They bound
// long strings and long arrays, not wide objects (every entry is kept) or multi-byte text.
const TOOL_CALL_BUDGET = 64 * 1024;
const ITEM_BUDGET = 256 * 1024;
const NODE_COST = 8;
const MARKER_COST = 32;
// Ids, names, paths and queries stay whole, so an item keeps its identity across passes.
const SHORT_STRING = 1024;
const MAX_DEPTH = 64;
export function assertPluginTimelineDataSize(data: JsonValue): void {
const serializedBytes = Buffer.byteLength(JSON.stringify(data), "utf8");
if (serializedBytes > PLUGIN_TIMELINE_DATA_MAX_BYTES) {
@@ -17,66 +27,119 @@ function retainTextPrefix(text: string, length: number): string {
return Buffer.from(text.slice(0, length), "utf16le").toString("utf16le");
}
function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
if (
item.type !== "tool_call" ||
item.detail.type !== "shell" ||
item.status !== "failed" ||
typeof item.error !== "object" ||
item.error === null ||
!("content" in item.error) ||
typeof item.error.content !== "string" ||
item.error.content.length <= TOOL_CALL_CONTENT_MAX_LENGTH
) {
return item;
}
return {
...item,
error: {
...item.error,
content: retainTextPrefix(item.error.content, TOOL_CALL_CONTENT_MAX_LENGTH),
},
};
function formatSize(units: number): string {
if (units < 1024) return `${units} B`;
if (units < 1024 * 1024) return `${(units / 1024).toFixed(1)} KB`;
return `${(units / (1024 * 1024)).toFixed(1)} MB`;
}
function limitPlainText(item: AgentTimelineItem): AgentTimelineItem {
if (
item.type !== "tool_call" ||
item.detail.type !== "plain_text" ||
typeof item.detail.text !== "string" ||
item.detail.text.length <= TOOL_CALL_CONTENT_MAX_LENGTH
) {
return item;
}
const notice = "\n\n[Content truncated]";
return {
...item,
detail: {
...item.detail,
text:
retainTextPrefix(item.detail.text, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice,
},
};
function truncatedMarker(units: number): string {
return `…[truncated ${formatSize(units)}]`;
}
interface ItemSize {
nodeCost: number;
stringLengths: number[];
tooDeep: boolean;
}
function measure(value: unknown, depth: number, size: ItemSize): void {
size.nodeCost += NODE_COST;
if (depth > MAX_DEPTH) {
size.tooDeep = true;
} else if (typeof value === "string") {
size.stringLengths.push(value.length);
} else if (Array.isArray(value)) {
for (const element of value) measure(element, depth + 1, size);
} else if (typeof value === "object" && value !== null) {
for (const [key, child] of Object.entries(value)) {
size.nodeCost += key.length;
measure(child, depth + 1, size);
}
}
}
/** The largest per-string length that keeps all strings, once cut, within `available`. */
function findStringCap(lengths: readonly number[], available: number): number {
let low = 0;
let high = 0;
for (const length of lengths) high = Math.max(high, length);
while (low < high) {
const cap = Math.ceil((low + high) / 2);
let total = 0;
for (const length of lengths) total += Math.min(length, cap);
if (total <= available) low = cap;
else high = cap - 1;
}
return low;
}
interface CutState {
stringCap: number;
remaining: number;
}
function cutString(text: string, cap: number): string {
if (text.length <= cap) return text;
// A partial data URL is useless to every reader, so drop it whole.
if (text.startsWith("data:")) return truncatedMarker(text.length);
return retainTextPrefix(text, cap) + truncatedMarker(text.length - cap);
}
function cut(value: unknown, depth: number, state: CutState): unknown {
state.remaining -= NODE_COST;
if (depth > MAX_DEPTH) return "…[truncated]";
if (typeof value === "string") {
const text = cutString(value, state.stringCap);
state.remaining -= text.length;
return text;
}
if (Array.isArray(value)) {
let changed = false;
const output: unknown[] = [];
for (const element of value) {
const remainingBefore = state.remaining;
const next = cut(element, depth + 1, state);
if (state.remaining < 0) {
// Drop this and later elements. A marker element would break typed arrays.
state.remaining = remainingBefore;
changed = true;
break;
}
changed ||= next !== element;
output.push(next);
}
return changed ? output : value;
}
if (typeof value === "object" && value !== null) {
let changed = false;
const output: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
state.remaining -= key.length;
const next = cut(child, depth + 1, state);
changed ||= next !== child;
output[key] = next;
}
return changed ? output : value;
}
return value;
}
/** Returns the item itself when it fits its budget; otherwise a copy with its largest strings cut. */
export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTimelineItem {
item = limitFailedShellError(item);
item = limitPlainText(item);
if (
item.type !== "tool_call" ||
item.detail.type !== "shell" ||
typeof item.detail.output !== "string"
) {
return item;
}
if (item.detail.output.length <= TOOL_CALL_CONTENT_MAX_LENGTH) {
return item;
}
return {
...item,
detail: {
...item.detail,
output: retainTextPrefix(item.detail.output, TOOL_CALL_CONTENT_MAX_LENGTH),
},
};
const budget = item.type === "tool_call" ? TOOL_CALL_BUDGET : ITEM_BUDGET;
const size: ItemSize = { nodeCost: 0, stringLengths: [], tooDeep: false };
measure(item, 0, size);
let stringTotal = 0;
for (const length of size.stringLengths) stringTotal += length;
if (!size.tooDeep && size.nodeCost + stringTotal <= budget) return item;
// Strings get what structure leaves, but at least half the budget, less a margin for JSON
// escaping. Arrays drop the elements that do not fit, which bounds items whose short strings
// or structure alone are too big.
const markerCost =
MARKER_COST * size.stringLengths.filter((length) => length > SHORT_STRING).length;
const available = (budget * 15) / 16 - Math.min(size.nodeCost, budget / 2) - markerCost;
const stringCap = Math.max(SHORT_STRING, findStringCap(size.stringLengths, available));
return cut(item, 0, { stringCap, remaining: budget }) as AgentTimelineItem;
}
@@ -181,6 +181,20 @@ describe("projected timeline retention", () => {
},
]);
});
it("serves a merged assistant message within the item budget", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent", { epoch: "e" });
const chunk = "a".repeat(200 * 1024);
for (let index = 0; index < 10; index++) {
store.append("agent", { type: "assistant_message", messageId: "m", text: chunk });
}
const [row] = store.fetch("agent", { limit: 1 }).rows;
expect(row).toMatchObject({ seqStart: 1, seqEnd: 10 });
expect(row.item).toMatchObject({ type: "assistant_message", messageId: "m" });
expect(Buffer.byteLength(JSON.stringify(row.item))).toBeLessThanOrEqual(256 * 1024);
expect(store.getLastAssistantMessage("agent")).toHaveLength(10 * chunk.length);
});
});
describe("projected sequence ownership", () => {
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type {
AgentTimelineFetchOptions,
@@ -120,7 +121,12 @@ export class InMemoryAgentTimelineStore {
hasNewer: page.hasNewer,
startSeq: page.startSeq,
endSeq: page.endSeq,
rows: page.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)),
// Stored rows are already within budget; this bounds assistant and reasoning merges.
rows: page.entries.map((entry) =>
Object.assign({ seq: entry.seqEnd }, entry, {
item: limitAgentTimelineItemContent(entry.item),
}),
),
};
}
@@ -0,0 +1,133 @@
import { describe, expect, test } from "vitest";
import { AgentBackgroundWorkStore } from "./index.js";
const T0 = "2026-10-06T10:00:00.000Z";
const T1 = "2026-10-06T10:05:00.000Z";
describe("AgentBackgroundWorkStore", () => {
test("stamps new items and keeps their start time across replacements", () => {
const store = new AgentBackgroundWorkStore();
expect(
store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: "sleep 20" }], T0),
).toEqual([{ id: "bash-1", kind: "shell", description: "sleep 20", startedAt: T0 }]);
expect(
store.apply(
"agent-a",
[
{ id: "bash-1", kind: "shell", description: "sleep 20" },
{ id: "task-1", kind: "other", description: null },
],
T1,
),
).toEqual([
{ id: "bash-1", kind: "shell", description: "sleep 20", startedAt: T0 },
{ id: "task-1", kind: "other", description: null, startedAt: T1 },
]);
});
test("returns null when the replacement changes nothing", () => {
const store = new AgentBackgroundWorkStore();
store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: "sleep 20" }], T0);
expect(
store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: "sleep 20" }], T1),
).toBeNull();
expect(store.apply("agent-b", [], T1)).toEqual([]);
expect(store.apply("agent-b", [], T1)).toBeNull();
});
test("distinguishes an unknown inventory from an observed empty inventory", () => {
const store = new AgentBackgroundWorkStore();
expect(store.summary("agent-a")).toBeUndefined();
store.apply("agent-a", []);
expect(store.summary("agent-a")).toEqual({ count: 0 });
store.clear("agent-a");
expect(store.summary("agent-a")).toBeUndefined();
});
test("keeps the first occurrence of a duplicated id", () => {
const store = new AgentBackgroundWorkStore();
expect(
store.apply(
"agent-a",
[
{ id: "bash-1", kind: "shell", description: "first" },
{ id: "bash-1", kind: "shell", description: "second" },
],
T0,
),
).toEqual([{ id: "bash-1", kind: "shell", description: "first", startedAt: T0 }]);
});
test("scopes lists per agent and clears one agent", () => {
const store = new AgentBackgroundWorkStore();
store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: null }], T0);
store.apply("agent-b", [{ id: "bash-2", kind: "shell", description: null }], T0);
expect(store.clear("agent-a")).toEqual([]);
expect(store.clear("agent-a")).toBeNull();
expect(store.list("agent-a")).toEqual([]);
expect(store.list("agent-b")).toHaveLength(1);
});
test("keeps only the wire fields of each input", () => {
const store = new AgentBackgroundWorkStore();
const input = { id: "bash-1", kind: "shell", description: null, pid: 4242 };
expect(store.apply("agent-a", [input], T0)).toEqual([
{ id: "bash-1", kind: "shell", description: null, startedAt: T0 },
]);
});
test("list returns a copy", () => {
const store = new AgentBackgroundWorkStore();
store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: null }], T0);
store.list("agent-a").pop();
expect(store.list("agent-a")).toHaveLength(1);
});
});
test("stop tokens follow a live item and change when an id is reused", () => {
const store = new AgentBackgroundWorkStore();
const input = { id: "1", kind: "shell", description: null, canStop: true };
const first = store.apply("agent", [input], T0)![0];
expect(first.stopToken).toBeTruthy();
expect(store.apply("agent", [input], T1)).toBeNull();
store.apply("agent", []);
const second = store.apply("agent", [input], T0)![0];
expect(second.stopToken).not.toBe(first.stopToken);
});
test("revokes a stop token while inventory ownership is being re-established", () => {
const store = new AgentBackgroundWorkStore();
const item = { id: "1", kind: "shell", description: null, canStop: true };
const first = store.apply("agent", [item])![0];
const stale = store.apply("agent", [{ ...item, canStop: false }])![0];
expect(stale.stopToken).toBeUndefined();
expect(store.apply("agent", [item])![0].stopToken).not.toBe(first.stopToken);
});
test("publishes command changes without replacing a live item's identity", () => {
const store = new AgentBackgroundWorkStore();
const item = {
id: "1",
kind: "monitor",
description: "Watch build",
command: "tail -f build.log",
canStop: true,
};
const first = store.apply("agent", [item], T0)![0];
expect(first.command).toBe("tail -f build.log");
const changed = store.apply("agent", [{ ...item, command: "tail -f other.log" }], T1)![0];
expect(changed).toMatchObject({
command: "tail -f other.log",
startedAt: T0,
stopToken: first.stopToken,
});
});
@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
export interface AgentBackgroundWorkInput {
id: string;
/** Provider-neutral kind; consumers treat unknown kinds as other work. */
kind: string;
description: string | null;
command?: string;
canStop?: boolean;
}
export interface AgentBackgroundWorkItem extends AgentBackgroundWorkInput {
startedAt: string;
stopToken?: string;
}
/**
* Live background work per agent. Providers report the whole list on every change, so `apply`
* replaces it. `startedAt` is stamped the first time an id is seen and kept while it stays listed.
* Nothing is persisted; providers determine when their live inventory is no longer available.
*/
export class AgentBackgroundWorkStore {
private readonly items = new Map<string, AgentBackgroundWorkItem[]>();
apply(
agentId: string,
inputs: readonly AgentBackgroundWorkInput[],
timestamp: string = new Date().toISOString(),
): AgentBackgroundWorkItem[] | null {
const known = this.items.has(agentId);
const previous = this.items.get(agentId) ?? [];
const previousById = new Map(previous.map((item) => [item.id, item]));
const seen = new Set<string>();
const next: AgentBackgroundWorkItem[] = [];
for (const input of inputs) {
if (seen.has(input.id)) continue;
seen.add(input.id);
next.push({
id: input.id,
kind: input.kind,
description: input.description,
...(input.command ? { command: input.command } : {}),
...(input.canStop
? { canStop: true, stopToken: previousById.get(input.id)?.stopToken ?? randomUUID() }
: {}),
startedAt: previousById.get(input.id)?.startedAt ?? timestamp,
});
}
if (known && sameItems(previous, next)) return null;
this.items.set(agentId, next);
return [...next];
}
list(agentId: string): AgentBackgroundWorkItem[] {
return [...(this.items.get(agentId) ?? [])];
}
summary(agentId: string): { count: number } | undefined {
const items = this.items.get(agentId);
return items ? { count: items.length } : undefined;
}
clear(agentId: string): AgentBackgroundWorkItem[] | null {
if (!this.items.delete(agentId)) return null;
return [];
}
}
function sameItems(
left: readonly AgentBackgroundWorkItem[],
right: readonly AgentBackgroundWorkItem[],
): boolean {
return (
left.length === right.length &&
left.every((item, index) => {
const other = right[index];
return (
item.id === other.id &&
item.kind === other.kind &&
item.description === other.description &&
item.command === other.command &&
item.canStop === other.canStop &&
item.stopToken === other.stopToken &&
item.startedAt === other.startedAt
);
})
);
}
@@ -78,7 +78,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: OPENCODE_MODES,
}),
).toThrow(
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan",
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode in settings.modeId. Available modes for 'opencode': build, plan",
);
});
@@ -92,7 +92,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: CODEX_MODES,
}),
).toThrow(
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
);
});
@@ -180,7 +180,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
targetUnattendedMode: "full-access",
}),
).toThrow(
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
);
});
@@ -195,7 +195,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
targetUnattendedMode: undefined,
}),
).toThrow(
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode. Available modes for 'zai-custom': unknown",
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode in settings.modeId. Available modes for 'zai-custom': unknown",
);
});
@@ -77,7 +77,7 @@ export function resolveAndValidateCreateAgentMode(
}
throw new Error(
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode in settings.modeId. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
);
}
@@ -21,7 +21,17 @@ import { resolveCreateAgentTitles } from "../create-agent-title.js";
import { buildAgentPrompt } from "../prompt-attachments.js";
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
import { resolveCreateAgentIntent } from "./intent.js";
import {
inheritAgentBackground,
inheritWorkspaceBackground,
resolveCreateAgentIntent,
} from "./intent.js";
/** How a workspace this creation makes inherits its creator's visibility. */
interface McpWorkspaceIntent {
background?: boolean;
callerWorkspaceId?: string;
}
export interface CreateAgentCommandDependencies {
finishSubscriptions?: FinishSubscriptions;
@@ -40,7 +50,7 @@ export interface CreateAgentCommandDependencies {
export type EnsureWorkspaceForCreate = (
cwd: string,
firstAgentContext?: FirstAgentContext,
context?: { callerWorkspaceId?: string },
context?: { background?: boolean; callerWorkspaceId?: string },
) => Promise<string>;
export interface CreateAgentFromSessionInput {
@@ -49,6 +59,7 @@ export interface CreateAgentFromSessionInput {
agentId?: string;
config: AgentSessionConfig;
workspaceId: string;
background: boolean;
initialPrompt?: string;
source?: AgentPromptSource;
clientMessageId?: string;
@@ -77,6 +88,8 @@ export interface CreateAgentFromMcpInput {
promptFailure?: CreateAgentPromptFailureMode;
notifyOnFinish: boolean;
internal?: boolean;
/** Omitted with a caller, the caller agent's own background applies. */
background?: boolean;
detached?: boolean;
owner?: AgentOwner;
env?: Record<string, string>;
@@ -255,6 +268,7 @@ async function resolveSessionCreateAgent(
// provider session either. Same as the daemon's own ephemeral helpers.
...(sessionConfig.internal ? { persistSession: false } : {}),
workspaceId: requireResolvedWorkspaceId(workspaceId),
background: input.background,
},
prompt: hasPromptContent ? prompt : undefined,
source: input.source,
@@ -277,12 +291,16 @@ async function resolveMcpCreateAgent(
? requireParentAgent(dependencies.agentManager, input.callerAgentId)
: null;
const cwd = resolveMcpInitialCwd(input, parentAgent);
const workspaceIntent = {
background: inheritWorkspaceBackground(input.background, parentAgent),
callerWorkspaceId: parentAgent?.workspaceId,
};
const { resolvedCwd, createdWorkspaceId, createdWorktree } = await resolveMcpCwd({
dependencies,
cwd,
worktree: input.worktree,
initialPrompt: input.initialPrompt ?? "",
callerWorkspaceId: parentAgent?.workspaceId,
workspaceIntent,
});
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
const intent = await resolveCreateAgentIntent({
@@ -300,7 +318,7 @@ async function resolveMcpCreateAgent(
dependencies,
resolvedCwd,
input.initialPrompt ?? "",
parentAgent?.workspaceId,
workspaceIntent,
),
),
cwd: resolvedCwd,
@@ -329,6 +347,7 @@ async function resolveMcpCreateAgent(
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
workspaceId: intent.workspaceId,
owner: input.owner,
background: inheritAgentBackground(input.background, parentAgent),
env: input.env,
},
prompt: trimmedPrompt ? trimmedPrompt : undefined,
@@ -410,16 +429,12 @@ async function ensureWorkspaceForMcpCreate(
dependencies: CreateAgentCommandDependencies,
cwd: string,
initialPrompt: string,
callerWorkspaceId?: string,
workspaceIntent: McpWorkspaceIntent,
): Promise<string | undefined> {
if (!dependencies.ensureWorkspaceForCreate) {
return undefined;
}
return dependencies.ensureWorkspaceForCreate(
cwd,
{ prompt: initialPrompt },
{ callerWorkspaceId },
);
return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt }, workspaceIntent);
}
async function sendInitialPrompt(
@@ -512,7 +527,7 @@ async function resolveMcpCwd(params: {
dependencies: CreateAgentCommandDependencies;
cwd: string;
initialPrompt: string;
callerWorkspaceId?: string;
workspaceIntent: McpWorkspaceIntent;
worktree: CreateAgentFromMcpInput["worktree"];
}): Promise<{
resolvedCwd: string;
@@ -542,7 +557,7 @@ async function resolveMcpCwd(params: {
const createdWorktree = await createMcpWorktree({
input: {
cwd: params.cwd,
callerWorkspaceId: params.callerWorkspaceId,
...params.workspaceIntent,
worktreeSlug: worktree.worktreeName,
branchName: worktree.branchName,
refName: worktree.refName,
@@ -6,6 +6,30 @@ export interface CreateAgentCaller {
workspaceId?: string;
}
/** The agent on whose behalf something is created, as far as background inheritance goes. */
export interface BackgroundCaller {
background?: boolean;
}
/** Omitted, an agent inherits its caller agent's own background; without a caller it is visible. */
export function inheritAgentBackground(
requested: boolean | undefined,
caller: BackgroundCaller | null | undefined,
): boolean {
return requested ?? caller?.background === true;
}
/**
* Omitted, a new workspace inherits its caller's effective background. A background caller
* agent decides it here; otherwise `undefined` lets provisioning apply the caller workspace's value.
*/
export function inheritWorkspaceBackground(
requested: boolean | undefined,
caller: BackgroundCaller | null | undefined,
): boolean | undefined {
return requested ?? (caller?.background === true ? true : undefined);
}
export interface CreateAgentPlacement {
workspaceId: string;
cwd: string;
@@ -0,0 +1,139 @@
import { resolveWorkspaceRootAgent } from "../workspace-directory.js";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { buildStoredAgentPayload } from "./agent-projections.js";
import { AgentStorage } from "./agent-storage.js";
import { AgentDirectory, type BackgroundVisibility } from "./directory.js";
import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
const NOW = "2026-03-01T12:00:00.000Z";
let home: string;
let storage: AgentStorage;
let directory: AgentDirectory;
function workspace(workspaceId: string, background: boolean): PersistedWorkspaceRecord {
return {
workspaceId,
projectId: "project",
cwd: home,
kind: "local_checkout",
displayName: workspaceId,
background,
createdAt: NOW,
updatedAt: NOW,
archivedAt: null,
};
}
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), "agent-directory-"));
storage = new AgentStorage(home, createTestLogger());
const base = {
provider: "codex",
cwd: home,
createdAt: NOW,
updatedAt: NOW,
labels: {},
lastStatus: "idle" as const,
};
await storage.upsert({ ...base, id: "visible", workspaceId: "open" });
await storage.upsert({ ...base, id: "background", workspaceId: "open", background: true });
await storage.upsert({ ...base, id: "in-background-workspace", workspaceId: "hidden" });
await storage.upsert({ ...base, id: "internal", workspaceId: "open", internal: true });
const workspaces = [workspace("open", false), workspace("hidden", true)];
directory = new AgentDirectory({
storage,
manager: { listAgents: () => [] },
workspaces: {
get: async (id) => workspaces.find((entry) => entry.workspaceId === id) ?? null,
},
projectLive: () => {
throw new Error("No live agents in this suite");
},
projectStored: (record) => buildStoredAgentPayload(record, ["codex"]),
isProviderVisible: () => true,
isStoredProviderAvailable: () => true,
});
});
afterEach(async () => {
await rm(home, { recursive: true, force: true });
});
async function listedIds(includeBackground: BackgroundVisibility): Promise<string[]> {
return (await directory.list({ includeBackground })).map((agent) => agent.id).toSorted();
}
test("default lists hide agents that are background or live in a background workspace", async () => {
expect(await listedIds(false)).toEqual(["visible"]);
});
test("inclusive lists show every background agent", async () => {
expect(await listedIds(true)).toEqual(["background", "in-background-workspace", "visible"]);
});
test("workspace-only visibility hides agent-level background agents from older clients", async () => {
expect(await listedIds("workspaces")).toEqual(["in-background-workspace", "visible"]);
});
test("includes applies the same visibility to a single agent", async () => {
const background = { workspaceId: "open", background: true };
const inBackgroundWorkspace = { workspaceId: "hidden" };
expect(await directory.includes(background)).toBe(false);
expect(await directory.includes(background, "workspaces")).toBe(false);
expect(await directory.includes(background, true)).toBe(true);
expect(await directory.includes(inBackgroundWorkspace)).toBe(false);
expect(await directory.includes(inBackgroundWorkspace, "workspaces")).toBe(true);
});
test("prompt recency ignores background agents", async () => {
const base = {
provider: "codex",
cwd: home,
workspaceId: "open",
createdAt: NOW,
updatedAt: NOW,
labels: {},
lastStatus: "idle" as const,
};
await storage.upsert({ ...base, id: "visible", lastUserMessageAt: "2026-03-02T00:00:00.000Z" });
await storage.upsert({
...base,
id: "background",
background: true,
lastUserMessageAt: "2026-03-03T00:00:00.000Z",
});
expect(await directory.workspaceLastUserMessageTimes()).toEqual(
new Map([["open", "2026-03-02T00:00:00.000Z"]]),
);
});
test("scoped status queries retain cross-workspace parent context", async () => {
const base = { provider: "codex", cwd: home, createdAt: NOW, updatedAt: NOW };
await storage.upsert({ ...base, id: "parent", workspaceId: "open" });
await storage.upsert({
...base,
id: "child",
workspaceId: "hidden",
labels: { "paseo.parent-agent-id": "parent" },
lastStatus: "running",
});
const agents = await directory.list({
workspaceIds: ["hidden"],
includeBackground: true,
includeAncestors: true,
});
const child = agents.find((agent) => agent.id === "child")!;
expect(
resolveWorkspaceRootAgent(child, new Map(agents.map((agent) => [agent.id, agent])))?.id,
).toBe("child");
expect(child.status).toBe("running");
expect(
(await directory.list({ workspaceIds: ["hidden"], includeBackground: true })).map((a) => a.id),
).not.toContain("parent");
});
+116 -37
View File
@@ -1,15 +1,37 @@
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
import type { AgentSnapshotPayload } from "../messages.js";
import type { WorkspaceRegistry } from "../workspace-registry.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
/** Public agent discovery owns workspace visibility for live and durable records. */
/**
* Which background agents a discovery read admits. An agent is effectively background when
* it or its workspace is background.
* - `false`: none, the default for lists.
* - `true`: all of them.
* - `"workspaces"`: only agents in background workspaces.
* COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor
* >= v0.15.0. Inclusive reads from sources without `background_agents` use it: older apps
* replicate inclusively and auto-open a tab for every root agent.
*/
export type BackgroundVisibility = boolean | "workspaces";
interface DiscoverableAgent {
workspaceId?: string;
internal?: boolean;
background?: boolean;
}
/** Public agent discovery owns background visibility for live and durable records. */
export class AgentDirectory {
constructor(
private readonly deps: {
manager: Pick<AgentManager, "listAgents">;
storage: Pick<AgentStorage, "list">;
workspaces: Pick<WorkspaceRegistry, "get" | "list">;
storage: Pick<
AgentStorage,
"get" | "listActive" | "listByWorkspaces" | "lastUserMessageAtByWorkspace"
>;
workspaces: Pick<WorkspaceRegistry, "get">;
projectLive(agent: ManagedAgent): Promise<AgentSnapshotPayload>;
projectStored(record: StoredAgentRecord): AgentSnapshotPayload;
isProviderVisible(provider: string): boolean;
@@ -18,70 +40,127 @@ export class AgentDirectory {
) {}
/** Human prompt recency includes archived/unavailable agents without projecting their UI payloads. */
async workspaceLastUserMessageTimes(): Promise<Map<string, string>> {
async workspaceLastUserMessageTimes(
workspaceIds?: Iterable<string>,
): Promise<Map<string, string>> {
const requested = workspaceIds ? new Set(workspaceIds) : undefined;
const live = this.deps.manager.listAgents();
const liveIds = new Set(live.map((agent) => agent.id));
const times = new Map<string, string>();
const record = (workspaceId: string | undefined, at: string | null | undefined) => {
if (!workspaceId || !at) return;
const previous = times.get(workspaceId);
if (!previous || at > previous) times.set(workspaceId, at);
};
for (const agent of await this.deps.storage.list()) {
if (!agent.internal && !liveIds.has(agent.id))
record(agent.workspaceId, agent.lastUserMessageAt);
}
const times = new Map(
await this.deps.storage.lastUserMessageAtByWorkspace(requested, {
excludeAgentIds: live.map((agent) => agent.id),
}),
);
for (const agent of live) {
if (!agent.internal) record(agent.workspaceId, agent.lastUserMessageAt?.toISOString());
if (agent.internal || agent.background || !agent.workspaceId || !agent.lastUserMessageAt)
continue;
if (requested && !requested.has(agent.workspaceId)) continue;
const at = agent.lastUserMessageAt.toISOString();
if (at > (times.get(agent.workspaceId) ?? "")) times.set(agent.workspaceId, at);
}
return times;
}
async includes(
agent: Pick<AgentSnapshotPayload, "workspaceId" | "internal">,
includeBackground = false,
agent: DiscoverableAgent,
visibility: BackgroundVisibility = false,
): Promise<boolean> {
if (agent.internal) return false;
if (includeBackground || !agent.workspaceId) return true;
return !(await this.deps.workspaces.get(agent.workspaceId))?.background;
const workspace = agent.workspaceId ? await this.deps.workspaces.get(agent.workspaceId) : null;
return admits(agent, workspace?.background === true, visibility);
}
private async appendAncestors(
live: ManagedAgent[],
selectedLive: ManagedAgent[],
records: StoredAgentRecord[],
): Promise<void> {
const selectedIds = new Set([...selectedLive, ...records].map((agent) => agent.id));
const liveById = new Map(live.map((agent) => [agent.id, agent]));
const pending = [...selectedLive, ...records];
for (const agent of pending) {
const parentId = getParentAgentIdFromLabels(agent.labels);
if (!parentId || selectedIds.has(parentId)) continue;
selectedIds.add(parentId);
const liveParent = liveById.get(parentId);
if (liveParent) {
pending.push(liveParent);
selectedLive.push(liveParent);
} else {
const storedParent = await this.deps.storage.get(parentId);
if (storedParent) {
pending.push(storedParent);
records.push(storedParent);
}
}
}
}
async list(filter?: {
workspaceIds?: Iterable<string>;
/** Include parent chains by ID for workspace root classification. */
includeAncestors?: boolean;
labels?: Record<string, string>;
includeArchived?: boolean;
includeBackground?: boolean;
includeBackground?: BackgroundVisibility;
includeUnavailablePersisted?: boolean;
}): Promise<AgentSnapshotPayload[]> {
const requested = filter?.workspaceIds ? new Set(filter.workspaceIds) : null;
const live = this.deps.manager.listAgents();
const liveIds = new Set(live.map((agent) => agent.id));
const labels = Object.entries(filter?.labels ?? {});
const backgroundIds = new Set(
filter?.includeBackground
? []
: (await this.deps.workspaces.list())
.filter((workspace) => workspace.background)
.map((workspace) => workspace.workspaceId),
const visibility = filter?.includeBackground ?? false;
const records = requested
? await this.deps.storage.listByWorkspaces(requested, filter)
: await this.deps.storage.listActive(filter);
const selectedLive = requested
? live.filter((agent) => requested.has(agent.workspaceId ?? ""))
: live;
if (filter?.includeAncestors && requested) {
await this.appendAncestors(live, selectedLive, records);
}
const workspaceIds = new Set(
[...selectedLive, ...records].flatMap((agent) =>
agent.workspaceId ? [agent.workspaceId] : [],
),
);
const matches = (agent: {
archivedAt?: string | null;
workspaceId?: string;
labels?: Record<string, string>;
}) =>
const backgroundWorkspaceIds = new Set<string>();
if (visibility !== true) {
for (const id of workspaceIds) {
if ((await this.deps.workspaces.get(id))?.background) backgroundWorkspaceIds.add(id);
}
}
const matches = (
agent: DiscoverableAgent & { archivedAt?: string | null; labels?: Record<string, string> },
) =>
(filter?.includeArchived || !agent.archivedAt) &&
(filter?.includeBackground || !agent.workspaceId || !backgroundIds.has(agent.workspaceId)) &&
admits(
agent,
agent.workspaceId !== undefined && backgroundWorkspaceIds.has(agent.workspaceId),
visibility,
) &&
labels.every(([key, value]) => agent.labels?.[key] === value);
const stored = (await this.deps.storage.list())
.filter((record) => !record.internal && !liveIds.has(record.id) && matches(record))
const stored = records
.filter((record) => !liveIds.has(record.id) && matches(record))
.filter(
(record) =>
filter?.includeUnavailablePersisted || this.deps.isStoredProviderAvailable(record),
)
.map((record) => this.deps.projectStored(record));
const active = await Promise.all(
live.filter(matches).map((agent) => this.deps.projectLive(agent)),
selectedLive.filter(matches).map((agent) => this.deps.projectLive(agent)),
);
return [...active, ...stored]
.filter(matches)
.filter((agent) => this.deps.isProviderVisible(agent.provider));
}
}
function admits(
agent: DiscoverableAgent,
inBackgroundWorkspace: boolean,
visibility: BackgroundVisibility,
): boolean {
if (agent.internal) return false;
if (agent.background) return visibility === true;
return !inBackgroundWorkspace || visibility !== false;
}
@@ -0,0 +1,149 @@
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, expect, test } from "vitest";
import { createHostResolver, type HostOperations } from "./index.js";
type Handler = ((event?: unknown) => void) | null;
/** A connected host from before background agents: background workspaces, no background agents. */
class OlderHostSocket {
readyState = 1;
readonly requests: Array<Record<string, unknown>> = [];
onopen: Handler = null;
onmessage: Handler = null;
onclose: Handler = null;
onerror: Handler = null;
private readonly createSent = Promise.withResolvers<Record<string, unknown>>();
constructor() {
queueMicrotask(() => this.onopen?.());
}
createRequest(): Promise<Record<string, unknown>> {
return this.createSent.promise;
}
send(data: string): void {
const frame = JSON.parse(data);
if (frame.type === "hello") {
this.reply({
type: "status",
payload: {
status: "server_info",
serverId: "srv_older",
hostname: null,
version: null,
features: {
providerUsageList: true,
providersSnapshotCwd: true,
ownedSubscriptions: true,
backgroundWorkspaces: true,
},
},
});
return;
}
if (frame.type !== "session") return;
const message = frame.message;
this.requests.push(message);
if (message.type === "fetch_workspaces_request") {
this.reply({
type: "fetch_workspaces_response",
payload: {
requestId: message.requestId,
entries: [workspace],
pageInfo: emptyPage,
},
});
}
if (message.type === "create_agent_request") this.createSent.resolve(message);
}
close(): void {
this.readyState = 3;
this.onclose?.({ code: 1000, reason: "closed" });
}
private reply(message: object): void {
this.onmessage?.({ data: JSON.stringify({ type: "session", message }) });
}
}
const emptyPage = { nextCursor: null, prevCursor: null, hasMore: false };
const workspace = {
id: "workspace_older",
projectId: "project_older",
projectDisplayName: "Older",
projectRootPath: "/repo/older",
workspaceDirectory: "/repo/older",
projectKind: "git",
workspaceKind: "directory",
name: "older",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: "2026-05-16T00:00:00.000Z",
scripts: [],
gitRuntime: null,
githubRuntime: null,
};
const clients: DaemonClient[] = [];
afterEach(async () => {
await Promise.all(clients.splice(0).map((client) => client.close()));
});
async function olderHost() {
const socket = new OlderHostSocket();
const client = new DaemonClient({
url: "ws://older.test",
clientId: "older-host-test",
webSocketFactory: () => socket,
reconnect: { enabled: false },
});
clients.push(client);
await client.connect();
return { client, socket };
}
function resolverFor(client: DaemonClient, callerBackground: boolean) {
return createHostResolver({
serverId: "origin",
callerAgentId: "caller",
caller: () => ({ background: callerBackground }),
local: {} as HostOperations,
hosts: () => ({
directory: async () => ({ self: { serverId: "origin", label: "Origin" }, hosts: [] }),
use: (_serverId, action) => action(client),
}),
});
}
const createArgs = {
workspaceId: workspace.id,
provider: "codex/gpt-5.4",
title: "Helper",
initialPrompt: "Summarize",
};
test("a visible caller still creates agents on a host without background agents", async () => {
const { client, socket } = await olderHost();
const creating = resolverFor(client, false).use("srv_older", (host) =>
host.createAgent(createArgs, {} as never),
);
creating.catch(() => {});
const request = await socket.createRequest();
expect(request).toMatchObject({ workspaceId: workspace.id });
expect(request).not.toHaveProperty("background");
});
test("a background caller's remote child asks the host to update instead of turning visible", async () => {
const { client, socket } = await olderHost();
await expect(
resolverFor(client, true).use("srv_older", (host) => host.createAgent(createArgs, {} as never)),
).rejects.toThrow("Update the host to use background agents.");
expect(socket.requests.map((request) => request.type)).not.toContain("create_agent_request");
});
@@ -16,6 +16,11 @@ import type { ConnectedHosts } from "../../connected-hosts/index.js";
import { resolveRequiredProviderModel } from "../mcp-shared.js";
import { selectAgentListItems } from "../agent-projections.js";
import { summarizeAgentActivity } from "../activity-curator.js";
import {
inheritAgentBackground,
inheritWorkspaceBackground,
type BackgroundCaller,
} from "../create-agent/intent.js";
import { expandUserPath } from "../../path-utils.js";
import type { PaseoToolExecutionContext, PaseoToolResult } from "../tools/types.js";
@@ -177,6 +182,7 @@ const remoteCreate = z
workspaceId: z.string().min(1),
labels: z.record(z.string(), z.string()).optional(),
notifyOnFinish: z.boolean().optional(),
background: z.boolean().optional(),
idempotencyKey: z.string().optional(),
settings: z
.object({
@@ -197,6 +203,8 @@ export function createHostResolver(input: {
local: HostOperations;
hosts: () => ToolHosts;
callerAgentId?: string;
/** The destination can't see the caller, so its background defaults resolve before routing. */
caller: () => BackgroundCaller | null;
lockedCwd?: string;
}) {
return {
@@ -214,9 +222,7 @@ export function createHostResolver(input: {
async createAgent(args) {
const parsed = remoteCreate.parse(args);
const { provider, model } = resolveRequiredProviderModel(parsed.provider);
const workspace = (await client.fetchWorkspaces({})).entries.find(
(row) => row.id === parsed.workspaceId,
);
const workspace = await client.findWorkspace(parsed.workspaceId);
if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`);
const labels = {
...parsed.labels,
@@ -235,6 +241,10 @@ export function createHostResolver(input: {
featureValues: parsed.settings?.features,
},
workspaceId: parsed.workspaceId,
// Older hosts reject any background with a workspaceId, so a visible create omits it.
...(inheritAgentBackground(parsed.background, input.caller())
? { background: true }
: {}),
labels,
initialPrompt: parsed.initialPrompt,
idempotencyKey: parsed.idempotencyKey,
@@ -359,7 +369,10 @@ export function createHostResolver(input: {
worktreeSlug: args.worktreeSlug as string | undefined,
},
title: args.title as string | undefined,
background: args.background as boolean | undefined,
background: inheritWorkspaceBackground(
args.background as boolean | undefined,
input.caller(),
),
});
if (response.error || !response.workspace)
throw new Error(response.error ?? "Workspace creation failed");
@@ -399,14 +412,16 @@ function remoteObservation(client: DaemonClient): HostObservation {
// A new turn may start between the snapshot and the current-response read.
// Bound that wait and refresh state instead of attributing the old event to it.
const current = await client.waitForFinish(agentId, 100);
if (current.status === "timeout")
snapshot = (await client.fetchAgent(agentId))?.agent ?? null;
else {
if (!current.final && current.error && current.error !== `Agent not found: ${agentId}`)
throw new Error(current.error);
snapshot = current.final;
lastMessage = current.lastMessage;
if (current.status === "timeout") {
// Queued delivery can leave a closed/error snapshot visible before the new turn.
// A timed-out wait has not established an outcome for that queued work.
if (event?.type === "permission_resolved") handler(snapshot, event);
return;
}
if (!current.final && current.error && current.error !== `Agent not found: ${agentId}`)
throw new Error(current.error);
snapshot = current.final;
lastMessage = current.lastMessage;
}
handler(snapshot, event, lastMessage);
};
@@ -221,7 +221,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
listImportableSessions,
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">;
const agentStorage = {
list: async () => [
listAll: async () => [
{
provider: "codex",
persistence: {
@@ -231,7 +231,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
},
} as StoredAgentRecord,
],
} satisfies Pick<AgentStorage, "list">;
} satisfies Pick<AgentStorage, "listAll">;
const result = await listImportableProviderSessions({
request: makeRequest({
@@ -303,7 +303,7 @@ test("listImportableProviderSessions looks past already-imported rows to fill th
listImportableSessions,
},
agentStorage: {
list: async () => [
listAll: async () => [
{
provider: "claude",
persistence: { provider: "claude", sessionId: "already-imported" },
@@ -360,7 +360,7 @@ test("listImportableProviderSessions requests a bounded deep scan for search res
const result = await listImportableProviderSessions({
request: makeRequest({ query: "INVOICE", limit: 10 }),
agentManager: { listAgents: () => [], listImportableSessions },
agentStorage: { list: async () => [] },
agentStorage: { listAll: async () => [] },
providerSnapshotManager: { getProviderLabel: (provider) => provider },
});
@@ -397,7 +397,7 @@ test("listImportableProviderSessions includes a provider session after its Paseo
listImportableSessions: async () => makeImportableSessionsResult([archivedSession]),
},
agentStorage: {
list: async () => [
listAll: async () => [
{
provider: "claude",
archivedAt: "2026-04-30T12:01:00.000Z",
@@ -441,7 +441,7 @@ test("listImportableProviderSessions includes an archived provider session still
listImportableSessions: async () => makeImportableSessionsResult([archivedSession]),
},
agentStorage: {
list: async () => [
listAll: async () => [
{
id: agentId,
provider: "claude",
@@ -489,8 +489,8 @@ test("listImportableProviderSessions filters out metadata generation sessions",
listImportableSessions: async () => makeImportableSessionsResult(sessions),
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
listAll: async () => [],
} satisfies Pick<AgentStorage, "listAll">,
providerSnapshotManager: { getProviderLabel: () => "Codex" },
});
@@ -525,8 +525,8 @@ test("listImportableProviderSessions keeps realpath-equivalent cwd matches", asy
]),
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
listAll: async () => [],
} satisfies Pick<AgentStorage, "listAll">,
providerSnapshotManager: { getProviderLabel: () => "Pi" },
});
@@ -542,8 +542,8 @@ test("listImportableProviderSessions rejects invalid since values", async () =>
listImportableSessions: async () => makeImportableSessionsResult([]),
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
agentStorage: {
list: async () => [],
} satisfies Pick<AgentStorage, "list">,
listAll: async () => [],
} satisfies Pick<AgentStorage, "listAll">,
providerSnapshotManager: { getProviderLabel: () => "" },
}),
).rejects.toMatchObject(
@@ -64,7 +64,7 @@ export class ImportSessionsRequestError extends Error {
export interface ListImportableProviderSessionsInput {
request: FetchRecentProviderSessionsRequestMessage;
agentManager: Pick<AgentManager, "listAgents" | "listImportableSessions">;
agentStorage: Pick<AgentStorage, "list">;
agentStorage: Pick<AgentStorage, "listAll">;
providerSnapshotManager: Pick<ProviderSnapshotManager, "getProviderLabel">;
}
@@ -342,12 +342,13 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n
async function collectImportedProviderSessions(
agentManager: Pick<AgentManager, "listAgents">,
agentStorage: Pick<AgentStorage, "list">,
agentStorage: Pick<AgentStorage, "listAll">,
providerFilter: Set<string> | undefined,
): Promise<{ handles: Set<string>; count: number }> {
const handles = new Set<string>();
const sessions = new Set<string>();
const records = await agentStorage.list();
// Explicit provider-history import deduplicates against archived as well as active sessions.
const records = await agentStorage.listAll();
const storedRecordsById = new Map(records.map((record) => [record.id, record]));
const collect = (
@@ -380,7 +381,7 @@ function toProviderSessionHandleKey(provider: string, providerHandleId: string):
return `${provider}\0${providerHandleId}`;
}
function isMetadataGenerationSession(input: { firstPromptPreview: string | null }): boolean {
export function isMetadataGenerationSession(input: { firstPromptPreview: string | null }): boolean {
return (
input.firstPromptPreview?.trimStart().startsWith(METADATA_GENERATION_PROMPT_PREFIX) ?? false
);
@@ -404,6 +404,7 @@ describe("Suite A: Core Fixes", () => {
expect(parseAgentMessage(callerPrompts[0]).source).toEqual({
kind: "agent-notification",
agentId: childId,
serverId: daemon.daemon.getServerId(),
title: "Notification child",
event: "finished",
});
@@ -517,6 +518,7 @@ describe("Suite A: Core Fixes", () => {
expect(notification.source).toEqual({
kind: "agent-notification",
agentId: childId,
serverId: daemon.daemon.getServerId(),
title: "Watched child",
event: "errored",
});
@@ -579,7 +581,11 @@ describe("Suite A: Core Fixes", () => {
type: "tool_call",
agentMessage: {
event: outcome,
sender: { id: childId, title: "Follow-up child" },
sender: {
id: childId,
serverId: daemonHandle.daemon.getServerId(),
title: "Follow-up child",
},
text: expect.stringContaining(`(Follow-up child) ${outcome}.`),
},
}),
@@ -1165,7 +1171,7 @@ describe("Suite E: Workspace Tools", () => {
.callTool({ name: "archive_workspace", args: { workspaceId } })
.catch(() => undefined);
}
});
}, 30_000);
test("agent-scoped workspace creation inherits visibility and permits an explicit false override", async () => {
const parentWorkspace = await callToolStructured(topLevelClient, "create_workspace", {
@@ -1191,12 +1197,43 @@ describe("Suite E: Workspace Tools", () => {
});
ownedWorkspaces.push(str(inherited.workspaceId));
expect(inherited.background).toBe(true);
expect(inherited.parentWorkspaceId).toBe(parentWorkspaceId);
const visible = await callToolStructured(scoped, "create_workspace", {
isolation: "local",
background: false,
});
ownedWorkspaces.push(str(visible.workspaceId));
expect(visible.background).toBe(false);
expect(visible.parentWorkspaceId).toBe(parentWorkspaceId);
const worktree = await callToolStructured(scoped, "create_workspace", {
isolation: "worktree",
branchName: "parentage-child",
baseBranch: "main",
background: false,
});
ownedWorkspaces.push(str(worktree.workspaceId));
expect(worktree.parentWorkspaceId).toBe(parentWorkspaceId);
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemonHandle.port}/ws` });
try {
await client.connect();
const created = await client.createWorkspace({
source: { kind: "directory", path: worktreeRepoCwd },
callerAgentId: str(parent.agentId),
background: false,
});
const workspaceId = str(created.workspace?.id);
ownedWorkspaces.push(workspaceId);
expect(created.workspace?.parentWorkspaceId).toBe(parentWorkspaceId);
const listed = await client.fetchWorkspaces();
expect(listed.entries).toContainEqual(
expect.objectContaining({
id: workspaceId,
parentWorkspaceId: parentWorkspaceId,
}),
);
} finally {
await client.close();
}
} finally {
await scoped?.close();
for (const workspaceId of ownedWorkspaces.toReversed())
@@ -1204,5 +1241,138 @@ describe("Suite E: Workspace Tools", () => {
.callTool({ name: "archive_workspace", args: { workspaceId } })
.catch(() => undefined);
}
}, 30_000);
test("a background agent's children and workspaces are background unless it overrides", async () => {
const visibleAgentId = await createTopLevelAgent();
const workspaceId = daemonHandle.daemon.agentManager.getAgent(visibleAgentId)?.workspaceId;
const caller = await callToolStructured(topLevelClient, "create_agent", {
workspaceId,
title: "Background caller",
provider: "claude/claude-test-model",
initialPrompt: "Say done.",
background: true,
});
const callerId = str(caller.agentId);
const background = (agentId: string) =>
daemonHandle.daemon.agentManager.getAgent(agentId)?.background;
expect(background(callerId)).toBe(true);
const scoped = await createMcpClient(
`http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${callerId}`,
);
const ownedWorkspaces: string[] = [];
try {
const inheritedChild = await callToolStructured(scoped, "create_agent", {
relationship: { kind: "subagent" },
workspace: { kind: "current" },
title: "Inherited child",
provider: "claude/claude-test-model",
initialPrompt: "Say done.",
notifyOnFinish: false,
});
expect(background(str(inheritedChild.agentId))).toBe(true);
const visibleChild = await callToolStructured(scoped, "create_agent", {
relationship: { kind: "subagent" },
workspace: { kind: "current" },
title: "Visible child",
provider: "claude/claude-test-model",
initialPrompt: "Say done.",
notifyOnFinish: false,
background: false,
});
expect(background(str(visibleChild.agentId))).toBe(false);
const inheritedWorkspace = await callToolStructured(scoped, "create_workspace", {
isolation: "local",
});
ownedWorkspaces.push(str(inheritedWorkspace.workspaceId));
expect(inheritedWorkspace.background).toBe(true);
} finally {
await scoped.close();
await archiveAgentIfPresent(callerId);
await archiveAgentIfPresent(visibleAgentId);
for (const id of ownedWorkspaces)
await topLevelClient
.callTool({ name: "archive_workspace", args: { workspaceId: id } })
.catch(() => undefined);
}
});
test("a background subagent stays out of default lists, notifies its parent and archives with it", async () => {
const callerPrompts: string[] = [];
const daemon = await createTestPaseoDaemon({
agentClients: createTestAgentClients({
onStartTurn(prompt, config) {
if (config.title === "Background parent" && typeof prompt === "string")
callerPrompts.push(prompt);
},
}),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
let mcp: McpClient | undefined;
try {
await client.connect();
const caller = await client.createAgent({
provider: "claude",
cwd: daemon.paseoHome,
title: "Background parent",
modeId: "bypassPermissions",
});
mcp = await createMcpClient(
buildExpectedAgentMcpUrl({ host: "127.0.0.1", port: daemon.port, agentId: caller.id }),
);
const created = await callToolStructured(mcp, "create_agent", {
relationship: { kind: "subagent" },
workspace: { kind: "current" },
provider: "codex/gpt-5.4",
title: "Background child",
initialPrompt: "say done and stop",
notifyOnFinish: true,
background: true,
settings: { modeId: "full-access" },
});
const childId = str(created.agentId);
expect((await client.fetchAgent(childId))?.agent).toMatchObject({
background: true,
workspaceId: caller.workspaceId,
labels: { [PARENT_AGENT_ID_LABEL]: caller.id },
});
expect((await client.fetchAgents()).entries.map((entry) => entry.agent.id)).not.toContain(
childId,
);
expect(
(await client.fetchAgents({ filter: { includeBackground: true } })).entries.map(
(entry) => entry.agent.id,
),
).toContain(childId);
await waitFor({
timeoutMs: 10000,
label: "parent finish notification",
check: () => (callerPrompts.length > 0 ? true : null),
});
expect(parseAgentMessage(callerPrompts[0]).source).toMatchObject({
kind: "agent-notification",
agentId: childId,
event: "finished",
});
expect((await client.fetchAgent(childId))?.agent.requiresAttention).toBe(false);
await waitFor({
timeoutMs: 10000,
label: "parent idle",
check: () =>
daemon.daemon.agentManager.getAgent(caller.id)?.lifecycle === "idle" ? true : null,
});
await client.archiveAgent(caller.id);
await waitFor({
timeoutMs: 10000,
label: "child archived with parent",
check: async () => ((await client.fetchAgent(childId))?.agent.archivedAt ? true : null),
});
} finally {
await mcp?.close();
await client.close();
await daemon.close();
}
}, 30_000);
});
@@ -292,7 +292,9 @@ function buildAgentStorageSpies() {
setTitle: vi.fn().mockResolvedValue(undefined),
upsert: vi.fn().mockResolvedValue(undefined),
applySnapshot: vi.fn(),
list: vi.fn().mockResolvedValue([]),
listActive: vi.fn().mockResolvedValue([]),
listByWorkspaces: vi.fn().mockResolvedValue([]),
lastUserMessageAtByWorkspace: async () => new Map<string, string>(),
remove: vi.fn(),
};
}
@@ -639,7 +641,16 @@ class BoundaryAgentManagerFake {
}
class BoundaryAgentStorageFake {
public async list(): Promise<StoredAgentRecord[]> {
public async listActive(): Promise<StoredAgentRecord[]> {
return [];
}
async listByWorkspaces() {
return [];
}
async lastUserMessageAtByWorkspace() {
return new Map<string, string>();
}
async listAll() {
return [];
}
}
@@ -772,7 +783,8 @@ function createPaseoWorktreeForMcpTest(options: {
initialize: async () => {},
existsOnDisk: async () => true,
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
list: async () => Array.from(workspaces.values()),
listAll: async () => Array.from(workspaces.values()),
listActive: async () => Array.from(workspaces.values()).filter((w) => !w.archivedAt),
update: async (workspaceId, updater) => {
const workspace = workspaces.get(workspaceId);
if (!workspace) return null;
@@ -894,7 +906,11 @@ async function createAgentMcpServer(
const server = await createAgentMcpServerWithDependencies({
agentMessages: () => messages,
finishSubscriptions: () => subscriptions,
workspaceRegistry: { get: async () => null, list: async () => [], upsert: async () => {} },
workspaceRegistry: {
get: async () => null,
listActive: async () => [],
upsert: async () => {},
},
...options,
});
serverSubscriptionRecords.set(server, records);
@@ -1493,7 +1509,6 @@ describe("terminal MCP tools", () => {
id: "term-1",
name: "daemon",
cwd: process.cwd(),
getState: vi.fn().mockReturnValue({ scrollback: [], grid: [[]] }),
}),
captureTerminal,
});
@@ -1648,11 +1663,11 @@ describe("create_agent MCP tool", () => {
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ cwd: existingCwd }),
undefined,
{ workspaceId: "workspace-created" },
{ workspaceId: "workspace-created", background: false },
);
});
it("rejects public internal helpers and the old top-level execution background option", async () => {
it("rejects public internal helpers", async () => {
const workdir = await mkdtemp(join(tmpdir(), "mcp-public-helper-removal-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const agentManager = new AgentManager({
@@ -1670,9 +1685,6 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
const input = { title: "Helper", provider: "codex/gpt-5.4", initialPrompt: "Summarize" };
await expect(tool.handler({ ...input, internal: true })).rejects.toThrow(/Unrecognized key/);
await expect(tool.handler({ ...input, background: true })).rejects.toThrow(
/Unrecognized key/,
);
} finally {
await removeAgentStateDir(agentManager, storage, workdir);
}
@@ -1794,7 +1806,7 @@ describe("create_agent MCP tool", () => {
cwd: existingCwd,
}),
undefined,
{ workspaceId: "wks_existing" },
{ workspaceId: "wks_existing", background: false },
);
});
@@ -1838,7 +1850,7 @@ describe("create_agent MCP tool", () => {
featureValues: { fast_mode: true },
}),
undefined,
{ workspaceId: "workspace-created" },
{ workspaceId: "workspace-created", background: false },
);
});
@@ -2034,7 +2046,7 @@ describe("create_agent MCP tool", () => {
title: "Fix auth bug",
}),
undefined,
{ workspaceId: "workspace-created" },
{ workspaceId: "workspace-created", background: false },
);
});
@@ -2069,7 +2081,7 @@ describe("create_agent MCP tool", () => {
title: "Fix auth",
}),
undefined,
{ workspaceId: "workspace-created" },
{ workspaceId: "workspace-created", background: false },
);
});
@@ -2113,6 +2125,7 @@ describe("create_agent MCP tool", () => {
{
labels: { source: "mcp" },
workspaceId: "workspace-created",
background: false,
},
);
});
@@ -2215,7 +2228,7 @@ describe("create_agent MCP tool", () => {
title: "Directory agent",
}),
undefined,
{ workspaceId: "workspace-directory-auto-title" },
{ workspaceId: "workspace-directory-auto-title", background: false },
);
expect(workspaceRecords.get("workspace-directory-auto-title")).toMatchObject({
title: "Directory Workspace Title",
@@ -2700,7 +2713,7 @@ describe("create_agent MCP tool", () => {
providerSnapshotManager: createOpenCodeManager().manager,
workspaceRegistry: {
get: vi.fn(async () => workspace),
list: vi.fn(async () => [workspace]),
listActive: vi.fn(async () => [workspace]),
upsert: vi.fn(async () => undefined),
},
logger,
@@ -2733,7 +2746,7 @@ describe("create_agent MCP tool", () => {
providerSnapshotManager: createOpenCodeManager().manager,
workspaceRegistry: {
get: vi.fn(async () => backgroundWorkspace),
list: vi.fn(async () => [backgroundWorkspace]),
listActive: vi.fn(async () => [backgroundWorkspace]),
upsert: vi.fn(async () => undefined),
},
createDirectoryWorkspace: async (cwd, _title, _projectId, context) => {
@@ -2829,12 +2842,13 @@ describe("create_agent MCP tool", () => {
source: "voice",
},
workspaceId: "wks_voice",
background: false,
},
);
await rm(baseDir, { recursive: true, force: true });
});
it("rejects background from caller agents and defaults notify-on-finish on", async () => {
it("defaults notify-on-finish on for caller agents", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "parent-agent",
@@ -2854,16 +2868,6 @@ describe("create_agent MCP tool", () => {
});
const tool = registeredTool(server, "create_agent");
await expect(
tool.handler({
...subagentCurrentWorkspace(),
title: "Child",
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: false,
}),
).rejects.toThrow(/Unrecognized key/);
const parsed = await tool.inputSchema.safeParseAsync({
...subagentCurrentWorkspace(),
title: "Child",
@@ -3020,6 +3024,7 @@ describe("create_agent MCP tool", () => {
source: "handoff",
},
workspaceId: "wks_parent",
background: false,
},
);
});
@@ -3081,6 +3086,7 @@ describe("create_agent MCP tool", () => {
[PARENT_AGENT_ID_LABEL]: "parent-agent",
},
workspaceId: "wks_parent",
background: false,
},
);
});
@@ -3219,6 +3225,7 @@ describe("create_agent MCP tool", () => {
expect(agentIdArg).toBeUndefined();
expect(optionsArg).toEqual({
workspaceId: "workspace-created",
background: false,
});
});
@@ -3296,7 +3303,7 @@ describe("create_agent MCP tool", () => {
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "dynamic" }),
undefined,
{ workspaceId: "workspace-created" },
{ workspaceId: "workspace-created", background: false },
);
});
@@ -3335,7 +3342,7 @@ describe("create_agent MCP tool", () => {
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
undefined,
{ workspaceId: "workspace-created" },
{ workspaceId: "workspace-created", background: false },
);
});
@@ -6335,7 +6342,7 @@ describe("agent snapshot MCP serialization", () => {
createManagedAgent({ id: "in-child-cwd", cwd: "/tmp/workspace/packages/server" }),
createManagedAgent({ id: "other-cwd", cwd: "/tmp/other" }),
]);
spies.agentStorage.list.mockResolvedValue([
spies.agentStorage.listActive.mockResolvedValue([
createStoredRecord({
id: "stored-in-cwd",
cwd: "/tmp/workspace",
@@ -6394,7 +6401,7 @@ describe("agent snapshot MCP serialization", () => {
updatedAt: new Date(old),
}),
]);
spies.agentStorage.list.mockResolvedValue([
spies.agentStorage.listActive.mockResolvedValue([
createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }),
createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }),
createStoredRecord({
@@ -6435,7 +6442,7 @@ describe("agent snapshot MCP serialization", () => {
archivedAt: new Date(now - index * 60 * 1000).toISOString(),
}),
);
spies.agentStorage.list.mockResolvedValue([
spies.agentStorage.listActive.mockResolvedValue([
...recentArchivedRecords,
createStoredRecord({
id: "old-archived",
@@ -6466,7 +6473,7 @@ describe("agent snapshot MCP serialization", () => {
it("returns compact list items for stored archived agents", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const now = new Date().toISOString();
spies.agentStorage.list.mockResolvedValue([
spies.agentStorage.listActive.mockResolvedValue([
createStoredRecord({
id: "stored-archived-compact",
cwd: REPO_CWD,
@@ -6585,7 +6592,7 @@ describe("agent snapshot MCP serialization", () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const now = new Date().toISOString();
spies.agentManager.listAgents.mockReturnValue([createManagedAgent()]);
spies.agentStorage.list.mockResolvedValue([
spies.agentStorage.listActive.mockResolvedValue([
createStoredRecord({
id: "stored-non-archived",
updatedAt: now,
@@ -53,6 +53,7 @@ const CAPABILITIES = [
interface ProviderHarnessOptions {
capabilities?: ProviderConnection["capabilities"];
completeTurn?: boolean;
closeAcknowledgementOnly?: boolean;
openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void;
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
}
@@ -208,6 +209,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
}
if (input.type === "session.close") {
emit({ type: "session.closed", sessionId: input.sessionId });
if (options.closeAcknowledgementOnly) return;
}
if ("requestId" in input) {
emit({ type: "request.completed", requestId: input.requestId });
@@ -246,6 +248,572 @@ function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]
return events.filter((event) => event.type === type);
}
function createRewindProviderHarness(
options: {
reuseAnswerId?: boolean;
forkOnRevert?: boolean;
handleInput?: ProviderHarnessOptions["handleInput"];
} = {},
) {
const nativeMessages: string[] = [];
let originalMessages: string[] | null = null;
const harness = createProviderHarness({
closeAcknowledgementOnly: true,
capabilities: [
...CAPABILITIES,
"session.revert.conversation",
"session.revert.files",
"session.revert.both",
],
async handleInput(input, emit) {
if (await options.handleInput?.(input, emit)) return true;
if (input.type === "session.open" && input.history === "replay") {
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [
...CAPABILITIES,
"session.revert.conversation",
"session.revert.files",
"session.revert.both",
],
restoration: "core",
persistence: input.persistence ?? { version: 1, data: { token: "root" } },
cwd: input.config.cwd,
});
const messages =
options.forkOnRevert &&
(input.persistence?.data as { token?: string })?.token === "root" &&
originalMessages
? originalMessages
: nativeMessages;
for (const [index, text] of messages.entries()) {
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: { type: "user_message", id: `user-${text}`, text, revertToken: text },
});
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "assistant_message",
id: options.reuseAnswerId ? `answer-${index + 1}` : `answer-${text}`,
text: `Reply ${text}`,
},
});
}
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
}
if (input.type === "session.prompt") {
if (input.prompt.input.type !== "message") throw new Error("Expected a message");
const text = input.prompt.input.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("");
nativeMessages.push(text);
const turnId = `turn-${text}`;
emit({
type: "session.prompt_result",
sessionId: input.sessionId,
clientMessageId: input.prompt.clientMessageId,
result: { type: "turn", turnId },
});
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" });
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "user_message",
id: `user-${text}`,
text,
clientMessageId: input.prompt.clientMessageId,
revertToken: text,
},
});
emit({
type: "timeline.item",
sessionId: input.sessionId,
item: {
type: "assistant_message",
id: options.reuseAnswerId ? `answer-${nativeMessages.length}` : `answer-${text}`,
text: `Reply ${text}`,
},
});
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" });
return true;
}
if (input.type === "session.revert") {
if (options.forkOnRevert && input.scope !== "files") {
originalMessages = [...nativeMessages];
emit({
type: "session.persistence",
sessionId: input.sessionId,
persistence: { version: 1, data: { token: "fork" } },
});
}
if (input.scope !== "files")
nativeMessages.splice(nativeMessages.indexOf(String(input.token)));
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
return false;
},
});
return { ...harness, nativeMessages };
}
async function withRewindAgent(
run: (context: {
manager: AgentManager;
agentId: string;
nativeMessages: string[];
messages: () => string[];
}) => Promise<void>,
options: Parameters<typeof createRewindProviderHarness>[0] = {},
) {
const harness = createRewindProviderHarness(options);
const logger = createTestLogger();
const registry = new PluginAgentClientRegistry(logger);
registry.replace([harness.registration]);
const manager = new AgentManager({
logger,
clients: registry.clients(),
providerDefinitions: registry.definitions(),
});
const agent = await manager.createAgent(
{ provider: harness.registration.id, cwd: tmpdir() },
undefined,
{ workspaceId: undefined },
);
const messages = () =>
manager
.getTimeline(agent.id)
.flatMap((item) =>
item.type === "user_message" || item.type === "assistant_message" ? [item.text] : [],
);
try {
await manager.runAgent(agent.id, "A", { clientMessageId: "client-A" });
await manager.runAgent(agent.id, "B", { clientMessageId: "client-B" });
await manager.runAgent(agent.id, "C", { clientMessageId: "client-C" });
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
await run({ manager, agentId: agent.id, nativeMessages: harness.nativeMessages, messages });
} finally {
await manager.closeAgent(agent.id);
await registry.shutdown();
}
}
test("conversation rewind discards withdrawn plugin turns before and after a replacement prompt", async () => {
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
await manager.rewind(agentId, "user-B", "conversation");
const afterRewind = messages();
expect(nativeMessages).toEqual(["A"]);
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
const afterReplacement = messages();
expect(nativeMessages).toEqual(["A", "D"]);
expect({ afterRewind, afterReplacement }).toEqual({
afterRewind: ["A", "Reply A"],
afterReplacement: ["A", "Reply A", "D", "Reply D"],
});
});
});
test("file-only plugin rewind preserves the conversation before and after a replacement prompt", async () => {
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
await manager.rewind(agentId, "user-B", "files");
expect(nativeMessages).toEqual(["A", "B", "C"]);
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C", "D", "Reply D"]);
});
});
test("plugin rewind restores translation state when a replacement reuses a discarded item id", async () => {
const harness = createRewindProviderHarness({ reuseAnswerId: true });
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() });
const live: AgentStreamEvent[] = [];
try {
await session.run("A");
await session.run("B");
session.subscribe((event) => live.push(event));
await session.revertConversation!({ messageId: "user-B" });
live.length = 0;
await session.run("B revised");
const assistant = live.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
);
expect(harness.nativeMessages).toEqual(["A", "B revised"]);
expect(assistant).toEqual(["Reply B revised"]);
} finally {
await session.close();
await registry.shutdown();
}
});
async function withPluginRewindSession(
options: Parameters<typeof createRewindProviderHarness>[0],
run: (
session: Awaited<ReturnType<AgentClient["createSession"]>>,
harness: ReturnType<typeof createRewindProviderHarness>,
) => Promise<void>,
) {
const harness = createRewindProviderHarness(options);
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() });
try {
await session.run("A");
await session.run("B");
await session.run("C");
await run(session, harness);
} finally {
await session.close();
await registry.shutdown();
}
}
async function pluginMessages(session: Awaited<ReturnType<AgentClient["createSession"]>>) {
const events = await Array.fromAsync(session.streamHistory());
return events.flatMap((event) =>
event.type === "timeline" &&
(event.item.type === "user_message" || event.item.type === "assistant_message")
? [event.item.text]
: [],
);
}
test("combined plugin rewind replays the retained native branch", async () => {
await withPluginRewindSession({}, async (session) => {
await session.revertBoth!({ messageId: "user-B" });
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
await session.revertConversation!({ messageId: "user-A" });
expect(await pluginMessages(session)).toEqual([]);
});
});
test("plugin rewind resumes the latest persistence rather than the original handle", async () => {
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type === "session.revert")
emit({
type: "session.persistence",
sessionId: input.sessionId,
persistence: { version: 1, data: { token: "fork" } },
});
return false;
},
},
async (session, harness) => {
await session.revertConversation!({ messageId: "user-B" });
expect(harness.inputs.findLast((input) => input.type === "session.open")).toMatchObject({
persistence: { version: 1, data: { token: "fork" } },
history: "replay",
});
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
},
);
});
test("failed native plugin rewind preserves history and does not reopen", async () => {
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.revert") return false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "native rewind failed" },
});
return true;
},
},
async (session, harness) => {
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"native rewind failed",
);
expect(await pluginMessages(session)).toEqual([
"A",
"Reply A",
"B",
"Reply B",
"C",
"Reply C",
]);
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
},
);
});
test("failed plugin rewind replay blocks prompts until retry without repeating native rewind", async () => {
let failReplay = true;
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
return false;
failReplay = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "replay failed" },
});
return true;
},
},
async (session, harness) => {
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"replay failed",
);
await expect(session.run("D")).rejects.toThrow("Retry the rewind or reload");
await session.revertConversation!({ messageId: "user-B" });
await session.run("D");
expect(await pluginMessages(session)).toEqual(["A", "Reply A", "D", "Reply D"]);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
},
);
});
test("retrying a failed plugin rewind replay replaces the manager timeline before prompting", async () => {
let failReplay = true;
await withRewindAgent(
async ({ manager, agentId, messages }) => {
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
"replay failed",
);
await expect(manager.runAgent(agentId, "D")).rejects.toThrow("Retry the rewind or reload");
await manager.rewind(agentId, "user-B", "conversation");
expect(messages()).toEqual(["A", "Reply A"]);
await manager.runAgent(agentId, "D");
expect(messages()).toEqual(["A", "Reply A", "D", "Reply D"]);
},
{
handleInput: async (input, emit) => {
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
return false;
failReplay = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "replay failed" },
});
return true;
},
},
);
});
test("reload after failed plugin rewind replay resumes the committed fork", async () => {
let failReplay = true;
await withRewindAgent(
async ({ manager, agentId, messages }) => {
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
"replay failed",
);
await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true });
await manager.hydrateTimelineFromProvider(agentId, { force: true });
expect(messages()).toEqual(["A", "Reply A"]);
},
{
forkOnRevert: true,
handleInput: async (input, emit) => {
if (input.type === "session.close") {
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
return false;
failReplay = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "replay failed" },
});
return true;
},
},
);
});
test("closing during plugin rewind replay closes each native binding once", async () => {
let opened!: () => void;
let release!: () => void;
const replayStarted = new Promise<void>((resolve) => {
opened = resolve;
});
const replayAllowed = new Promise<void>((resolve) => {
release = resolve;
});
await withPluginRewindSession(
{
handleInput: async (input) => {
if (input.type === "session.open" && input.history === "replay") {
opened();
await replayAllowed;
}
return false;
},
},
async (session, harness) => {
const rewind = session.revertConversation!({ messageId: "user-B" });
await replayStarted;
const closing = session.close();
release();
await Promise.all([rewind, closing]);
const closes = harness.inputs.flatMap((input) =>
input.type === "session.close" ? [input.sessionId] : [],
);
expect(closes).toHaveLength(2);
expect(new Set(closes).size).toBe(2);
},
);
});
test("plugin rewind retries a failed close before reopening the native writer", async () => {
let failClose = true;
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.close" || !failClose) return false;
failClose = false;
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "close failed" },
});
return true;
},
},
async (session, harness) => {
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"close failed",
);
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
},
);
});
test("plugin rewind retries permission settlement before retiring its session", async () => {
let failPermission = true;
await withPluginRewindSession(
{
handleInput: async (input) => {
if (input.type !== "session.permission" || !failPermission) return false;
failPermission = false;
throw new Error("permission delivery failed");
},
},
async (session, harness) => {
const opened = harness.inputs.find((input) => input.type === "session.open")!;
if (opened.type !== "session.open") throw new Error("Missing root");
harness.emit({
type: "session.permission",
sessionId: opened.sessionId,
request: { id: "pending", name: "tool", kind: "tool" },
});
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"permission delivery failed",
);
expect(session.getPendingPermissions()).toHaveLength(1);
expect(harness.inputs.filter((input) => input.type === "session.close")).toHaveLength(0);
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
expect(session.getPendingPermissions()).toEqual([]);
expect(harness.inputs.filter((input) => input.type === "session.permission")).toHaveLength(2);
},
);
});
test("plugin rewind replaces child listeners and settles old pending permissions", async () => {
await withPluginRewindSession({}, async (session, harness) => {
const root = harness.inputs.find((input) => input.type === "session.open")!;
if (root.type !== "session.open") throw new Error("Missing root");
harness.emit({
type: "session.permission",
sessionId: root.sessionId,
request: { id: "old-permission", name: "tool", kind: "tool", title: "Run tool" },
});
expect(session.getPendingPermissions()).toHaveLength(1);
await session.revertConversation!({ messageId: "user-B" });
expect(session.getPendingPermissions()).toEqual([]);
expect(harness.inputs).toContainEqual(
expect.objectContaining({
type: "session.permission",
permissionId: "old-permission",
response: { behavior: "deny", message: "Session replaced after rewind" },
}),
);
harness.emit({
type: "timeline.item",
sessionId: "child-1",
item: { type: "assistant_message", id: "child-message", text: "Discarded child update" },
});
const history = await Array.fromAsync(session.streamHistory());
expect(history.filter((event) => event.type === "provider_subagent")).toEqual([]);
const reopened = harness.inputs.findLast((input) => input.type === "session.open")!;
if (reopened.type !== "session.open") throw new Error("Missing reopened root");
harness.emit({
type: "session.opened",
sessionId: "child-1",
parentSessionId: reopened.sessionId,
capabilities: [],
restoration: "parent",
cwd: tmpdir(),
});
harness.emit({
type: "timeline.item",
sessionId: "child-1",
item: { type: "assistant_message", id: "child-message", text: "New child reply" },
});
const next = await Array.fromAsync(session.streamHistory());
expect(next).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: expect.objectContaining({
type: "timeline",
item: expect.objectContaining({ text: "New child reply" }),
}),
}),
);
});
});
test("nonpersisted plugin sessions reject conversation rewind before native mutation", async () => {
await withPluginRewindSession(
{
handleInput: async (input, emit) => {
if (input.type !== "session.open") return false;
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [...CAPABILITIES, "session.revert.conversation", "session.revert.files"],
restoration: "core",
cwd: input.config.cwd,
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
},
},
async (session, harness) => {
expect(session.capabilities.supportsRewindConversation).toBe(false);
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
"requires session persistence",
);
expect(harness.inputs.filter((input) => input.type === "session.revert")).toEqual([]);
await session.revertFiles!({ messageId: "user-B" });
expect(harness.nativeMessages).toEqual(["A", "B", "C"]);
},
);
});
function openNestedChildren(rootSessionId: string, emit: (event: ProviderEvent) => void) {
for (const [sessionId, parentSessionId] of [
["a", rootSessionId],
@@ -603,6 +603,7 @@ class ProviderRuntimeSession {
>();
private readonly activeTurnIds = new Set<string>();
private terminal = false;
private closePromise: Promise<void> | null = null;
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
persistence: ProviderPersistence | null = null;
@@ -724,6 +725,14 @@ class ProviderRuntimeSession {
this.runtime.removeSession(this.id, this.providerSessionId);
return;
}
this.closePromise ??= this.closeNativeSession().catch((error) => {
this.closePromise = null;
throw error;
});
await this.closePromise;
}
private async closeNativeSession(): Promise<void> {
try {
await this.runtime.complete({
type: "session.close",
@@ -732,9 +741,9 @@ class ProviderRuntimeSession {
});
} catch (error) {
if (!this.runtime.isClosed) throw error;
} finally {
this.runtime.removeSession(this.id, this.providerSessionId);
}
// A failed close still needs its routing for a subsequent session.closed ack.
this.runtime.removeSession(this.id, this.providerSessionId);
}
beginOpen(requestId: string): Promise<void> {
@@ -1092,11 +1101,40 @@ class PluginAgentClient implements AgentClient {
persistence: input.persistence,
history: input.history,
});
const session = new PluginAgentSession(this.provider, bridge, () => {
this.rootsBySession.delete(bridge.id);
});
this.rootsBySession.set(bridge.id, session);
this.attachPendingChildren();
const forget = () => {
for (const [id, owner] of this.rootsBySession) {
if (owner === session) this.rootsBySession.delete(id);
}
};
const bind = (next: ProviderRuntimeSession) => {
this.rootsBySession.set(next.id, session);
this.attachPendingChildren();
};
const config = mapSessionConfig(input.config, input.launchContext, input.persist);
const session = new PluginAgentSession(
this.provider,
bridge,
forget,
async (persistence, state) => {
forget();
return await this.runtime.openSession({
sessionId: randomUUID(),
config: {
...config,
model: state.model,
mode: state.mode,
thinkingOption: state.thinkingOption,
settings: Object.fromEntries(
state.settings.map((setting) => [setting.id, setting.value]),
),
},
persistence,
history: "replay",
});
},
bind,
);
bind(bridge);
return session;
}
@@ -1157,16 +1195,32 @@ class PluginAgentSession implements AgentSession {
private readonly revertTokens = new Map<string, ProviderTimelineItem["revertToken"]>();
private readonly timelineSnapshots = new Map<string, TimelineTextSnapshot>();
private readonly subagentIdsBySession = new Map<string, string | null>();
private readonly childUnsubscribes = new Map<string, () => void>();
private readonly children = new Map<
string,
{ session: ProviderRuntimeSession; unsubscribe: () => void }
>();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
private unsubscribe: (() => void) | null = null;
private currentTurnId: string | null = null;
private closed = false;
private replay: {
persistence: ProviderPersistence;
config: ProviderConfigState;
messageId: string;
scope: "conversation" | "both";
closed: boolean;
} | null = null;
private replaying: Promise<void> | null = null;
constructor(
readonly provider: string,
private readonly bridge: ProviderRuntimeSession,
private bridge: ProviderRuntimeSession,
private readonly onClose: () => void,
private readonly reopen: (
persistence: ProviderPersistence,
state: ProviderConfigState,
) => Promise<ProviderRuntimeSession>,
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
) {
this.subagentIdsBySession.set(bridge.id, null);
for (const event of bridge.history) this.accept(event, false);
@@ -1183,7 +1237,13 @@ class PluginAgentSession implements AgentSession {
}
get capabilities(): AgentCapabilityFlags {
return agentCapabilities(this.bridge.negotiatedCapabilities);
const capabilities = agentCapabilities(this.bridge.negotiatedCapabilities);
const resumable = this.canReplayHistory;
return {
...capabilities,
supportsRewindConversation: capabilities.supportsRewindConversation && resumable,
supportsRewindBoth: capabilities.supportsRewindBoth && resumable,
};
}
get features(): AgentFeature[] {
@@ -1211,6 +1271,7 @@ class PluginAgentSession implements AgentSession {
prompt: AgentPromptInput,
options: AgentRunOptions = {},
): Promise<{ turnId: string }> {
this.assertReplayComplete();
const clientMessageId = options.clientMessageId ?? randomUUID();
const result = await this.bridge.prompt({
clientMessageId,
@@ -1233,6 +1294,7 @@ class PluginAgentSession implements AgentSession {
prompt: AgentPromptInput,
options: SteerActiveTurnOptions,
): Promise<SteerResult> {
this.assertReplayComplete();
if (!this.bridge.negotiatedCapabilities.includes("prompt.steer")) {
return { status: "unavailable" };
}
@@ -1256,6 +1318,7 @@ class PluginAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
await this.ensureReplay();
for (const event of this.history.read(this.provider)) yield event;
}
@@ -1283,6 +1346,7 @@ class PluginAgentSession implements AgentSession {
}
async setMode(modeId: string): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ mode: modeId });
}
@@ -1319,17 +1383,10 @@ class PluginAgentSession implements AgentSession {
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
this.unsubscribe?.();
this.unsubscribe = null;
for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe();
this.childUnsubscribes.clear();
this.subagentIdsBySession.clear();
this.history.clear();
this.timelineSnapshots.clear();
this.childSnapshots.clear();
this.revertTokens.clear();
this.pendingPermissions.clear();
this.permissionResponses.clear();
this.listeners.clear();
await this.replaying?.catch(() => undefined);
await this.clearBinding();
this.replay = null;
this.listeners.clear();
this.onClose();
await this.bridge.close();
@@ -1343,15 +1400,19 @@ class PluginAgentSession implements AgentSession {
}));
}
async setModel(modelId: string | null): Promise<void> {
async setModel(modelId: string | null): Promise<{ model: string | null }> {
this.assertReplayComplete();
await this.bridge.configure({ model: modelId });
return { model: this.bridge.config.model ?? null };
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({ thinkingOption: thinkingOptionId });
}
async setFeature(featureId: string, value: unknown): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
});
@@ -1415,10 +1476,10 @@ class PluginAgentSession implements AgentSession {
const snapshots = new Map<string, TimelineTextSnapshot>();
this.childSnapshots.set(childId, snapshots);
for (const event of child.history) this.acceptChildEvent(childId, event, snapshots);
this.childUnsubscribes.set(
child.id,
child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)),
);
this.children.set(child.id, {
session: child,
unsubscribe: child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)),
});
}
private accept(event: ProviderEvent, live: boolean): void {
@@ -1636,7 +1697,84 @@ class PluginAgentSession implements AgentSession {
}
}
private async clearBinding(): Promise<void> {
this.unsubscribe?.();
this.unsubscribe = null;
for (const child of this.children.values()) {
child.unsubscribe();
await child.session.close();
}
this.children.clear();
this.subagentIdsBySession.clear();
this.history.clear();
this.timelineSnapshots.clear();
this.childSnapshots.clear();
this.revertTokens.clear();
this.pendingPermissions.clear();
this.permissionResponses.clear();
this.currentTurnId = null;
}
private assertReplayComplete(): void {
if (this.replay) {
throw new Error(
"Plugin rewind history refresh is incomplete. Retry the rewind or reload the agent before continuing.",
);
}
}
// A native rewind has already committed. Until replay succeeds, neither history
// nor a new prompt may use the retired binding. Keep its latest handle for retry.
private async ensureReplay(): Promise<void> {
if (!this.replay) return;
if (this.closed) throw new StaleProviderSessionError(this.id);
if (!this.replaying) {
this.replaying = this.replayBinding().finally(() => {
this.replaying = null;
});
}
await this.replaying;
}
private async replayBinding(): Promise<void> {
const replay = this.replay!;
if (!replay.closed) {
for (const request of this.pendingPermissions.values()) {
await this.respondToPermission(request.id, {
behavior: "deny",
message: "Session replaced after rewind",
});
this.pendingPermissions.delete(request.id);
this.permissionResponses.delete(request.id);
}
await this.clearBinding();
await this.bridge.close();
replay.closed = true;
}
const bridge = await this.reopen(replay.persistence, replay.config);
this.bridge = bridge;
this.subagentIdsBySession.set(bridge.id, null);
for (const event of bridge.history) this.accept(event, false);
this.unsubscribe = bridge.onEvent((event) => this.accept(event, true));
this.onBind(bridge);
this.replay = null;
}
private get canReplayHistory(): boolean {
return (
this.bridge.restoration === "core" &&
this.bridge.persistence !== null &&
this.bridge.negotiatedCapabilities.includes("session.persistence")
);
}
private async revert(messageId: string, scope: "conversation" | "files" | "both") {
const pending = this.replay;
await this.ensureReplay();
if (pending?.messageId === messageId && pending.scope === scope) return;
if (scope !== "files" && !this.canReplayHistory) {
throw new Error("Plugin conversation rewind requires session persistence for history replay");
}
const token = this.revertTokens.get(messageId);
if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`);
await this.bridge.revert({
@@ -1646,6 +1784,12 @@ class PluginAgentSession implements AgentSession {
token,
scope,
});
if (scope !== "files") {
const persistence = this.bridge.persistence;
if (!persistence) throw new Error("Plugin rewind removed session persistence");
this.replay = { persistence, config: this.bridge.config, messageId, scope, closed: false };
await this.ensureReplay();
}
}
private publish(event: AgentStreamEvent): void {
@@ -468,6 +468,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response),
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
interrupt: () => inner.interrupt(),
stopBackgroundWork: inner.stopBackgroundWork?.bind(inner),
close: () => inner.close(),
canStopRuntime: inner.canStopRuntime?.bind(inner),
listCommands: inner.listCommands?.bind(inner),
@@ -22,7 +22,12 @@ interface CreateAgentProviderRuntimeOptions {
export async function createAgentProviderRuntime(
options: CreateAgentProviderRuntimeOptions,
): Promise<AgentProviderRuntime> {
const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger });
const configuredPort =
options.snapshotManager.runtimeSettings?.opencode?.env?.PASEO_OPENCODE_BRIDGE_PORT;
const port = configuredPort === undefined ? undefined : Number(configuredPort);
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535))
throw new Error("PASEO_OPENCODE_BRIDGE_PORT must be an integer between 1 and 65535");
const bridge = new OpenCodeBridge({ paseoHome: options.paseoHome, logger: options.logger, port });
try {
await bridge.start();
const snapshotManager = new ProviderSnapshotManager({
@@ -80,7 +80,7 @@ describe("ProviderSubagentStore", () => {
const [row] = subagents.fetchTimeline("parent-a", "child-1").rows;
expect(row?.item).toMatchObject({
type: "tool_call",
detail: { type: "shell", output: "x".repeat(64 * 1024) },
detail: { type: "shell", output: expect.stringMatching(/^x+…\[truncated \d+\.\d KB\]$/) },
});
});
@@ -0,0 +1,298 @@
import { afterEach, describe, expect, test } from "vitest";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import type { SessionConfigOption, SessionUpdate } from "@agentclientprotocol/sdk";
import { ACPAgentClient, type SessionStateResponse } from "./acp-agent.js";
import { CopilotACPAgentClient, COPILOT_ALLOW_ALL_MODE_ID } from "./copilot-acp-agent.js";
import type { AgentSession } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
const modes = {
currentModeId: "accept_edits",
availableModes: [
{ id: "default", name: "Default" },
{ id: "accept_edits", name: "Accept Edits" },
{ id: "dont_ask", name: "Don't Ask" },
],
};
function editOptions(currentValue = "workspace_session"): SessionConfigOption[] {
return [
{
id: "edit_approval_policy",
name: "Edits",
category: "mode",
type: "select",
currentValue,
options: [
{ value: "ask", name: "Default" },
{ value: "workspace_session", name: "Accept Edits" },
{ value: "session", name: "Don't Ask" },
],
},
];
}
// A controlled ACP process, speaking the same stdio protocol as a provider.
// Notifications precede session/new's response to exercise queued initialization updates.
const modePeerScript = String.raw`
import { createInterface } from "node:readline";
import { readFileSync, appendFileSync } from "node:fs";
const scenario = JSON.parse(readFileSync(process.argv[2], "utf8"));
let configOptions = scenario.state.configOptions;
const send = (message) => process.stdout.write(JSON.stringify(message) + "\n");
createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
const reply = (result) => send({jsonrpc: "2.0", id: request.id, result});
const reject = (message) => send({jsonrpc: "2.0", id: request.id, error: {code: -32602, message}});
switch (request.method) {
case "initialize":
reply({protocolVersion: 1, agentCapabilities: {}});
break;
case "session/new":
for (const update of scenario.updates) {
send({jsonrpc: "2.0", method: "session/update", params: {sessionId: "mode-session", update}});
}
reply({sessionId: "mode-session", ...scenario.state});
break;
case "session/set_mode":
appendFileSync(process.argv[3], JSON.stringify(request) + "\n");
if (request.params.modeId === scenario.rejectedMode) {
reject("mode denied");
} else if (!scenario.state.modes?.availableModes.some(mode => mode.id === request.params.modeId)) {
reject("invalid_params");
} else {
reply({});
}
break;
case "session/set_config_option":
appendFileSync(process.argv[3], JSON.stringify(request) + "\n");
configOptions = configOptions.map(option => option.id === request.params.configId
? {...option, currentValue: request.params.value} : option);
reply({configOptions});
break;
default:
reject("Unexpected request: " + request.method);
}
});
`;
interface ModeCall {
method: string;
params: { sessionId: string; modeId?: string; configId?: string; value?: string };
}
let session: AgentSession | undefined;
let directory: string | undefined;
afterEach(async () => {
try {
await session?.close();
} finally {
session = undefined;
if (directory) await rm(directory, { recursive: true, force: true });
directory = undefined;
}
});
async function fixture(
options: {
state?: Omit<SessionStateResponse, "sessionId">;
updates?: SessionUpdate[];
rejectedMode?: string;
startupMode?: string;
copilot?: boolean;
} = {},
) {
const artifacts = path.resolve(".artifacts");
await mkdir(artifacts, { recursive: true });
directory = await mkdtemp(path.join(artifacts, "acp-mode-"));
const script = path.join(directory, "peer.mjs");
const scenario = path.join(directory, "scenario.json");
const calls = path.join(directory, "calls.jsonl");
await writeFile(script, modePeerScript);
await writeFile(calls, "");
await writeFile(
scenario,
JSON.stringify({
state: options.state ?? { modes, configOptions: editOptions() },
updates: options.updates ?? [
{ sessionUpdate: "config_option_update", configOptions: editOptions() },
],
rejectedMode: options.rejectedMode,
}),
);
const command: [string, ...string[]] = [process.execPath, script, scenario, calls];
const client = options.copilot
? new CopilotACPAgentClient({
logger: createTestLogger(),
runtimeSettings: { command: { mode: "replace", argv: command } },
})
: new ACPAgentClient({
provider: "mode-peer",
logger: createTestLogger(),
defaultCommand: command,
});
session = await client.createSession({
provider: options.copilot ? "copilot" : "mode-peer",
cwd: directory,
modeId: options.startupMode,
});
return {
session,
async calls(): Promise<ModeCall[]> {
const lines = (await readFile(calls, "utf8")).trim();
return lines
? lines.split("\n").map((line) => {
const { method, params } = JSON.parse(line) as ModeCall;
return { method, params };
})
: [];
},
};
}
const modeCall = (modeId: string) => ({
method: "session/set_mode",
params: { sessionId: "mode-session", modeId },
});
const configCall = (configId: string, value: string) => ({
method: "session/set_config_option",
params: { sessionId: "mode-session", configId, value },
});
describe("ACP mode and config-value namespaces over stdio", () => {
test("selects Don't Ask from the advertised catalogue after a queued config update", async () => {
const peer = await fixture();
const available = await peer.session.getAvailableModes();
const dontAsk = available.find((mode) => mode.label === "Don't Ask");
expect(dontAsk).toBeDefined();
await peer.session.setMode(dontAsk!.id);
expect(await peer.calls()).toEqual([modeCall("dont_ask")]);
expect(await peer.session.getCurrentMode()).toBe("dont_ask");
expect(available.map((mode) => mode.id)).toEqual(["default", "accept_edits", "dont_ask"]);
});
test("keeps a pushed current mode through separate-policy and empty config updates", async () => {
const peer = await fixture({
updates: [
{ sessionUpdate: "current_mode_update", currentModeId: "dont_ask" },
{ sessionUpdate: "config_option_update", configOptions: editOptions("session") },
{ sessionUpdate: "config_option_update", configOptions: [] },
],
});
expect(await peer.session.getCurrentMode()).toBe("dont_ask");
expect((await peer.session.getAvailableModes()).map((mode) => mode.id)).toEqual([
"default",
"accept_edits",
"dont_ask",
]);
expect(await peer.calls()).toEqual([]);
});
test("ignores policy values outside the advertised session modes without an RPC", async () => {
const peer = await fixture();
await peer.session.setMode("session");
expect(await peer.calls()).toEqual([]);
expect(await peer.session.getCurrentMode()).toBe("accept_edits");
});
test("retains confirmed state after rejection without falling back to a config write", async () => {
const peer = await fixture({ rejectedMode: "dont_ask", updates: [] });
await expect(peer.session.setMode("dont_ask")).rejects.toThrow("mode denied");
expect(await peer.session.getCurrentMode()).toBe("accept_edits");
expect(await peer.calls()).toEqual([modeCall("dont_ask")]);
});
test("selects a config-only mode through its owning config option", async () => {
const peer = await fixture({ state: { configOptions: editOptions() } });
await peer.session.setMode("session");
expect(await peer.calls()).toEqual([configCall("edit_approval_policy", "session")]);
expect(await peer.session.getCurrentMode()).toBe("session");
});
test.each([true, false])("routes startup overrides with session modes: %s", async (withModes) => {
const peer = await fixture({
state: { modes: withModes ? modes : undefined, configOptions: editOptions() },
startupMode: withModes ? "dont_ask" : "session",
});
expect(await peer.calls()).toEqual([
withModes ? modeCall("dont_ask") : configCall("edit_approval_policy", "session"),
]);
expect(await peer.session.getCurrentMode()).toBe(withModes ? "dont_ask" : "session");
});
});
const agentMode = "https://agentclientprotocol.com/protocol/session-modes#agent";
const planMode = "https://agentclientprotocol.com/protocol/session-modes#plan";
function copilotConfig(allowAll: string): SessionConfigOption[] {
return [
{
id: "mode",
name: "Mode",
category: "mode",
type: "select",
currentValue: agentMode,
options: [
{ value: agentMode, name: "Agent" },
{ value: planMode, name: "Plan" },
],
},
{
id: "allow_all",
name: "Allow All",
type: "select",
currentValue: allowAll,
options: [
{ value: "on", name: "On" },
{ value: "off", name: "Off" },
],
},
];
}
function copilotFixture(initial: string, pushed: string) {
return fixture({
copilot: true,
state: {
modes: {
currentModeId: agentMode,
availableModes: [
{ id: agentMode, name: "Agent" },
{ id: planMode, name: "Plan" },
],
},
configOptions: copilotConfig(initial),
},
updates: [{ sessionUpdate: "config_option_update", configOptions: copilotConfig(pushed) }],
});
}
describe("Copilot Allow All through the public client", () => {
test("adopts an Allow All notification and disables it before switching modes", async () => {
const peer = await copilotFixture("off", "on");
expect(await peer.session.getCurrentMode()).toBe(COPILOT_ALLOW_ALL_MODE_ID);
await peer.session.setMode(planMode);
expect(await peer.calls()).toEqual([configCall("allow_all", "off"), modeCall(planMode)]);
expect(await peer.session.getCurrentMode()).toBe(planMode);
});
test("adopts an Allow All off notification after initializing with it enabled", async () => {
const peer = await copilotFixture("on", "off");
expect(await peer.session.getCurrentMode()).toBe(agentMode);
expect(await peer.calls()).toEqual([]);
});
test("selects Allow All and switches away in provider RPC order", async () => {
const peer = await copilotFixture("off", "off");
await peer.session.setMode(COPILOT_ALLOW_ALL_MODE_ID);
expect(await peer.session.getCurrentMode()).toBe(COPILOT_ALLOW_ALL_MODE_ID);
await peer.session.setMode(planMode);
expect(await peer.calls()).toEqual([
modeCall(agentMode),
configCall("allow_all", "on"),
configCall("allow_all", "off"),
modeCall(planMode),
]);
expect(await peer.session.getCurrentMode()).toBe(planMode);
});
});
@@ -1,6 +1,6 @@
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { EventEmitter, once } from "node:events";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -99,13 +99,34 @@ describe("buildACPClientCapabilities", () => {
interface ACPSessionInternals {
sessionId: string | null;
connection: { prompt: (...args: unknown[]) => Promise<PromptResponse> };
connection: {
prompt: (...args: unknown[]) => Promise<PromptResponse>;
signal: AbortSignal;
};
activeForegroundTurnId: string | null;
configOptions: SessionConfigOption[];
translateSessionUpdate(update: SessionUpdate): AgentStreamEvent[];
acpMcpServers(): unknown[];
}
function openPromptConnection(
prompt: (...args: unknown[]) => Promise<unknown>,
): ACPSessionInternals["connection"] {
return {
prompt: prompt as ACPSessionInternals["connection"]["prompt"],
signal: new AbortController().signal,
};
}
function terminalEvents(events: AgentStreamEvent[]): AgentStreamEvent[] {
return events.filter(
(event) =>
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled",
);
}
interface ACPModelSelectionInternals {
sessionId: string | null;
connection: {
@@ -119,6 +140,7 @@ interface ACPModelSelectionInternals {
}
interface ACPConfiguredOverrideInternals {
applySessionState(response: SessionStateResponse): void;
sessionId: string | null;
connection: {
setSessionMode: (input: { sessionId: string; modeId: string }) => Promise<void>;
@@ -423,6 +445,24 @@ function prepareConfiguredOverrideSession(
configOptions: options.configOptions ?? [],
}));
const internals = asInternals<ACPConfiguredOverrideInternals>(session);
const availableModes = options.availableModes ?? [];
const modes =
availableModes.length > 0
? {
currentModeId: options.currentMode ?? availableModes[0]!.id,
availableModes: availableModes.map((mode) => ({
id: mode.id,
label: mode.label,
description: mode.description,
name: mode.label,
})),
}
: undefined;
internals.applySessionState({
sessionId: "session-1",
modes,
configOptions: options.configOptions ?? [],
});
internals.sessionId = "session-1";
internals.connection = {
setSessionMode,
@@ -480,7 +520,7 @@ test("ACP setModel only uses config-option fallback when the matching select cho
setSessionConfigOption.mockClear();
await expect(session.setModel("new-provider-model")).resolves.toBeUndefined();
await expect(session.setModel("new-provider-model")).resolves.toEqual({ model: "sonnet" });
expect(childLogger.warn).toHaveBeenCalledWith(
{ value: "new-provider-model" },
expect.stringContaining("is not a valid claude-acp model config option"),
@@ -2634,6 +2674,72 @@ describe("ACPAgentSession slash commands", () => {
},
]);
});
test("lists late command batches through the public ACP client", async () => {
const artifacts = path.resolve(".artifacts");
await mkdir(artifacts, { recursive: true });
const directory = await mkdtemp(path.join(artifacts, "acp-command-inventory-"));
const script = path.join(directory, "agent.mjs");
await writeFile(
script,
`
import { createInterface } from "node:readline";
const send = (message) => process.stdout.write(JSON.stringify(message) + "\\n");
const help = { name: "help", description: "Show available commands" };
const skill = { name: "xlsx", description: "Work with spreadsheets", input: { hint: "[instructions]" } };
const publish = (commands) => send({
jsonrpc: "2.0", method: "session/update", params: {
sessionId: "inventory-session", update: {
sessionUpdate: "available_commands_update", availableCommands: commands,
},
},
});
createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
if (request.method === "initialize") {
send({ jsonrpc: "2.0", id: request.id, result: { protocolVersion: 1, agentCapabilities: {} } });
} else if (request.method === "session/new") {
// The first batch is already cached when createSession returns.
publish([help]);
send({ jsonrpc: "2.0", id: request.id, result: { sessionId: "inventory-session" } });
setTimeout(() => publish([help]), 100);
setTimeout(() => publish([help, skill]), 350);
} else if (request.id !== undefined) {
send({ jsonrpc: "2.0", id: request.id, error: { code: -32601, message: "Method not found" } });
}
});
`,
);
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [process.execPath, script],
});
try {
const session = await client.createSession({ provider: "acp", cwd: directory });
try {
const commands = await session.listCommands!();
expect(commands).toEqual([
{
name: "help",
description: "Show available commands",
argumentHint: "",
kind: "command",
},
{
name: "xlsx",
description: "Work with spreadsheets",
argumentHint: "[instructions]",
kind: "command",
},
]);
expect(await session.listCommands!()).toEqual(commands);
} finally {
await session.close();
}
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
describe("ACPAgentSession", () => {
@@ -2987,7 +3093,7 @@ describe("ACPAgentSession", () => {
);
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event as { type: string; turnId?: string });
@@ -3025,7 +3131,7 @@ describe("ACPAgentSession", () => {
);
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event);
@@ -3074,7 +3180,7 @@ describe("ACPAgentSession", () => {
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event);
@@ -3101,7 +3207,7 @@ describe("ACPAgentSession", () => {
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event);
@@ -3145,7 +3251,7 @@ describe("ACPAgentSession", () => {
);
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event);
@@ -3206,7 +3312,7 @@ describe("ACPAgentSession", () => {
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event);
@@ -3245,7 +3351,7 @@ describe("ACPAgentSession", () => {
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event);
@@ -3298,7 +3404,7 @@ describe("ACPAgentSession", () => {
const prompt = vi.fn(() => new Promise<PromptResponse>(() => {}));
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event);
@@ -3347,7 +3453,7 @@ describe("ACPAgentSession", () => {
);
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => {
events.push(event as { type: string; turnId?: string; error?: string });
@@ -3380,7 +3486,7 @@ describe("ACPAgentSession", () => {
);
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
asInternals<ACPSessionInternals>(session).connection = { prompt };
asInternals<ACPSessionInternals>(session).connection = openPromptConnection(prompt);
session.subscribe((event) => events.push(event));
const { turnId } = await session.startTurn([
@@ -3485,6 +3591,148 @@ describe("ACPAgentSession", () => {
error: expect.not.stringContaining("[object Object]"),
});
});
function fakeACPAgent(prompt: Agent["prompt"]): Agent {
return {
async initialize() {
return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: {} };
},
async newSession() {
return { sessionId: "session-1" };
},
prompt,
async authenticate() {},
async cancel() {},
};
}
async function connectInMemoryACPAgent(
agent: Agent,
options: { closeAfterPromptResponse?: boolean } = {},
): Promise<{
agentConnection: AgentSideConnection;
connection: ClientSideConnection;
sessionId: string;
closeTransport: () => Promise<void>;
}> {
const clientToAgent = new TransformStream<Uint8Array, Uint8Array>();
const agentToClient = new TransformStream<Uint8Array, Uint8Array>();
const agentOutput = agentToClient.writable.getWriter();
const decoder = new TextDecoder();
const agentConnection = new AgentSideConnection(
() => agent,
ndJsonStream(
new WritableStream<Uint8Array>({
async write(chunk) {
await agentOutput.write(chunk);
if (options.closeAfterPromptResponse && decoder.decode(chunk).includes("stopReason")) {
await agentOutput.close();
}
},
}),
clientToAgent.readable,
),
);
const connection = new ClientSideConnection(
() => ({
async requestPermission() {
return { outcome: { outcome: "cancelled" } };
},
async sessionUpdate() {},
}),
ndJsonStream(clientToAgent.writable, agentToClient.readable),
);
await connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {},
clientInfo: { name: "Paseo test", version: "dev" },
});
const { sessionId } = await connection.newSession({
cwd: "/tmp/paseo-acp-test",
mcpServers: [],
});
return {
agentConnection,
connection,
sessionId,
closeTransport: () => agentOutput.close(),
};
}
test("startTurn fails instead of hanging when the ACP connection has closed", async () => {
const agent = await connectInMemoryACPAgent(
fakeACPAgent(async () => ({ stopReason: "end_turn" })),
);
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals<ACPSessionInternals>(session).sessionId = agent.sessionId;
asInternals<ACPSessionInternals>(session).connection = agent.connection;
await agent.closeTransport();
await agent.connection.closed;
await expect(session.startTurn("second")).rejects.toThrow("ACP agent disconnected");
expect(events.some((event) => event.type === "turn_started")).toBe(false);
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
}, 2_000);
test("a prompt whose ACP transport closes mid-turn fails the turn", async () => {
let promptReceived!: () => void;
const received = new Promise<void>((resolve) => {
promptReceived = resolve;
});
const agent = await connectInMemoryACPAgent(
fakeACPAgent(() => {
promptReceived();
return new Promise(() => {});
}),
);
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals<ACPSessionInternals>(session).sessionId = agent.sessionId;
asInternals<ACPSessionInternals>(session).connection = agent.connection;
const { turnId } = await session.startTurn("hello");
await received;
await agent.closeTransport();
await vi.waitFor(
() =>
expect(terminalEvents(events)).toEqual([
expect.objectContaining({
type: "turn_failed",
turnId,
error: "ACP agent disconnected",
}),
]),
{ timeout: 1_000 },
);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(terminalEvents(events)).toHaveLength(1);
expect(asInternals<ACPSessionInternals>(session).activeForegroundTurnId).toBeNull();
}, 3_000);
test("a turn answered just before its ACP transport closes still completes", async () => {
const agent = await connectInMemoryACPAgent(
fakeACPAgent(async () => ({ stopReason: "end_turn" })),
{ closeAfterPromptResponse: true },
);
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals<ACPSessionInternals>(session).sessionId = agent.sessionId;
asInternals<ACPSessionInternals>(session).connection = agent.connection;
const { turnId } = await session.startTurn("hello");
await agent.connection.closed;
await new Promise((resolve) => setTimeout(resolve, 50));
expect(terminalEvents(events)).toEqual([
expect.objectContaining({ type: "turn_completed", turnId }),
]);
}, 3_000);
});
interface ACPCloseInternals {
@@ -4424,6 +4672,109 @@ describe("ACPAgentSession close() with an unresponsive provider", () => {
}, 15_000);
});
const EXITING_ACP_AGENT = `
import { Readable, Writable } from "node:stream";
const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL);
new AgentSideConnection(
() => ({
async initialize() {
return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: {} };
},
async newSession() {
return { sessionId: "exiting-session" };
},
async authenticate() {},
async cancel() {},
async prompt({ prompt }) {
if (prompt[0]?.text === "crash") {
process.stderr.write("fixture crash\\n");
process.exit(3);
}
setTimeout(() => process.exit(0), 100);
return { stopReason: "end_turn" };
},
}),
ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)),
);
`;
describe("ACPAgentSession after its provider process exits", () => {
let dir: string | null = null;
let session: ACPAgentSession | null = null;
afterEach(async () => {
await session?.close();
session = null;
if (dir) await rm(dir, { recursive: true, force: true });
dir = null;
});
async function startExitingProvider(): Promise<ACPAgentSession> {
dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-exiting-"));
const agentScript = path.join(dir, "agent.mjs");
await writeFile(agentScript, EXITING_ACP_AGENT);
session = new ACPAgentSession(
{ provider: "exiting-acp", cwd: dir },
{
provider: "exiting-acp",
logger: createTestLogger(),
defaultCommand: [process.execPath, agentScript],
defaultModes: [],
capabilities: { supportsStreaming: true, supportsSessionPersistence: true },
launchEnv: {
ACP_SDK_URL: pathToFileURL(
createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"),
).href,
},
},
);
await session.initializeNewSession();
return session;
}
test("startTurn after the provider exits while idle fails instead of hanging", async () => {
const provider = await startExitingProvider();
const events: AgentStreamEvent[] = [];
provider.subscribe((event) => events.push(event));
const internals = asInternals<{ child: ChildProcess; connection: ClientSideConnection }>(
provider,
);
const { child, connection } = internals;
const { turnId } = await provider.startTurn("first");
await vi.waitFor(() =>
expect(terminalEvents(events)).toEqual([
expect.objectContaining({ type: "turn_completed", turnId }),
]),
);
if (child.exitCode === null) await once(child, "exit");
await connection.closed;
await expect(provider.startTurn("second")).rejects.toThrow(
"ACP agent disconnected (exit code 0)",
);
expect(terminalEvents(events)).toHaveLength(1);
}, 10_000);
test("a provider that crashes mid-turn still reports its exit status", async () => {
const provider = await startExitingProvider();
const events: AgentStreamEvent[] = [];
provider.subscribe((event) => events.push(event));
const { turnId } = await provider.startTurn("crash");
await vi.waitFor(() => expect(terminalEvents(events)).toHaveLength(1), { timeout: 3_000 });
await new Promise((resolve) => setTimeout(resolve, 50));
expect(terminalEvents(events)).toEqual([
expect.objectContaining({
type: "turn_failed",
turnId,
error: "ACP agent exited unexpectedly (3)",
}),
]);
}, 10_000);
});
// An ACP agent that advertises two models (or none, with ACP_ADVERTISE_MODELS=none) but,
// like Cline, runs any model id it is given.
const PERMISSIVE_MODEL_ACP_AGENT = `
@@ -1,10 +1,12 @@
import { ACPProviderOptionsSchema } from "./acp-options.js";
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import { once } from "node:events";
import fs from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { Readable, Writable } from "node:stream";
import { setImmediate } from "node:timers/promises";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
import type { ProcessTerminator } from "../../../utils/tree-kill.js";
@@ -120,6 +122,7 @@ import {
} from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { resolveResumeProcessCwd } from "./resume-process-cwd.js";
import {
buildStringCommandShellInvocation,
createStringCommandShellEnvOverlay,
@@ -142,27 +145,37 @@ function assertChildWithPipes(
}
}
// Node reports a failed spawn (missing binary or missing cwd) as a child
// `error` event; without a listener it becomes an uncaught exception.
function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promise<never> {
// Watch process failures as well as spawn errors: the SDK leaves initialize
// pending when the child exits without replying. `close` waits for stderr to
// drain, so the failure includes the launcher's diagnostic.
function rejectOnProcessFailure(child: ChildProcess, stderrChunks: string[]): Promise<never> {
return new Promise<never>((_, reject) => {
child.once("error", (error) => {
const stderr = stderrChunks.join("").trim();
reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error)));
});
child.once("close", (code, signal) => {
const stderr = stderrChunks.join("").trim();
const status = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
reject(
new Error(
`ACP agent process exited before initialization completed (${status})${
stderr ? `\n${stderr}` : ""
}`,
),
);
});
});
}
async function isDirectory(target: string): Promise<boolean> {
try {
return (await fs.stat(target)).isDirectory();
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return false;
}
throw error;
function describeACPDisconnect(child: ChildProcess | null): string {
if (child?.signalCode) {
return `ACP agent disconnected (signal ${child.signalCode})`;
}
if (child?.exitCode != null) {
return `ACP agent disconnected (exit code ${child.exitCode})`;
}
return "ACP agent disconnected";
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -322,9 +335,18 @@ const ACP_PROBE_CLOSE_TIMEOUT_MS = 2_000;
// request when the provider never answers, so these calls must be bounded
// or the provider process is never terminated.
const ACP_CLOSE_REQUEST_TIMEOUT_MS = 2_000;
const ACP_DISCONNECT_EXIT_GRACE_MS = 1_000;
const ACP_IMPORT_HISTORY_LOAD_TIMEOUT_MS = 30_000;
const ACP_IMPORT_HISTORY_BUDGET_MS = 60_000;
/**
* An ACP agent publishes its slash-command inventory incrementally. Wait this
* long without another batch before treating the inventory as complete. MiniMax
* publishes its skills about 350ms after its built-in commands.
*/
const COMMAND_INVENTORY_QUIET_MS = 600;
const COMMAND_INVENTORY_POLL_MS = 25;
function summarizeMalformedACPStdoutError(error: unknown): { type: string; message: string } {
return {
type: error instanceof Error ? error.name : typeof error,
@@ -542,7 +564,7 @@ interface ACPProcessTransport {
connection: ClientSideConnection;
stderrChunks: string[];
spawnReady: Promise<void>;
spawnError: Promise<never>;
processError: Promise<never>;
}
interface ACPImportPromptPreviews {
@@ -634,6 +656,7 @@ interface PendingPermission {
resolve: (response: RequestPermissionResponse) => void;
reject: (error: Error) => void;
turnId: string | null;
toolCallId: string;
}
interface PendingUserMessage {
@@ -1380,6 +1403,7 @@ export class ACPAgentClient implements AgentClient {
client?: ACPClient;
},
): Promise<SpawnedACPProcess> {
const configuredOptions = ACPProviderOptionsSchema.parse(options?.providerOptions ?? {});
const transport = await this.spawnTransport(launchEnv, options?.client);
const probe: UninitializedACPProcess = {
child: transport.child,
@@ -1391,7 +1415,7 @@ export class ACPAgentClient implements AgentClient {
const initialize = await this.initializeTransport(
transport,
options?.initializeTimeoutMs,
options?.providerOptions,
configuredOptions.clientCapabilities,
);
const initializedProbe: SpawnedACPProcess = {
...probe,
@@ -1425,7 +1449,7 @@ export class ACPAgentClient implements AgentClient {
stderrChunks.push(chunk.toString());
});
const spawnErrorPromise = rejectOnSpawnError(child, stderrChunks);
const processErrorPromise = rejectOnProcessFailure(child, stderrChunks);
const spawnReadyPromise = new Promise<void>((resolve) => {
child.once("spawn", () => {
resolve();
@@ -1444,16 +1468,15 @@ export class ACPAgentClient implements AgentClient {
connection,
stderrChunks,
spawnReady: spawnReadyPromise,
spawnError: spawnErrorPromise,
processError: processErrorPromise,
};
}
protected async initializeTransport(
transport: ACPProcessTransport,
initializeTimeoutMs?: number,
providerOptions?: Record<string, unknown>,
clientCapabilities?: ACPClientCapabilities,
): Promise<InitializeResponse> {
const configuredOptions = ACPProviderOptionsSchema.parse(providerOptions ?? {});
let timeout: ReturnType<typeof setTimeout> | null = null;
const initializeTimeoutPromise = initializeTimeoutMs
? new Promise<never>((_, reject) => {
@@ -1470,11 +1493,11 @@ export class ACPAgentClient implements AgentClient {
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: buildACPClientCapabilities(
this.clientCapabilityMeta,
configuredOptions.clientCapabilities ?? this.clientCapabilities,
clientCapabilities ?? this.clientCapabilities,
),
clientInfo: { name: "Paseo", version: "dev" },
}),
transport.spawnError,
transport.processError,
...(initializeTimeoutPromise ? [initializeTimeoutPromise] : []),
]),
);
@@ -1555,7 +1578,7 @@ export class ACPAgentClient implements AgentClient {
try {
transport = await this.spawnTransport(PROBE_ENV);
await withTimeout(
Promise.race([transport.spawnReady, transport.spawnError]),
Promise.race([transport.spawnReady, transport.processError]),
phaseTimeoutMs,
`ACP spawn timed out after ${phaseTimeoutMs}ms`,
);
@@ -1746,6 +1769,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private readonly earlySessionUpdates: SessionNotification[] = [];
private currentMode: string | null = null;
private availableModes: AgentMode[];
private sessionModeState: SessionStateResponse["modes"] = null;
private currentModel: string | null = null;
private availableModels: AvailableACPModel[] | null = null;
private thinkingOptionId: string | null = null;
@@ -1753,7 +1777,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private lastActivityAt: string | null = null;
private configOptions: SessionConfigOption[] = [];
private cachedCommands: AgentSlashCommand[] = [];
private commandsReadyDeferred: { promise: Promise<void>; resolve: () => void } | null = null;
/** When the last command batch arrived; null while nothing has been published. */
private commandsUpdatedAt: number | null = null;
private commandsReadySettled = false;
private waitForInitialCommands: boolean;
private initialCommandsWaitTimeoutMs: number;
@@ -1967,6 +1992,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (!this.connection || !this.sessionId) {
throw new Error(`${this.provider} session is not initialized`);
}
if (this.connection.signal.aborted) {
throw new Error(describeACPDisconnect(this.child));
}
if (this.activeForegroundTurnId) {
throw new Error("A foreground turn is already active");
}
@@ -1981,7 +2009,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.emitSubmittedUserMessage(prompt, messageId, turnId, options?.clientMessageId);
void this.connection
const connection = this.connection;
const child = this.child;
const failOnDisconnect = () => void this.failDisconnectedTurn(turnId, child);
connection.signal.addEventListener("abort", failOnDisconnect, { once: true });
void connection
.prompt({
sessionId: this.sessionId,
messageId,
@@ -2001,11 +2033,35 @@ export class ACPAgentSession implements AgentSession, ACPClient {
diagnostic: this.collectDiagnostic(summary.diagnostic ?? summary.message),
turnId,
});
});
})
.finally(() => connection.signal.removeEventListener("abort", failOnDisconnect));
return { turnId };
}
// The SDK aborts the connection without rejecting the pending prompt, and before the child's
// exit event, so wait for the exit status before failing the turn.
private async failDisconnectedTurn(turnId: string, child: ChildProcess | null): Promise<void> {
await setImmediate();
if (child && child.exitCode === null && child.signalCode === null) {
await once(child, "exit", {
signal: AbortSignal.timeout(ACP_DISCONNECT_EXIT_GRACE_MS),
}).catch(() => undefined);
}
if (this.closed || this.activeForegroundTurnId !== turnId) {
return;
}
this.synthesizeCanceledToolCalls();
const error = describeACPDisconnect(child);
this.finishTurn({
type: "turn_failed",
provider: this.provider,
error,
diagnostic: this.collectDiagnostic(error),
turnId,
});
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
if (this.sessionId) {
@@ -2051,58 +2107,39 @@ export class ACPAgentSession implements AgentSession, ACPClient {
];
}
private ensureCommandsReadyDeferred(): void {
if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) {
return;
}
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
this.commandsReadyDeferred = { promise, resolve };
}
private settleCommandsReady(): void {
if (this.commandsReadySettled) {
return;
}
this.commandsReadySettled = true;
this.commandsReadyDeferred?.resolve();
this.commandsReadyDeferred = null;
}
private async waitForCommandsReady(): Promise<void> {
const deferred = this.commandsReadyDeferred;
if (!deferred) {
return;
}
let timer: ReturnType<typeof setTimeout> | null = null;
try {
await Promise.race([
deferred.promise,
new Promise<void>((resolve) => {
timer = setTimeout(resolve, this.initialCommandsWaitTimeoutMs);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
/**
* Providers publish their command inventory incrementally. MiniMax sends its
* built-in commands twice, then a third batch about 350ms later that merges in
* every discovered skill. Returning after the first batch loses the skills.
*
* Wait for the inventory to go quiet rather than for the first batch. The
* quiet window must exceed the gap between a provider's own batches; 600ms
* covers MiniMax with margin. `initialCommandsWaitTimeoutMs` still bounds the
* whole wait for providers that publish once or never publish at all.
*/
private async waitForCommandsToSettle(): Promise<void> {
const deadline = Date.now() + this.initialCommandsWaitTimeoutMs;
while (!this.commandsReadySettled && !this.closed) {
const remaining = deadline - Date.now();
if (remaining <= 0) return;
const quietFor = this.commandsUpdatedAt === null ? 0 : Date.now() - this.commandsUpdatedAt;
if (quietFor >= COMMAND_INVENTORY_QUIET_MS) return;
await new Promise<void>((resolve) =>
setTimeout(resolve, Math.min(remaining, COMMAND_INVENTORY_POLL_MS)),
);
}
}
async listCommands(): Promise<AgentSlashCommand[]> {
if (this.cachedCommands.length > 0) {
return this.cachedCommands;
}
if (!this.waitForInitialCommands || this.closed) {
if (this.commandsReadySettled || this.closed || !this.waitForInitialCommands) {
return this.cachedCommands;
}
this.ensureCommandsReadyDeferred();
await this.waitForCommandsReady();
await this.waitForCommandsToSettle();
this.settleCommandsReady();
return this.cachedCommands;
}
@@ -2112,11 +2149,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
throw new Error("ACP session not initialized");
}
const selection = resolveACPModeSelection({
modeId,
availableModes: this.availableModes,
configOptions: this.configOptions,
});
const selection = this.resolveModeSelection(modeId);
await this.setModeWithSelection({ modeId, selection });
}
@@ -2142,7 +2175,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (providerResult.configOptions) {
this.configOptions = this.transformConfigOptions(providerResult.configOptions);
}
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
this.availableModes = this.modesFromConfig().modes;
this.pushEvent({
type: "mode_changed",
provider: this.provider,
@@ -2221,7 +2254,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: modeId,
label: "mode",
});
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
this.availableModes = this.modesFromConfig().modes;
this.pushEvent({
type: "mode_changed",
provider: this.provider,
@@ -2248,16 +2281,17 @@ export class ACPAgentSession implements AgentSession, ACPClient {
};
}
async setModel(modelId: string | null): Promise<void> {
async setModel(modelId: string | null): Promise<{ model: string | null }> {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
}
if (!modelId) {
this.currentModel = null;
return;
return { model: this.currentModel };
}
await this.setModelWithSelection({ modelId, selection: this.resolveModelSelection(modelId) });
return { model: this.currentModel };
}
private resolveModelSelection(modelId: string): ACPModelSelection {
@@ -2653,6 +2687,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
resolve,
reject,
turnId: this.activeForegroundTurnId,
toolCallId: params.toolCall.toolCallId,
});
});
@@ -2733,14 +2768,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
}
// Cache an asynchronously-delivered slash-command batch and unblock any
// listCommands() call that is waiting on the initial batch. Used when a
// provider supplies an extensionCommandsParser whose result arrives after
// session/new (e.g. via a vendor extension notification). The ready gate is
// always settled — even for an empty batch — so a provider that legitimately
// reports no commands does not leave listCommands() blocked for the full
// initial-commands timeout. An optional sessionId scopes the batch to this
// session; notifications addressed to a different session are ignored.
// Cache an asynchronously-delivered slash-command batch from a provider that
// supplies an extensionCommandsParser (e.g. a vendor extension notification).
// An empty batch is the provider saying it has no commands, so it settles the
// waiter immediately instead of letting it wait out the quiet window. An
// optional sessionId scopes the batch to this session; notifications
// addressed to a different session are ignored.
private applyResolvedCommands(
commands: AgentSlashCommand[],
options?: { sessionId?: string },
@@ -2754,7 +2787,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
if (commands.length > 0) {
this.commandsUpdatedAt = Date.now();
this.cachedCommands = commands;
// A non-empty batch may be followed by more. Let the quiet window decide
// when the inventory is complete rather than settling here; an empty
// batch is the provider saying it has nothing, which must still unblock
// the waiter immediately.
return;
}
this.settleCommandsReady();
}
@@ -2877,11 +2916,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
// Loading an archived agent's history runs nothing in its working directory, which
// may have been removed with its worktree. session/load still names the original
// directory; only the process starts from the home directory.
private async resolveProcessCwd(): Promise<string> {
if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) {
return this.config.cwd;
}
return homedir();
private resolveProcessCwd(): Promise<string> {
return resolveResumeProcessCwd(this.config.cwd, this.resumePurpose);
}
private async spawnProcess(): Promise<SpawnedACPProcess> {
@@ -2910,7 +2946,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
child.stderr.on("data", (chunk: Buffer | string) => {
stderrChunks.push(chunk.toString());
});
const spawnError = rejectOnSpawnError(child, stderrChunks);
const processError = rejectOnProcessFailure(child, stderrChunks);
child.once("exit", (code, signal) => {
if (this.closed || this.historyRuntimeReleased) {
return;
@@ -2947,7 +2983,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
),
clientInfo: { name: "Paseo", version: "dev" },
}),
spawnError,
processError,
]),
);
@@ -2972,6 +3008,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
: response;
this.configOptions = this.transformConfigOptions(transformed.configOptions ?? []);
this.sessionModeState = transformed.modes ?? null;
const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions);
this.availableModes = modeInfo.modes;
@@ -2997,11 +3034,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private async applyConfiguredOverrides(): Promise<void> {
const configuredModeId = this.config.modeId;
if (configuredModeId && configuredModeId !== this.currentMode) {
const selection = resolveACPModeSelection({
modeId: configuredModeId,
availableModes: this.availableModes,
configOptions: this.configOptions,
});
const selection = this.resolveModeSelection(configuredModeId);
await this.setModeWithSelection({ modeId: configuredModeId, selection });
}
const configuredModelId = this.config.model;
@@ -3138,13 +3171,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.handleUsageUpdate(update);
return pendingUserEvents;
case "available_commands_update":
this.commandsUpdatedAt = Date.now();
this.cachedCommands = update.availableCommands.map((command) => ({
name: command.name,
description: command.description,
argumentHint: command.input?.hint ?? "",
kind: "command",
}));
this.settleCommandsReady();
// An empty batch means the agent has no commands and would otherwise
// wait out the whole quiet window. A non-empty batch may be one of
// several, so completion belongs to the quiet window.
if (update.availableCommands.length === 0) {
this.settleCommandsReady();
}
return pendingUserEvents;
default:
return pendingUserEvents;
@@ -3209,6 +3248,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
snapshot = this.toolSnapshotTransformer(snapshot);
}
this.toolCalls.set(toolCallId, snapshot);
if (update.status === "completed" || update.status === "failed") {
this.cancelPendingPermissions((pending) => pending.toolCallId === toolCallId);
}
return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))];
}
@@ -3249,9 +3291,37 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.currentMode = this.transformModeId(update.currentModeId);
}
private modesFromConfig() {
let modes = this.sessionModeState;
if (modes) {
let currentModeId = this.currentMode ?? modes.currentModeId;
const configMode = findSelectConfigOption({
configOptions: this.configOptions,
category: "mode",
});
// Copilot config updates name real modes; Hermes config values are a separate policy namespace.
if (configMode && modes.availableModes.some((mode) => mode.id === configMode.currentValue)) {
currentModeId = configMode.currentValue;
}
modes = { ...modes, currentModeId };
}
return deriveModesFromACP(this.defaultModes, modes, this.configOptions);
}
private resolveModeSelection(modeId: string): ACPModeSelection {
const hasSessionModes = Boolean(this.sessionModeState?.availableModes.length);
const modeConfig = findSelectConfigOption({
configOptions: this.configOptions,
category: "mode",
});
const configOnly = !hasSessionModes && modeConfig !== null;
const availableModes = configOnly ? [] : this.availableModes;
return resolveACPModeSelection({ modeId, availableModes, configOptions: this.configOptions });
}
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
this.configOptions = this.transformConfigOptions(update.configOptions);
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
const modeInfo = this.modesFromConfig();
const nextMode = modeInfo.currentModeId;
const nextModel = deriveCurrentConfigValue(this.configOptions, "model");
const nextThinkingOptionId = deriveCurrentConfigValue(this.configOptions, "thought_level");
@@ -3416,11 +3486,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
): void {
this.deliverTranslatedEvents(this.flushPendingUserMessage());
// A turn that ends without completing leaves no way to answer its open
// permission requests, so tell the agent they were cancelled.
if (event.type !== "turn_completed") {
this.cancelPendingPermissions();
}
// Failed and canceled turns clear every request in the manager. Answer the
// same set here so an outside-turn request cannot keep the provider blocked.
this.cancelPendingPermissions(
(pending) => event.type !== "turn_completed" || pending.turnId === event.turnId,
);
this.activeForegroundTurnId = null;
this.fallbackAssistantMessageId = null;
if (this.submittedUserMessageTurnId === event.turnId) {
@@ -3429,11 +3499,21 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.pushEvent(event);
}
private cancelPendingPermissions(): void {
for (const pending of this.pendingPermissions.values()) {
private cancelPendingPermissions(
matches: (pending: PendingPermission) => boolean = () => true,
): void {
for (const [requestId, pending] of this.pendingPermissions) {
if (!matches(pending)) continue;
this.pendingPermissions.delete(requestId);
pending.resolve({ outcome: { outcome: "cancelled" } });
this.pushEvent({
type: "permission_resolved",
provider: this.provider,
requestId,
resolution: { behavior: "deny", message: "Permission request canceled" },
turnId: pending.turnId ?? undefined,
});
}
this.pendingPermissions.clear();
}
private emitBootstrapThreadEvent(): void {
@@ -0,0 +1,112 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { describe, expect, test } from "vitest";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
// Exercise the public catalog API with a real child, including stdout EOF and
// stderr draining. No provider installation or external service is involved.
const failingCommand: [string, ...string[]] = [
process.execPath,
"-e",
"process.stdin.once('data', () => { process.stderr.write('launcher failed: fixture startup error\\n'); process.exitCode = 1; process.stdin.destroy(); });",
];
describe("ACP catalog startup failures", () => {
test("reports a process that exits during initialize with its stderr", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: failingCommand,
});
await expect(
client.fetchCatalog({ scope: "workspace", cwd: process.cwd(), force: true }),
).rejects.toThrow(/launcher failed: fixture startup error/);
}, 5_000);
test("session startup reports the same process exit with its stderr", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: failingCommand,
});
await expect(client.createSession({ provider: "acp", cwd: process.cwd() })).rejects.toThrow(
/launcher failed: fixture startup error/,
);
}, 5_000);
test("provider diagnostics report the startup exit instead of an initialize timeout", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: failingCommand,
diagnosticPhaseTimeoutMs: 2_000,
});
const { diagnostic } = await client.getDiagnostic();
expect(diagnostic).toContain(
"ACP agent process exited before initialization completed (exit code 1)",
);
expect(diagnostic).toContain("launcher failed: fixture startup error");
expect(diagnostic).not.toContain("initialize timed out");
});
test("invalid provider options reject without crashing the host process", async () => {
const clientModule = new URL("./generic-acp-agent.ts", import.meta.url).href;
const loggerModule = new URL("../../../test-utils/test-logger.ts", import.meta.url).href;
const script = `
import { GenericACPAgentClient } from ${JSON.stringify(clientModule)};
import { createTestLogger } from ${JSON.stringify(loggerModule)};
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [process.execPath, '-e', 'setInterval(() => {}, 1000)'],
});
try {
await client.fetchCatalog({
scope: 'workspace', cwd: process.cwd(), force: true,
providerOptions: { clientCapabilities: { fs: 'invalid' } },
});
throw new Error('Expected invalid options to reject');
} catch (error) {
if (error.name !== 'ZodError') throw error;
}
// Let child cleanup and Node's unhandled-rejection check complete.
await new Promise(resolve => setTimeout(resolve, 100));
console.log('host stayed alive');
`;
const result = await promisify(execFile)(
process.execPath,
["--import", "tsx", "--input-type=module", "-e", script],
{ timeout: 10_000 },
);
expect(result.stdout).toContain("host stayed alive");
expect(result.stderr).toBe("");
});
test("a successful catalog probe still returns its models and closes cleanly", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [
process.execPath,
"-e",
`
require('node:readline').createInterface({ input: process.stdin }).on('line', (line) => {
const request = JSON.parse(line);
const result = request.method === 'initialize'
? { protocolVersion: 1, agentCapabilities: {} }
: { sessionId: 'fixture', models: {
currentModelId: 'fixture-model',
availableModels: [{ modelId: 'fixture-model', name: 'Fixture model' }]
} };
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\\n');
});
`,
],
});
const catalog = await client.fetchCatalog({
scope: "workspace",
cwd: process.cwd(),
force: true,
});
expect(catalog.models).toEqual([
expect.objectContaining({ id: "fixture-model", label: "Fixture model" }),
]);
});
});
@@ -466,13 +466,13 @@ describe("background Claude subagents", () => {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "[Bash] ls -1",
log: "",
},
});
});
test.each(["before", "after"])(
"keeps one logged Task card when child tools start %s the parent finishes",
"keeps one Task card when child tools start %s the parent finishes",
async (timing) => {
// A child can stream across the main session's turn boundary. Claude opens the next
// main-session turn with system/init; bare child frames do not open a turn.
@@ -605,7 +605,7 @@ describe("background Claude subagents", () => {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "[Bash] ls -1",
log: "",
},
});
},
@@ -712,19 +712,36 @@ describe("background Claude subagents", () => {
test("Claude runtime safety protects silent shells, monitors and pending completion notifications", () => {
const safety = new ClaudeRuntimeSafety();
const observe = (message: unknown) => safety.observe(message as SDKMessage);
observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" });
expect(observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" })).toEqual([]);
expect(safety.canStopRuntime()).toBe(true);
expect(
observe({
type: "system",
subtype: "background_tasks_changed",
tasks: [
{ task_id: "quiet-shell", task_type: "local_bash", description: "sleep 7200" },
{ task_id: "monitor", task_type: "local_bash", description: "watch build" },
{ task_id: "native-child", task_type: "local_agent", description: "research" },
],
}),
).toEqual([
{ id: "quiet-shell", kind: "shell", description: "sleep 7200", canStop: true },
{ id: "monitor", kind: "shell", description: "watch build", canStop: true },
]);
expect(safety.canStopRuntime()).toBe(false);
observe({
type: "system",
subtype: "background_tasks_changed",
tasks: [
{ task_id: "quiet-shell", task_type: "local_bash", description: "sleep 7200" },
{ task_id: "monitor", task_type: "local_bash", description: "watch build" },
{ task_id: "native-child", task_type: "local_agent", description: "research" },
],
subtype: "task_started",
task_id: "monitor",
tool_use_id: "tool-monitor",
});
expect(safety.canStopRuntime()).toBe(false);
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
expect(
observe({
type: "assistant",
message: { content: [{ type: "tool_use", id: "tool-monitor", name: "Monitor" }] },
})?.find((item) => item.id === "monitor")?.kind,
).toBe("monitor");
expect(observe({ type: "system", subtype: "background_tasks_changed", tasks: [] })).toEqual([]);
expect(safety.canStopRuntime()).toBe(false);
for (const task_id of ["quiet-shell", "monitor"])
observe({ type: "system", subtype: "task_notification", task_id });
@@ -736,6 +753,108 @@ test("Claude runtime safety protects silent shells, monitors and pending complet
expect(safety.canStopRuntime()).toBe(false);
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
expect(safety.canStopRuntime()).toBe(true);
safety.retire();
expect(
observe({
type: "system",
subtype: "background_tasks_changed",
tasks: [{ task_id: "late", task_type: "local_bash" }],
}),
).toBeUndefined();
safety.reset();
expect(
observe({
type: "system",
subtype: "background_tasks_changed",
tasks: [{ task_id: "new", task_type: "local_bash" }],
}),
).toEqual([{ id: "new", kind: "shell", description: null, canStop: true }]);
});
test("emits inventory only when relevant observations change it", () => {
const safety = new ClaudeRuntimeSafety();
const observe = (message: unknown) => safety.observe(message as SDKMessage);
expect(observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" })).toEqual([]);
expect(
observe({
type: "system",
subtype: "background_tasks_changed",
tasks: [{ task_id: "m", task_type: "local_bash" }],
}),
).toEqual([{ id: "m", kind: "shell", description: null, canStop: true }]);
expect(
observe({
type: "system",
subtype: "background_tasks_changed",
tasks: [{ task_id: "m", task_type: "local_bash" }],
}),
).toBeUndefined();
expect(
observe({ type: "system", subtype: "task_started", task_id: "m", tool_use_id: "tool" }),
).toBeUndefined();
expect(
observe({
type: "assistant",
message: { content: [{ type: "tool_use", id: "tool", name: "Monitor" }] },
}),
).toEqual([{ id: "m", kind: "monitor", description: null, canStop: true }]);
expect(
observe({ type: "assistant", message: { content: [{ type: "text", text: "streamed" }] } }),
).toBeUndefined();
expect(
observe({ type: "system", subtype: "task_started", task_id: "m", tool_use_id: "other" }),
).toEqual([{ id: "m", kind: "shell", description: null, canStop: true }]);
});
test("Claude runtime safety releases a process that has not been sent a turn", () => {
const safety = new ClaudeRuntimeSafety();
// A resumed process that only answered control requests never sends init.
expect(safety.canStopRuntime()).toBe(true);
safety.notePromptSent();
expect(safety.canStopRuntime()).toBe(false);
safety.observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" } as SDKMessage);
expect(safety.canStopRuntime()).toBe(true);
safety.reset();
expect(safety.canStopRuntime()).toBe(true);
// A CLI without a roster that ran a turn may own background work.
safety.observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" } as SDKMessage);
expect(safety.canStopRuntime()).toBe(false);
safety.retire();
expect(safety.canStopRuntime()).toBe(false);
});
test("Claude session started by a control request without a turn can stop its runtime", async () => {
let launched = 0;
const query = {
next: () => new Promise<IteratorResult<SDKMessage>>(() => {}),
return: async () => ({ done: true, value: undefined }),
close: () => {},
interrupt: async () => {},
setPermissionMode: async () => {},
setModel: async () => {},
supportedModels: async () => [],
supportedCommands: async () => [],
[Symbol.asyncIterator]() {
return this;
},
} as unknown as Query;
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
resolveBinary: async () => "/test/claude/bin",
queryFactory: ({ prompt }) => {
if (typeof prompt === "string") throw new Error("Expected streaming input");
launched++;
return query;
},
}).createSession({ provider: "claude", cwd: process.cwd() });
try {
// Reopening a chat lists its commands, which launches Claude with no prompt.
await session.listCommands!();
expect(launched).toBe(1);
expect(await session.canStopRuntime!()).toBe(true);
} finally {
await session.close();
}
});
test("Claude session runtime safety observes pumped background work and autonomous replies", async () => {
@@ -0,0 +1,226 @@
import { expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryFactory } from "./query.js";
const LIMIT = "You've hit your session limit · resets 5:20pm (Asia/Singapore)";
interface ScriptedTurn {
// An API error reply as Claude Code writes it: a synthetic assistant message with `error`.
errorReply?: string;
// Assistant text the model wrote before or after it.
before?: string;
after?: string;
isError: boolean;
subtype?:
| "error_max_turns"
| "error_during_execution"
| "error_max_budget_usd"
| "error_max_structured_output_retries";
usage?: {
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
totalCostUsd: number;
};
}
// A query dependency that speaks Claude Code's stream interface, one scripted turn per prompt.
function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory {
return ({ prompt }) => {
let index = 0;
const assistant = (id: string, text: string, error?: string) => ({
type: "assistant",
session_id: "error-session",
parent_tool_use_id: null,
uuid: id,
message: {
id,
role: "assistant",
model: error ? "<synthetic>" : "claude-opus-5-5",
content: [{ type: "text", text }],
stop_reason: "stop_sequence",
usage: { input_tokens: 0, output_tokens: 0 },
},
...(error ? { error } : {}),
});
const messages = (async function* () {
if (typeof prompt === "string") throw new Error("Expected streaming input");
for await (const _message of prompt) {
const turn = turns[index];
index += 1;
yield {
type: "system",
subtype: "init",
session_id: "error-session",
permissionMode: "default",
model: "claude-opus-5-5",
};
if (turn.before) yield assistant(`before-${index}`, turn.before);
if (turn.errorReply) yield assistant(`error-${index}`, turn.errorReply, "rate_limit");
if (turn.after) yield assistant(`after-${index}`, turn.after);
yield {
type: "result",
subtype: turn.subtype ?? "success",
session_id: "error-session",
duration_ms: 1,
duration_api_ms: 0,
is_error: turn.isError,
...(turn.subtype ? { errors: ["Reached maximum number of turns (1)"] } : {}),
num_turns: 1,
result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""),
stop_reason: "stop_sequence",
total_cost_usd: turn.usage?.totalCostUsd ?? 0,
usage: {
input_tokens: turn.usage?.inputTokens ?? 0,
output_tokens: turn.usage?.outputTokens ?? 0,
cache_read_input_tokens: turn.usage?.cachedInputTokens ?? 0,
},
modelUsage: {},
permission_denials: [],
uuid: `result-${index}`,
};
}
})();
return Object.assign(messages, {
close: () => {
void messages.return();
},
interrupt: async () => undefined,
setModel: async () => undefined,
setPermissionMode: async () => undefined,
supportedModels: async () => [],
supportedCommands: async () => [],
}) as ReturnType<ClaudeQueryFactory>;
};
}
async function runTurns(turns: ScriptedTurn[]) {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: scriptedQuery(turns),
resolveBinary: async () => process.execPath,
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
try {
const results = [];
for (const [index] of turns.entries()) {
results.push(await collectSessionTurnEvents(session, `turn ${index + 1}`));
}
return results;
} finally {
await session.close();
}
}
function assistantTexts(events: Awaited<ReturnType<typeof runTurns>>[number]): string[] {
return events.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
);
}
function terminal(events: Awaited<ReturnType<typeof runTurns>>[number]) {
return events.find(
(event) =>
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled",
);
}
test("a turn Claude Code ends on an API error fails with the error, shown once", async () => {
const [limited, overloaded, next] = await runTurns([
{ errorReply: LIMIT, isError: true },
{
before: "Checking the deploy doc.",
errorReply: "API Error: 529 Overloaded",
isError: true,
},
{ after: "Done.", isError: false },
]);
expect(terminal(limited)).toMatchObject({
type: "turn_failed",
error: LIMIT,
});
expect(assistantTexts(limited)).toEqual([]);
expect(terminal(overloaded)).toMatchObject({
type: "turn_failed",
error: "API Error: 529 Overloaded",
});
expect(assistantTexts(overloaded)).toEqual(["Checking the deploy doc."]);
// The session keeps working after a failed turn.
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(next)).toEqual(["Done."]);
});
test("a failed turn retains its final token counts and cost", async () => {
const usage = {
inputTokens: 120,
cachedInputTokens: 80,
outputTokens: 30,
totalCostUsd: 0.012,
};
const [failed] = await runTurns([
{ before: "Checking the deploy doc.", errorReply: LIMIT, isError: true, usage },
]);
expect(failed).toContainEqual(
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
);
expect(terminal(failed)).toMatchObject({ type: "turn_failed", error: LIMIT });
expect(assistantTexts(failed)).toEqual(["Checking the deploy doc."]);
});
test("an error reply the turn moves past is still shown", async () => {
const [recovered, completed] = await runTurns([
{
errorReply: "API Error: 529 Overloaded",
after: "Recovered.",
isError: false,
},
{ errorReply: LIMIT, isError: false },
]);
expect(terminal(recovered)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(recovered)).toEqual(["API Error: 529 Overloaded", "Recovered."]);
expect(terminal(completed)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(completed)).toEqual([LIMIT]);
});
test.each([
"error_max_turns",
"error_during_execution",
"error_max_budget_usd",
"error_max_structured_output_retries",
] as const)("a %s result publishes final usage before failure", async (subtype) => {
const usage = {
inputTokens: 2,
cachedInputTokens: 12577,
outputTokens: 110,
totalCostUsd: 0.00365537,
};
const [failed, next] = await runTurns([
{ before: "Running the command.", isError: true, subtype, usage },
{ after: "Done.", isError: false },
]);
// Collection ends at the terminal event, so this also checks ordering.
expect(failed).toContainEqual(
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
);
expect(terminal(failed)).toMatchObject({
type: "turn_failed",
error: "Reached maximum number of turns (1)",
});
expect(assistantTexts(failed)).toEqual(["Running the command."]);
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(next)).toEqual(["Done."]);
});
@@ -614,6 +614,43 @@ describe("ClaudeAgentSession persisted history load", () => {
);
});
test("replays a message steered into a running turn as the user's message", async () => {
const queued = (commandMode: string, prompt: unknown, sourceUuid?: string) => ({
type: "attachment",
uuid: `attachment-${commandMode}`,
parentUuid: "a1",
attachment: {
type: "queued_command",
commandMode,
prompt,
...(sourceUuid ? { source_uuid: sourceUuid } : {}),
},
});
const entries = [
{ type: "user", uuid: "u1", parentUuid: null, message: { content: "first" } },
{ type: "assistant", uuid: "a1", parentUuid: "u1", message: { content: "working" } },
queued("prompt", [{ type: "text", text: "steered while working" }], "steered-1"),
queued("task-notification", "<task-notification><task-id>t1</task-id></task-notification>"),
{ type: "assistant", uuid: "a2", parentUuid: "a1", message: { content: "answer" } },
];
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n"));
const session = await resume();
try {
const users = [];
for await (const event of session.streamHistory()) {
if (event.type === "timeline" && event.item.type === "user_message") users.push(event.item);
}
expect(users).toEqual([
{ type: "user_message", text: "first", messageId: "u1" },
{ type: "user_message", text: "steered while working", messageId: "steered-1" },
]);
} finally {
await session.close();
}
});
test("loads readable history when the temporary directory is unavailable", async () => {
const file = transcriptPath();
const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } };
@@ -1,3 +1,4 @@
import { constants as bufferConstants } from "node:buffer";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
@@ -236,4 +237,64 @@ describe("importing a renamed Claude session", () => {
await expect(importSession(sessionId)).rejects.toThrow();
});
test("reads the rename from a transcript too large to hold as one string", async () => {
const sessionId = "oversized-transcript";
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
await fs.mkdir(projectDir, { recursive: true });
const transcript = path.join(projectDir, `${sessionId}.jsonl`);
// A hole stands in for the long session this used to fail on: the file only
// has to be longer than a string can be, and the rename sits at its end.
await fs.writeFile(transcript, "");
await fs.truncate(transcript, bufferConstants.MAX_STRING_LENGTH + 1);
await fs.appendFile(
transcript,
`\n${[
userRecord({ sessionId, content: "Trace the slow query" }),
{ type: "custom-title", customTitle: "Slow query hunt", sessionId },
]
.map((record) => JSON.stringify(record))
.join("\n")}\n`,
);
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Slow query hunt");
});
test("reads the rename from behind more records than one read covers", async () => {
const sessionId = "late-records-rename";
await writeSession({
sessionId,
records: [
userRecord({ sessionId, content: "Review this project" }),
{ type: "custom-title", customTitle: "Scrollback review", sessionId },
// One record longer than a single read, then enough records to leave the
// rename several reads back from the end of the file.
userRecord({ sessionId, content: "x".repeat(128 * 1024) }),
...Array.from({ length: 64 }, (_, index) =>
userRecord({ sessionId, content: `${"y".repeat(4 * 1024)} ${index}` }),
),
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Scrollback review");
});
test("imports a large record after the last rename without repeatedly copying it", async () => {
const sessionId = "large-record-after-rename";
await writeSession({
sessionId,
records: [
{ type: "custom-title", customTitle: "Large attachment review", sessionId },
userRecord({ sessionId, content: "x".repeat(96 * 1024 * 1024) }),
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Large attachment review");
});
});
@@ -349,8 +349,11 @@ async function startSteeredTurn(sessionId: string): Promise<{
expectedTurnId: start.value.turnId,
clientMessageId: "steer-client",
});
expect(steered).toEqual({ status: "accepted" });
await waitFor(() => query?.prompts.length === 2);
expect(steered).toEqual({
status: "accepted",
providerMessageId: query?.prompts[1]?.uuid,
});
return { session, query: () => query, turn };
}
@@ -972,7 +975,10 @@ test("steers an autonomous turn through its existing query without restarting it
const steeredTimeline = await autonomousEvents.next();
const completion = await autonomousEvents.next();
expect(steer).toEqual({ status: "accepted" });
expect(steer).toEqual({
status: "accepted",
providerMessageId: queryRef?.prompts[1]?.uuid,
});
expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queryRef?.interrupt).not.toHaveBeenCalled();
expect(queryRef?.prompts.map((prompt) => prompt.text)).toEqual(["seed prompt", "steer prompt"]);
@@ -143,6 +143,13 @@ function getLatestCompletedBashCall(events: AgentStreamEvent[]): ToolCallTimelin
.find((item) => item.status === "completed" && item.name.toLowerCase() === "bash");
}
function hasBackgroundShell(events: AgentStreamEvent[]): boolean {
return events.some(
(event) =>
event.type === "background_work" && event.items?.some((item) => item.kind === "shell"),
);
}
function getInternalQuery(session: AgentSession): unknown {
return (session as AgentSession & { query?: unknown }).query ?? null;
}
@@ -419,6 +426,39 @@ describe("ClaudeAgentSession integration", () => {
}
}, 180_000);
test("lists a background shell as background work until it exits", async () => {
const handle = await createSession({ cwdPrefix: "claude-agent-background-work-" });
try {
const liveEventsPromise = collectSubscribedUntil(
handle.session,
(event, events) =>
event.type === "background_work" &&
event.items?.length === 0 &&
hasBackgroundShell(events),
90_000,
);
await collectUntilTerminal(
streamSession(
handle.session,
[
"Use the Bash tool with run_in_background.",
"Run exactly: sleep 8",
"Do not wait for the task result.",
"Reply immediately with exactly: SPAWNED",
].join(" "),
),
{ timeoutMs: 90_000 },
);
const liveEvents = await liveEventsPromise;
expect(hasBackgroundShell(liveEvents)).toBe(true);
expect(liveEvents.at(-1)).toMatchObject({ type: "background_work", items: [] });
} finally {
await cleanupSession(handle);
}
}, 180_000);
test("surfaces permission requests and resumes after approval", async () => {
const handle = await createSession({
cwdPrefix: "claude-agent-permission-",
@@ -1,7 +1,11 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDir } from "./project-dir.js";
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
import type { AgentSession } from "../../agent-sdk-types.js";
@@ -267,3 +271,49 @@ describe("Claude rewind after a turn whose last assistant message came from a su
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]);
});
});
describe("Claude rewind to a message steered into a running turn", () => {
test("forks at the entry Claude read the message after", async () => {
const configDir = mkdtempSync(path.join(tmpdir(), "claude-rewind-steer-"));
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = configDir;
const conversation = createConversation([{ assistantMessageId: "assistant-1" }]);
const rewindSdk = new FakeClaudeSdk();
const session = await createSession(conversation, rewindSdk);
try {
await runTurns(session, 1);
// A steered message opens no turn: Claude Code records it as a queued_command attachment
// under the uuid Paseo minted for it, hung off the entry it was read after.
const projectDir = await claudeProjectDir(process.cwd(), { configDir });
mkdirSync(projectDir, { recursive: true });
const entries = [
{ type: "user", uuid: conversation.userMessageIds[0], parentUuid: null },
{ type: "assistant", uuid: "assistant-1", parentUuid: conversation.userMessageIds[0] },
{ type: "user", uuid: "tool-result-1", parentUuid: "assistant-1" },
{
type: "attachment",
uuid: "queued-command-1",
parentUuid: "tool-result-1",
attachment: { type: "queued_command", source_uuid: "steered-message-1" },
},
];
writeFileSync(
path.join(projectDir, `${SESSION_ID}.jsonl`),
entries.map((entry) => `${JSON.stringify(entry)}\n`).join(""),
);
await session.revertConversation?.({ messageId: "steered-message-1" });
} finally {
await session.close();
if (previousConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
}
rmSync(configDir, { recursive: true, force: true });
}
expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "tool-result-1" }]);
});
});
@@ -118,6 +118,22 @@ const RUNNING_WORKFLOW_TURN_EVENTS = [
COMPLETED_TURN_EVENTS[2],
];
const BACKGROUND_SHELL_TURN_EVENTS = [
COMPLETED_TURN_EVENTS[0],
{
type: "system",
subtype: "background_tasks_changed",
tasks: [
{ task_id: "bash-1", task_type: "local_bash", description: "sleep 20" },
{ task_id: "wf-1", task_type: "local_workflow", description: "Run the spec workflow" },
],
uuid: "00000000-0000-4000-8000-000000000001",
session_id: "claude-runtime-exit-session",
},
COMPLETED_TURN_EVENTS[1],
COMPLETED_TURN_EVENTS[2],
];
const MISSING_RESUMED_CONVERSATION_RESULT = {
type: "result",
subtype: "error_during_execution",
@@ -272,6 +288,155 @@ describe("Claude runtime exit", () => {
}
});
test("reports background tasks except subagents as background work", async () => {
const queryFactory = vi.fn(() => createQueryMock(BACKGROUND_SHELL_TURN_EVENTS));
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(createChildProcessStub());
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.run("start a background shell");
expect(events).toContainEqual(
expect.objectContaining({
type: "background_work",
provider: "claude",
items: [{ id: "bash-1", kind: "shell", description: "sleep 20", canStop: true }],
}),
);
} finally {
await session.close();
}
});
test("empties background work when the process dies while idle", async () => {
let capturedOptions: Options | undefined;
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
capturedOptions = options;
return createQueryMock(BACKGROUND_SHELL_TURN_EVENTS);
});
const child = createChildProcessStub();
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
await session.run("start a background shell");
capturedOptions?.spawnClaudeCodeProcess?.(SPAWN_OPTIONS);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
child.emit("exit", 1, null);
expect(events).toContainEqual(
expect.objectContaining({ type: "background_work", provider: "claude", items: null }),
);
} finally {
await session.close();
}
});
test("empties background work when a query restart retires the process", async () => {
let capturedOptions: Options | undefined;
const child = createChildProcessStub();
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
capturedOptions = options;
return createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, {
tail: new Promise<never>(() => undefined),
onReturn: () => child.emit("exit", 0, null),
});
});
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
await session.run("start a background shell");
capturedOptions?.spawnClaudeCodeProcess?.(SPAWN_OPTIONS);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.setThinkingOption(null);
await session.listCommands();
expect(events).toContainEqual(
expect.objectContaining({ type: "background_work", provider: "claude", items: null }),
);
} finally {
await session.close();
}
});
test("ignores background work a retired query reports after its restart", async () => {
let capturedOptions: Options | undefined;
let deliverLateEvent: ((event: unknown) => void) | undefined;
let retiredQuery: Query | undefined;
const child = createChildProcessStub();
const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => {
capturedOptions = options;
if (retiredQuery) {
return createQueryMock([], { tail: new Promise<never>(() => undefined) });
}
retiredQuery = createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, {
tail: new Promise<unknown>((resolve) => {
deliverLateEvent = resolve;
}),
onReturn: () => child.emit("exit", 0, null),
});
return retiredQuery;
});
vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
await session.run("start a background shell");
capturedOptions?.spawnClaudeCodeProcess?.(SPAWN_OPTIONS);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.setThinkingOption(null);
await session.listCommands();
expect(queryFactory).toHaveBeenCalledTimes(2);
// The retired query still had this queued when the restart emptied the list.
deliverLateEvent?.(BACKGROUND_SHELL_TURN_EVENTS[1]);
// One next() per scripted event and one for the late event; the pump stops
// reading a query it no longer owns instead of routing that event.
await vi.waitFor(() =>
expect(retiredQuery?.next).toHaveBeenCalledTimes(BACKGROUND_SHELL_TURN_EVENTS.length + 1),
);
// Let a routed late event reach subscribers before checking the list.
await new Promise<void>((resolve) => setImmediate(resolve));
const backgroundWork = events.filter((event) => event.type === "background_work");
expect(backgroundWork.at(-1)).toEqual(
expect.objectContaining({ type: "background_work", provider: "claude", items: null }),
);
} finally {
await session.close();
}
});
test("tree-kills the retired process when the resumed conversation is gone", async () => {
let capturedOptions: Options | undefined;
let deliverMissingConversation: ((event: unknown) => void) | undefined;
@@ -348,4 +513,77 @@ describe("Claude runtime exit", () => {
await session.close();
}
});
test("waits for a stopped task notification after the stop request is accepted", async () => {
let deliver!: (message: unknown) => void;
const tail = new Promise<unknown>((resolve) => {
deliver = resolve;
});
const query = createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, { tail });
query.stopTask = async () => {};
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: () => query,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
await session.run("start a background shell");
let settled = false;
const pending = session.stopBackgroundWork!("bash-1").then(() => {
settled = true;
return undefined;
});
await Promise.resolve();
expect(settled).toBe(false);
deliver({
type: "system",
subtype: "task_notification",
task_id: "bash-1",
status: "stopped",
output_file: "",
summary: "Stopped",
uuid: "00000000-0000-4000-8000-000000000002",
session_id: "claude-runtime-exit-session",
});
await pending;
expect(settled).toBe(true);
await expect(session.stopBackgroundWork!("wf-1")).rejects.toThrow();
} finally {
await session.close();
}
});
test.each(["completed", "failed"])(
"reports the task's %s outcome when it races a stop",
async (status) => {
let deliver!: (message: unknown) => void;
const tail = new Promise<unknown>((resolve) => {
deliver = resolve;
});
const query = createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, { tail });
query.stopTask = async () => {};
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: () => query,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
await session.run("start a background shell");
const result = session.stopBackgroundWork!("bash-1");
deliver({
type: "system",
subtype: "task_notification",
task_id: "bash-1",
status,
output_file: "",
summary: "Finished",
uuid: "00000000-0000-4000-8000-000000000003",
session_id: "claude-runtime-exit-session",
});
await expect(result).rejects.toThrow(status);
} finally {
await session.close();
}
},
);
});
@@ -0,0 +1,48 @@
import pino from "pino";
import { describe, expect, test } from "vitest";
import { ClaudeAgentClient } from "./agent.js";
describe("Claude runtime info before a turn", () => {
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
test("reports the explicitly configured model before the first turn", async () => {
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-haiku-5-5",
});
try {
expect(await session.getRuntimeInfo()).toMatchObject({ model: "claude-haiku-5-5" });
} finally {
await session.close();
}
});
test("reports the persisted configured model after rebuilding an idle session", async () => {
const session = await client.resumeSession({
provider: "claude",
sessionId: "runtime-model-before-turn",
metadata: { cwd: process.cwd(), model: "claude-haiku-5-5" },
});
try {
expect(await session.getRuntimeInfo()).toMatchObject({
sessionId: "runtime-model-before-turn",
model: "claude-haiku-5-5",
});
expect((await session.getRuntimeInfo()).model).toBe("claude-haiku-5-5");
expect((await session.getRuntimeInfo()).extra?.runtimeModel).toBeUndefined();
} finally {
await session.close();
}
});
test("leaves the model unknown when Claude will choose its default", async () => {
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
expect((await session.getRuntimeInfo()).model).toBeNull();
} finally {
await session.close();
}
});
});
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
@@ -77,6 +78,29 @@ function sidechainEntry(options: { agentId?: string; stopReason?: string | null
});
}
function buildQueryMock(events: unknown[]) {
let index = 0;
return {
next: vi.fn(async () => {
if (index >= events.length) return { done: true, value: undefined };
const value = events[index];
index += 1;
return { done: false, value };
}),
interrupt: vi.fn(async () => undefined),
return: vi.fn(async () => undefined),
close: vi.fn(() => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => []),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
[Symbol.asyncIterator]() {
return this;
},
};
}
describe("ClaudeAgentSession persisted subagent replay", () => {
const logger = createTestLogger();
const queryFactory = vi.fn(() => {
@@ -737,7 +761,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
);
}, 120_000);
test("limits retained child shell output to the timeline's existing content limit", async () => {
test("limits retained child shell output to the timeline item budget", async () => {
const output = "x".repeat(128 * 1024);
writeSession({
parentLines: [taskToolUse(), taskToolResult()],
@@ -782,7 +806,10 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
type: "timeline",
id: TOOL_USE_ID,
item: expect.objectContaining({
detail: expect.objectContaining({ type: "shell", output: output.slice(0, 64 * 1024) }),
detail: expect.objectContaining({
type: "shell",
output: expect.stringMatching(/^x+…\[truncated \d+\.\d KB\]$/),
}),
}),
}),
]);
@@ -1259,4 +1286,106 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
);
expect(items).toEqual([{ type: "assistant_message", text: report }]);
});
test("keeps a subagent resumed after the session reopens on the row its transcript restored", async () => {
// Verified on the wire (Claude Code 2.1.309): SendMessage resumes a finished subagent under its
// original task_id but a new tool_use_id, and the child's frames still name the original Task
// call as their parent. A reopened session only knows that child from its transcript.
writeSession({
parentLines: [taskToolUse(), taskToolResult()],
meta: JSON.stringify({ agentType: "general-purpose", toolUseId: TOOL_USE_ID }),
sidechainLines: [
JSON.stringify({
type: "assistant",
isSidechain: true,
agentId: AGENT_ID,
sessionId: "replay-session",
timestamp: "2026-07-26T06:27:50.000Z",
effort: "high",
message: {
role: "assistant",
model: "claude-sonnet-5",
content: [{ type: "text", text: "summary of the docs" }],
stop_reason: "end_turn",
},
}),
],
});
const liveQuery = vi.fn(() =>
buildQueryMock([
{
type: "system",
subtype: "init",
session_id: "replay-session",
permissionMode: "default",
},
{
type: "system",
subtype: "task_started",
task_id: AGENT_ID,
tool_use_id: "toolu_send_message",
description: "Summarize the docs",
subagent_type: "general-purpose",
task_type: "local_agent",
is_backgrounded: true,
prompt: "Summarize them again",
},
{
type: "assistant",
parent_tool_use_id: TOOL_USE_ID,
session_id: "replay-session",
message: {
id: "msg_resumed_child",
role: "assistant",
model: "claude-opus-5",
content: [{ type: "text", text: "summary again" }],
},
},
{
type: "system",
subtype: "task_updated",
task_id: AGENT_ID,
patch: { status: "completed" },
},
{
type: "result",
subtype: "success",
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
total_cost_usd: 0,
},
]),
);
const session = await new ClaudeAgentClient({
logger,
queryFactory: liveQuery,
resolveVersion: async () => "2.1.309",
resolveBinary: async () => "/test/claude/bin",
}).resumeSession({ provider: "claude", sessionId: "replay-session" }, { cwd });
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(session, "Ask it to summarize again")) {
events.push(event);
if (event.type === "turn_completed" || event.type === "turn_failed") break;
}
await session.close();
const live = events.flatMap((event) =>
event.type === "provider_subagent" ? [event.event] : [],
);
expect(new Set(live.map((event) => event.id))).toEqual(new Set([TOOL_USE_ID]));
expect(live).toContainEqual(
expect.objectContaining({
type: "upsert",
id: TOOL_USE_ID,
subtitle: "general-purpose · Opus 5 · High",
}),
);
expect(live).toContainEqual(
expect.objectContaining({
type: "timeline",
id: TOOL_USE_ID,
item: expect.objectContaining({ type: "assistant_message", text: "summary again" }),
}),
);
expect(live.at(-1)).toMatchObject({ type: "upsert", id: TOOL_USE_ID, status: "completed" });
});
});
@@ -273,7 +273,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
queryFactory.mockReset();
});
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
test("labels the Task card from its input and preserves callId lifecycle collapse", async () => {
const session = await new ClaudeAgentClient({
logger,
queryFactory,
@@ -307,9 +307,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
expect(latest.detail.subAgentType).toBe("Explore");
expect(latest.detail.description).toBe("Inspect repository structure");
expect(latest.detail.log).toContain("[Read] README.md");
expect(latest.detail.log).toContain("[Edit] src/index.ts");
expect(latest.detail.log).not.toContain("VERY_LARGE_OLD_STRING");
expect(latest.detail.log).toBe("");
const rows: AgentTimelineRow[] = timelineToolCalls.map((item, index) => ({
seq: index + 1,
@@ -396,10 +394,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
.map((event) => event.item)
.at(-1);
expect(latestSubAgentUpdate?.detail).toMatchObject({
type: "sub_agent",
log: expect.stringContaining("[Read] README.md"),
});
expect(latestSubAgentUpdate?.detail).toMatchObject({ type: "sub_agent", log: "" });
});
test("routes a resumed SendMessage sidechain to the original child without a second Task card", async () => {
@@ -553,7 +548,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" });
});
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
test("keeps the Task card the same size while the child runs", async () => {
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
const session = await new ClaudeAgentClient({
@@ -568,24 +563,27 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
await session.close();
const timelineToolCalls = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "tool_call",
const runningCards = events
.flatMap((event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.callId === "task-tail-1" &&
event.item.status === "running" &&
event.item.detail.type === "sub_agent"
? [event.item]
: [],
)
.map((event) => event.item)
.filter((item) => item.callId === "task-tail-1");
const subAgentUpdates = timelineToolCalls.filter((item) => item.detail.type === "sub_agent");
const latest = subAgentUpdates[subAgentUpdates.length - 1];
expect(latest).toBeDefined();
if (!latest || latest.detail.type !== "sub_agent") {
throw new Error("expected sub_agent detail");
}
.map((item) => JSON.stringify(item));
expect(runningCards).toHaveLength(1);
expect(latest.detail.log).not.toContain("[Read] file-1.md");
expect(latest.detail.log).not.toContain("[Read] file-5.md");
expect(latest.detail.log).toContain("[Read] file-6.md");
expect(latest.detail.log).toContain("[Read] file-205.md");
const childToolCalls = events.filter(
(event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.id === "task-tail-1" &&
event.event.item.type === "tool_call",
);
expect(childToolCalls).toHaveLength(205);
});
test("keeps a long main-thread tool call out of the subagents track", async () => {
@@ -729,6 +729,196 @@ describe("ClaudeAgentSession features", () => {
return { queryFactory, queryMock, launches };
}
function createStreamingQueryMock() {
const mock = createQueryMock();
const messages: SDKMessage[] = [];
let pending: ((result: IteratorResult<SDKMessage, void>) => void) | null = null;
let ended = false;
const finish = () => {
ended = true;
pending?.({ done: true, value: undefined });
pending = null;
};
mock.queryMock.close.mockImplementation(finish);
mock.queryMock.return.mockImplementation(async () => finish());
mock.queryMock[Symbol.asyncIterator] = () => ({
next: async () => {
const message = messages.shift();
if (message) return { done: false, value: message };
if (ended) return { done: true, value: undefined };
return new Promise<IteratorResult<SDKMessage, void>>((resolve) => {
pending = resolve;
});
},
});
return {
...mock,
push(message: SDKMessage) {
if (pending) {
pending({ done: false, value: message });
pending = null;
} else {
messages.push(message);
}
},
};
}
async function startToolForClosure() {
const mock = createStreamingQueryMock();
const session = await new ClaudeAgentClient({
logger,
queryFactory: mock.queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
const events: AgentStreamEvent[] = [];
let toolStarted!: () => void;
const running = new Promise<void>((resolve) => {
toolStarted = resolve;
});
const unsubscribe = session.subscribe((event) => {
events.push(event);
if (
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "running"
)
toolStarted();
});
await session.startTurn("run the tool");
mock.push({
type: "stream_event",
session_id: "closure-session",
uuid: "00000000-0000-4000-8000-000000000001",
parent_tool_use_id: null,
event: {
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "closure-tool", name: "Bash", input: {} },
},
});
mock.push({
type: "stream_event",
session_id: "closure-session",
uuid: "00000000-0000-4000-8000-000000000002",
parent_tool_use_id: null,
event: { type: "content_block_stop", index: 0 },
});
await running;
return { ...mock, session, events, unsubscribe };
}
test("runtime close publishes tool cleanup without reporting a user turn cancellation", async () => {
const fixture = await startToolForClosure();
try {
await fixture.session.close();
expect(fixture.queryMock.close).toHaveBeenCalledTimes(1);
expect(
fixture.events.filter(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "canceled",
),
).toHaveLength(1);
expect(
fixture.events.filter(
(event) =>
event.type === "turn_canceled" ||
event.type === "turn_completed" ||
event.type === "turn_failed",
),
).toEqual([]);
} finally {
fixture.unsubscribe();
await fixture.session.close();
}
});
test("explicit interruption still publishes tool cleanup and one turn cancellation", async () => {
const fixture = await startToolForClosure();
try {
await fixture.session.interrupt();
expect(
fixture.events.filter(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "canceled",
),
).toHaveLength(1);
expect(fixture.events.filter((event) => event.type === "turn_canceled")).toHaveLength(1);
} finally {
fixture.unsubscribe();
await fixture.session.close();
}
});
test("runtime close resolves permissions once without reporting a user turn cancellation", async () => {
const { queryFactory } = createQueryMock();
const session = await new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
const abort = new AbortController();
try {
await session.startTurn("run the tool");
const canUseTool = queryFactory.mock.calls[0]?.[0].options.canUseTool;
if (!canUseTool) throw new Error("Expected canUseTool callback");
const permission = canUseTool(
"Bash",
{ command: "printf test" },
{ signal: abort.signal, toolUseID: "tool-closed" },
);
const rejected = expect(permission).rejects.toThrow("Claude session closed");
await session.close();
abort.abort();
await rejected;
expect(session.getPendingPermissions()).toEqual([]);
expect(events.filter(isPermissionResolvedEvent)).toHaveLength(1);
expect(events.filter((event) => event.type === "turn_canceled")).toEqual([]);
} finally {
unsubscribe();
await session.close();
}
});
test("runtime close prevents a query from launching after pending options resolve", async () => {
const { queryFactory } = createQueryMock();
let optionsStarted!: () => void;
let allowOptions!: () => void;
const started = new Promise<void>((resolve) => {
optionsStarted = resolve;
});
const allowed = new Promise<void>((resolve) => {
allowOptions = resolve;
});
const session = await new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => {
optionsStarted();
await allowed;
return "/test/claude/bin";
},
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
const turn = session.startTurn("must not run after closure");
try {
await started;
await session.close();
allowOptions();
await turn;
expect(queryFactory).not.toHaveBeenCalled();
} finally {
allowOptions();
await turn;
await session.close();
}
});
test("publishes a resolution when the SDK aborts a permission callback", async () => {
const { queryFactory } = createQueryMock();
const session = await new ClaudeAgentClient({
@@ -1224,16 +1414,17 @@ describe("ClaudeAgentSession features", () => {
const iterator = input[Symbol.asyncIterator]();
await expect(iterator.next()).resolves.toMatchObject({ value: { type: "user" } });
await expect(
session.steerActiveTurn?.("same turn follow-up", {
expectedTurnId: turnId,
clientMessageId: "steer-client-id",
}),
).resolves.toEqual({ status: "accepted" });
const admission = await session.steerActiveTurn?.("same turn follow-up", {
expectedTurnId: turnId,
clientMessageId: "steer-client-id",
});
expect(admission).toEqual({ status: "accepted", providerMessageId: expect.any(String) });
if (admission?.status !== "accepted") throw new Error("Expected steer admission");
await expect(iterator.next()).resolves.toMatchObject({
value: {
type: "user",
priority: "next",
uuid: admission.providerMessageId,
message: { content: [{ type: "text", text: "same turn follow-up" }] },
},
});
@@ -1291,7 +1482,7 @@ describe("ClaudeAgentSession features", () => {
expectedTurnId: turnId,
clearPendingPermissions: true,
}),
).resolves.toEqual({ status: "accepted" });
).resolves.toEqual({ status: "accepted", providerMessageId: expect.any(String) });
await expect(firstPermission).resolves.toMatchObject({
behavior: "deny",
interrupt: undefined,
@@ -1350,7 +1541,7 @@ describe("ClaudeAgentSession features", () => {
const permission = internal.handlePermissionRequest("Write", {}, { toolUseID: "tool-1" });
await expect(
session.steerActiveTurn?.("system notification", { expectedTurnId: turnId }),
).resolves.toEqual({ status: "accepted" });
).resolves.toEqual({ status: "accepted", providerMessageId: expect.any(String) });
expect(session.getPendingPermissions()).toHaveLength(1);
const requestId = session.getPendingPermissions()[0]!.id;
await session.respondToPermission(requestId, { behavior: "deny", message: "test cleanup" });
@@ -2717,8 +2908,13 @@ describe("ClaudeAgentSession context window usage", () => {
await session.startTurn("turn", { clientMessageId: "client-message-1" });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(events.slice(0, 2).map((event) => event.type)).toEqual(["turn_started", "timeline"]);
expect(events[1]).toMatchObject({
const started = events.findIndex((event) => event.type === "turn_started");
const userMessage = events.findIndex(
(event) => event.type === "timeline" && event.item.type === "user_message",
);
expect(started).toBeGreaterThanOrEqual(0);
expect(userMessage).toBeGreaterThan(started);
expect(events[userMessage]).toMatchObject({
type: "timeline",
item: { type: "user_message", clientMessageId: "client-message-1" },
});
@@ -2789,7 +2985,7 @@ describe("ClaudeAgentSession context window usage", () => {
expect(persistedQueryFactory.mock.calls[0]?.[0].options.persistSession).toBe(true);
});
test("classifies Claude root-only commands separately from inline skills", async () => {
test("classifies Claude root-only commands separately from inline skills and hides internal ones", async () => {
const queryFactory = vi.fn(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
void prompt;
return {
@@ -2822,6 +3018,11 @@ describe("ClaudeAgentSession context window usage", () => {
description: "Start a new session with empty context",
argumentHint: "",
},
{
name: "__remote-workflow",
description: "Run a remote workflow (server-launched sessions only)",
argumentHint: "",
},
],
rewindFiles: async () => ({ canRewind: true }),
[Symbol.asyncIterator]() {
@@ -82,6 +82,7 @@ import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { discoverClaudeModels } from "./model-discovery.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import {
findSteeredMessageParent,
realClaudeRewindSdk,
revertClaudeConversation,
revertClaudeFiles,
@@ -441,6 +442,12 @@ interface SlashCommandInvocation {
rawInput: string;
}
// Claude Code reserves `__`-prefixed commands for its own server-launched sessions
// (for example `/__remote-workflow`); a user cannot run them from a Paseo agent.
function isClaudeInternalSlashCommand(commandName: string): boolean {
return commandName.startsWith("__");
}
function classifyClaudeSlashCommand(commandName: string): AgentSlashCommand["kind"] {
// Claude exposes commands and skills as one flat SDK list, without structured source
// metadata. Keep obvious root-only/session controls out of inline autocomplete and
@@ -2293,6 +2300,7 @@ class ClaudeAgentSession implements AgentSession {
private readonly resolveBinary: () => Promise<string>;
private query: Query | null = null;
private readonly runtimeSafety = new ClaudeRuntimeSafety();
private readonly backgroundStops = new Map<string, (outcome: string) => void>();
private processingSdkMessages = 0;
private readonly harnessEnvironment: Record<string, string>;
private readonly usageSessionKey = randomUUID();
@@ -2388,6 +2396,12 @@ class ClaudeAgentSession implements AgentSession {
private mainTurnInFlight = false;
private foregroundHasVisibleActivity = false;
private activeTurnHasAssistantText = false;
/**
* The reply Claude Code writes when a turn stops on an API error ("You've hit your session
* limit", "API Error: 529 ..."), held back until the result says whether the turn failed: a
* failed turn shows the same text as its error, so showing both would repeat it.
*/
private withheldErrorReply: AgentTimelineItem[] = [];
private readonly contextUsage: ClaudeContextUsageState;
private userMessageIds: string[] = [];
private readonly emittedUserMessageIds = new Set<string>();
@@ -2459,7 +2473,7 @@ class ClaudeAgentSession implements AgentSession {
const info: AgentRuntimeInfo = {
provider: "claude",
sessionId: this.claudeSessionId,
model: this.lastOptionsModel,
model: this.lastOptionsModel ?? this.config.model ?? null,
modeId: this.currentMode ?? null,
...(this.lastRuntimeModel
? {
@@ -2530,6 +2544,7 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false;
this.activeTurnHasAssistantText = false;
this.withheldErrorReply = [];
this.contextUsage.beginTurn();
this.transitionTurnState("foreground", "foreground turn started");
this.clearRecentStderr();
@@ -2576,6 +2591,7 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundQuery = this.query;
this.activeForegroundInput = this.input;
this.startQueryPump();
this.runtimeSafety.notePromptSent();
this.input.push(sdkMessage);
setTimeout(() => {
if (this.activeForegroundTurnId === turnId) {
@@ -2627,7 +2643,7 @@ class ClaudeAgentSession implements AgentSession {
return { status: "unavailable" };
}
this.enqueueSteer(input, message, options.clearPendingPermissions === true);
return { status: "accepted" };
return { status: "accepted", ...(message.uuid ? { providerMessageId: message.uuid } : {}) };
}
private enqueueSteer(
@@ -2661,6 +2677,33 @@ class ClaudeAgentSession implements AgentSession {
};
}
async stopBackgroundWork(id: string): Promise<void> {
const query = this.query;
if (!query || !this.runtimeSafety.canStop(id))
throw new Error("Background work is no longer available");
if (this.backgroundStops.has(id)) throw new Error("Stop is already pending");
let timer: ReturnType<typeof setTimeout> | undefined;
let finish!: (outcome: string) => void;
const outcome = new Promise<string>((resolve) => {
finish = resolve;
this.backgroundStops.set(id, resolve);
});
try {
const result = await Promise.race([
Promise.all([query.stopTask(id), outcome]).then(([, value]) => value),
outcome.then((value) => (value === "stopped" ? new Promise<string>(() => {}) : value)),
new Promise<string>((resolve) => {
timer = setTimeout(() => resolve("timeout"), 15000);
}),
]);
if (result !== "stopped") throw new Error(`Background work stop: ${result}`);
if (this.query !== query) throw new Error("Background work connection changed");
} finally {
clearTimeout(timer);
if (this.backgroundStops.get(id) === finish) this.backgroundStops.delete(id);
}
}
async interrupt(): Promise<void> {
if (this.cancelCurrentTurn) {
this.cancelCurrentTurn();
@@ -2740,7 +2783,7 @@ class ClaudeAgentSession implements AgentSession {
this.currentMode = normalized;
}
async setModel(modelId: string | null): Promise<void> {
async setModel(modelId: string | null): Promise<{ model: string | null }> {
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId.trim() : null;
const activeQuery = await this.ensureQuery();
@@ -2758,6 +2801,7 @@ class ClaudeAgentSession implements AgentSession {
this.cachedRuntimeInfo = null;
// Model change affects persistence metadata, so invalidate cached handle.
this.persistence = null;
return { model: this.lastOptionsModel ?? this.config.model ?? null };
}
private reconcileThinkingOptionForModel(modelId: string | null): void {
@@ -3010,7 +3054,9 @@ class ClaudeAgentSession implements AgentSession {
this.closed = true;
await this.ensureHistoryReady();
this.rejectAllPendingPermissions(new Error("Claude session closed"));
this.cancelCurrentTurn?.();
// AgentManager settles the run after runtime release. Preserve cleanup records
// without reporting this closure as an explicit turn cancellation.
this.flushPendingToolCalls();
this.subscribers.clear();
this.activeForegroundTurnId = null;
this.activeForegroundQuery = null;
@@ -3077,6 +3123,7 @@ class ClaudeAgentSession implements AgentSession {
const commands = await q.supportedCommands();
const commandMap = new Map<string, AgentSlashCommand>();
for (const cmd of commands) {
if (isClaudeInternalSlashCommand(cmd.name)) continue;
if (!commandMap.has(cmd.name)) {
commandMap.set(cmd.name, {
name: cmd.name,
@@ -3094,7 +3141,7 @@ class ClaudeAgentSession implements AgentSession {
async revertConversation(input: { messageId: string }): Promise<void> {
await this.ensureHistoryReady();
const target = this.resolveConversationRewindTarget(input.messageId);
const target = await this.resolveConversationRewindTarget(input.messageId);
if (target.kind === "fresh-session") {
this.startFreshConversationSession();
return;
@@ -3429,12 +3476,24 @@ class ClaudeAgentSession implements AgentSession {
return messageId;
}
private resolveConversationRewindTarget(messageId: string): ClaudeConversationRewindTarget {
private async resolveConversationRewindTarget(
messageId: string,
): Promise<ClaudeConversationRewindTarget> {
const targetUserMessageId = this.resolveClaudeMessageId(messageId);
const index = this.rewindTurnAnchors.findIndex(
(anchor) => anchor.userMessageId === targetUserMessageId,
);
if (index < 0) {
// A message steered into a running turn opens no turn of its own.
const transcriptPath = this.claudeSessionId
? await this.resolveHistoryPath(this.claudeSessionId)
: null;
const steerParent = transcriptPath
? await findSteeredMessageParent(transcriptPath, targetUserMessageId)
: null;
if (steerParent) {
return { kind: "fork", messageId: steerParent };
}
throw new Error(`Claude rewind target ${messageId} is not in the tracked conversation`);
}
@@ -3456,6 +3515,7 @@ class ClaudeAgentSession implements AgentSession {
const oldQuery = this.query;
if (!oldQuery) return;
const oldInput = this.input;
for (const finish of this.backgroundStops.values()) finish("connection lost");
// Null out query/input BEFORE awaiting the old iterator's return so the
// old pump sees this.query !== activeQuery and skips failActiveTurns.
this.query = null;
@@ -3505,9 +3565,12 @@ class ClaudeAgentSession implements AgentSession {
const input = createAsyncMessageInput<SDKUserMessage>();
const options = await this.buildOptions(launchMode);
// Closure can finish while launch options are being prepared.
if (this.closed) throw new Error("Claude session is closed");
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
this.input = input;
this.runtimeSafety.reset();
this.dispatchEvents([{ type: "background_work", provider: "claude", items: null }]);
// A fresh Claude process has no turn of its own in flight.
this.mainTurnInFlight = false;
this.query = claudeQuery(
@@ -4103,11 +4166,15 @@ class ClaudeAgentSession implements AgentSession {
}
private failRunningRuntimeTasks(): void {
this.dispatchEvents(
foldSubagentObservations(this.taskProtocolSource.failRunningTasks()).map(
for (const finish of this.backgroundStops.values()) finish("connection lost");
this.runtimeSafety.retire();
this.dispatchEvents([
...foldSubagentObservations(this.taskProtocolSource.failRunningTasks()).map(
(event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event }),
),
);
// The SDK resets its background task set when the CLI process (re)starts.
{ type: "background_work", provider: "claude", items: null },
]);
}
private startQueryPump(): void {
@@ -4175,12 +4242,19 @@ class ClaudeAgentSession implements AgentSession {
if (!this.ownsQuery(activeQuery)) return true;
this.processingSdkMessages++;
try {
this.runtimeSafety.observe(message);
const backgroundWork = this.runtimeSafety.observe(message);
if (message.type === "system" && message.subtype === "task_notification")
this.backgroundStops.get(message.task_id)?.(message.status);
logRawMessage(message);
consecutiveInterruptAbortRecoveries = 0;
if (await this.handleMissingResumedConversation(message, activeQuery)) return true;
if (!this.ownsQuery(activeQuery)) return true;
await this.routeSdkMessageFromPump(message);
if (!this.ownsQuery(activeQuery)) return true;
if (backgroundWork && this.runtimeSafety.isCurrentInventory(backgroundWork))
this.dispatchEvents([
{ type: "background_work", provider: "claude", items: backgroundWork },
]);
return false;
} finally {
this.processingSdkMessages--;
@@ -4406,6 +4480,21 @@ class ClaudeAgentSession implements AgentSession {
}) satisfies AgentStreamEvent,
);
if (!readClaudeParentToolUseId(message)) {
if (message.type === "assistant" && message.error) {
this.withheldErrorReply.push(...assistantTimelineEvents.map((event) => event.item));
return messageEvents;
}
if (
message.type === "assistant" ||
message.type === "user" ||
message.type === "stream_event"
) {
const released: AgentStreamEvent[] = [];
this.releaseWithheldErrorReply(released);
return [...released, ...messageEvents, ...assistantTimelineEvents];
}
}
return [...messageEvents, ...assistantTimelineEvents];
}
@@ -4667,7 +4756,7 @@ class ClaudeAgentSession implements AgentSession {
/**
* The Task card in the parent transcript, built from the declaration.
*
* The sidechain tracker also emits this card, enriched with the child's action log, and the two
* The sidechain tracker also emits this card when it first sees the child, and the two
* collapse on the shared tool-call id. Emitting it at declaration matters because a
* backgrounded subagent produces no sidechain frames at all — verified on the wire — so the
* tracker never runs for one and its card would otherwise stay an unlabeled "Task".
@@ -4802,6 +4891,13 @@ class ClaudeAgentSession implements AgentSession {
this.appendTaskNotificationEvents(message, events);
return;
}
if (message.subtype === "background_tasks_changed") {
this.logger.debug(
{ tasks: message.tasks.map(({ task_id, task_type }) => ({ task_id, task_type })) },
"claude.background_tasks_changed",
);
return;
}
if (message.subtype === "task_progress") {
return;
}
@@ -4953,6 +5049,23 @@ class ClaudeAgentSession implements AgentSession {
events: AgentStreamEvent[],
): void {
const usage = this.convertUsage(message);
// Claude Code reports a turn that stopped on an API error (a usage limit, an overloaded or
// rejected request) as `success` with `is_error`, carrying the error text in `result`.
if (message.subtype === "success" && message.is_error) {
const withheld = this.withheldErrorReply;
this.withheldErrorReply = [];
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const withheldText = withheld
.map((item) => (item.type === "assistant_message" ? item.text : ""))
.join("\n")
.trim();
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
events.push(...this.sidechainTracker.finishAll("failed"));
events.push(this.buildTurnFailedEvent(resultText || withheldText || "Claude run failed"));
return;
}
const releasedErrorReply = this.withheldErrorReply.length > 0;
this.releaseWithheldErrorReply(events);
if (message.subtype === "success") {
events.push(...this.sidechainTracker.finishAll("completed"));
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
@@ -4962,7 +5075,12 @@ class ClaudeAgentSession implements AgentSession {
// accounting from provider gateways does not duplicate streamed output.
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const outputTokens = message.usage?.output_tokens;
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
if (
resultText.length > 0 &&
outputTokens === 0 &&
!this.activeTurnHasAssistantText &&
!releasedErrorReply
) {
events.push({
type: "timeline",
provider: "claude",
@@ -4980,10 +5098,19 @@ class ClaudeAgentSession implements AgentSession {
"errors" in message && Array.isArray(message.errors) && message.errors.length > 0
? message.errors.join("\n")
: "Claude run failed";
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
events.push(...this.sidechainTracker.finishAll("failed"));
events.push(this.buildTurnFailedEvent(errorMessage));
}
private releaseWithheldErrorReply(events: AgentStreamEvent[]): void {
if (this.withheldErrorReply.length === 0) return;
for (const item of this.withheldErrorReply) {
events.push({ type: "timeline", item, provider: "claude" });
}
this.withheldErrorReply = [];
}
private createClaudeSessionChangedNotice(
oldSessionId: string,
newSessionId: string,
@@ -5442,6 +5569,7 @@ class ClaudeAgentSession implements AgentSession {
convertEntry: (entry) => this.convertHistoryEntry(entry),
})),
];
this.taskProtocolSource.restore(subagentReplay.restored);
const restoredProviderSubagentIds = new Set(
observations
.filter((observation) => observation.kind === "declared")
@@ -6276,6 +6404,7 @@ function buildClaudeSubagentCardDetail(
type: "sub_agent",
...(facts.title ? { subAgentType: facts.title } : {}),
...(facts.description ? { description: facts.description } : {}),
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
};
@@ -6430,6 +6559,10 @@ function convertClaudeHistoryEntryPreamble(
if (entry.type === "user" && isSyntheticHistoryUserEntry(entry)) {
return { shortCircuit: [] };
}
const steeredPrompt = readSteeredPromptHistoryItem(entry);
if (steeredPrompt) {
return { shortCircuit: [steeredPrompt] };
}
// Only user and assistant records are conversation turns. Claude Code 2.1.x also journals each
// API request as `api-request-blob` records whose message replays the system context.
if (entry.type !== "user" && entry.type !== "assistant") {
@@ -6456,6 +6589,21 @@ function isProviderImageMessage(item: AgentTimelineItem): boolean {
return item.type === "assistant_message" && isProviderImageMarkdown(item.text);
}
/**
* A message steered into a running turn. Claude Code gives it no user entry: it records a
* queued_command attachment in prompt mode, under the uuid Paseo minted for the message, which is
* also what a rewind to it resolves. The other modes carry task notifications, not user messages.
*/
function readSteeredPromptHistoryItem(entry: ClaudeHistoryEntry): AgentTimelineItem | null {
if (entry.type !== "attachment") return null;
const attachment = toObjectRecord(entry.attachment);
if (attachment?.type !== "queued_command" || attachment.commandMode !== "prompt") return null;
const text = extractUserMessageText(attachment.prompt);
if (!text) return null;
const messageId = readTrimmedString(attachment.source_uuid);
return { type: "user_message", text, ...(messageId ? { messageId } : {}) };
}
export async function convertClaudeHistoryEntry(
entry: ClaudeHistoryEntry,
mapBlocks: (
@@ -6819,40 +6967,79 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
}
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
const CLAUDE_CUSTOM_TITLE_NEEDLE = Buffer.from(`"${CLAUDE_CUSTOM_TITLE_TYPE}"`);
const CLAUDE_TITLE_SCAN_CHUNK_BYTES = 64 * 1024;
function readClaudeCustomTitleFromRecord(record: Buffer): string | null {
// The needle also matches a prompt that quotes it, so a record still has to
// parse as a `custom-title` entry carrying a usable name. Records without the
// needle are never decoded, so a long one costs a byte search and nothing more.
if (!record.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(record.toString("utf8").trim());
} catch {
return null;
}
const entry = toObjectRecord(parsed);
if (!entry || entry.isSidechain || entry.type !== CLAUDE_CUSTOM_TITLE_TYPE) {
return null;
}
return normalizeClaudeSessionTitle(
typeof entry.customTitle === "string" ? entry.customTitle : null,
);
}
/**
* The `custom-title` record is re-emitted every time the session is saved, so
* the current name is the last usable one in the file. Searching backwards finds it without
* walking the transcript, which matters because sessions reach tens of
* megabytes.
* the current name is the last usable one in the file. Reading backwards a chunk
* at a time finds it without walking the transcript, which matters because
* sessions reach tens of megabytes, and never holds the file as one string, so a
* transcript past V8's maximum string length still imports.
*/
function readLastClaudeCustomTitle(content: string): string | null {
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
let at = content.lastIndexOf(needle);
while (at >= 0) {
const lineStart = content.lastIndexOf("\n", at) + 1;
const lineEnd = content.indexOf("\n", at);
const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd);
let parsed: unknown;
try {
parsed = JSON.parse(line.trim());
} catch {
parsed = undefined;
}
const entry = toObjectRecord(parsed);
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
const title = normalizeClaudeSessionTitle(
typeof entry.customTitle === "string" ? entry.customTitle : null,
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
const handle = await fsPromises.open(filePath, "r");
try {
const stats = await handle.stat();
if (!stats.isFile()) {
// Opening a directory succeeds on Windows even though reading it does not,
// so reject the way reading the transcript as a file would.
throw Object.assign(
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
{ code: "EISDIR", path: filePath },
);
if (title) {
return title;
}
}
// The needle also matches a prompt that quotes it, and a record can carry no
// usable name, so keep looking backwards for one that does.
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
let end = stats.size;
// Retain unfinished record pieces in reverse file order. Assemble each
// complete record once, so a large tool/image record costs linear work.
let pieces: Buffer[] = [];
while (end > 0) {
const length = Math.min(CLAUDE_TITLE_SCAN_CHUNK_BYTES, end);
const chunk = Buffer.alloc(length);
await handle.read(chunk, 0, length, end - length);
let recordEnd = chunk.length;
for (
let at = chunk.lastIndexOf(10);
at >= 0;
at = at === 0 ? -1 : chunk.lastIndexOf(10, at - 1)
) {
pieces.push(chunk.subarray(at + 1, recordEnd));
const record = Buffer.concat(pieces.toReversed());
const title = readClaudeCustomTitleFromRecord(record);
if (title) return title;
pieces = [];
recordEnd = at;
}
if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd));
end -= length;
}
// The first record in the file has no newline in front of it.
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed()));
} finally {
await handle.close();
}
return null;
}
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
@@ -6864,9 +7051,8 @@ async function readClaudeRenamedSessionTitle(input: {
sessionId: string;
configDir: string;
}): Promise<string | null> {
let content: string;
try {
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
} catch (error) {
// A session with no transcript on disk has no rename to carry over. Every
// other read failure is a real fault and stays explicit.
@@ -6875,7 +7061,6 @@ async function readClaudeRenamedSessionTitle(input: {
}
throw error;
}
return readLastClaudeCustomTitle(content);
}
function normalizeImportablePromptPreview(text: string): string | null {
@@ -0,0 +1,21 @@
import { describe, expect, test } from "vitest";
import { toBackgroundWorkInputs } from "./background-work.js";
describe("toBackgroundWorkInputs", () => {
test("maps shells, drops subagents and workflows", () => {
expect(
toBackgroundWorkInputs([
{ task_id: "bash-1", task_type: "local_bash", description: "sleep 20" },
{ task_id: "agent-1", task_type: "local_agent", description: "Explore" },
{ task_id: "wf-1", task_type: "local_workflow", description: "Run the spec workflow" },
{ task_id: "x-1", task_type: "remote_something", description: " " },
{ task_id: "bash-2", task_type: "local_bash" },
]),
).toEqual([
{ id: "bash-1", kind: "shell", description: "sleep 20", canStop: true },
{ id: "x-1", kind: "other", description: null, canStop: false },
{ id: "bash-2", kind: "shell", description: null, canStop: true },
]);
});
});
@@ -0,0 +1,36 @@
import type { AgentBackgroundWorkInput } from "../../background-work/index.js";
import { CLAUDE_SUBAGENT_TASK_TYPE, CLAUDE_WORKFLOW_TASK_TYPE } from "./subagents/live-source.js";
export interface ClaudeBackgroundTask {
task_id: string;
task_type: string;
/** The user's `claude` binary can differ from the SDK typings, so this is read defensively. */
description?: unknown;
}
/** Subagents and workflows already live in the subagents track. */
const SUBAGENT_TASK_TYPES = new Set([CLAUDE_SUBAGENT_TASK_TYPE, CLAUDE_WORKFLOW_TASK_TYPE]);
/** Maps `background_tasks_changed.tasks` to provider-agnostic background work. */
export function toBackgroundWorkInputs(
tasks: readonly ClaudeBackgroundTask[],
): AgentBackgroundWorkInput[] {
return tasks
.filter((task) => !SUBAGENT_TASK_TYPES.has(task.task_type))
.map((task) => ({
id: task.task_id,
kind: toKind(task.task_type),
canStop: task.task_type === "local_bash",
description: readDescription(task.description),
}));
}
/** The inventory owner refines shell entries when launch metadata identifies a monitor. */
function toKind(taskType: string): string {
if (taskType === "local_bash") return "shell";
return "other";
}
function readDescription(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
@@ -365,7 +365,21 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
);
});
it("appends concrete models from Claude settings.json", async () => {
it("appends concrete models from Claude settings.json with thinking options", async () => {
const customModelThinkingOptions = [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
{ id: "max", label: "Max" },
];
const fableThinkingOptions = [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
{ id: "xhigh", label: "Extra High" },
{ id: "max", label: "Max" },
{ id: CLAUDE_ULTRACODE_THINKING_OPTION_ID, label: "Ultra Code" },
];
const configDir = await createClaudeConfigDir({
model: "us.anthropic.claude-opus-4-7[1m]",
env: {
@@ -393,42 +407,49 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
id: "us.anthropic.claude-opus-4-7[1m]",
label: "us.anthropic.claude-opus-4-7[1m]",
description: "From Claude settings.json model",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "openrouter/anthropic/claude-sonnet-4.5",
label: "openrouter/anthropic/claude-sonnet-4.5",
description: "From Claude settings.json env.ANTHROPIC_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "ollama/qwen3-coder",
label: "ollama/qwen3-coder",
description: "From Claude settings.json env.ANTHROPIC_SMALL_FAST_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "claude-fable-5-1[1M]",
label: "claude-fable-5-1[1M]",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_FABLE_MODEL",
thinkingOptions: fableThinkingOptions,
},
{
provider: "claude",
id: "bedrock-opus-from-env",
label: "bedrock-opus-from-env",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_OPUS_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "glm-5.1",
label: "glm-5.1",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_SONNET_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "glm-5",
label: "glm-5",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_HAIKU_MODEL",
thinkingOptions: customModelThinkingOptions,
},
]);
expect(models.filter((model) => model.id === "claude-fable-5-1[1M]")).toHaveLength(1);
@@ -83,7 +83,7 @@ export async function getClaudeModelsWithSettings(
const manifestModel = getClaudeModels(claudeCodeVersion).find(
(candidate) => candidate.id === model.id,
);
models.push({ ...manifestModel, ...model });
models.push(resolveConfiguredClaudeModel({ ...manifestModel, ...model }));
}
return models;
@@ -1,11 +1,20 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
readlinkSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import type { Query } from "@anthropic-ai/claude-agent-sdk";
import {
findSteeredMessageParent,
revertClaudeConversation,
revertClaudeConversationAndFiles,
revertClaudeFiles,
@@ -217,4 +226,96 @@ describe("Claude rewind against the Claude SDK", () => {
"first reply",
]);
});
test("forks just before a message steered into a running turn", async () => {
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-rewind-steer-"));
delete process.env.CLAUDE_CONFIG_DIR;
const projectDir = path.join(tempRoot, "projects", "-repo");
mkdirSync(projectDir, { recursive: true });
const sessionId = randomUUID();
const firstUser = randomUUID();
const toolUse = randomUUID();
const toolResult = randomUUID();
const steeredMessage = randomUUID();
const queuedCommand = randomUUID();
const transcriptPath = path.join(projectDir, `${sessionId}.jsonl`);
writeTranscript(transcriptPath, sessionId, [
{ type: "user", uuid: firstUser, parentUuid: null, text: "first" },
{ type: "assistant", uuid: toolUse, parentUuid: firstUser, text: "running a tool" },
{ type: "user", uuid: toolResult, parentUuid: toolUse, text: "tool output" },
// What Claude Code writes when it reads a steered message between two tool calls.
JSON.stringify({
type: "attachment",
uuid: queuedCommand,
parentUuid: toolResult,
sessionId,
attachment: {
type: "queued_command",
prompt: [{ type: "text", text: "steered" }],
source_uuid: steeredMessage,
},
}),
{ type: "assistant", uuid: randomUUID(), parentUuid: queuedCommand, text: "reply to steer" },
]);
const parent = await findSteeredMessageParent(transcriptPath, steeredMessage);
expect(parent).toBe(toolResult);
expect(await findSteeredMessageParent(transcriptPath, randomUUID())).toBeNull();
let currentSessionId = sessionId;
await revertClaudeConversation({
sdk: realClaudeRewindSdk,
sessionId,
transcriptPath,
messageId: parent!,
setSessionId: (nextSessionId) => {
currentSessionId = nextSessionId;
},
});
expect(readMessageTexts(path.join(projectDir, `${currentSessionId}.jsonl`))).toEqual([
"first",
"running a tool",
"tool output",
]);
});
});
test.runIf(process.platform === "linux")(
"releases the transcript descriptor after an early steer match",
async () => {
const artifacts = path.resolve(".artifacts");
mkdirSync(artifacts, { recursive: true });
const root = mkdtempSync(path.join(artifacts, "claude-rewind-descriptor-"));
const transcriptPath = path.join(root, "session.jsonl");
const matchingEntry = {
type: "attachment",
parentUuid: "before-steer",
attachment: { type: "queued_command", source_uuid: "steered-message" },
};
const unreadEntry = JSON.stringify({ type: "assistant", message: "x".repeat(1024) });
writeFileSync(
transcriptPath,
JSON.stringify(matchingEntry) + "\n" + (unreadEntry + "\n").repeat(1200),
);
const openDescriptors = () =>
readdirSync("/proc/self/fd").filter((descriptor) => {
try {
return readlinkSync(path.join("/proc/self/fd", descriptor)) === transcriptPath;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}).length;
try {
expect(openDescriptors()).toBe(0);
for (let attempt = 0; attempt < 5; attempt++) {
await expect(findSteeredMessageParent(transcriptPath, "steered-message")).resolves.toBe(
"before-steer",
);
}
await expect.poll(openDescriptors, { timeout: 1000 }).toBe(0);
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
@@ -1,5 +1,7 @@
import { createReadStream } from "node:fs";
import { appendFile, readFile } from "node:fs/promises";
import path from "node:path";
import { createInterface } from "node:readline";
import {
forkSession as claudeForkSession,
type Query,
@@ -70,6 +72,54 @@ function isSessionStoreEntry(value: unknown): value is SessionStoreEntry {
);
}
/**
* The entry a message steered into a running turn follows. Claude Code gives such a message no
* entry of its own: it records a queued_command attachment, under the uuid Paseo minted for the
* message, hung off the entry it was read after. Forking at that entry drops the message and
* everything after it. Null when the transcript holds no such attachment.
*/
export async function findSteeredMessageParent(
transcriptPath: string,
messageId: string,
): Promise<string | null> {
const input = createReadStream(transcriptPath, { encoding: "utf8" });
const lines = createInterface({
input,
crlfDelay: Infinity,
});
try {
for await (const line of lines) {
if (!line.includes(messageId) || !line.includes('"queued_command"')) continue;
let entry: unknown;
try {
entry = JSON.parse(line);
} catch {
continue;
}
const record = entry as {
type?: unknown;
parentUuid?: unknown;
attachment?: { type?: unknown; source_uuid?: unknown };
};
if (
record.type === "attachment" &&
record.attachment?.type === "queued_command" &&
record.attachment.source_uuid === messageId &&
typeof record.parentUuid === "string"
) {
return record.parentUuid;
}
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
} finally {
lines.close();
input.destroy();
}
return null;
}
export async function revertClaudeConversation(input: {
sdk: ClaudeRewindSdk;
sessionId: string | null;
@@ -0,0 +1,48 @@
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { expect, test } from "vitest";
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
const message = (value: unknown) => value as SDKMessage;
test("enriches only live tasks with their correlated launch metadata", () => {
const runtime = new ClaudeRuntimeSafety();
runtime.observe(
message({
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "tool-1",
name: "Monitor",
input: { description: "Watch build", command: "tail -f build.log" },
},
{ type: "tool_use", id: "old", name: "Bash", input: { command: "old command" } },
],
},
}),
);
runtime.observe(
message({ type: "system", subtype: "task_started", task_id: "task-1", tool_use_id: "tool-1" }),
);
expect(
runtime.observe(
message({
type: "system",
subtype: "background_tasks_changed",
tasks: [{ task_id: "task-1", task_type: "local_bash", description: "Watch build" }],
}),
),
).toEqual([
{
id: "task-1",
kind: "monitor",
description: "Watch build",
command: "tail -f build.log",
canStop: true,
},
]);
expect(
runtime.observe(message({ type: "system", subtype: "background_tasks_changed", tasks: [] })),
).toEqual([]);
});
@@ -1,42 +1,172 @@
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AgentBackgroundWorkInput } from "../../background-work/index.js";
import { parseClaudeCodeVersion } from "./model-manifest.js";
import { toBackgroundWorkInputs, type ClaudeBackgroundTask } from "./background-work.js";
/** Process-local background ownership. Completion delivery can follow an empty roster. */
export class ClaudeRuntimeSafety {
private supported = false;
private liveTasks = new Set<string>();
private inventoryState: "unknown" | "supported" | "retired" = "unknown";
// Background work starts inside a turn, and init only arrives with a turn. A process that
// has neither been sent a prompt nor reported init (a resumed chat that only answered
// control requests) owns no background work.
private turnSeen = false;
private liveTasks: readonly ClaudeBackgroundTask[] = [];
private published: AgentBackgroundWorkInput[] | undefined;
private readonly awaitingNotifications = new Set<string>();
private readonly monitorTools = new Set<string>();
private readonly toolInputs = new Map<string, { description?: string; command?: string }>();
private readonly taskTools = new Map<string, string>();
reset(): void {
this.supported = false;
this.liveTasks.clear();
this.inventoryState = "unknown";
this.turnSeen = false;
this.liveTasks = [];
this.published = undefined;
this.awaitingNotifications.clear();
this.monitorTools.clear();
this.toolInputs.clear();
this.taskTools.clear();
}
observe(message: SDKMessage): void {
if (message.type !== "system") return;
retire(): void {
this.reset();
this.inventoryState = "retired";
}
/** Called before a prompt is written to this process. */
notePromptSent(): void {
this.turnSeen = true;
}
/** Returns a full replacement only when the visible inventory changes. */
observe(message: SDKMessage): AgentBackgroundWorkInput[] | undefined {
if (this.inventoryState === "retired") return undefined;
const changed = this.observeTools(message);
const systemChanged = message.type === "system" ? this.observeSystem(message) : false;
if ((!changed && !systemChanged) || this.inventoryState !== "supported") return undefined;
const items = this.buildInventory();
if (this.published && sameInventory(this.published, items)) return undefined;
this.published = items;
return items;
}
private observeTools(message: SDKMessage): boolean {
if (message.type === "assistant") {
let relevant = false;
for (const block of message.message.content) {
if (block.type === "tool_use" && (block.name === "Monitor" || block.name === "Bash")) {
if (block.name === "Monitor") relevant = this.observeMonitor(block.id) || relevant;
relevant = this.observeInput(block.id, block.input) || relevant;
}
}
return relevant;
}
if (message.type === "stream_event" && message.event.type === "content_block_start") {
const block = message.event.content_block;
if (block.type === "tool_use" && block.name === "Monitor")
return this.observeMonitor(block.id);
}
return false;
}
private observeInput(id: string, value: unknown): boolean {
const input = value as Record<string, unknown> | null;
const description = typeof input?.description === "string" ? input.description.trim() : "";
const command = typeof input?.command === "string" ? input.command.trim() : "";
const previous = this.toolInputs.get(id);
if (description === (previous?.description ?? "") && command === (previous?.command ?? ""))
return false;
this.toolInputs.set(id, {
...(description ? { description } : {}),
...(command ? { command } : {}),
});
return this.liveTasks.some((task) => this.taskTools.get(task.task_id) === id);
}
private observeMonitor(id: string): boolean {
if (this.monitorTools.has(id)) return false;
this.monitorTools.add(id);
return this.liveTasks.some((task) => this.taskTools.get(task.task_id) === id);
}
private observeSystem(message: Extract<SDKMessage, { type: "system" }>): boolean {
if (message.subtype === "task_started" && message.tool_use_id) {
const previous = this.taskTools.get(message.task_id);
this.taskTools.set(message.task_id, message.tool_use_id);
return (
previous !== message.tool_use_id &&
(this.monitorTools.has(message.tool_use_id) ||
this.toolInputs.has(message.tool_use_id) ||
(previous !== undefined &&
(this.monitorTools.has(previous) || this.toolInputs.has(previous)))) &&
this.liveTasks.some((task) => task.task_id === message.task_id)
);
}
if (message.subtype === "init") {
// 2.1.292 is a known roster-capable release. Older binaries can demonstrate
// support by sending a roster; a fresh process starts with an empty roster.
this.turnSeen = true;
const version = parseClaudeCodeVersion(message.claude_code_version ?? "");
if (
version &&
(version[0] > 2 ||
(version[0] === 2 && (version[1] > 1 || (version[1] === 1 && version[2] >= 292))))
)
this.supported = true;
) {
const changed = this.inventoryState !== "supported";
this.inventoryState = "supported";
return changed;
}
}
if (message.subtype === "background_tasks_changed") {
this.supported = true;
this.liveTasks = new Set(message.tasks.map((task) => task.task_id));
for (const id of this.liveTasks) this.awaitingNotifications.add(id);
}
if (message.subtype === "task_notification") {
this.awaitingNotifications.delete(message.task_id);
this.inventoryState = "supported";
this.liveTasks = message.tasks;
for (const task of this.liveTasks) this.awaitingNotifications.add(task.task_id);
return true;
}
if (message.subtype === "task_notification") this.awaitingNotifications.delete(message.task_id);
return false;
}
canStopRuntime(): boolean {
return this.supported && this.liveTasks.size === 0 && this.awaitingNotifications.size === 0;
if (this.inventoryState === "unknown" && !this.turnSeen) return true;
return (
this.inventoryState === "supported" &&
this.liveTasks.length === 0 &&
this.awaitingNotifications.size === 0
);
}
canStop(id: string): boolean {
return this.buildInventory().some((item) => item.id === id && item.canStop);
}
isCurrentInventory(items: AgentBackgroundWorkInput[]): boolean {
return this.inventoryState === "supported" && this.published === items;
}
private buildInventory(): AgentBackgroundWorkInput[] {
return toBackgroundWorkInputs(this.liveTasks).map((item) => {
const tool = this.taskTools.get(item.id) ?? "";
if (this.monitorTools.has(tool)) item.kind = "monitor";
const input = this.toolInputs.get(tool);
if (input?.description) item.description = input.description;
if (input?.command) item.command = input.command;
return item;
});
}
}
function sameInventory(
left: readonly AgentBackgroundWorkInput[],
right: readonly AgentBackgroundWorkInput[],
): boolean {
return (
left.length === right.length &&
left.every(
(item, index) =>
item.id === right[index].id &&
item.kind === right[index].kind &&
item.description === right[index].description &&
item.command === right[index].command &&
item.canStop === right[index].canStop,
)
);
}
@@ -4,7 +4,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
describe("ClaudeSidechainTracker", () => {
it("retains a background child's actions across the parent finishing", () => {
it("keeps a background child's card across the parent finishing", () => {
const tracker = new ClaudeSidechainTracker({
getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }),
isDescriptorOwnedElsewhere: () => true,
@@ -15,27 +15,67 @@ describe("ClaudeSidechainTracker", () => {
parent_tool_use_id: "task-1",
message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] },
}) as unknown as SDKMessage;
tracker.handleMessage(action("ls", "ls -1"), "task-1");
const parentCards = (events: ReturnType<typeof tracker.handleMessage>) =>
events.filter((event) => event.type === "timeline");
const childCallIds = (events: ReturnType<typeof tracker.handleMessage>) =>
events.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.item.type === "tool_call"
? [event.event.item.callId]
: [],
);
expect(parentCards(tracker.handleMessage(action("ls", "ls -1"), "task-1"))).toHaveLength(1);
expect(tracker.finishAll("completed")).toEqual([]);
const events = tracker.handleMessage(action("pwd", "pwd"), "task-1");
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }),
}),
}),
);
const afterFinish = tracker.handleMessage(action("pwd", "pwd"), "task-1");
expect(parentCards(afterFinish)).toEqual([]);
expect(childCallIds(afterFinish)).toEqual(["pwd"]);
tracker.delete("task-1");
const next = tracker.handleMessage(action("next", "ls"), "task-1");
expect(next).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
detail: expect.objectContaining({ log: "[Bash] ls" }),
}),
}),
);
expect(parentCards(next)).toHaveLength(1);
expect(childCallIds(next)).toEqual(["next"]);
});
it("shows a streamed child tool call's input once the assistant message brings it", () => {
const tracker = new ClaudeSidechainTracker({ getToolInput: () => null });
const frames = [
{
type: "stream_event",
parent_tool_use_id: "task-1",
event: {
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "bash-1", name: "Bash", input: {} },
},
},
{
type: "assistant",
parent_tool_use_id: "task-1",
message: {
content: [
{ type: "tool_use", id: "bash-1", name: "Bash", input: { command: "echo child" } },
],
},
},
] as unknown as SDKMessage[];
const childCards = frames
.flatMap((frame) => tracker.handleMessage(frame, "task-1"))
.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.item.type === "tool_call"
? [event.event.item]
: [],
);
expect(childCards.at(-1)).toMatchObject({
callId: "bash-1",
status: "running",
detail: { type: "shell", command: "echo child" },
});
});
it("uses Claude's native agent name for the provider subagent title", () => {
@@ -5,7 +5,6 @@ import {
mapClaudeFailedToolCall,
mapClaudeRunningToolCall,
} from "./tool-call-mapper.js";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import {
isClaudeSubagentHandbackToolName,
readClaudeSubagentHandback,
@@ -20,21 +19,17 @@ interface ClaudeContentChunk {
type ClaudeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
interface SubAgentActionEntry {
index: number;
interface SubAgentChildCall {
toolName: string;
input: unknown;
summary?: string;
}
interface SubAgentActivityState {
name?: string;
subAgentType?: string;
description?: string;
actions: SubAgentActionEntry[];
actionKeys: string[];
nextActionIndex: number;
actionIndexByKey: Map<string, number>;
/** The child's tool calls awaiting results, which carry no input of their own. */
runningCalls: Map<string, SubAgentChildCall>;
completedActionKeys: Set<string>;
}
@@ -44,7 +39,6 @@ interface SubAgentActionCandidate {
input: unknown;
}
const MAX_SUB_AGENT_LOG_ENTRIES = 200;
const MAX_SUB_AGENT_SUMMARY_CHARS = 160;
function readTrimmedString(value: unknown): string | undefined {
@@ -79,8 +73,7 @@ export class ClaudeSidechainTracker {
needsSyntheticParentToolCard?: (toolUseId: string) => boolean;
/**
* The parent's tool call once Claude has answered it. A background child streams its frames
* after that answer, so its card keeps the settled status and output and gains the child's
* action log.
* after that answer, so its card keeps the settled status and output.
*/
getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined;
}) {
@@ -91,28 +84,27 @@ export class ClaudeSidechainTracker {
}
handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] {
const existingState = this.activeSidechains.get(parentToolUseId);
const state =
this.activeSidechains.get(parentToolUseId) ??
existingState ??
({
actions: [],
actionKeys: [],
nextActionIndex: 1,
actionIndexByKey: new Map<string, number>(),
runningCalls: new Map<string, SubAgentChildCall>(),
completedActionKeys: new Set<string>(),
} satisfies SubAgentActivityState);
this.activeSidechains.set(parentToolUseId, state);
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
// The parent card is a launch marker: it changes only when the child is first seen or the
// task input names it differently, never with the child's activity.
const cardUpdated =
this.updateSubAgentContextFromTaskInput(state, parentToolUseId) || !existingState;
const actionCandidates = this.extractSubAgentActionCandidates(message);
const childTimelineItems = [
...this.extractSubAgentTimelineItems(message, state),
...this.extractSubAgentToolResults(message, state),
];
let actionUpdated = false;
for (const action of actionCandidates) {
if (state.completedActionKeys.has(action.key)) continue;
if (this.appendSubAgentAction(state, action)) {
actionUpdated = true;
if (this.trackChildCall(state, action)) {
const toolCall = mapClaudeRunningToolCall({
name: action.toolName,
callId: action.key,
@@ -125,7 +117,7 @@ export class ClaudeSidechainTracker {
}
}
if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) {
if (!cardUpdated && childTimelineItems.length === 0) {
return [];
}
@@ -145,11 +137,8 @@ export class ClaudeSidechainTracker {
type: "sub_agent",
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
...(state.description ? { description: state.description } : {}),
log: state.actions
.map((action) =>
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
)
.join("\n"),
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
};
@@ -170,18 +159,19 @@ export class ClaudeSidechainTracker {
},
];
const parentCard: AgentStreamEvent[] = this.needsSyntheticParentToolCard(parentToolUseId)
? [
{
type: "timeline",
item: {
...toolCall,
detail,
const parentCard: AgentStreamEvent[] =
cardUpdated && this.needsSyntheticParentToolCard(parentToolUseId)
? [
{
type: "timeline",
item: {
...toolCall,
detail,
},
provider: "claude",
},
provider: "claude",
},
]
: [];
]
: [];
return [
...descriptorEvents,
@@ -303,8 +293,7 @@ export class ClaudeSidechainTracker {
if (!isClaudeContentChunk(block) || !block.type.endsWith("tool_result")) continue;
const callId = readTrimmedString(block.tool_use_id);
if (!callId || state.completedActionKeys.has(callId)) continue;
const actionIndex = state.actionIndexByKey.get(callId);
const action = actionIndex === undefined ? undefined : state.actions[actionIndex];
const action = state.runningCalls.get(callId);
const toolName = action?.toolName ?? readTrimmedString(block.tool_name);
if (!toolName) continue;
const params = {
@@ -317,6 +306,7 @@ export class ClaudeSidechainTracker {
? mapClaudeFailedToolCall({ ...params, error: block })
: mapClaudeCompletedToolCall(params);
if (toolCall) {
state.runningCalls.delete(callId);
state.completedActionKeys.add(callId);
items.push(toolCall);
}
@@ -412,13 +402,12 @@ export class ClaudeSidechainTracker {
const key =
readTrimmedString(block.id) ??
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
return [
{
key,
toolName: block.name,
input: block.input ?? null,
},
];
// A streamed call starts with `{}` until its input arrives, so an empty input is not final.
const input =
block.input && typeof block.input === "object" && Object.keys(block.input).length > 0
? block.input
: null;
return [{ key, toolName: block.name, input }];
}
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
@@ -448,84 +437,20 @@ export class ClaudeSidechainTracker {
return [];
}
private appendSubAgentAction(
/** Returns whether the call is new or has gained its input, so its running card changes. */
private trackChildCall(
state: SubAgentActivityState,
candidate: SubAgentActionCandidate,
): boolean {
const normalizedToolName = readTrimmedString(candidate.toolName);
if (!normalizedToolName) {
const toolName = readTrimmedString(candidate.toolName);
if (!toolName) {
return false;
}
const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input);
const existingIndex = state.actionIndexByKey.get(candidate.key);
if (existingIndex !== undefined) {
const existing = state.actions[existingIndex];
if (!existing) {
return false;
}
const nextSummary = existing.summary ?? summary;
if (existing.toolName === normalizedToolName && existing.summary === nextSummary) {
return false;
}
state.actions[existingIndex] = {
...existing,
toolName: normalizedToolName,
input: existing.input ?? candidate.input,
...(nextSummary ? { summary: nextSummary } : {}),
};
return true;
const existing = state.runningCalls.get(candidate.key);
if (existing && (existing.input !== null || candidate.input === null)) {
return false;
}
state.actions.push({
index: state.nextActionIndex,
toolName: normalizedToolName,
input: candidate.input,
...(summary ? { summary } : {}),
});
state.nextActionIndex += 1;
state.actionKeys.push(candidate.key);
this.trimSubAgentTail(state);
this.rebuildSubAgentActionIndex(state);
state.runningCalls.set(candidate.key, { toolName, input: candidate.input });
return true;
}
private trimSubAgentTail(state: SubAgentActivityState): void {
while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) {
state.actions.shift();
const removedKey = state.actionKeys.shift();
if (removedKey) state.completedActionKeys.delete(removedKey);
}
}
private rebuildSubAgentActionIndex(state: SubAgentActivityState): void {
state.actionIndexByKey.clear();
for (let index = 0; index < state.actionKeys.length; index += 1) {
const key = state.actionKeys[index];
if (key) {
state.actionIndexByKey.set(key, index);
}
}
}
private deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined {
const runningToolCall = mapClaudeRunningToolCall({
name: toolName,
callId: `sub-agent-summary-${toolName}`,
input,
output: null,
});
if (!runningToolCall) {
return undefined;
}
const display = buildToolCallDisplayModel({
name: runningToolCall.name,
status: runningToolCall.status,
error: runningToolCall.error,
detail: runningToolCall.detail,
metadata: runningToolCall.metadata,
});
return this.normalizeSubAgentText(display.summary);
}
}
@@ -156,6 +156,42 @@ describe("ClaudeTaskProtocolSource", () => {
]);
});
it("resumes a child a reopened session restored on its original row", () => {
const source = new ClaudeTaskProtocolSource();
source.restore([
{
taskId: "a1730a6215e1f5cf6",
id: "toolu_original",
presentation: { title: "general-purpose", model: "claude-sonnet-5", effort: "high" },
},
]);
// Restored is not running: a canceled turn and an unannounced frame leave it alone.
expect(source.cancelRunningForegroundTasks()).toEqual([]);
expect(source.resolveSubagentId("toolu_original")).toBeUndefined();
expect(
source.observe(taskStarted({ tool_use_id: "toolu_resumed", prompt: "Resumed prompt" })),
).toEqual([
{ kind: "status", id: "toolu_original", status: "running" },
{
kind: "timeline",
id: "toolu_original",
item: { type: "user_message", text: "Resumed prompt" },
},
]);
expect(source.resolveSubagentId("toolu_original")).toBe("toolu_original");
expect(source.resolveSubagentId("toolu_resumed")).toBe("toolu_original");
expect(
source.observeSidechainFrame(
{ type: "assistant", message: { model: "claude-opus-5" } } as unknown as SDKMessage,
"toolu_original",
),
).toEqual([
{ kind: "subtitle", id: "toolu_original", subtitle: "general-purpose · Opus 5 · High" },
]);
});
it("declares a distinct task separately", () => {
const source = new ClaudeTaskProtocolSource();
source.observe(taskStarted({ task_id: "first", tool_use_id: "toolu_first" }));
@@ -49,9 +49,9 @@ interface TaskStartedMessage {
}
/** Task-tool subagents. Backgrounded shell commands announce as `local_bash`. */
const CLAUDE_SUBAGENT_TASK_TYPE = "local_agent";
export const CLAUDE_SUBAGENT_TASK_TYPE = "local_agent";
/** Workflow executions use the same announced task lifecycle as Task-tool subagents. */
const CLAUDE_WORKFLOW_TASK_TYPE = "local_workflow";
export const CLAUDE_WORKFLOW_TASK_TYPE = "local_workflow";
/**
* Not every announced task belongs in the subagents track. Verified on the wire:
@@ -121,6 +121,16 @@ function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
/**
* A subagent a reopened session rebuilt from its transcript. Its `taskId` (the transcript's
* agentId) is the task id Claude announces again when SendMessage resumes the child.
*/
export interface ClaudeRestoredSubagent {
taskId: string;
id: string;
presentation: ClaudeSubagentPresentationFacts;
}
export interface ClaudeTaskProtocolSourceInput {
/**
* The parent's Task tool input, by tool_use id.
@@ -162,6 +172,14 @@ export class ClaudeTaskProtocolSource {
private readonly backgroundedIds = new Set<string>();
/** Last status emitted per subagent, so a redundant announcement is not re-broadcast. */
private readonly lastStatusById = new Map<string, ProviderSubagentStatus>();
/**
* Children a reopened session restored from its transcript, by task id. Claude resumes one under
* its old task id with a new tool id, while its frames keep the original Task call as parent.
* Without this the resume reads as a new task: a second row, and every frame dropped as
* undeclared. Held apart from the declared table so a restored child is left alone until
* Claude announces it again.
*/
private readonly restoredByTaskId = new Map<string, ClaudeRestoredSubagent>();
/** Claude facts stay inside the provider boundary; clients receive one compact subtitle. */
private readonly presentationById = new Map<string, ClaudeSubagentPresentationFacts>();
private readonly lastSubtitleById = new Map<string, string>();
@@ -246,6 +264,14 @@ export class ClaudeTaskProtocolSource {
}
}
/** Remember the children a reopened session restored, for Claude to resume by task id. */
restore(subagents: Iterable<ClaudeRestoredSubagent>): void {
for (const subagent of subagents) {
if (this.subagentIdByTaskId.has(subagent.taskId)) continue;
this.restoredByTaskId.set(subagent.taskId, subagent);
}
}
/**
* Forget the session.
*
@@ -259,6 +285,7 @@ export class ClaudeTaskProtocolSource {
this.ownerSubagentIdByToolUseId.clear();
this.ownerSubagentIdByTaskId.clear();
this.declaredIds.clear();
this.restoredByTaskId.clear();
this.workflowTaskIds.clear();
this.lastWorkflowResultByTaskId.clear();
this.idsWithExistingParentToolCard.clear();
@@ -315,7 +342,8 @@ export class ClaudeTaskProtocolSource {
if (!id || message.skip_transcript === true || !isProviderSubagentTask(message)) return [];
this.sawTaskStarted = true;
const existingId = this.subagentIdByTaskId.get(message.task_id);
const existingId =
this.subagentIdByTaskId.get(message.task_id) ?? this.adoptRestored(message.task_id);
this.recordBackgrounded(existingId ?? id, message.is_backgrounded);
if (existingId) {
return this.observeExistingTaskStart(message, id, existingId);
@@ -324,6 +352,20 @@ export class ClaudeTaskProtocolSource {
return this.observeNewTaskStart(message, id, parentSubagentId);
}
/** Declare a restored child under the id its transcript gave it, with what it already showed. */
private adoptRestored(taskId: string): string | undefined {
const restored = this.restoredByTaskId.get(taskId);
if (!restored) return undefined;
this.restoredByTaskId.delete(taskId);
this.subagentIdByTaskId.set(taskId, restored.id);
this.canonicalIdByToolUseId.set(restored.id, restored.id);
this.declaredIds.add(restored.id);
this.presentationById.set(restored.id, restored.presentation);
const subtitle = buildClaudeSubagentSubtitle(restored.presentation);
if (subtitle) this.lastSubtitleById.set(restored.id, subtitle);
return restored.id;
}
private observeExistingTaskStart(
message: TaskStartedMessage,
toolUseId: string,
@@ -85,6 +85,50 @@ describe("parseClaudeSubagentMeta", () => {
});
describe("observeReplaySubagents", () => {
it("restores a resumed child on its original row after consuming async history", async () => {
async function* entries(): AsyncGenerator<ClaudeReplayEntry> {
yield { type: "assistant", message: { model: "claude-opus-5" } };
yield {
type: "assistant",
effort: "high",
message: { model: "claude-sonnet-5", usage: { input_tokens: 10, output_tokens: 2 } },
};
}
const replay = await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: entries() }],
parent: parentWithTaskCall(),
convertEntry: async () => [],
});
expect(replay.restored).toEqual([
{
taskId: AGENT_ID,
id: TOOL_USE_ID,
presentation: {
title: "general-purpose",
model: "claude-sonnet-5",
effort: "high",
usage: { totalTokens: 12 },
},
},
]);
const live = new ClaudeTaskProtocolSource();
live.restore(replay.restored);
expect(
live.observe({
type: "system",
subtype: "task_started",
task_id: AGENT_ID,
tool_use_id: "resumed-tool",
description: "Resume the captured task",
task_type: "local_agent",
is_backgrounded: true,
uuid: "00000000-0000-0000-0000-000000000000",
session_id: "reopened-session",
}),
).toEqual([{ kind: "status", id: TOOL_USE_ID, status: "running" }]);
expect(live.resolveSubagentId("resumed-tool")).toBe(TOOL_USE_ID);
});
it("links a subagent to its Task call through meta.toolUseId", async () => {
const observations = (
await observeReplaySubagents({
@@ -5,6 +5,7 @@ import { normalizeProviderReplayTimestamp } from "../../../provider-history-time
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { isClaudeInterruptPlaceholderText } from "../interrupt-placeholder.js";
import { resolveObservedClaudeModelId } from "../models.js";
import type { ClaudeRestoredSubagent } from "./live-source.js";
import type { SubagentObservation } from "./observation.js";
import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js";
@@ -246,6 +247,7 @@ async function observeSubagent(
parent: ClaudeReplayParentFacts,
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
toolOwners: Map<string, string>,
restored: ClaudeRestoredSubagent[],
parentSubagentId?: string,
): Promise<SubagentObservation[]> {
const link = resolveParentLink(subagent, parent);
@@ -276,13 +278,14 @@ async function observeSubagent(
const observations = [
declareSubagent(subagent, link, toolCall, parentSubagentId, firstTimestamp),
];
const subtitle = observeSubtitle(
link,
toolCall?.title ?? subagent.meta?.agentType,
runtime,
usage,
lastTimestamp,
);
const title = toolCall?.title ?? subagent.meta?.agentType;
// The agentId is the task_id Claude resumes this child under, so live can find it again.
restored.push({
taskId: subagent.agentId,
id: link.id,
presentation: { ...(title ? { title } : {}), ...runtime, ...(usage ? { usage } : {}) },
});
const subtitle = observeSubtitle(link, title, runtime, usage, lastTimestamp);
if (subtitle) observations.push(subtitle);
for (const observation of timeline) observations.push(observation);
const terminalStatus = link.status ?? childTerminalStatus;
@@ -337,9 +340,14 @@ export async function observeReplaySubagents(input: {
subagents: readonly ClaudeReplaySubagentInput[];
parent: ClaudeReplayParentFacts;
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> {
}): Promise<{
observations: SubagentObservation[];
toolOwners: ReadonlyMap<string, string>;
restored: readonly ClaudeRestoredSubagent[];
}> {
const observations: SubagentObservation[] = [];
const toolOwners = new Map<string, string>();
const restored: ClaudeRestoredSubagent[] = [];
const unresolved = [...input.subagents].sort(
(left, right) => (left.meta?.spawnDepth ?? 1) - (right.meta?.spawnDepth ?? 1),
);
@@ -364,6 +372,7 @@ export async function observeReplaySubagents(input: {
parent,
input.convertEntry,
toolOwners,
restored,
ownerId,
)) {
observations.push(observation);
@@ -373,7 +382,7 @@ export async function observeReplaySubagents(input: {
madeProgress = true;
}
}
return { observations, toolOwners };
return { observations, toolOwners, restored };
}
function resolveReplayOwner(
@@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { createServer } from "node:http";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { once } from "node:events";
import { AgentManager } from "../agent-manager.js";
@@ -217,6 +217,33 @@ function waitForEvent<TEvent extends AgentStreamEvent>(params: {
}
describe("Codex app-server provider (local e2e)", () => {
test.runIf(isCodexInstalled())(
"concurrent import scans initialize successfully in a fresh shared Codex home",
async () => {
const artifacts = path.resolve(".artifacts");
mkdirSync(artifacts, { recursive: true });
const root = mkdtempSync(path.join(artifacts, "codex-concurrent-startup-"));
try {
for (let round = 0; round < 4; round++) {
const codexHome = path.join(root, String(round));
mkdirSync(codexHome);
const provider = new CodexAppServerAgentClient(createTestLogger(), {
env: { CODEX_HOME: codexHome },
});
const results = await Promise.allSettled(
Array.from({ length: 24 }, () => provider.listImportableSessions({ limit: 1 })),
);
expect(results).toEqual(
Array.from({ length: 24 }, () => ({ status: "fulfilled", value: [] })),
);
}
} finally {
rmSync(root, { recursive: true, force: true });
}
},
60_000,
);
test.runIf(isCodexInstalled())(
"reloads a persisted idle thread repeatedly without overlapping writers",
async () => {
@@ -264,6 +291,98 @@ describe("Codex app-server provider (local e2e)", () => {
30_000,
);
test.runIf(isCodexInstalled())(
"reopens a promptless agent repeatedly before its first model request",
async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-promptless-cwd-"));
const logger = createTestLogger();
const storage = new AgentStorage(path.join(cwd, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new CodexAppServerAgentClient(logger) },
registry: storage,
logger,
});
let agentId: string | undefined;
try {
const created = await manager.createAgent(
{ provider: "codex", cwd, modeId: "auto", model: "mock-model" },
undefined,
{ workspaceId: undefined },
);
agentId = created.id;
// A real app-server rejects an unsaved thread on resume. No model request is
// needed to prove that reopening the agent remains safe before its first turn.
for (let i = 0; i < 2; i++) {
const reopened = await manager.reloadAgentSession(agentId);
expect(reopened.lifecycle).toBe("idle");
expect(reopened.persistence).toBeNull();
}
await storage.flush();
expect((await storage.get(agentId))?.persistence).toBeNull();
} finally {
if (agentId && manager.getAgent(agentId)) await manager.closeAgent(agentId);
await storage.flush();
rmSync(cwd, { recursive: true, force: true });
}
},
30_000,
);
test.runIf(isCodexInstalled())(
"reopens an agent that was never prompted without resuming its unsaved thread",
async () => {
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-unprompted-cwd-"));
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-unprompted-home-"));
const mockServer = await startMockResponsesServer([
assistantMessageSse("first"),
assistantMessageSse("second"),
]);
const logger = createTestLogger();
const storage = new AgentStorage(path.join(cwd, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new CodexAppServerAgentClient(logger) },
registry: storage,
logger,
});
let agentId: string | undefined;
try {
writeMockCodexConfig(codexHome, mockServer.url);
vi.stubEnv("CODEX_HOME", codexHome);
const created = await manager.createAgent(
{ provider: "codex", cwd, modeId: "auto", model: "mock-model" },
undefined,
{ workspaceId: undefined },
);
agentId = created.id;
// Codex writes a thread's rollout at its first turn, so there is nothing to resume yet.
expect(manager.getAgent(agentId)?.persistence).toBeNull();
await storage.flush();
expect((await storage.get(agentId))?.persistence).toBeNull();
await manager.reloadAgentSession(agentId);
await manager.runAgent(agentId, "first prompt after reopening");
const threadId = manager.getAgent(agentId)?.persistence?.sessionId;
expect(threadId).toEqual(expect.any(String));
await storage.flush();
expect((await storage.get(agentId))?.persistence?.sessionId).toBe(threadId);
const reloaded = await manager.reloadAgentSession(agentId);
expect(reloaded.persistence?.sessionId).toBe(threadId);
await manager.runAgent(agentId, "continue the saved thread");
expect(manager.getAgent(agentId)?.lifecycle).toBe("idle");
expect(mockServer.requestBodies.at(-1)).toContain("first prompt after reopening");
} finally {
if (agentId && manager.getAgent(agentId)) await manager.closeAgent(agentId);
await storage.flush();
vi.unstubAllEnvs();
await mockServer.close();
rmSync(cwd, { recursive: true, force: true });
rmSync(codexHome, { recursive: true, force: true });
}
},
30_000,
);
test.runIf(isCodexInstalled())(
"surfaces request_user_input from the app-server as question permissions and timeline tool calls",
async () => {
@@ -587,6 +587,10 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
createFakeCodexAppServer(),
];
let spawnCount = 0;
let markReconnectStarted!: () => void;
const reconnectStarted = new Promise<void>((resolve) => {
markReconnectStarted = resolve;
});
let releaseReconnect: (() => void) | undefined;
const reconnectGate = new Promise<void>((resolve) => {
releaseReconnect = resolve;
@@ -602,6 +606,7 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
}
spawnCount += 1;
if (spawnCount > 1) {
markReconnectStarted();
await reconnectGate;
}
return appServer.child;
@@ -614,12 +619,14 @@ test("concurrent session APIs share one reconnect after an idle provider exit",
const runtimeInfo = session.getRuntimeInfo();
const turnStart = session.startTurn("continue after reconnect");
await reconnectStarted;
const reconnectSpawnCount = spawnCount - 1;
releaseReconnect?.();
await expect(runtimeInfo).resolves.toMatchObject({ provider: "codex" });
await expect(turnStart).resolves.toMatchObject({ turnId: expect.any(String) });
expect(reconnectSpawnCount).toBe(1);
expect(spawnCount).toBe(2);
} finally {
releaseReconnect?.();
await session.close();
@@ -77,12 +77,19 @@ CHILD_SENTINEL in your own response.
const completedChildIndex = events.findIndex(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "completed" &&
event.item.detail.type === "sub_agent" &&
event.item.detail.log.includes("CHILD_SENTINEL"),
event.type === "provider_subagent" &&
event.event.type === "upsert" &&
event.event.status === "completed",
);
const childAssistantText = events
.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.item.type === "assistant_message"
? [event.event.item.text]
: [],
)
.join("");
const rootMessageIndexes = events.flatMap((event, index) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [index] : [],
);
@@ -105,6 +112,7 @@ CHILD_SENTINEL in your own response.
.map((item) => item.text)
.join("");
expect(topLevelAssistantText).not.toContain("CHILD_SENTINEL");
expect(childAssistantText).toContain("CHILD_SENTINEL");
expect(completedChildIndex).toBeGreaterThanOrEqual(0);
expect(rootMessageIndexes[0]).toBeGreaterThan(completedChildIndex);
expect(terminalIndexes).toHaveLength(1);
@@ -35,15 +35,14 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
});
test("fetchCatalog settles when the refresh deadline aborts an unanswered initialize", async () => {
const client = new CodexAppServerAgentClient(logger, {
command: {
mode: "replace",
argv: ["/nonexistent/codex-binary-that-does-not-exist"],
},
});
const child = createCodexAppServerChildProcess();
const kill = vi.spyOn(child, "kill");
Object.assign(client, { spawnAppServer: async () => child });
const client = new CodexAppServerAgentClient(logger, undefined, {
appServerProcess: {
resolvePrefix: async () => ({ command: "codex", args: [] }),
spawn: () => child,
},
});
await expect(
runProviderRefreshWithDeadline({
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,8 @@ import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js";
// Runs the real `codex` binary against a local Responses endpoint standing in
// for a custom provider's OPENAI_BASE_URL, so no login is needed.
@@ -88,52 +89,106 @@ describe("Codex custom provider import list", () => {
for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
});
test("lists a custom provider's session under that provider and imports it there", async () => {
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-"));
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-"));
cleanups.push(() => rmSync(codexHome, { recursive: true, force: true }));
cleanups.push(() => rmSync(cwd, { recursive: true, force: true }));
const { baseUrl, server, requestCount } = await startResponsesEndpoint();
cleanups.push(() => closeServer(server));
test.each([
{ providerId: "my-codex", seedLegacy: false, seedBuiltin: false },
{ providerId: "ollama", seedLegacy: false, seedBuiltin: false },
{ providerId: "my-codex", seedLegacy: true, seedBuiltin: false },
{ providerId: "ollama", seedLegacy: false, seedBuiltin: true },
])(
"lists and imports sessions for $providerId (legacy: $seedLegacy, native: $seedBuiltin)",
async ({ providerId, seedLegacy, seedBuiltin }) => {
const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-"));
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-"));
cleanups.push(() => rmSync(codexHome, { recursive: true, force: true }));
cleanups.push(() => rmSync(cwd, { recursive: true, force: true }));
const { baseUrl, server, requestCount } = await startResponsesEndpoint();
cleanups.push(() => closeServer(server));
const logger = createTestLogger();
const customCodex = new CodexAppServerAgentClient(
logger,
{ env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } },
{ customProvider: { id: "my-codex", label: "My Codex", extends: "codex" } },
);
const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } });
const logger = createTestLogger();
if (seedLegacy || seedBuiltin) {
// Seed an old custom session or a native built-in session with real Codex.
const environment = {
...process.env,
CODEX_HOME: codexHome,
CODEX_OSS_BASE_URL: baseUrl,
OPENAI_API_KEY: "test-key",
};
const legacy = new CodexAppServerAgentSession(
{ provider: "codex", cwd, model: "mock-model" },
null,
logger,
async () =>
spawnProcess("codex", ["app-server"], { cwd, env: environment, stdio: "pipe" }),
{
environment,
customCodexConfig: {
model_provider: providerId,
model_providers: seedBuiltin
? {}
: {
[providerId]: {
name: "My Codex",
base_url: baseUrl,
wire_api: "responses",
env_key: "OPENAI_API_KEY",
requires_openai_auth: false,
},
},
},
},
);
try {
await legacy.run("hello from the legacy provider");
} finally {
await legacy.close();
}
}
const customCodex = new CodexAppServerAgentClient(
logger,
{ env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } },
{ customProvider: { id: providerId, label: "My Codex", extends: "codex" } },
);
const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } });
const session = await customCodex.createSession({
provider: "codex",
cwd,
model: "mock-model",
});
try {
await session.run("hello from the custom provider");
} finally {
await session.close();
}
const session = await customCodex.createSession({
provider: "codex",
cwd,
model: "mock-model",
});
try {
await session.run("hello from the custom provider");
} finally {
await session.close();
}
const customSessions = await customCodex.listImportableSessions({ cwd });
const stockSessions = await stockCodex.listImportableSessions({ cwd });
const customSessions = await customCodex.listImportableSessions({ cwd });
const stockSessions = await stockCodex.listImportableSessions({ cwd });
expect(customSessions.map((entry) => entry.firstPromptPreview)).toEqual([
"hello from the custom provider",
]);
expect(stockSessions).toEqual([]);
expect(customSessions.map((entry) => entry.firstPromptPreview).sort()).toEqual(
seedLegacy
? ["hello from the custom provider", "hello from the legacy provider"]
: ["hello from the custom provider"],
);
expect(stockSessions).toEqual([]);
const config = { provider: "codex", cwd, model: "mock-model" };
const imported = await customCodex.importSession(
{ providerHandleId: customSessions[0].providerHandleId, cwd },
{ config, storedConfig: config },
);
const requestsBeforeImportedTurn = requestCount();
try {
await imported.session.run("hello again after import");
} finally {
await imported.session.close();
}
expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn);
}, 120_000);
const config = { provider: "codex", cwd, model: "mock-model" };
const sessionToImport = seedLegacy
? customSessions.find(
(entry) => entry.firstPromptPreview === "hello from the legacy provider",
)!
: customSessions[0];
const imported = await customCodex.importSession(
{ providerHandleId: sessionToImport.providerHandleId, cwd },
{ config, storedConfig: config },
);
const requestsBeforeImportedTurn = requestCount();
try {
await imported.session.run("hello again after import");
} finally {
await imported.session.close();
}
expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn);
},
120_000,
);
});
@@ -0,0 +1,704 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { AgentSession } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
waitForNextEvent,
waitForTimelineToolCall,
} from "./codex/test-utils/fake-app-server.js";
const THREAD = "thread-1";
const TERMINATE = "thread/backgroundTerminals/terminate";
type Handler = (params: unknown) => unknown;
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function paramsOf(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
let sessions: AgentSession[] = [];
afterEach(async () => {
for (const session of sessions) await session.close().catch(() => undefined);
sessions = [];
});
/**
* A Codex app-server whose interrupt ends the turn the way Codex does: an `interrupted`
* turn completion, then the response. `terminate` answers per process.
*/
function createHarness(
options: {
terminate?: (processId: string) => unknown;
interruptResponse?: (completeTurn: () => void) => unknown;
} = {},
) {
const handlers: Record<string, Handler> = {
"thread/backgroundTerminals/list": () => ({ data: [], nextCursor: null }),
"turn/interrupt": () => {
const completeTurn = () =>
appServer.completeTurn({ threadId: THREAD, status: "interrupted" });
if (options.interruptResponse) return options.interruptResponse(completeTurn);
completeTurn();
return {};
},
[TERMINATE]: (params) =>
options.terminate?.(String(paramsOf(params).processId)) ?? { terminated: true },
};
const appServer: FakeCodexAppServer = createFakeCodexAppServer(handlers);
const session = new CodexAppServerAgentSession(
{
provider: "codex",
cwd: "/workspace/project",
modeId: "auto",
model: "gpt-5.4",
},
null,
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
function requests(method: string): Record<string, unknown>[] {
return appServer
.requests()
.filter((message) => message.method === method && "id" in message)
.map((message) => paramsOf(message.params));
}
function terminated(): string[] {
return requests(TERMINATE)
.map((params) => String(params.processId))
.sort();
}
// Starts a turn and lets Codex identify it as `nativeTurnId`.
async function startTurn(prompt: string, nativeTurnId: string): Promise<void> {
const before = requests("turn/start").length;
const started = session.startTurn(prompt);
await vi.waitFor(() => expect(requests("turn/start")).toHaveLength(before + 1), {
timeout: 5_000,
});
const turnStarted = waitForNextEvent(session, "turn_started");
appServer.startsTurn({ threadId: THREAD, turnId: nativeTurnId });
await started;
await turnStarted;
}
async function runsCommand(params: { turnId: string; processId: string }): Promise<void> {
const itemId = `exec-${params.processId}`;
const shown = waitForTimelineToolCall(session, itemId);
appServer.startsTerminalCommand({
threadId: THREAD,
turnId: params.turnId,
itemId,
processId: params.processId,
command: `sleep ${params.processId}`,
});
await shown;
}
async function completesTurn(): Promise<void> {
const completed = waitForNextEvent(session, "turn_completed");
appServer.completeTurn({ threadId: THREAD });
await completed;
}
// Interrupts the running turn and waits for the session to report it canceled, as a
// caller does before it starts the next turn: Codex notifications are handled in order
// but asynchronously, so the interrupt's answer can arrive before the turn has ended.
async function interrupts(): Promise<void> {
const canceled = waitForNextEvent(session, "turn_canceled");
await session.interrupt();
await canceled;
}
return {
appServer,
session,
requests,
terminated,
startTurn,
runsCommand,
completesTurn,
interrupts,
};
}
// Turn A leaves a server running on purpose and completes. Turn B starts two
// commands, and a subagent on its own thread starts a third, then B is interrupted.
async function interruptTurnB(harness: ReturnType<typeof createHarness>): Promise<void> {
await harness.startTurn("start a server", "native-A");
await harness.runsCommand({ turnId: "native-A", processId: "101" });
await harness.completesTurn();
await harness.startTurn("run two commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
// A subagent thread's command shows no row here; the next command's row shows it
// was read, because notifications are read in order.
harness.appServer.startsTerminalCommand({
threadId: "child-thread",
turnId: "native-B",
itemId: "exec-209",
processId: "209",
command: "sleep 209",
});
await harness.runsCommand({ turnId: "native-B", processId: "202" });
await harness.interrupts();
}
describe("an interrupted Codex turn's commands end before the next turn", () => {
test("terminates exactly the processes the interrupted turn started, by id, and never cleans", async () => {
const harness = createHarness();
await interruptTurnB(harness);
await harness.startTurn("correction", "native-C");
expect(harness.terminated()).toEqual(["201", "202"]);
expect(harness.requests(TERMINATE).every((params) => params.threadId === THREAD)).toBe(true);
// `clean` would end every terminal on the thread, an earlier turn's included.
expect(
harness.appServer
.requests()
.filter(
(message) =>
String(message.method).startsWith("thread/backgroundTerminals/") &&
message.method !== "thread/backgroundTerminals/list",
)
.map((message) => message.method),
).toEqual([TERMINATE, TERMINATE]);
harness.appServer.assertNoErrors();
});
test("leaves the next turn's commands running when Codex starts that turn before acknowledging the interrupt", async () => {
const acknowledge = deferred();
const harness = createHarness({
// Codex ends B, starts a turn of its own (a goal continuation), and only then
// answers the interrupt.
interruptResponse: (completeTurn) => {
completeTurn();
return acknowledge.promise.then(() => ({}));
},
});
await harness.startTurn("run two commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.runsCommand({ turnId: "native-B", processId: "202" });
const interrupting = harness.session.interrupt();
await vi.waitFor(() => expect(harness.requests("turn/interrupt")).toHaveLength(1));
const autonomousTurn = waitForNextEvent(harness.session, "turn_started");
harness.appServer.startsTurn({ threadId: THREAD, turnId: "native-C" });
await autonomousTurn;
await harness.runsCommand({ turnId: "native-C", processId: "301" });
acknowledge.resolve();
await interrupting;
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(harness.terminated()).toEqual(["201", "202"]);
});
test("sends the next turn/start only after the terminates are answered", async () => {
const answer = deferred();
const harness = createHarness({
terminate: () => answer.promise.then(() => ({ terminated: true })),
});
await interruptTurnB(harness);
const next = harness.startTurn("correction", "native-C");
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(harness.requests("turn/start")).toHaveLength(2);
answer.resolve();
await next;
expect(harness.requests("turn/start")).toHaveLength(3);
});
test.each(["initial", "late"] as const)(
"tracks both cleanup sweeps when the %s answer arrives first",
async (answeredFirst) => {
const first = deferred();
const second = deferred();
const harness = createHarness({
terminate: (processId) =>
(processId === "201" ? first : second).promise.then(() => ({ terminated: true })),
});
await harness.startTurn("run commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.interrupts();
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201"]));
const next = harness.startTurn("correction", "native-C");
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
// The next turn is already waiting when another process is announced.
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-202",
processId: "202",
command: "sleep 202",
});
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "duplicate-202",
processId: "202",
command: "sleep 202",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
(answeredFirst === "initial" ? first : second).resolve();
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
(answeredFirst === "initial" ? second : first).resolve();
await next;
expect(harness.terminated()).toEqual(["201", "202"]);
// Accepted state survives the completed sweep and a newer foreground turn.
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-203",
processId: "203",
command: "sleep 203",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202", "203"]));
harness.appServer.assertNoErrors();
},
);
test("keeps the first sweep pending when a late sweep answers before the next start", async () => {
const first = deferred();
const harness = createHarness({
terminate: (processId) =>
processId === "201"
? first.promise.then(() => ({ terminated: true }))
: { terminated: true },
});
await harness.startTurn("run commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.interrupts();
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-202",
processId: "202",
command: "sleep 202",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
const next = harness.startTurn("correction", "native-C");
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
first.resolve();
await next;
harness.appServer.assertNoErrors();
});
test("forgets completed commands but retains failed commands at interrupt", async () => {
const harness = createHarness();
await harness.startTurn("run commands", "native-B");
for (const processId of ["201", "202"]) {
await harness.runsCommand({ turnId: "native-B", processId });
harness.appServer.child.stdout.write(
JSON.stringify({
method: "item/completed",
params: {
threadId: THREAD,
turnId: "native-B",
item: {
id: `exec-${processId}`,
type: "commandExecution",
processId,
command: `sleep ${processId}`,
status: processId === "201" ? "completed" : "failed",
exitCode: processId === "201" ? 0 : null,
},
},
}) + "\n",
);
}
// A following command's row is a barrier for both completion handlers.
await harness.runsCommand({ turnId: "native-B", processId: "203" });
await harness.interrupts();
await harness.startTurn("correction", "native-C");
expect(harness.terminated()).toEqual(["202", "203"]);
harness.appServer.assertNoErrors();
});
test("retains accepted interrupts for only the latest sixteen turns", async () => {
const harness = createHarness();
for (let index = 0; index < 17; index++) {
await harness.startTurn("run a command", `native-${index}`);
await harness.interrupts();
}
await harness.startTurn("correction", "native-current");
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-0",
itemId: "evicted-201",
processId: "201",
command: "sleep 201",
});
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-16",
itemId: "retained-202",
processId: "202",
command: "sleep 202",
});
await harness.runsCommand({ turnId: "native-current", processId: "301" });
expect(harness.terminated()).toEqual(["202"]);
harness.appServer.assertNoErrors();
});
test("a failing or unanswered terminate does not block or fail the next turn", async () => {
const harness = createHarness({
// 201 is refused while the next turn waits; 202 is never answered.
terminate: (processId) =>
processId === "201"
? new Promise((resolve) => setTimeout(resolve, 300)).then(() => ({
__jsonRpcError: { code: -32600, message: "invalid background terminal process id" },
}))
: new Promise(() => {}),
});
await interruptTurnB(harness);
const startedAt = Date.now();
await harness.startTurn("correction", "native-C");
expect(Date.now() - startedAt).toBeLessThan(5_000);
expect(harness.requests("turn/start")).toHaveLength(3);
expect(harness.terminated()).toEqual(["201", "202"]);
}, 15_000);
});
test("publishes every page of the current thread's live background terminals", async () => {
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": (params) => {
const cursor = paramsOf(params).cursor;
return cursor
? { data: [{ itemId: "item-2", processId: "2", command: "sleep 2" }], nextCursor: null }
: { data: [{ itemId: "item-1", processId: "1", command: "sleep 1" }], nextCursor: "next" };
},
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", modeId: "auto", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
const events: import("../agent-sdk-types.js").AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
await vi.waitFor(() =>
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [
{ kind: "shell", description: null, command: "sleep 1" },
{ kind: "shell", description: null, command: "sleep 2" },
],
}),
);
});
test("discards a terminal list overtaken by a command event", async () => {
const first = deferred<unknown>();
let lists = 0;
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": () =>
++lists === 1 ? first.promise : { data: [], nextCursor: null },
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", modeId: "auto", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
const events: import("../agent-sdk-types.js").AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
await vi.waitFor(() => expect(lists).toBe(1));
const shown = waitForTimelineToolCall(session, "item-new");
appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "turn",
itemId: "item-new",
processId: "new",
command: "sleep 1",
});
await shown;
first.resolve({
data: [{ itemId: "old", processId: "old", command: "sleep 2" }],
nextCursor: null,
});
await vi.waitFor(() =>
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [],
}),
);
expect(events.filter((event) => event.type === "background_work")).not.toContainEqual(
expect.objectContaining({ items: [expect.objectContaining({ command: "sleep 2" })] }),
);
});
test("does not publish a terminal response from a lost runtime", async () => {
const first = deferred<unknown>();
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": () => first.promise,
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", modeId: "auto", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
const events: import("../agent-sdk-types.js").AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
await appServer.waitForRequest("thread/backgroundTerminals/list");
appServer.disconnect();
first.resolve({
data: [{ itemId: "old", processId: "old", command: "sleep 2" }],
nextCursor: null,
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(events.filter((event) => event.type === "background_work")).not.toContainEqual(
expect.objectContaining({ items: [expect.objectContaining({ command: "sleep 2" })] }),
);
});
test("accepts a slow terminal snapshot after the polling interval", async () => {
const pending = deferred<unknown>();
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": () => pending.promise,
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
const events: import("../agent-sdk-types.js").AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
await appServer.waitForRequest("thread/backgroundTerminals/list");
await new Promise((resolve) => setTimeout(resolve, 5200));
expect(
appServer.requests().filter((message) => message.method === "thread/backgroundTerminals/list"),
).toHaveLength(1);
pending.resolve({ data: [{ processId: "slow", command: "sleep 120" }], nextCursor: null });
await vi.waitFor(() =>
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [{ command: "sleep 120" }],
}),
);
expect(
appServer.requests().filter((message) => message.method === "thread/backgroundTerminals/list"),
).toHaveLength(1);
}, 12000);
test("does not retry an unsupported inventory on later command events", async () => {
let calls = 0;
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": () => {
calls++;
return { __jsonRpcError: { code: -32601, message: "Method not found" } };
},
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
await session.connect();
await vi.waitFor(() => expect(calls).toBe(1));
const shown = waitForTimelineToolCall(session, "unsupported-item");
appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "turn",
itemId: "unsupported-item",
processId: "1",
command: "sleep 1",
});
await shown;
await new Promise((resolve) => setTimeout(resolve, 100));
expect(calls).toBe(1);
});
test("retries transient inventory failures and cancels retries on stop", async () => {
vi.useFakeTimers();
try {
const { CodexBackgroundInventory } = await import("./codex/background-inventory.js");
let calls = 0;
let fail = true;
const inventory = new CodexBackgroundInventory(() => {});
inventory.start({
client: {
request: async () => {
calls++;
if (fail) throw new Error("temporary");
return { data: [{ processId: "live", command: "sleep 120" }], nextCursor: null };
},
},
thread: THREAD,
});
await vi.advanceTimersByTimeAsync(0);
expect(inventory.snapshot()).toBeNull();
inventory.changed(THREAD);
await vi.advanceTimersByTimeAsync(0);
expect(calls).toBe(1);
fail = false;
await vi.advanceTimersByTimeAsync(5000);
expect(inventory.snapshot()?.items).toMatchObject([{ command: "sleep 120" }]);
fail = true;
await vi.advanceTimersByTimeAsync(5000);
expect(inventory.snapshot()?.items).toMatchObject([{ command: "sleep 120" }]);
inventory.stop();
const stoppedCalls = calls;
await vi.advanceTimersByTimeAsync(15000);
expect(calls).toBe(stoppedCalls);
} finally {
vi.useRealTimers();
}
});
test("does not retry a failure from a stopped inventory generation", async () => {
vi.useFakeTimers();
try {
const { CodexBackgroundInventory } = await import("./codex/background-inventory.js");
let reject!: (error: Error) => void;
let calls = 0;
const response = new Promise<unknown>((_, fail) => {
reject = fail;
});
const inventory = new CodexBackgroundInventory(() => {});
inventory.start({
client: {
request: () => {
calls++;
return response;
},
},
thread: THREAD,
});
inventory.stop();
reject(new Error("late failure"));
await vi.advanceTimersByTimeAsync(15000);
expect(calls).toBe(1);
expect(inventory.snapshot()).toBeNull();
} finally {
vi.useRealTimers();
}
});
test("stops only the selected live background terminal and confirms the roster", async () => {
const live = new Set(["1", "2"]);
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": () => ({
data: [...live].map((processId) => ({ processId, command: `sleep ${processId}` })),
nextCursor: null,
}),
"thread/backgroundTerminals/terminate": (params) => ({
terminated: live.delete(String(paramsOf(params).processId)),
}),
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
const events: import("../agent-sdk-types.js").AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
await vi.waitFor(() =>
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [{ command: "sleep 1" }, { command: "sleep 2" }],
}),
);
const item = events
.findLast((event) => event.type === "background_work")
?.items?.find((entry) => entry.command === "sleep 1");
expect(item).toBeDefined();
await session.stopBackgroundWork(item!.id);
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [{ command: "sleep 2" }],
});
await expect(session.stopBackgroundWork(item!.id)).rejects.toThrow();
});
test("reports a rejected background stop and keeps the item available", async () => {
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": () => ({
data: [{ processId: "1", command: "sleep 1" }],
nextCursor: null,
}),
"thread/backgroundTerminals/terminate": () => ({ terminated: false }),
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
const events: import("../agent-sdk-types.js").AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
await vi.waitFor(() =>
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [{ command: "sleep 1" }],
}),
);
const item = events.findLast((event) => event.type === "background_work")?.items?.[0];
expect(item).toBeDefined();
await expect(session.stopBackgroundWork(item!.id)).rejects.toThrow("not terminated");
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [{ command: "sleep 1" }],
});
});
test("does not confirm a stop after the provider connection is lost", async () => {
const response = deferred<unknown>();
const appServer = createFakeCodexAppServer({
"thread/backgroundTerminals/list": () => ({
data: [{ processId: "1", command: "sleep 1" }],
nextCursor: null,
}),
"thread/backgroundTerminals/terminate": () => response.promise,
});
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" },
{ sessionId: THREAD },
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
const events: import("../agent-sdk-types.js").AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.connect();
await vi.waitFor(() =>
expect(events.findLast((event) => event.type === "background_work")).toMatchObject({
items: [{ command: "sleep 1" }],
}),
);
const item = events.findLast((event) => event.type === "background_work")?.items?.[0];
expect(item).toBeDefined();
const pending = session.stopBackgroundWork(item!.id);
await appServer.waitForRequest("thread/backgroundTerminals/terminate");
appServer.disconnect();
response.resolve({ terminated: true });
await expect(pending).rejects.toThrow();
});
@@ -0,0 +1,347 @@
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { createCodexAppServerChildProcess } from "./test-utils/fake-app-server.js";
import { CodexAppServerStartupCoordinator } from "./app-server-startup.js";
import { CodexAppServerExitError } from "./app-server-transport.js";
function sqliteStartupError(): CodexAppServerExitError {
return new CodexAppServerExitError({
exitCode: 1,
exitSignal: null,
stderr: "failed to initialize sqlite state runtime",
});
}
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe("Codex app-server startup coordinator", () => {
test("returns initialize metadata so sessions retain server capabilities", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const client = {
request: async () => ({ userAgent: "codex/0.164.0" }),
notify: () => {},
dispose: async () => {},
};
const started = await coordinator.start({
stateDirKey: "/codex/metadata",
spawn: createCodexAppServerChildProcess,
createClient: () => client,
initializeParams: {},
logger: createTestLogger(),
});
expect(started).toEqual({ client, initialized: { userAgent: "codex/0.164.0" } });
});
test("serializes a burst through initialize but retains all initialized clients", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const firstEntered = deferred();
const releaseFirst = deferred();
let spawned = 0;
let activeInitializations = 0;
let maxActiveInitializations = 0;
let disposed = 0;
const starts = Array.from({ length: 24 }, () =>
coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
spawned++;
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async (method: string) => {
expect(method).toBe("initialize");
activeInitializations++;
maxActiveInitializations = Math.max(maxActiveInitializations, activeInitializations);
if (spawned === 1) {
firstEntered.resolve();
await releaseFirst.promise;
}
activeInitializations--;
return {};
},
notify: vi.fn(),
dispose: async () => {
disposed++;
},
}),
initializeParams: {},
logger: createTestLogger(),
}),
);
await firstEntered.promise;
expect(spawned).toBe(1);
releaseFirst.resolve();
const clients = await Promise.all(starts);
expect(clients).toHaveLength(24);
expect(spawned).toBe(24);
expect(maxActiveInitializations).toBe(1);
expect(disposed).toBe(0);
});
test("allows different Codex homes to initialize concurrently", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const bothEntered = deferred();
const release = deferred();
let entered = 0;
const start = (stateDirKey: string) =>
coordinator.start({
stateDirKey,
spawn: createCodexAppServerChildProcess,
createClient: () => ({
request: async () => {
entered++;
if (entered === 2) bothEntered.resolve();
await release.promise;
return {};
},
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
const first = start("/codex/one");
const second = start("/codex/two");
await bothEntered.promise;
expect(entered).toBe(2);
release.resolve();
await Promise.all([first, second]);
});
test("retries only SQLite startup failures after disposing the failed child", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const events: string[] = [];
let attempt = 0;
const client = await coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
attempt++;
events.push(`spawn ${attempt}`);
return createCodexAppServerChildProcess();
},
createClient: () => {
const number = attempt;
return {
request: async () => {
events.push(`initialize ${number}`);
if (number === 1) throw sqliteStartupError();
return {};
},
notify: () => events.push(`initialized ${number}`),
dispose: async () => {
events.push(`dispose ${number}`);
},
};
},
initializeParams: {},
logger: createTestLogger(),
});
expect(client.client).toEqual(expect.objectContaining({ request: expect.any(Function) }));
expect(events).toEqual([
"spawn 1",
"initialize 1",
"dispose 1",
"spawn 2",
"initialize 2",
"initialized 2",
]);
});
test("does not retry unrelated startup failures", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
let spawned = 0;
let disposed = 0;
await expect(
coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
spawned++;
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => {
throw new Error("database is locked while reading unrelated configuration");
},
notify: vi.fn(),
dispose: async () => {
disposed++;
},
}),
initializeParams: {},
logger: createTestLogger(),
}),
).rejects.toThrow("database is locked while reading unrelated configuration");
expect(spawned).toBe(1);
expect(disposed).toBe(1);
});
test("bounds persistent SQLite failures and leaves the lane usable", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
let spawned = 0;
let disposed = 0;
let locked = true;
const start = () =>
coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
spawned++;
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => {
if (locked) throw sqliteStartupError();
return {};
},
notify: vi.fn(),
dispose: async () => {
disposed++;
},
}),
initializeParams: {},
logger: createTestLogger(),
});
await expect(start()).rejects.toThrow("failed to initialize sqlite state runtime");
expect(spawned).toBe(3);
expect(disposed).toBe(3);
locked = false;
await expect(start()).resolves.toEqual(
expect.objectContaining({
client: expect.objectContaining({ request: expect.any(Function) }),
}),
);
expect(spawned).toBe(4);
expect(disposed).toBe(3);
});
test("a cancelled waiter never spawns or overtakes the active startup", async () => {
const coordinator = new CodexAppServerStartupCoordinator();
const firstEntered = deferred();
const releaseFirst = deferred();
const events: string[] = [];
const first = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn first");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => {
firstEntered.resolve();
await releaseFirst.promise;
return {};
},
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
await firstEntered.promise;
const controller = new AbortController();
const queued = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn cancelled");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => ({}),
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
signal: controller.signal,
});
controller.abort(new Error("cancelled"));
await expect(queued).rejects.toThrow("cancelled");
const next = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn next");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => ({}),
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
expect(events).toEqual(["spawn first"]);
releaseFirst.resolve();
await Promise.all([first, next]);
expect(events).toEqual(["spawn first", "spawn next"]);
});
test("a timed-out initialize disposes its child and releases the next waiter", async () => {
vi.useFakeTimers();
try {
const coordinator = new CodexAppServerStartupCoordinator({
attemptTimeoutMs: 10,
queueTimeoutMs: 100,
});
const firstEntered = deferred();
const events: string[] = [];
const first = coordinator.start({
stateDirKey: "/codex/shared",
spawn: createCodexAppServerChildProcess,
createClient: () => ({
request: () => {
firstEntered.resolve();
return new Promise<never>(() => {});
},
notify: vi.fn(),
dispose: async () => {
events.push("dispose first");
},
}),
initializeParams: {},
logger: createTestLogger(),
});
await firstEntered.promise;
const next = coordinator.start({
stateDirKey: "/codex/shared",
spawn: () => {
events.push("spawn next");
return createCodexAppServerChildProcess();
},
createClient: () => ({
request: async () => ({}),
notify: vi.fn(),
dispose: vi.fn(async () => {}),
}),
initializeParams: {},
logger: createTestLogger(),
});
const firstResult = expect(first).rejects.toThrow("Timed out initializing Codex app-server");
await vi.advanceTimersByTimeAsync(10);
await firstResult;
await expect(next).resolves.toEqual(
expect.objectContaining({
client: expect.objectContaining({ request: expect.any(Function) }),
}),
);
expect(events).toEqual(["dispose first", "spawn next"]);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,189 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import { CodexAppServerExitError } from "./app-server-transport.js";
const DEFAULT_QUEUE_TIMEOUT_MS = 60_000;
const DEFAULT_ATTEMPT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_ATTEMPTS = 3;
interface InitializableAppServerClient {
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
notify(method: string, params?: unknown): void;
dispose(): Promise<void>;
}
interface StartupOptions<TClient extends InitializableAppServerClient> {
stateDirKey: string;
spawn: () => ChildProcessWithoutNullStreams | Promise<ChildProcessWithoutNullStreams>;
createClient: (child: ChildProcessWithoutNullStreams) => TClient;
initializeParams: unknown;
logger: Pick<Logger, "warn" | "debug">;
signal?: AbortSignal;
}
interface StartedAppServer<TClient> {
client: TClient;
initialized: unknown;
}
interface CoordinatorOptions {
queueTimeoutMs?: number;
attemptTimeoutMs?: number;
maxAttempts?: number;
}
async function waitForSignal<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
signal.throwIfAborted();
let onAbort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([operation, aborted]);
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
}
async function disposeUnownedChild(child: ChildProcessWithoutNullStreams): Promise<void> {
const result = await terminateWithTreeKill(child, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 1_000,
});
if (result === "kill-timeout") {
throw new Error("Codex app-server did not report exit after SIGKILL");
}
}
export class CodexAppServerStartupCoordinator {
private readonly tails = new Map<string, Promise<void>>();
private readonly queueTimeoutMs: number;
private readonly attemptTimeoutMs: number;
private readonly maxAttempts: number;
constructor(options: CoordinatorOptions = {}) {
this.queueTimeoutMs = options.queueTimeoutMs ?? DEFAULT_QUEUE_TIMEOUT_MS;
this.attemptTimeoutMs = options.attemptTimeoutMs ?? DEFAULT_ATTEMPT_TIMEOUT_MS;
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
}
async start<TClient extends InitializableAppServerClient>(
options: StartupOptions<TClient>,
): Promise<StartedAppServer<TClient>> {
options.signal?.throwIfAborted();
const previous = this.tails.get(options.stateDirKey) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
// An aborted waiter must not let later waiters pass the current owner.
const tail = previous.then(() => gate);
this.tails.set(options.stateDirKey, tail);
void tail.then(
() => this.tails.get(options.stateDirKey) === tail && this.tails.delete(options.stateDirKey),
);
const queueAbort = new AbortController();
const onAbort = () => queueAbort.abort(options.signal?.reason);
options.signal?.addEventListener("abort", onAbort, { once: true });
const queueTimer = setTimeout(
() => queueAbort.abort(new Error("Timed out waiting to start Codex app-server")),
this.queueTimeoutMs,
);
try {
await waitForSignal(previous, queueAbort.signal);
clearTimeout(queueTimer);
options.signal?.throwIfAborted();
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
try {
return await this.startAttempt(options);
} catch (error) {
const isRetryableStartupFailure =
error instanceof CodexAppServerExitError &&
error.reason === "sqlite_state_initialization";
if (
options.signal?.aborted ||
attempt === this.maxAttempts ||
!isRetryableStartupFailure
) {
throw error;
}
options.logger.warn(
{ err: error, attempt, maxAttempts: this.maxAttempts },
"Codex app-server SQLite startup failed; retrying with a fresh process",
);
}
}
throw new Error("Codex app-server startup attempts exhausted");
} finally {
clearTimeout(queueTimer);
options.signal?.removeEventListener("abort", onAbort);
release();
}
}
private async startAttempt<TClient extends InitializableAppServerClient>(
options: StartupOptions<TClient>,
): Promise<StartedAppServer<TClient>> {
const attemptAbort = new AbortController();
const onAbort = () => attemptAbort.abort(options.signal?.reason);
options.signal?.addEventListener("abort", onAbort, { once: true });
const attemptTimer = setTimeout(
() => attemptAbort.abort(new Error("Timed out initializing Codex app-server")),
this.attemptTimeoutMs,
);
let child: ChildProcessWithoutNullStreams | undefined;
let client: TClient | undefined;
try {
attemptAbort.signal.throwIfAborted();
// Production hands in a synchronous spawn closure. Resolve the binary
// before entering the lane, then create the child while holding it.
const spawnPromise = Promise.resolve(options.spawn());
try {
child = await waitForSignal(spawnPromise, attemptAbort.signal);
} catch (error) {
if (attemptAbort.signal.aborted) {
// A future asynchronous spawner may finish after cancellation.
void spawnPromise
.then((lateChild) => disposeUnownedChild(lateChild))
.catch((cleanupError) =>
options.logger.warn({ err: cleanupError }, "Failed to clean up late Codex spawn"),
);
}
throw error;
}
attemptAbort.signal.throwIfAborted();
client = options.createClient(child);
const initialized = await waitForSignal(
client.request("initialize", options.initializeParams, this.attemptTimeoutMs),
attemptAbort.signal,
);
attemptAbort.signal.throwIfAborted();
client.notify("initialized", {});
return { client, initialized };
} catch (error) {
try {
if (client) {
await client.dispose();
} else if (child) {
await disposeUnownedChild(child);
}
} catch (cleanupError) {
options.logger.warn(
{ err: cleanupError, startupError: error },
"Failed to clean up Codex app-server after startup failure",
);
throw new Error("Codex app-server startup cleanup failed", { cause: cleanupError });
}
throw error;
} finally {
clearTimeout(attemptTimer);
options.signal?.removeEventListener("abort", onAbort);
}
}
}
export const codexAppServerStartup = new CodexAppServerStartupCoordinator();
@@ -5,9 +5,57 @@ import {
createCodexAppServerChildProcess,
createFakeCodexAppServer,
} from "./test-utils/fake-app-server.js";
import { CodexAppServerClient } from "./app-server-transport.js";
import { CodexAppServerClient, CodexAppServerExitError } from "./app-server-transport.js";
describe("Codex app-server transport", () => {
test("classifies startup diagnostics delivered after the child exits", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const request = client.request("initialize", {});
const result = expect(request).rejects.toMatchObject({
name: "CodexAppServerExitError",
reason: "sqlite_state_initialization",
stderr: "failed to initialize sqlite state runtime",
});
child.exitCode = 1;
child.emit("exit", 1, null);
child.stderr.write("failed to initialize sqlite state runtime");
child.emit("close", 1, null);
try {
await result;
} finally {
child.stdout.end();
child.stderr.end();
child.stdin.end();
}
});
test.each([
{
stderr: "Error: failed to initialize sqlite state runtime under /isolated/.codex",
reason: "sqlite_state_initialization",
},
{ stderr: "database is locked while loading configuration", reason: "other" },
] as const)("classifies a failed startup from child stderr as $reason", async (entry) => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const request = client.request("initialize", {});
child.stderr.write(entry.stderr);
child.exitCode = 1;
child.emit("exit", 1, null);
await expect(request).rejects.toMatchObject({
name: "CodexAppServerExitError",
reason: entry.reason,
exitCode: 1,
exitSignal: null,
stderr: entry.stderr,
} satisfies Partial<CodexAppServerExitError>);
child.stdout.end();
child.stderr.end();
child.stdin.end();
});
test("ignores non-JSON stdout lines without dropping pending requests", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
@@ -64,6 +64,39 @@ export class CodexAppServerRpcError extends Error {
}
}
interface CodexAppServerExitDetails {
exitCode: number | null;
exitSignal: NodeJS.Signals | null;
stderr: string;
}
export class CodexAppServerExitError extends Error {
readonly reason: "sqlite_state_initialization" | "other";
readonly exitCode: number | null;
readonly exitSignal: NodeJS.Signals | null;
readonly stderr: string;
constructor(details: CodexAppServerExitDetails) {
const message =
details.exitCode === 0 && !details.exitSignal
? "Codex app-server exited"
: `Codex app-server exited with code ${details.exitCode ?? "null"} and signal ${details.exitSignal ?? "null"}`;
super(`${message}\n${details.stderr}`.trim());
this.name = "CodexAppServerExitError";
this.exitCode = details.exitCode;
this.exitSignal = details.exitSignal;
this.stderr = details.stderr;
// Codex reports this pre-initialize failure only on stderr, not as a
// JSON-RPC error. Classify it here so callers branch on a typed reason.
this.reason =
details.exitCode === 1 &&
!details.exitSignal &&
details.stderr.includes("failed to initialize sqlite state runtime")
? "sqlite_state_initialization"
: "other";
}
}
type RequestHandler = (params: unknown, requestId: number) => unknown;
type NotificationHandler = (method: string, params: unknown) => void;
type UnexpectedTerminationHandler = (error: Error) => void;
@@ -234,14 +267,24 @@ export class CodexAppServerClient {
this.handleUnexpectedTermination(err);
});
const reportExit = (code: number | null, signal: NodeJS.Signals | null) => {
this.handleUnexpectedTermination(
new CodexAppServerExitError({
exitCode: code,
exitSignal: signal,
stderr: this.stderrBuffer,
}),
);
};
child.on("exit", (code, signal) => {
const message =
code === 0 && !signal
? "Codex app-server exited"
: `Codex app-server exited with code ${code ?? "null"} and signal ${signal ?? "null"}`;
const error = new Error(`${message}\n${this.stderrBuffer}`.trim());
this.handleUnexpectedTermination(error);
// Startup retries need complete diagnostics. Node drains the child's
// stdio before close, but exit can precede the final stderr data.
const initializing = Array.from(this.pending.values()).some(
(request) => request.method === "initialize",
);
if (!initializing) reportExit(code, signal);
});
child.on("close", reportExit);
}
setUnexpectedTerminationHandler(handler: UnexpectedTerminationHandler): void {
@@ -0,0 +1,187 @@
import { z } from "zod";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import { CodexAppServerRpcError } from "./app-server-transport.js";
type InventoryEvent = Extract<AgentStreamEvent, { type: "background_work" }>;
interface Client {
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
}
export class CodexBackgroundInventory {
private generation = 0;
private client: Client | null = null;
private thread: string | null = null;
private readonly threads = new Set<string>();
private origins = new Map<string, { threadId: string; processId: string }>();
private pending = false;
private dirty = false;
private unsupported = false;
private retrying = false;
private timer: ReturnType<typeof setTimeout> | null = null;
private current: InventoryEvent | null = null;
private readonly listeners = new Set<() => void>();
async stopItem(id: string): Promise<void> {
const client = this.client;
const origin = this.origins.get(id);
const generation = this.generation;
if (!client || !origin || !this.current?.items?.some((item) => item.id === id))
throw new Error("Background terminal is no longer available");
const result = z
.object({ terminated: z.boolean() })
.parse(
await client.request(
"thread/backgroundTerminals/terminate",
{ threadId: origin.threadId, processId: origin.processId },
10000,
),
);
if (generation !== this.generation) throw new Error("Background terminal connection changed");
if (!result.terminated) throw new Error("Background terminal was not terminated");
await this.waitUntilAbsent(id, generation);
}
private waitUntilAbsent(id: string, generation: number): Promise<void> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => finish(new Error("Background terminal confirmation timed out")),
15000,
);
const finish = (error?: Error) => {
clearTimeout(timer);
this.listeners.delete(check);
if (error) reject(error);
else resolve();
};
const check = () => {
if (generation !== this.generation)
finish(new Error("Background terminal connection changed"));
else if (this.current?.items && !this.current.items.some((item) => item.id === id))
finish();
};
this.listeners.add(check);
this.changed(this.thread);
});
}
private notify() {
for (const listener of this.listeners) listener();
}
constructor(private readonly emit: (event: InventoryEvent) => void) {}
snapshot() {
return this.current;
}
start({ client, thread }: { client: Client; thread: string | null }) {
this.stop();
this.client = client;
this.thread = thread;
this.unsupported = false;
this.changed(thread);
}
stop() {
this.generation++;
this.notify();
this.client = null;
this.thread = null;
this.threads.clear();
this.origins.clear();
this.pending = false;
this.dirty = false;
this.retrying = false;
if (this.timer) clearTimeout(this.timer);
this.timer = null;
if (this.current) this.emit({ type: "background_work", provider: "codex", items: null });
this.current = null;
}
changed(thread: string | null) {
this.thread = thread;
if (thread) this.threads.add(thread);
if (!this.client || !thread || this.unsupported) return;
this.dirty = true;
if (!this.retrying) this.refresh();
}
private refresh() {
if (this.pending || !this.client || !this.thread || this.unsupported) return;
if (this.timer) clearTimeout(this.timer);
this.timer = null;
this.pending = true;
this.dirty = false;
const generation = this.generation;
const client = this.client;
const threads = [...this.threads];
void (async () => {
let failed = false;
try {
const { items, origins, activeThreads } = await readInventories(client, threads);
if (generation !== this.generation || this.dirty) return;
this.origins = origins;
this.threads.clear();
for (const threadId of activeThreads) this.threads.add(threadId);
if (this.thread) this.threads.add(this.thread);
this.current = { type: "background_work", provider: "codex", items };
this.emit(this.current);
this.notify();
} catch (error) {
failed = true;
if (
generation === this.generation &&
error instanceof CodexAppServerRpcError &&
error.code === -32601
)
this.unsupported = true;
} finally {
if (generation === this.generation) {
this.pending = false;
if (this.dirty && !failed && !this.unsupported) this.refresh();
else if (!this.unsupported && (failed || this.current?.items?.length)) {
this.retrying = failed;
this.timer = setTimeout(() => {
this.timer = null;
this.retrying = false;
this.refresh();
}, 5000);
this.timer.unref();
}
}
}
})();
}
}
async function readInventories(client: Client, threads: string[]) {
const items: NonNullable<InventoryEvent["items"]> = [];
const origins = new Map<string, { threadId: string; processId: string }>();
const activeThreads = new Set<string>();
for (const threadId of threads) {
let cursor: string | null = null;
do {
const page = z
.object({
data: z.array(z.object({ processId: z.string(), command: z.string() })),
nextCursor: z.string().nullable(),
})
.parse(
await client.request(
"thread/backgroundTerminals/list",
{ threadId, ...(cursor ? { cursor } : {}) },
10_000,
),
);
for (const row of page.data) {
const id = JSON.stringify([threadId, row.processId]);
origins.set(id, { threadId, processId: row.processId });
activeThreads.add(threadId);
items.push({
id,
kind: "shell",
canStop: true,
description: null,
...(row.command ? { command: row.command } : {}),
});
}
cursor = page.nextCursor;
} while (cursor);
}
return { items, origins, activeThreads };
}
@@ -43,6 +43,13 @@ interface FakeTerminalInput {
processId: string;
text: string;
}
interface FakeFileChange {
threadId: string;
turnId: string;
callId: string;
path: string;
diff: string;
}
interface FakeLegacyPatch {
threadId: string;
callId: string;
@@ -88,6 +95,7 @@ export interface FakeCodexAppServer {
completesSubAgentActivity(params: FakeSubAgentActivity): void;
completesCompaction(params: { threadId: string; itemId: string }): void;
runsLegacyCommand(params: FakeLegacyCommand): void;
startsFileChange(params: FakeFileChange): void;
appliesLegacyPatch(params: FakeLegacyPatch): void;
completesCommand(params: FakeLegacyCommand): void;
completesSilentCommand(params: FakeSilentCommand): void;
@@ -138,6 +146,10 @@ export function createCodexAppServerChildProcess(): CodexAppServerChildProcess {
exitCode: null,
signalCode: null,
}) as CodexAppServerChildProcess;
// A real ChildProcess closes its stdio after exit, then emits close.
child.once("exit", (code, signal) => {
queueMicrotask(() => child.emit("close", code, signal));
});
child.kill = ((signal?: NodeJS.Signals | number) => {
queueMicrotask(() => child.emit("exit", null, signal ?? null));
return true;
@@ -167,6 +179,7 @@ export function createFakeCodexAppServer(
"skills/list": () => ({ data: [] }),
"thread/start": () => ({ thread: { id: "thread-1" } }),
"thread/loaded/list": () => ({ data: [] }),
"thread/backgroundTerminals/list": () => ({ data: [], nextCursor: null }),
"thread/resume": () => ({}),
"turn/start": () => ({}),
"thread/fork": (params) => ({
@@ -467,6 +480,20 @@ export function createFakeCodexAppServer(
success: true,
});
},
startsFileChange(params) {
writeNotification("item/started", {
threadId: params.threadId,
turnId: params.turnId,
item: {
type: "fileChange",
id: params.callId,
status: "inProgress",
changes: [
{ path: params.path, kind: { type: "update", move_path: null }, diff: params.diff },
],
},
});
},
appliesLegacyPatch(params) {
const changes = [
{
@@ -1,4 +1,3 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
@@ -27,15 +26,24 @@ async function openCodexThread(
const appServer = createFakeCodexAppServer(
fakeCodexThreadHistory({ "saved-thread": thread }, options),
);
const provider = new CodexAppServerAgentClient(createTestLogger());
const provider = new CodexAppServerAgentClient(
createTestLogger(),
{
command: { mode: "replace", argv: ["codex-must-not-run-in-this-test"] },
},
{
appServerProcess: {
resolvePrefix: async () => ({ command: "codex", args: [] }),
spawn: () => appServer.child,
},
},
);
const internals = provider as unknown as {
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
};
internals.goalsEnabledPromise = Promise.resolve(false);
internals.autoReviewEnabledPromise = Promise.resolve(false);
internals.spawnAppServer = async () => appServer.child;
const session = await provider.resumeSession({ sessionId: "saved-thread" });
return { appServer, session };
}
@@ -528,6 +528,165 @@ describe("codex tool-call mapper", () => {
});
});
it.each([
["inProgress", "running"],
["completed", "completed"],
])("keeps both fileChange diffs when the patch is %s", (status, expectedStatus) => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-two-files",
status,
changes: [
{
path: "/tmp/repo/alpha.txt",
kind: { type: "update", move_path: null },
diff: "@@ -1 +1 @@\n-color = blue\n+color = green\n",
},
{
path: "/tmp/repo/beta.txt",
kind: { type: "update", move_path: null },
diff: "@@ -1 +1 @@\n-size = small\n+size = large\n",
},
],
},
{ cwd: "/tmp/repo" },
);
expect(item).toEqual({
type: "tool_call",
callId: "codex-two-files",
name: "apply_patch",
status: expectedStatus,
error: null,
detail: {
type: "edit",
filePath: "alpha.txt",
unifiedDiff: [
"diff --git a/alpha.txt b/alpha.txt",
"--- a/alpha.txt",
"+++ b/alpha.txt",
"@@ -1 +1 @@",
"-color = blue",
"+color = green",
"",
"diff --git a/beta.txt b/beta.txt",
"--- a/beta.txt",
"+++ b/beta.txt",
"@@ -1 +1 @@",
"-size = small",
"+size = large",
].join("\n"),
},
});
});
it("uses the move destination in generated multi-file diff headers", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-moved-file",
status: "completed",
changes: [
{
path: "/tmp/repo/old.txt",
kind: { type: "update", move_path: "/tmp/repo/new.txt" },
diff: "@@ -1 +1 @@\n-old\n+new\n",
},
{
path: "/tmp/repo/other.txt",
kind: { type: "update", move_path: null },
diff: "@@ -1 +1 @@\n-before\n+after\n",
},
],
},
{ cwd: "/tmp/repo" },
);
expect(item).toEqual({
type: "tool_call",
callId: "codex-moved-file",
name: "apply_patch",
status: "completed",
error: null,
detail: {
type: "edit",
filePath: "old.txt",
unifiedDiff: [
"diff --git a/old.txt b/new.txt",
"--- a/old.txt",
"+++ b/new.txt",
"@@ -1 +1 @@",
"-old",
"+new",
"",
"diff --git a/other.txt b/other.txt",
"--- a/other.txt",
"+++ b/other.txt",
"@@ -1 +1 @@",
"-before",
"+after",
].join("\n"),
},
});
});
it("keeps update, add, and delete changes in one fileChange diff", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-mixed-files",
status: "completed",
changes: [
{
path: "/tmp/repo/alpha.txt",
kind: { type: "update", move_path: null },
diff: "--- a/alpha.txt\n+++ b/alpha.txt\n@@ -1 +1 @@\n-blue\n+green\n",
},
{ path: "/tmp/repo/new.txt", kind: { type: "add" }, diff: "new\n\nvalue\n" },
{ path: "/tmp/repo/old.txt", kind: { type: "delete" }, diff: "old\n\nvalue\n" },
],
},
{ cwd: "/tmp/repo" },
);
expect(item).toEqual({
type: "tool_call",
callId: "codex-mixed-files",
name: "apply_patch",
status: "completed",
error: null,
detail: {
type: "edit",
filePath: "alpha.txt",
unifiedDiff: [
"diff --git a/alpha.txt b/alpha.txt",
"--- a/alpha.txt",
"+++ b/alpha.txt",
"@@ -1 +1 @@",
"-blue",
"+green",
"",
"diff --git a/new.txt b/new.txt",
"--- /dev/null",
"+++ b/new.txt",
"@@ -0,0 +1,3 @@",
"+new",
"+",
"+value",
"",
"diff --git a/old.txt b/old.txt",
"--- a/old.txt",
"+++ /dev/null",
"@@ -1,3 +0,0 @@",
"-old",
"-",
"-value",
].join("\n"),
},
});
});
it("maps fileChange object-style change payloads keyed by path", () => {
const item = expectMapped(
mapCodexToolCallFromThreadItem(
@@ -827,6 +986,44 @@ describe("codex tool-call mapper", () => {
});
});
it("maps multi-file path-only updates to unknown detail", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "codex-multi-file-path-only",
status: "completed",
changes: [
{ path: "/tmp/repo/first.ts", kind: "modify" },
{ path: "/tmp/repo/second.ts", kind: { type: "update", move_path: null } },
],
},
{ cwd: "/tmp/repo" },
);
expect(item).toEqual({
type: "tool_call",
callId: "codex-multi-file-path-only",
name: "apply_patch",
status: "completed",
error: null,
detail: {
type: "unknown",
input: {
files: [
{ path: "first.ts", kind: "modify" },
{ path: "second.ts", kind: "update" },
],
},
output: {
files: [
{ path: "first.ts", kind: "modify" },
{ path: "second.ts", kind: "update" },
],
},
},
});
});
it("maps path-only apply_patch tool-call payloads to unknown detail instead of empty edit detail", () => {
const item = expectMapped(
mapCodexToolCallEnvelope({
@@ -1102,3 +1299,186 @@ describe("codex tool-call mapper", () => {
`);
});
});
describe("multi-file diff budget", () => {
it("retains a labeled diff for later files when the first diff exceeds the detail limit", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "large-multi-file",
status: "completed",
changes: [
{
path: "/repo/alpha.txt",
kind: "modify",
diff: "@@ -1 +1 @@\n-old\n+" + "a".repeat(14000),
},
{ path: "/repo/beta.txt", kind: "modify", diff: "@@ -1 +1 @@\n-small\n+large\n" },
],
},
{ cwd: "/repo" },
);
expect(item?.detail.type).toBe("edit");
if (item?.detail.type !== "edit") throw new Error("Expected edit detail");
expect(item.detail.unifiedDiff).toContain("diff --git a/alpha.txt b/alpha.txt");
expect(item.detail.unifiedDiff).toContain("diff --git a/beta.txt b/beta.txt");
expect(item.detail.unifiedDiff).toContain("+large");
expect(item.detail.unifiedDiff!.length).toBeLessThanOrEqual(12000);
});
});
describe("multi-file preview metadata", () => {
it("preserves a path-keyed move destination", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "moved-keyed",
status: "completed",
changes: {
"/repo/old.txt": {
type: "update",
move_path: "/repo/new.txt",
unified_diff: "@@ -1 +1 @@\n-old\n+new\n",
},
"/repo/other.txt": { type: "update", unified_diff: "@@ -1 +1 @@\n-before\n+after\n" },
},
},
{ cwd: "/repo" },
);
expect(item?.detail.type).toBe("edit");
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.detail.unifiedDiff).toContain("diff --git a/old.txt b/new.txt");
expect(item.detail.unifiedDiff).toContain("+++ b/new.txt");
});
it("names the file whose diff is shown when the first entry has no content", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "partial-content",
status: "completed",
changes: [
{ path: "/repo/first.txt", kind: "modify" },
{ path: "/repo/second.txt", kind: "modify", diff: "@@ -1 +1 @@\n-old\n+new\n" },
],
},
{ cwd: "/repo" },
);
expect(item?.detail).toMatchObject({ type: "edit", filePath: "second.txt" });
});
it("keeps whole labels and marks omitted files when many long paths exceed the preview", () => {
const paths = Array.from(
{ length: 50 },
(_, index) => `src/${String(index).padStart(2, "0")}-${"x".repeat(108)}.txt`,
);
const item = mapCodexToolCallFromThreadItem({
type: "fileChange",
id: "many-files",
status: "completed",
changes: paths.map((path) => ({ path, kind: "modify", diff: "@@ -1 +1 @@\n-old\n+new\n" })),
});
if (item?.detail.type !== "edit") throw new Error("Expected edit");
const diff = item.detail.unifiedDiff!;
const headers = diff.split("\n").filter((line) => line.startsWith("diff --git "));
expect(headers.length).toBeGreaterThan(0);
const expectedHeaders = paths.map((path) => `diff --git a/${path} b/${path}`);
expect(expectedHeaders).toEqual(expect.arrayContaining(headers));
expect(diff).toContain("-old\n+new");
expect(diff).toContain(`${paths.length - headers.length} more files omitted`);
expect(diff.length).toBeLessThanOrEqual(12000);
});
});
describe("large multi-file additions", () => {
it("bounds a generated addition without throwing before later files are mapped", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "large-addition",
status: "completed",
changes: [
{ path: "/repo/generated.txt", kind: { type: "add" }, diff: "x\n".repeat(150000) },
{ path: "/repo/second.txt", kind: { type: "add" }, diff: "second\n" },
],
},
{ cwd: "/repo" },
);
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.status).toBe("completed");
expect(item.detail.unifiedDiff).toContain("diff --git a/generated.txt b/generated.txt");
expect(item.detail.unifiedDiff).toContain("diff --git a/second.txt b/second.txt");
expect(item.detail.unifiedDiff).toContain("+second");
expect(item.detail.unifiedDiff!.length).toBeLessThanOrEqual(12000);
});
});
describe("move-only entries in multi-file previews", () => {
it("shows a move without manufacturing added file contents from provider metadata", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "move-only",
status: "completed",
changes: [
{
path: "/repo/old.txt",
kind: { type: "update", move_path: "/repo/new.txt" },
diff: "\n\nMoved to: /repo/new.txt",
},
{ path: "/repo/second.txt", kind: "modify", diff: "@@ -1 +1 @@\n-small\n+large\n" },
],
},
{ cwd: "/repo" },
);
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.detail.unifiedDiff).toContain("rename from old.txt\nrename to new.txt");
expect(item.detail.unifiedDiff).toContain("diff --git a/second.txt b/second.txt");
expect(item.detail.unifiedDiff).not.toContain("+Moved to:");
expect(item.detail.unifiedDiff).not.toContain("@@ -0,0");
});
});
describe("single-file move compatibility", () => {
it("retains the destination outside the multi-file preview path", () => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "single-move",
status: "completed",
changes: [
{
path: "/repo/old.txt",
kind: { type: "update", move_path: "/repo/new.txt" },
diff: "\n\nMoved to: /repo/new.txt",
},
],
},
{ cwd: "/repo" },
);
expect(JSON.stringify(item)).toContain("new.txt");
});
});
describe("empty additions in multi-file previews", () => {
it.each(["inProgress", "completed"])(
"keeps an empty created file when the patch is %s",
(status) => {
const item = mapCodexToolCallFromThreadItem(
{
type: "fileChange",
id: "empty-addition",
status,
changes: [
{ path: "/repo/empty.txt", kind: { type: "add" }, diff: "" },
{ path: "/repo/second.txt", kind: "modify", diff: "@@ -1 +1 @@\n-old\n+new\n" },
],
},
{ cwd: "/repo" },
);
if (item?.detail.type !== "edit") throw new Error("Expected edit");
expect(item.detail.unifiedDiff).toContain(
"diff --git a/empty.txt b/empty.txt\n--- /dev/null\n+++ b/empty.txt",
);
expect(item.detail.unifiedDiff).toContain("diff --git a/second.txt b/second.txt");
},
);
});
@@ -4,6 +4,7 @@ import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
import {
extractCodexShellOutput,
normalizeToolCallStatus,
TOOL_CALL_DIFF_MAX_CHARS,
truncateDiffText,
} from "../tool-call-mapper-utils.js";
import { deriveCodexToolDetail, normalizeCodexFilePath } from "./tool-call-detail-parser.js";
@@ -400,7 +401,7 @@ function contentToDeletionDiff(filePath: string, content: string): string {
output.push(`diff --git a/${filePath} b/${filePath}`);
output.push(`--- a/${filePath}`);
output.push(`+++ /dev/null`);
const nonEmpty = lines.filter((l) => l.length > 0 || lines.indexOf(l) < lines.length - 1);
const nonEmpty = lines.slice(0, lines.at(-1) === "" ? -1 : undefined);
if (nonEmpty.length > 0) {
output.push(`@@ -1,${nonEmpty.length} +0,0 @@`);
for (const line of nonEmpty) {
@@ -713,6 +714,7 @@ interface CodexFileChangeEntry {
path: string;
kind?: string;
diff?: string;
movePath?: string;
}
function parseFileChangePath(
@@ -740,6 +742,9 @@ function parseFileChangePath(
}
function parseFileChangeKind(entry: Record<string, unknown>): string | undefined {
if (isRecord(entry.kind) && typeof entry.kind.type === "string") {
return entry.kind.type;
}
return (
(typeof entry.kind === "string" && entry.kind) ||
(typeof entry.type === "string" && entry.type) ||
@@ -767,10 +772,14 @@ function toFileChangeEntry(
if (!path) {
return null;
}
const kind = isRecord(entry.kind) ? entry.kind : undefined;
const movePath = entry.move_path ?? kind?.move_path;
return {
path,
kind: parseFileChangeKind(entry),
diff: parseFileChangeDiff(entry),
movePath:
typeof movePath === "string" ? normalizeCodexFilePath(movePath, options?.cwd) : undefined,
};
}
@@ -839,6 +848,105 @@ function resolveFileChangeTextFields(file: CodexFileChangeEntry | undefined): {
return asEditTextFields(file.diff);
}
function fileChangeToUnifiedDiff(file: CodexFileChangeEntry): string {
// Codex formats a content-preserving move as a notice, not added file text.
const isMoveOnlyNotice = file.movePath && file.diff?.startsWith("\n\nMoved to: ");
const { unifiedDiff, newString } = isMoveOnlyNotice ? {} : resolveFileChangeTextFields(file);
if (!unifiedDiff && !newString) {
if (file.kind === "add") {
return [
`diff --git a/${file.path} b/${file.path}`,
"--- /dev/null",
`+++ b/${file.path}`,
].join("\n");
}
return file.movePath
? [
`diff --git a/${file.path} b/${file.movePath}`,
`rename from ${file.path}`,
`rename to ${file.movePath}`,
].join("\n")
: "";
}
if (unifiedDiff?.startsWith("diff --git")) {
return unifiedDiff.replace(/\r?\n$/, "");
}
const destination = file.movePath ?? file.path;
const header = `diff --git a/${file.path} b/${destination}`;
if (unifiedDiff?.startsWith("--- ")) {
return `${header}\n${unifiedDiff.replace(/\r?\n$/, "")}`;
}
const left = file.kind === "add" ? "/dev/null" : `a/${file.path}`;
const right = file.kind === "delete" ? "/dev/null" : `b/${destination}`;
const lines = [header, `--- ${left}`, `+++ ${right}`];
if (unifiedDiff) {
lines.push(unifiedDiff.replace(/\r?\n$/, ""));
} else if (newString) {
const contentLines = newString.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
lines.push(`@@ -0,0 +1,${contentLines.length} @@`);
for (const line of contentLines) lines.push(`+${line}`);
}
return lines.join("\n");
}
function splitFileDiffPreview(diff: string): { header: string; body: string } {
const hunk = diff.indexOf("\n@@");
const bodyStart = hunk < 0 ? -1 : diff.indexOf("\n", hunk + 1);
return bodyStart < 0
? { header: diff, body: "" }
: { header: diff.slice(0, bodyStart + 1), body: diff.slice(bodyStart + 1) };
}
export function fileChangesToPreview(files: CodexFileChangeEntry[]): {
filePath: string | undefined;
unifiedDiff: string;
} {
const diffs = files
.map((file) => ({ filePath: file.path, diff: fileChangeToUnifiedDiff(file) }))
.filter((file) => file.diff.length > 0);
const filePath = diffs[0]?.filePath ?? files[0]?.path;
const combined = diffs.map((file) => file.diff).join("\n\n");
if (combined.length <= TOOL_CALL_DIFF_MAX_CHARS) return { filePath, unifiedDiff: combined };
const marker = "\n...[truncated]";
const omittedNotice = (count: number) =>
count > 0 ? `\n\n...[${count} more files omitted]` : "";
const previews = diffs.map((file) => splitFileDiffPreview(file.diff));
const visible: typeof previews = [];
let minimumSize = 0;
for (const preview of previews) {
// Keep whole labels and a useful body excerpt, or explicitly omit the file.
const required =
preview.header.length + Math.max(marker.length, Math.min(80, preview.body.length));
const nextSize = minimumSize + required + (visible.length > 0 ? 2 : 0);
if (
nextSize + omittedNotice(previews.length - visible.length - 1).length >
TOOL_CALL_DIFF_MAX_CHARS
)
break;
visible.push(preview);
minimumSize = nextSize;
}
const omitted = omittedNotice(previews.length - visible.length);
const headersSize = visible.reduce((size, preview) => size + preview.header.length, 0);
const bodyBudget = Math.floor(
(TOOL_CALL_DIFF_MAX_CHARS -
headersSize -
Math.max(0, visible.length - 1) * 2 -
omitted.length) /
Math.max(1, visible.length),
);
const unifiedDiff =
visible
.map(
({ header, body }) =>
header +
(body.length <= bodyBudget ? body : body.slice(0, bodyBudget - marker.length) + marker),
)
.join("\n\n") + omitted;
return { filePath, unifiedDiff };
}
function mapFileChangeItem(
item: z.infer<typeof CodexFileChangeItemSchema>,
options?: CodexMapperOptions,
@@ -873,17 +981,17 @@ function mapFileChangeItem(
const error = item.error ?? null;
const status = normalizeToolCallStatus(item.status, error, output);
const firstFile = files[0];
const firstTextFields = resolveFileChangeTextFields(firstFile);
const hasFirstTextFields = Object.keys(firstTextFields).length > 0;
const preview = files.length > 1 ? fileChangesToPreview(files) : undefined;
const filePath = preview?.filePath ?? firstFile?.path;
const textFields = preview
? { unifiedDiff: preview.unifiedDiff }
: resolveFileChangeTextFields(firstFile);
const hasTextFields = Boolean(textFields.unifiedDiff || textFields.newString);
const input = toNullableObject({
...inputBase,
...(firstFile?.path && hasFirstTextFields ? { path: firstFile.path } : {}),
...(hasFirstTextFields && firstTextFields.unifiedDiff
? { patch: firstTextFields.unifiedDiff }
: {}),
...(hasFirstTextFields && firstTextFields.newString
? { content: firstTextFields.newString }
: {}),
...(filePath && hasTextFields ? { path: filePath } : {}),
...(hasTextFields && textFields.unifiedDiff ? { patch: textFields.unifiedDiff } : {}),
...(hasTextFields && textFields.newString ? { content: textFields.newString } : {}),
});
return {
@@ -950,6 +1058,7 @@ function mapCollabAgentToolCallItem(
type: "sub_agent",
subAgentType: "Sub-agent",
...(item.prompt ? { description: item.prompt } : {}),
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
};
@@ -1006,6 +1115,7 @@ function mapSubAgentActivityItem(
type: "sub_agent",
subAgentType: nativeName,
description,
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
actions: [],
},
@@ -5,6 +5,7 @@ import path from "node:path";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { AgentManager } from "../agent-manager.js";
import type { AgentSession, AgentStreamEvent } from "../agent-sdk-types.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
@@ -12,6 +13,7 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
test.each([
{ ending: "fail", terminalEvent: "turn_failed" },
{ ending: "cancel", terminalEvent: "turn_canceled" },
{ ending: "end_turn", terminalEvent: "turn_completed" },
] as const)(
"answers a permission request left open by a $terminalEvent turn with cancelled",
async ({ ending, terminalEvent }) => {
@@ -25,6 +27,16 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
await waitForEvent(events, terminalEvent);
expect(hasEvent(events, "permission_requested")).toBe(true);
expect(session.getPendingPermissions()).toEqual([]);
const requested = permissionRequests(events)[0]!;
expect(resolvedEvents(events)).toEqual([
{
type: "permission_resolved",
provider: "acp",
requestId: requested.request.id,
resolution: { behavior: "deny", message: "Permission request canceled" },
turnId: requested.turnId,
},
]);
const replyStart = events.length;
await session.startTurn("turn two");
@@ -39,6 +51,84 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
});
},
);
test.each(["completed", "failed"] as const)(
"cancels only the matching permission when its tool becomes %s",
async (status) => {
await withFakeACPAgent(status, async (command, cwd) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.createSession({ provider: "acp", cwd });
try {
const events = collectEvents(session);
await session.startTurn("turn one");
await waitForToolUpdate(events);
expect(pendingToolIds(session)).toEqual(["tool-2"]);
expect(resolvedEvents(events)).toEqual([
expect.objectContaining({ requestId: permissionRequests(events)[0]!.request.id }),
]);
} finally {
await session.close();
}
});
},
);
test("keeps a permission raised outside a turn answerable after another turn completes", async () => {
await withFakeACPAgent("outside_turn", async (command, cwd) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.createSession({ provider: "acp", cwd });
try {
const outside = session.getPendingPermissions();
expect(pendingToolIds(session)).toEqual(["tool-outside"]);
const events = collectEvents(session);
await session.startTurn("turn one");
await waitForEvent(events, "turn_completed");
expect(session.getPendingPermissions()).toEqual(outside);
await session.respondToPermission(outside[0]!.id, { behavior: "allow" });
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
}
});
});
test.each(["fail", "cancel"] as const)(
"settles an outside-turn permission that the manager clears when a turn ends with %s",
async (ending) => {
await withFakeACPAgent(`outside_${ending}`, async (command, cwd) => {
const logger = createTestLogger();
const client = new GenericACPAgentClient({
logger,
command,
waitForInitialCommands: false,
});
const manager = new AgentManager({ clients: { acp: client }, logger });
const agent = await manager.createAgent({ provider: "acp", cwd }, undefined, {
workspaceId: undefined,
});
try {
await waitForManagedPermission(manager, agent.id);
if (ending === "fail") {
await expect(manager.runAgent(agent.id, "turn one")).rejects.toThrow(
"turn failed with a permission open",
);
} else {
await expect(manager.runAgent(agent.id, "turn one")).resolves.toMatchObject({
canceled: true,
});
}
expect(manager.getPendingPermissions(agent.id)).toEqual([]);
const result = await manager.runAgent(agent.id, "turn two");
expect(result.finalText).toBe(
'perm-outside answered: {"outcome":{"outcome":"cancelled"}}',
);
} finally {
await manager.closeAgent(agent.id);
await manager.flush();
}
});
},
);
});
function collectEvents(session: AgentSession): AgentStreamEvent[] {
@@ -47,6 +137,10 @@ function collectEvents(session: AgentSession): AgentStreamEvent[] {
return events;
}
async function waitForManagedPermission(manager: AgentManager, agentId: string): Promise<void> {
await expect.poll(() => manager.getPendingPermissions(agentId).length).toBe(1);
}
function hasEvent(
events: AgentStreamEvent[],
type: AgentStreamEvent["type"],
@@ -77,8 +171,32 @@ function assistantText(events: AgentStreamEvent[]): string {
.join("");
}
function permissionRequests(events: AgentStreamEvent[]) {
return events.filter((event) => event.type === "permission_requested");
}
function resolvedEvents(events: AgentStreamEvent[]) {
return events.filter((event) => event.type === "permission_resolved");
}
function pendingToolIds(session: AgentSession) {
return session.getPendingPermissions().map((request) => request.metadata?.toolCallId);
}
async function waitForToolUpdate(events: AgentStreamEvent[]): Promise<void> {
await expect.poll(() => assistantText(events)).toBe("tool terminal");
}
async function withFakeACPAgent(
ending: "fail" | "cancel",
ending:
| "fail"
| "cancel"
| "end_turn"
| "completed"
| "failed"
| "outside_turn"
| "outside_fail"
| "outside_cancel",
run: (command: [string, ...string[]], cwd: string) => Promise<void>,
): Promise<void> {
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-permission-turn-failure-"));
@@ -91,8 +209,7 @@ async function withFakeACPAgent(
}
}
// Turn one asks for permission, then fails or is cancelled while the request is
// still open.
// Turn one ends while its permission request is still open.
// Later turns report whether that request was ever answered.
const fakeACPAgentScript = `
const readline = require("node:readline");
@@ -101,15 +218,34 @@ const ending = process.argv[2];
const rl = readline.createInterface({ input: process.stdin });
let promptCount = 0;
let permissionAnswer = null;
let outsidePermissionAnswer = null;
function write(message) {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
}
function ask(sessionId, requestId, toolCallId) {
const toolCall = { toolCallId, title: "echo hello", kind: "execute", status: "pending" };
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call", ...toolCall } } });
write({
id: requestId,
method: "session/request_permission",
params: {
sessionId,
toolCall,
options: [
{ optionId: "allow", name: "Allow once", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" },
],
},
});
}
rl.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === undefined && message.id === "perm-1") {
permissionAnswer = message.result ?? { error: message.error };
if (message.method === undefined) {
if (message.id === "perm-1") permissionAnswer = message.result ?? { error: message.error };
if (message.id === "perm-outside") outsidePermissionAnswer = message.result ?? { error: message.error };
return;
}
if (message.method === "initialize") {
@@ -117,6 +253,7 @@ rl.on("line", (line) => {
return;
}
if (message.method === "session/new") {
if (ending.startsWith("outside_")) ask("session-1", "perm-outside", "tool-outside");
write({ id: message.id, result: { sessionId: "session-1" } });
return;
}
@@ -124,35 +261,32 @@ rl.on("line", (line) => {
const sessionId = message.params.sessionId;
promptCount += 1;
if (promptCount === 1) {
const toolCall = { toolCallId: "tool-1", title: "echo hello", kind: "execute", status: "pending" };
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call", ...toolCall } } });
write({
id: "perm-1",
method: "session/request_permission",
params: {
sessionId,
toolCall,
options: [
{ optionId: "allow", name: "Allow once", kind: "allow_once" },
{ optionId: "reject", name: "Reject", kind: "reject_once" },
],
},
});
ask(sessionId, "perm-1", "tool-1");
if (ending === "completed" || ending === "failed") {
ask(sessionId, "perm-2", "tool-2");
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call_update", toolCallId: "tool-1", status: ending } } });
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "tool terminal" } } } });
return;
}
setTimeout(() => {
if (ending === "cancel") {
if (ending === "cancel" || ending === "outside_cancel") {
write({ id: message.id, result: { stopReason: "cancelled" } });
} else if (ending === "end_turn" || ending === "outside_turn") {
write({ id: message.id, result: { stopReason: "end_turn" } });
} else {
write({ id: message.id, error: { code: -32603, message: "turn failed with a permission open" } });
}
}, 50);
return;
}
const state = permissionAnswer === null ? "unanswered" : "answered: " + JSON.stringify(permissionAnswer);
const answer = ending.startsWith("outside_") ? outsidePermissionAnswer : permissionAnswer;
const requestId = ending.startsWith("outside_") ? "perm-outside" : "perm-1";
const state = answer === null ? "unanswered" : "answered: " + JSON.stringify(answer);
write({
method: "session/update",
params: {
sessionId,
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "perm-1 " + state } },
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: requestId + " " + state } },
},
});
write({ id: message.id, result: { stopReason: "end_turn" } });
@@ -69,7 +69,7 @@ import {
type OmpRuntimeOptions,
} from "./provider-config.js";
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js";
import { getUserMessageText } from "./message-history.js";
import { mapOmpSystemNoticeToNotification } from "./system-notice.js";
@@ -130,7 +130,6 @@ export interface OmpAgentClientOptions {
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
runtime?: OmpRuntime;
subagentCardScheduler?: OmpSubagentCardScheduler;
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
@@ -205,7 +204,6 @@ interface OmpAgentSessionOptions {
initialState: OmpSessionState;
currentModeId?: string | null;
logger: Logger;
subagentCardScheduler?: OmpSubagentCardScheduler;
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
@@ -773,9 +771,7 @@ export class OmpAgentSession implements AgentSession {
this.logger.debug({ err: error }, "OMP context usage poll failed");
},
});
this.subagentCardTracker = new OmpSubagentCardTracker({
scheduler: options.subagentCardScheduler,
});
this.subagentCardTracker = new OmpSubagentCardTracker();
this.lastKnownThinkingOptionId =
normalizeOmpThinkingOption(options.config.thinkingOptionId) ??
this.state.thinkingLevel ??
@@ -1306,10 +1302,10 @@ export class OmpAgentSession implements AgentSession {
return null;
}
async setModel(modelId: string | null): Promise<void> {
async setModel(modelId: string | null): Promise<{ model: string | null }> {
const parsedReference = parseModelReference(modelId);
if (!parsedReference) {
return;
return { model: modelToId(this.state.model) };
}
if (!parsedReference.provider) {
throw new Error(`OMP model id must include a provider: ${modelId}`);
@@ -1318,6 +1314,7 @@ export class OmpAgentSession implements AgentSession {
const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id);
this.config.model = `${model.provider}/${model.id}`;
await this.refreshState();
return { model: modelToId(this.state.model) };
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
@@ -2155,8 +2152,7 @@ export class OmpAgentSession implements AgentSession {
return mapOmpToolDetail(toolCall, result, {
toolCallId,
bridgedTool: this.hostTools?.bridge?.tool(toolCall.toolName),
mapSubagentDetail: (detail) =>
this.subagentCardTracker.detailFor(toolCallId, detail) ?? detail,
mapSubagentDetail: (detail) => this.subagentCardTracker.detailFor(toolCallId, detail),
});
}
@@ -2246,7 +2242,6 @@ export class OmpAgentClient implements AgentClient {
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly subagentCardScheduler?: OmpSubagentCardScheduler;
private readonly providerIdleScheduler?: OmpProviderIdleScheduler;
private readonly noTurnScheduler?: OmpNoTurnScheduler;
private readonly usagePollScheduler?: OmpUsagePollScheduler;
@@ -2265,7 +2260,6 @@ export class OmpAgentClient implements AgentClient {
);
this.logger = options.logger;
this.runtimeSettings = runtimeSettings;
this.subagentCardScheduler = options.subagentCardScheduler;
this.providerIdleScheduler = options.providerIdleScheduler;
this.noTurnScheduler = options.noTurnScheduler;
this.usagePollScheduler = options.usagePollScheduler;
@@ -2349,7 +2343,6 @@ export class OmpAgentClient implements AgentClient {
initialState,
currentModeId: launchMode.modeId,
logger: this.logger,
subagentCardScheduler: this.subagentCardScheduler,
providerIdleScheduler: this.providerIdleScheduler,
noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler,
@@ -2421,7 +2414,6 @@ export class OmpAgentClient implements AgentClient {
initialState,
currentModeId: launchMode.modeId,
logger: this.logger,
subagentCardScheduler: this.subagentCardScheduler,
providerIdleScheduler: this.providerIdleScheduler,
noTurnScheduler: this.noTurnScheduler,
usagePollScheduler: this.usagePollScheduler,
@@ -637,7 +637,7 @@ describe("OMP history mapper", () => {
subAgentType: "explore",
description: "Inspect files",
childSessionId: "/tmp/omp-task/Explore.jsonl",
log: "done\ntranscript: /tmp/omp-task/Explore.jsonl",
log: "",
},
error: null,
},
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
const PARENT_TOOL_CALL_ID = "task-1";
@@ -59,184 +59,84 @@ const PROGRESS: OmpSubagentProgressPayload[] = [
},
];
class ManualScheduler implements OmpSubagentCardScheduler {
private currentMs = 0;
private nextId = 1;
private readonly timers = new Map<number, { dueMs: number; callback: () => void }>();
now(): number {
return this.currentMs;
}
setTimeout(callback: () => void, delayMs: number) {
const id = this.nextId;
this.nextId += 1;
this.timers.set(id, { dueMs: this.currentMs + delayMs, callback });
return { token: id };
}
clearTimeout(timer: { token: unknown }): void {
if (typeof timer.token === "number") {
this.timers.delete(timer.token);
}
}
advance(ms: number): void {
this.currentMs += ms;
const dueTimers = [...this.timers.entries()]
.filter(([, timer]) => timer.dueMs <= this.currentMs)
.sort((left, right) => left[1].dueMs - right[1].dueMs);
for (const [id, timer] of dueTimers) {
if (this.timers.delete(id)) {
timer.callback();
}
}
}
}
describe("OmpSubagentCardTracker", () => {
test("folds lifecycle and progress into one throttled sub-agent detail", () => {
const scheduler = new ManualScheduler();
test("keeps the card a launch marker while the child runs", () => {
const emitted: string[] = [];
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
},
});
tracker.handleLifecycle(LIFECYCLE);
const emit = (toolCallId: string) => {
emitted.push(toolCallId);
return true;
};
const tracker = new OmpSubagentCardTracker();
tracker.handleLifecycle(LIFECYCLE, emit);
for (const payload of PROGRESS) {
tracker.handleProgress(payload);
tracker.handleProgress(payload, emit);
}
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
const detailBeforeTrailing = tracker.detailFor(PARENT_TOOL_CALL_ID, {
type: "sub_agent",
subAgentType: "task",
description: "Task arg description wins",
log: "",
});
expect(detailBeforeTrailing).toEqual({
type: "sub_agent",
subAgentType: "task",
description: "Task arg description wins",
childSessionId: SESSION_FILE,
log: [
"EchoSubagent started",
"[bash] echo subagent-hi",
"[yield]",
"EchoSubagent completed",
].join("\n"),
});
scheduler.advance(500);
expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]);
});
test("aggregates batch progress streams into one index-prefixed log", () => {
const scheduler = new ManualScheduler();
const emitted: string[] = [];
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
},
});
tracker.handleLifecycle({
id: "Explore",
agent: "task",
description: "Inspect files",
status: "started",
sessionFile: "/tmp/one.jsonl",
parentToolCallId: "task.batch-1",
index: 0,
});
scheduler.advance(600);
tracker.handleProgress({
index: 5,
agent: "task",
task: "Run tests",
parentToolCallId: "task.batch-1",
progress: {
id: "Test",
status: "running",
description: "Run tests",
recentTools: [{ tool: "bash", args: "npm test", endMs: 10 }],
},
sessionFile: "/tmp/two.jsonl",
});
expect(emitted).toEqual(["task.batch-1", "task.batch-1"]);
expect(
tracker.detailFor("task.batch-1", {
tracker.detailFor(PARENT_TOOL_CALL_ID, {
type: "sub_agent",
subAgentType: "batch",
actions: [],
subAgentType: "task",
description: "Task arg description wins",
log: "",
}),
).toEqual({
type: "sub_agent",
subAgentType: "batch",
description: "Inspect files",
childSessionId: "/tmp/one.jsonl",
log: "[1/6] Explore started\n[6/6] [bash] npm test",
actions: [],
subAgentType: "task",
description: "Task arg description wins",
childSessionId: SESSION_FILE,
log: "",
});
});
test("keeps dirty trailing state available for final detail and cancels it on cleanup", () => {
const scheduler = new ManualScheduler();
test("re-emits the card when a later event names the child", () => {
const emitted: string[] = [];
const tracker = new OmpSubagentCardTracker({
scheduler,
emitToolCall: (toolCallId) => {
emitted.push(toolCallId);
return true;
const emit = (toolCallId: string) => {
emitted.push(toolCallId);
return true;
};
const tracker = new OmpSubagentCardTracker();
tracker.handleLifecycle({ ...LIFECYCLE, description: undefined, sessionFile: undefined }, emit);
tracker.handleProgress(PROGRESS[0], emit);
expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]);
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
type: "sub_agent",
childSessionId: SESSION_FILE,
log: "",
});
tracker.delete(PARENT_TOOL_CALL_ID);
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
type: "sub_agent",
log: "",
});
});
test("leaves the card alone when a later child of the same task starts", () => {
const emitted: string[] = [];
const emit = (toolCallId: string) => {
emitted.push(toolCallId);
return true;
};
const tracker = new OmpSubagentCardTracker();
tracker.handleLifecycle(LIFECYCLE, emit);
tracker.handleLifecycle(
{
...LIFECYCLE,
id: "Second",
index: 1,
description: "Second child",
sessionFile: "/s.jsonl",
},
emit,
);
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
type: "sub_agent",
description: "Run echo in subagent",
childSessionId: SESSION_FILE,
log: "",
});
tracker.handleLifecycle({
id: "Explore",
agent: "task",
description: "Inspect files",
status: "started",
parentToolCallId: "task-1",
index: 0,
});
scheduler.advance(100);
tracker.handleProgress({
index: 0,
agent: "task",
task: "Inspect files",
parentToolCallId: "task-1",
progress: {
id: "Explore",
status: "running",
recentOutput: ["found target file"],
},
});
expect(emitted).toEqual(["task-1"]);
expect(
tracker.detailFor("task-1", {
type: "sub_agent",
log: "",
}).log,
).toBe("Explore started\nfound target file");
tracker.delete("task-1");
scheduler.advance(500);
expect(emitted).toEqual(["task-1"]);
expect(
tracker.detailFor("task-1", {
type: "sub_agent",
log: "static",
}).log,
).toBe("static");
});
});
@@ -1,361 +1,106 @@
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
export interface OmpSubagentCardTimer {
readonly token: unknown;
}
export interface OmpSubagentCardScheduler {
now(): number;
setTimeout(callback: () => void, delayMs: number): OmpSubagentCardTimer;
clearTimeout(timer: OmpSubagentCardTimer): void;
}
export interface OmpSubagentCardTrackerOptions {
scheduler?: OmpSubagentCardScheduler;
emitToolCall?: (toolCallId: string) => boolean;
}
interface OmpSubagentLogLine {
key: string;
text: string;
}
interface OmpSubagentCardItem {
interface OmpSubagentCardChild {
index: number;
description?: string;
agent?: string;
status?: "pending" | "running" | "completed" | "failed" | "aborted";
childSessionId?: string;
lines: OmpSubagentLogLine[];
lineKeys: Set<string>;
}
interface OmpSubagentCardState {
items: Map<number, OmpSubagentCardItem>;
emitToolCall: (toolCallId: string) => boolean;
lastEmitMs: number | null;
trailingTimer: OmpSubagentCardTimer | null;
dirty: boolean;
}
const MAX_LOG_LINES_PER_ITEM = 200;
const MAX_LOG_LINE_CHARS = 240;
const THROTTLE_INTERVAL_MS = 500;
export function createOmpSubagentCardScheduler(): OmpSubagentCardScheduler {
return {
now: () => Date.now(),
setTimeout: (callback, delayMs) => ({ token: setTimeout(callback, delayMs) }),
clearTimeout: (timer) => {
clearTimeout(timer.token as ReturnType<typeof setTimeout>);
},
};
}
/**
* Fills a task card's description and child session from OMP's sub-agent events. The card is a
* launch marker that names the task's first child; the children's activity belongs to their own
* timelines.
*/
export class OmpSubagentCardTracker {
private readonly scheduler: OmpSubagentCardScheduler;
private readonly emitToolCall: (toolCallId: string) => boolean;
private readonly states = new Map<string, OmpSubagentCardState>();
constructor(options: OmpSubagentCardTrackerOptions) {
this.scheduler = options.scheduler ?? createOmpSubagentCardScheduler();
this.emitToolCall = options.emitToolCall ?? (() => false);
}
private readonly firstChildren = new Map<string, OmpSubagentCardChild>();
handleLifecycle(
payload: OmpSubagentLifecyclePayload,
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
emitToolCall: (toolCallId: string) => boolean,
): void {
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
if (!parentToolCallId) {
return;
}
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
const item = this.upsertItem(parentToolCallId, {
index: payload.index,
agent: payload.agent,
description: payload.description,
status: payload.status === "started" ? "running" : payload.status,
childSessionId: payload.sessionFile,
});
this.appendLine(
item,
`lifecycle:${payload.status}:${payload.id}`,
`${payload.id} ${payload.status}`,
this.update(
payload.parentToolCallId,
{
index: payload.index,
description: payload.description,
childSessionId: payload.sessionFile,
},
emitToolCall,
);
this.requestEmit(parentToolCallId);
}
handleProgress(
payload: OmpSubagentProgressPayload,
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
emitToolCall: (toolCallId: string) => boolean,
): void {
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
if (!parentToolCallId) {
return;
}
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
const item = this.upsertItem(parentToolCallId, {
index: payload.index,
agent: payload.agent,
description: payload.progress.description,
status: payload.progress.status,
childSessionId: payload.sessionFile,
});
const currentToolLine = summarizeTool(readRecord(payload.progress.currentTool));
if (currentToolLine) {
this.appendLine(item, `current:${currentToolLine.key}`, currentToolLine.text);
}
for (const tool of readRecordArray(payload.progress.recentTools)) {
const line = summarizeTool(tool);
if (line) {
this.appendLine(item, `tool:${line.key}`, line.text);
}
}
for (const output of readOutputLines(payload.progress.recentOutput)) {
this.appendLine(item, `output:${output}`, output);
}
if (payload.progress.status !== "running") {
this.appendLine(
item,
`status:${payload.progress.status}:${payload.progress.id}`,
`${payload.progress.id} ${payload.progress.status}`,
);
}
this.requestEmit(parentToolCallId);
this.update(
payload.parentToolCallId,
{
index: payload.index,
description: payload.progress.description,
childSessionId: payload.sessionFile,
},
emitToolCall,
);
}
detailFor(toolCallId: string, baseDetail: ToolCallDetail): ToolCallDetail {
if (baseDetail.type !== "sub_agent") {
return baseDetail;
}
const state = this.states.get(toolCallId);
if (!state) {
const first = this.firstChildren.get(toolCallId);
if (!first) {
return baseDetail;
}
const items = [...state.items.values()].sort((left, right) => left.index - right.index);
const firstItem = items[0];
if (!firstItem) {
return baseDetail;
}
const detail: ToolCallDetail = {
type: "sub_agent",
...(baseDetail.subAgentType ? { subAgentType: baseDetail.subAgentType } : {}),
...((baseDetail.description ?? firstItem.description)
? { description: baseDetail.description ?? firstItem.description }
: {}),
...((firstItem.childSessionId ?? baseDetail.childSessionId)
? { childSessionId: firstItem.childSessionId ?? baseDetail.childSessionId }
: {}),
log: buildLog(items, baseDetail.log),
const description = baseDetail.description ?? first.description;
const childSessionId = first.childSessionId ?? baseDetail.childSessionId;
return {
...baseDetail,
...(description ? { description } : {}),
...(childSessionId ? { childSessionId } : {}),
};
return baseDetail.actions ? { ...detail, actions: baseDetail.actions } : detail;
}
flush(toolCallId: string): void {
const state = this.states.get(toolCallId);
if (!state || !state.dirty) {
return;
}
this.emitNow(toolCallId, state);
}
delete(toolCallId: string): void {
const state = this.states.get(toolCallId);
if (state?.trailingTimer) {
this.scheduler.clearTimeout(state.trailingTimer);
}
this.states.delete(toolCallId);
this.firstChildren.delete(toolCallId);
}
clear(): void {
for (const toolCallId of this.states.keys()) {
this.delete(toolCallId);
}
this.firstChildren.clear();
}
private upsertItem(
parentToolCallId: string,
input: {
index: number;
agent?: string;
status?: "pending" | "running" | "completed" | "failed" | "aborted";
description?: string;
childSessionId?: string;
},
): OmpSubagentCardItem {
const state = this.stateFor(parentToolCallId);
const existing = state.items.get(input.index);
if (existing) {
existing.agent = readTrimmedString(input.agent) ?? existing.agent;
existing.status = input.status ?? existing.status;
existing.description = readTrimmedString(input.description) ?? existing.description;
existing.childSessionId = readTrimmedString(input.childSessionId) ?? existing.childSessionId;
return existing;
private update(
parentToolCallId: string | undefined,
input: { index: number; description?: string; childSessionId?: string },
emitToolCall: (toolCallId: string) => boolean,
): void {
const toolCallId = readTrimmedString(parentToolCallId);
if (!toolCallId) {
return;
}
const item: OmpSubagentCardItem = {
const first = this.firstChildren.get(toolCallId);
if (first && input.index > first.index) {
return;
}
const existing = first?.index === input.index ? first : undefined;
const next: OmpSubagentCardChild = {
index: input.index,
lines: [],
lineKeys: new Set<string>(),
...(readTrimmedString(input.agent) ? { agent: readTrimmedString(input.agent) } : {}),
...(input.status ? { status: input.status } : {}),
...(readTrimmedString(input.description)
? { description: readTrimmedString(input.description) }
: {}),
...(readTrimmedString(input.childSessionId)
? { childSessionId: readTrimmedString(input.childSessionId) }
: {}),
description: readTrimmedString(input.description) ?? existing?.description,
childSessionId: readTrimmedString(input.childSessionId) ?? existing?.childSessionId,
};
state.items.set(input.index, item);
return item;
}
private stateFor(parentToolCallId: string): OmpSubagentCardState {
const existing = this.states.get(parentToolCallId);
if (existing) {
return existing;
}
const state: OmpSubagentCardState = {
items: new Map(),
emitToolCall: this.emitToolCall,
lastEmitMs: null,
trailingTimer: null,
dirty: false,
};
this.states.set(parentToolCallId, state);
return state;
}
private appendLine(item: OmpSubagentCardItem, key: string, text: string): void {
const normalizedText = normalizeLine(text);
if (!normalizedText || item.lineKeys.has(key)) {
if (
existing &&
existing.description === next.description &&
existing.childSessionId === next.childSessionId
) {
return;
}
item.lines.push({ key, text: normalizedText });
item.lineKeys.add(key);
while (item.lines.length > MAX_LOG_LINES_PER_ITEM) {
const removed = item.lines.shift();
if (removed) {
item.lineKeys.delete(removed.key);
}
}
this.firstChildren.set(toolCallId, next);
emitToolCall(toolCallId);
}
private requestEmit(toolCallId: string): void {
const state = this.stateFor(toolCallId);
const now = this.scheduler.now();
const elapsedMs = state.lastEmitMs === null ? THROTTLE_INTERVAL_MS : now - state.lastEmitMs;
if (elapsedMs >= THROTTLE_INTERVAL_MS) {
this.emitNow(toolCallId, state);
return;
}
state.dirty = true;
if (state.trailingTimer) {
return;
}
state.trailingTimer = this.scheduler.setTimeout(() => {
state.trailingTimer = null;
if (state.dirty) {
this.emitNow(toolCallId, state);
}
}, THROTTLE_INTERVAL_MS - elapsedMs);
}
private emitNow(toolCallId: string, state: OmpSubagentCardState): void {
if (state.trailingTimer) {
this.scheduler.clearTimeout(state.trailingTimer);
state.trailingTimer = null;
}
state.dirty = false;
state.lastEmitMs = this.scheduler.now();
state.emitToolCall(toolCallId);
}
}
function buildLog(items: OmpSubagentCardItem[], fallback: string): string {
const itemCount = Math.max(...items.map((item) => item.index)) + 1;
const lines = items.flatMap((item) => {
const prefix = itemCount > 1 ? `[${item.index + 1}/${itemCount}] ` : "";
return item.lines.map((line) => `${prefix}${line.text}`);
});
return lines.length > 0 ? lines.join("\n") : fallback;
}
function summarizeTool(tool: Record<string, unknown> | null): OmpSubagentLogLine | null {
if (!tool) {
return null;
}
const toolName = readTrimmedString(tool.tool) ?? readTrimmedString(tool.name);
if (!toolName) {
return null;
}
const args = readTrimmedString(tool.args) ?? summarizeUnknown(tool.input);
const endMs = typeof tool.endMs === "number" ? `:${tool.endMs}` : "";
return {
key: `${toolName}:${args ?? ""}${endMs}`,
text: args ? `[${toolName}] ${args}` : `[${toolName}]`,
};
}
function readOutputLines(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item) => {
const text =
readTrimmedString(item) ??
(isRecord(item)
? (readTrimmedString(item.text) ??
readTrimmedString(item.output) ??
readTrimmedString(item.content))
: undefined);
return text ? [text] : [];
});
}
function readRecord(value: unknown): Record<string, unknown> | null {
return isRecord(value) ? value : null;
}
function readRecordArray(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.filter(isRecord) : [];
}
function summarizeUnknown(value: unknown): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value === "string") {
return readTrimmedString(value);
}
const json = JSON.stringify(value);
return typeof json === "string" ? normalizeLine(json) : undefined;
}
function normalizeLine(value: string): string | undefined {
const normalized = value.trim().replace(/\s+/g, " ");
if (!normalized) {
return undefined;
}
if (normalized.length <= MAX_LOG_LINE_CHARS) {
return normalized;
}
return `${normalized.slice(0, MAX_LOG_LINE_CHARS)}...`;
}
function readTrimmedString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -85,7 +85,7 @@ describe("OMP tool call mapper", () => {
expect(mapOmpToolDetail(parseToolArgs("todo", { op: "view" }), null)).toBeNull();
});
test("uses task result text and transcript path as the best static replay detail", () => {
test("takes the child session from the task transcript path", () => {
expect(
mapOmpToolDetail(
parseToolArgs("task", {
@@ -106,7 +106,7 @@ describe("OMP tool call mapper", () => {
subAgentType: "explore",
description: "Inspect the target files",
childSessionId: "/tmp/omp-task-static/Explore.jsonl",
log: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl",
log: "",
});
});
@@ -179,7 +179,6 @@ const OPAQUE_TEXT_TOOLS = new Set([
function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail {
const argRecord = isRecord(args) ? args : {};
const firstTask = Array.isArray(argRecord.tasks) ? argRecord.tasks.find(isRecord) : undefined;
const resultText = extractTextFromToolResult(result);
const childSessionId = readChildSessionId(result);
return {
type: "sub_agent",
@@ -200,7 +199,8 @@ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail
argRecord.assignment,
),
...(childSessionId ? { childSessionId } : {}),
log: resultText?.trim() ?? "",
// COMPAT(subagent-log): remove after 2027-04-30.
log: "",
};
}
@@ -349,7 +349,11 @@ type OpenCodeAgentConfig = Omit<AgentSessionConfig, "providerOptions"> & {
providerOptions: OpenCodeProviderOptions;
};
const OPENCODE_SESSION_ENV_KEYS = new Set(["PASEO_AGENT_ID", "PASEO_AGENT_CWD"]);
const OPENCODE_SESSION_ENV_KEYS = new Set([
"PASEO_AGENT_ID",
"PASEO_AGENT_CWD",
"PASEO_WORKSPACE_ID",
]);
function requiresDedicatedOpenCodeServer(
config: OpenCodeAgentConfig,
@@ -4846,15 +4850,6 @@ class OpenCodeAgentSession implements AgentSession {
...item,
status: "failed",
error,
detail:
item.detail.type === "sub_agent"
? {
...item.detail,
log: [item.detail.log, error.message]
.filter((entry) => entry.trim().length > 0)
.join("\n"),
}
: item.detail,
},
},
turnId,
@@ -80,8 +80,12 @@ describe("OpenCode bridge adapter", () => {
await rm(paseoHome, { recursive: true, force: true });
});
const runtime = new TestOpenCodeHarness();
runtime.enqueueClient(new TestOpenCodeClient());
runtime.enqueueClient(new TestOpenCodeClient());
const envClient = new TestOpenCodeClient();
const mcpClient = new TestOpenCodeClient();
envClient.sessionCreateResponse = { data: { id: "ses_env" } };
mcpClient.sessionCreateResponse = { data: { id: "ses_mcp" } };
runtime.enqueueClient(envClient);
runtime.enqueueClient(mcpClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
@@ -102,6 +106,12 @@ describe("OpenCode bridge adapter", () => {
);
expect(runtime.acquisitions.map(({ kind }) => kind)).toEqual(["dedicated", "dedicated"]);
await expect(readBridgeContext(bridge, "ses_env")).resolves.toEqual({
env: { PASEO_AGENT_ID: "one", CUSTOM_TOKEN: "secret" },
});
await expect(readBridgeContext(bridge, "ses_mcp")).resolves.toEqual({
env: { PASEO_AGENT_ID: "two" },
});
await customEnv.close();
await customMcp.close();
});
@@ -94,7 +94,7 @@ test("bridge preserves tool failure messages and classifies invalid requests as
const { tools } = await manifest.json();
expect(
tools.find((tool: { name: string }) => tool.name === "create_agent").inputSchema.properties,
).not.toHaveProperty("background");
).toHaveProperty("background");
expect(
tools.find((tool: { name: string }) => tool.name === "update_schedule").inputSchema
.properties,
@@ -20,6 +20,7 @@ import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../../tool
import { serializePaseoToolInputParameters } from "../../tools/paseo-tool-serialization.js";
import {
OpenCodeBridge,
decorateOpenCodeV2Env,
loadOpenCodeBridgePluginArtifact,
materializeOpenCodeV2Plugin,
} from "./bridge.js";
@@ -137,33 +138,28 @@ describe("OpenCodeBridge", () => {
};
expect(ajv.compile(createDefinition.inputSchema)(createInput)).toBe(true);
const v2Config = z
.object({ plugins: z.array(z.object({ package: z.string() })) })
.parse(JSON.parse((await bridge.decorateV2ServerEnv({})).OPENCODE_CONFIG_CONTENT));
const v2Module: {
default: { setup(context: V2TestPluginContext): Promise<() => Promise<void>> };
} = await import(
pathToFileURL(path.join(fileURLToPath(v2Config.plugins[0]!.package), "server.js")).href
);
const tools = new Map<string, V2TestTool>();
const dispose = await v2Module.default.setup({
options: plugin,
tool: {
transform: async (transform) => {
transform({
add: (tool) => {
tools.set(tool.name, tool);
},
});
return { dispose: async () => undefined };
const dispose = await setupBoundV2Plugin(
bridge,
{
tool: {
transform: async (transform) => {
transform({
add: (tool) => {
tools.set(tool.name, tool);
},
});
return { dispose: async () => undefined };
},
},
session: {
context: async () => [],
get: async () => ({ parentID: "bound" }),
hook: async () => ({ dispose: async () => undefined }),
},
},
session: {
context: async () => [],
get: async () => ({ parentID: "bound" }),
hook: async () => ({ dispose: async () => undefined }),
},
});
["bound"],
);
try {
const created = await tools
.get("paseo_create_agent")!
@@ -185,13 +181,18 @@ describe("OpenCodeBridge", () => {
expect(createDefinition.inputSchema).toMatchObject({
properties: { notifyOnFinish: { default: true } },
});
expect(createDefinition.inputSchema.properties).not.toHaveProperty("background");
expect(createDefinition.inputSchema.properties).toHaveProperty("background");
expect(sendDefinition.inputSchema).toMatchObject({
properties: { notifyOnFinish: { default: true } },
});
await expect(
catalog.executeTool("create_agent", { ...createInput, background: true }),
).rejects.toThrow("background");
await catalog.executeTool("create_agent", {
...createInput,
title: "Background child",
background: true,
});
expect(
agentManager.listAgents().find((agent) => agent.config.title === "Background child"),
).toMatchObject({ background: true });
const v1Module = await import(plugin.pluginUrl);
const hooks = await v1Module.default({}, plugin);
@@ -402,45 +403,35 @@ describe("OpenCodeBridge", () => {
const release = bridge.bindSession({ sessionId: "parent", env: {}, tools: catalog });
const releaseDisabled = bridge.bindSession({ sessionId: "disabled", env: {} });
try {
const env = await bridge.decorateV2ServerEnv({});
const config = z
.object({
plugins: z.array(
z.object({
package: z.string(),
options: z.object({ baseUrl: z.string(), token: z.string() }),
}),
),
})
.parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT));
expect(await bridge.decorateV2ServerEnv(env)).toEqual(env);
const plugin = config.plugins[0]!;
const pluginUrl = await bridge.materializeV2Plugin();
const env = decorateOpenCodeV2Env({}, pluginUrl);
expect(decorateOpenCodeV2Env(env, pluginUrl)).toEqual(env);
const tools = new Map<string, V2TestTool>();
let filter!: (input: V2TestContext) => Promise<void>;
const module: {
default: { setup(context: V2TestPluginContext): Promise<() => Promise<void>> };
} = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href);
const dispose = await module.default.setup({
options: plugin.options,
tool: {
transform: async (transform) => {
transform({
add: (tool) => {
tools.set(tool.name, tool);
},
});
return { dispose: async () => undefined };
const dispose = await setupBoundV2Plugin(
bridge,
{
tool: {
transform: async (transform) => {
transform({
add: (tool) => {
tools.set(tool.name, tool);
},
});
return { dispose: async () => undefined };
},
},
session: {
context: async () => [],
get: async ({ sessionID }) => ({ parentID: sessionID === "child" ? "parent" : "" }),
hook: async (_name, callback) => {
filter = callback;
return { dispose: async () => undefined };
},
},
},
session: {
context: async () => [],
get: async () => ({ parentID: "parent" }),
hook: async (_name, callback) => {
filter = callback;
return { dispose: async () => undefined };
},
},
});
["parent", "disabled"],
);
const allowed: V2TestContext = {
sessionID: "child",
tools: { paseo_echo_context: {}, native: {} },
@@ -453,6 +444,12 @@ describe("OpenCodeBridge", () => {
};
await filter(disabled);
expect(Object.keys(disabled.tools)).toEqual(["native"]);
const unrelated: V2TestContext = {
sessionID: "unrelated",
tools: { paseo_echo_context: {}, native: {} },
};
await filter(unrelated);
expect(Object.keys(unrelated.tools)).toEqual(["native"]);
await expect(
tools.get("paseo_echo_context")!.execute({ value: "child result" }, { sessionID: "child" }),
).resolves.toMatchObject({ content: [{ type: "text", text: "child result" }] });
@@ -486,35 +483,33 @@ describe("OpenCodeBridge", () => {
await bridge.start();
const release = bridge.bindSession({ sessionId: "caller", env: {}, tools: catalog });
try {
const env = await bridge.decorateV2ServerEnv({});
const { plugins } = JSON.parse(env.OPENCODE_CONFIG_CONTENT);
const plugin = plugins[0];
const module = await import(
pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href
);
const options = bridge.sharedV2Connection();
const tools = new Map<string, V2TestTool>();
const dispose = await module.default.setup({
options: plugin.options,
tool: {
transform: async (transform: (editor: { add(tool: V2TestTool): void }) => void) => {
transform({ add: (tool) => tools.set(tool.name, tool) });
return { dispose: async () => undefined };
const dispose = await setupBoundV2Plugin(
bridge,
{
tool: {
transform: async (transform: (editor: { add(tool: V2TestTool): void }) => void) => {
transform({ add: (tool) => tools.set(tool.name, tool) });
return { dispose: async () => undefined };
},
},
session: {
context: async () => [],
get: async () => ({ parentID: "" }),
hook: async () => ({ dispose: async () => undefined }),
},
},
session: {
context: async () => [],
get: async () => ({ parentID: "" }),
hook: async () => ({ dispose: async () => undefined }),
},
} satisfies V2TestPluginContext);
["caller"],
);
try {
const input = { provider: "codex", title: "Invalid provider", initialPrompt: "Hello" };
const response = await fetch(
`${plugin.options.baseUrl}/_internal/opencode/sessions/caller/tools/create_agent`,
`${options.baseUrl}/_internal/opencode/sessions/caller/tools/create_agent`,
{
method: "POST",
headers: {
Authorization: `Bearer ${plugin.options.token}`,
Authorization: `Bearer ${options.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(input),
@@ -572,35 +567,24 @@ describe("OpenCodeBridge", () => {
await bridge.start();
const release = bridge.bindSession({ sessionId: "session", env: {}, tools: catalog });
try {
const env = await bridge.decorateV2ServerEnv({});
const plugin = z
.object({
plugins: z.array(
z.object({
package: z.string(),
options: z.object({ baseUrl: z.string(), token: z.string() }),
}),
),
})
.parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT)).plugins[0]!;
const pluginTools = new Map<string, V2TestTool>();
const module: {
default: { setup(context: V2TestPluginContext): Promise<() => Promise<void>> };
} = await import(pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href);
const dispose = await module.default.setup({
options: plugin.options,
tool: {
transform: async (transform) => {
transform({ add: (tool) => pluginTools.set(tool.name, tool) });
return { dispose: async () => undefined };
const dispose = await setupBoundV2Plugin(
bridge,
{
tool: {
transform: async (transform) => {
transform({ add: (tool) => pluginTools.set(tool.name, tool) });
return { dispose: async () => undefined };
},
},
session: {
context: async () => [],
get: async () => ({ parentID: undefined }),
hook: async () => ({ dispose: async () => undefined }),
},
},
session: {
context: async () => [],
get: async () => ({ parentID: undefined }),
hook: async () => ({ dispose: async () => undefined }),
},
});
["session"],
);
const result = await pluginTools
.get("paseo_browser_screenshot")!
.execute({}, { sessionID: "session" });
@@ -642,7 +626,8 @@ describe("OpenCodeBridge", () => {
const tools = new Map<string, V2TestTool>();
let hook!: (input: V2TestContext) => Promise<void>;
const dispose = await module.default.setup({
options: { baseUrl: "", token: "" },
options: { mode: "structured-only" },
location: { directory: root },
tool: {
transform: async (transform) => {
transform({
@@ -657,7 +642,7 @@ describe("OpenCodeBridge", () => {
context: async () => history,
get: async () => ({ parentID: "parent" }),
hook: async (_name, callback) => {
hook = callback;
hook ??= callback;
return { dispose: async () => undefined };
},
},
@@ -701,9 +686,13 @@ describe("OpenCodeBridge", () => {
name.startsWith("paseo-v2-"),
),
).toEqual([]);
const env = await bridge.decorateV2ServerEnv({
OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: ["user-plugin"] }),
});
const pluginUrl = await bridge.materializeV2Plugin();
const env = decorateOpenCodeV2Env(
{
OPENCODE_CONFIG_CONTENT: JSON.stringify({ plugins: ["user-plugin"] }),
},
pluginUrl,
);
const config = z
.object({ plugins: z.tuple([z.literal("user-plugin"), z.object({ package: z.string() })]) })
.parse(JSON.parse(env.OPENCODE_CONFIG_CONTENT));
@@ -711,6 +700,9 @@ describe("OpenCodeBridge", () => {
expect((await stat(directory)).isDirectory()).toBe(true);
const entry = await readFile(path.join(directory, "server.js"), "utf8");
expect(entry).toContain('id: "paseo"');
expect(await readFile(path.join(directory, "state.mjs"), "utf8")).toBe(
await readFile(new URL("./v2/state.mjs", import.meta.url), "utf8"),
);
const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"));
expect(manifest).toMatchObject({ type: "module", exports: { "./server": "./server.js" } });
} finally {
@@ -856,7 +848,8 @@ interface V2TestContext {
system?: Array<{ type: "text"; text: string }>;
}
interface V2TestPluginContext {
options: { baseUrl: string; token: string };
options: Record<string, string>;
location?: { directory: string };
tool: {
transform(
callback: (editor: { add(tool: V2TestTool): void }) => void,
@@ -864,10 +857,33 @@ interface V2TestPluginContext {
};
session: {
context(input: { sessionID: string }): Promise<SessionMessageInfo[]>;
get(input: { sessionID: string }): Promise<{ parentID: string }>;
get(input: { sessionID: string }): Promise<{ parentID: string | undefined }>;
hook(
name: string,
callback: (input: V2TestContext) => Promise<void>,
): Promise<{ dispose(): Promise<void> }>;
};
}
async function setupBoundV2Plugin(
bridge: OpenCodeBridge,
context: Pick<V2TestPluginContext, "tool" | "session">,
sessionIds: string[],
): Promise<() => Promise<void>> {
const directory = fileURLToPath(await bridge.materializeV2Plugin());
const module = await import(pathToFileURL(path.join(directory, "server.js")).href);
let bind!: (input: { sessionID: string; baseUrl: string; token: string }) => Promise<unknown>;
const dispose: () => Promise<void> = await module.default.setup({
...context,
options: {},
location: { directory },
rpc: {
register: async (_definition: unknown, handlers: { bind: typeof bind }) => {
bind = handlers.bind;
return { dispose: async () => undefined };
},
},
});
for (const sessionID of sessionIds) await bind({ sessionID, ...bridge.sharedV2Connection() });
return dispose;
}
@@ -30,6 +30,7 @@ class OpenCodeBridgeClientError extends Error {
interface OpenCodeBridgeOptions {
paseoHome: string;
logger: Logger;
port?: number;
}
interface OpenCodeSessionBinding {
@@ -54,6 +55,7 @@ interface OpenCodeConfig {
export class OpenCodeBridge {
private readonly paseoHome: string;
private readonly logger: Logger;
private readonly port: number;
private readonly token = randomBytes(32).toString("hex");
private readonly sessions = new Map<string, OpenCodeSessionBinding>();
private server: Server | null = null;
@@ -65,6 +67,7 @@ export class OpenCodeBridge {
constructor(options: OpenCodeBridgeOptions) {
this.paseoHome = options.paseoHome;
this.logger = options.logger.child({ module: "agent", component: "opencode-bridge" });
this.port = options.port ?? 0;
}
async start(): Promise<void> {
@@ -75,7 +78,7 @@ export class OpenCodeBridge {
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.listen(this.port, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
@@ -94,6 +97,8 @@ export class OpenCodeBridge {
}
bindSession(input: BindOpenCodeSessionInput): () => void {
if (this.sessions.has(input.sessionId))
throw new Error(`OpenCode session ${input.sessionId} is already bound to a Paseo agent`);
const binding: OpenCodeSessionBinding = {
env: { ...input.env },
...(input.tools ? { tools: input.tools } : {}),
@@ -127,12 +132,13 @@ export class OpenCodeBridge {
};
}
async decorateV2ServerEnv(env: Record<string, string>): Promise<Record<string, string>> {
async materializeV2Plugin(): Promise<string> {
this.v2PluginUrl ??= await this.materializePlugin(2);
return decorateOpenCodeV2Env(env, this.v2PluginUrl, {
baseUrl: this.requireBaseUrl(),
token: this.token,
});
return this.v2PluginUrl;
}
sharedV2Connection(baseUrl = this.requireBaseUrl()): OpenCodePluginOptions {
return { baseUrl, token: this.token };
}
async close(): Promise<void> {
@@ -282,6 +288,8 @@ export async function materializeOpenCodeV2Plugin(paseoHome: string): Promise<st
// V2 ignores configured file paths; it loads a package directory's server entry point.
const directory = path.join(paseoHome, "runtime", "opencode", `paseo-v2-${digest}`);
await writeFileAtomic(path.join(directory, "server.js"), artifact);
const state = await readFile(new URL("./v2/state.mjs", import.meta.url));
await writeFileAtomic(path.join(directory, "state.mjs"), state);
await writeFileAtomic(
path.join(directory, "package.json"),
JSON.stringify({
@@ -66,6 +66,7 @@ test("retries a rejected version probe after the configured binary changes", asy
const script = join(root, "version.cjs");
const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, script] },
env: { XDG_STATE_HOME: root },
});
try {
await writeFile(script, 'console.log("opencode v2.0.3")');
@@ -95,6 +96,7 @@ test.each([
const script = join(root, "version.cjs");
const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, script] },
env: { XDG_STATE_HOME: root },
});
try {
await writeFile(script, initialSource);
@@ -193,6 +195,7 @@ test.each([
logger,
{
command: { mode: "replace", argv: [process.execPath, script] },
env: { XDG_STATE_HOME: root },
},
{ serverManager: new TrackedServerManager({ logger }) },
);
@@ -209,3 +212,38 @@ test.each([
},
10000,
);
test("selects v2 without probing the binary when an external server is configured", async () => {
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, "-e", "process.exit(1)"] },
env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" },
});
try {
const features = await client.listFeatures({ provider: "opencode", cwd: "/tmp/project" });
expect(features.map((feature) => feature.label)).toEqual(["Auto-accept"]);
} finally {
await client.shutdown();
}
});
test("keeps native Paseo tools available when an external server is configured", async () => {
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
const { OpenCodeBridge } = await import("./bridge.js");
const bridge = new OpenCodeBridge({ paseoHome: "/tmp", logger: createTestLogger() });
const external = new OpenCodeRuntimeClient(
createTestLogger(),
{ env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" } },
{ bridge },
);
const local = new OpenCodeRuntimeClient(createTestLogger(), undefined, { bridge });
try {
expect(external.capabilities.supportsNativePaseoTools).toBe(true);
expect(local.capabilities.supportsNativePaseoTools).toBe(true);
} finally {
await external.shutdown();
await local.shutdown();
}
});

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