Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 2a4def5004 fix(server): publish OpenCode snapshots before live suffixes
Commit snapshot cursors and publish each event together. Holding those events until attachment IO completed let live deltas overtake text that had already advanced the cursor. Require snapshot publishers and exercise text and reasoning interleaving against real image materialization.
2026-10-27 04:24:24 +01:00
Mohamed Boudra 16d7f5302e fix(server): reject attachment starts closed during conversion
Keep interruption terminal for prompts still being materialized, and reject a start if its session closes before provider submission. Cover both providers with real large-image conversions and make history reconciliation tests await the async timeline capability.
2026-10-27 04:24:24 +01:00
Mohamed Boudra 1aa38f4a99 fix(server): keep image materialization responsive
Decode and hash attachments in bounded yielding units, deduplicate before writing, and publish complete private files before provider timeline events. Await mapping through existing replay boundaries and serialize notification dispatch so turn completion cannot overtake an image.
2026-10-27 04:24:24 +01:00
Mohamed Boudra 173fca3c0b Keep the daemon responsive during Claude history replay (#7940)
* fix(server): keep Claude history replay responsive

Load persisted history through session readiness rather than the constructor. Async descriptor-owned replay preserves ordering while serving other requests, and rebind retires the old query before rebuilding history and rewind state.

* fix(server): recover Claude history readiness after rebind errors

* test(server): calibrate Claude replay fixture for CI

* fix(server): report Claude rewind rebind failures to callers
2026-10-27 10:23:59 +07:00
Luke LaBonte 7f6a805e0e feat(app): choose the composer send shortcut (#5503)
Add a client-local Send shortcut selector with Enter and Command/Ctrl+Enter choices. Preserve Enter as the default and route the selected shortcut through the existing send behavior. Cover selection, persistence, editing, and running-agent send behavior with browser tests.

Co-authored-by: Luke LaBonte <contact@lukelabonte.com>
2026-10-27 10:05:16 +07:00
Mohamed Boudra 9bc998fa38 fix(server): keep Git observation and worktree IO off the event loop (#7943)
* fix(server): keep Git observation and worktree IO off the event loop

Resolve subscription path identities before callbacks and serialize async worktree metadata mutations by canonical ownership. Git command tracing must not probe the working directory before spawning.

* test(server): verify Git trace records without filesystem probes

* fix(server): establish Git identities at observation setup

Read-only checkout refreshes should not resolve watcher namespaces. Observation tests await setup and compare cross-workspace fan-out without depending on completion order.

* fix(server): retain literal symlink targets in Git event identity

Canonical realpaths can erase a symlink target spelling when its parent is also a symlink, such as /var on macOS. Resolve these aliases once during setup so deleted and ignored event paths still match without per-event IO.

* fix(server): preserve Windows worktree path spelling

* fix(server): retain native aliases in worktree ownership

* test(server): write Git IO trace markers asynchronously
2026-10-27 09:57:35 +07:00
Mohamed Boudra 67a2459254 fix(server): keep project config work off the daemon hot path (#7944)
Project settings and script health updates synchronously read unbounded user files. Share admitted async snapshots across consumers, budget script validation, and serialize revision-checked edits while keeping lexical project identity.
2026-10-27 09:56:51 +07:00
Mohamed Boudra f14a4cc112 fix(server): keep password-protected config reload responsive (#7959)
* fix(server): keep password-protected config reload responsive

Resolve authentication only during startup. Runtime config writes and reloads share an async queue so durable state and live owners stay ordered, including overlapping plugin changes and rollback.

* test(server): adapt config consumers to async service-port reads

Provide the service-port capability in the existing Session and tool-bridge fixtures. Use the platform temporary directory for the plugin agent fixture so its working directory exists on Windows.
2026-10-27 09:56:47 +07:00
Mohamed Boudra a25f4f5065 perf(server): serve timeline pages from retained projection (#7958)
* perf(server): select timeline pages from retained projection

Keep page selection and source coverage indexes with the retained projection so bounded fetches and last-message lookups do not copy or reproject history. Omitted after limits use the existing 200-item store default; explicit zero remains unlimited for released CLI clients.

* test(server): keep timeline page oracle outside production builds
2026-10-27 09:56:42 +07:00
Mohamed Boudra 6077bc6219 Keep daemon diff highlighting responsive and bounded (#7945)
* fix(server): keep diff highlighting responsive and bounded

Tiny patches previously parsed both full source versions synchronously. Yield between parser work units and admit source by UTF-8 bytes, keeping large patches renderable without tokens. Cancel obsolete highlighting when a newer diff supersedes it.

* fix(server): keep diff cancellation with its requester

Independent commit viewers must not cancel each other. Use request-source cancellation for commit highlighting and cache invalidation for checkout highlighting. Oversized source versions use the existing budgeted hunk reconstruction so large lockfiles retain useful syntax tokens.
2026-10-27 09:56:36 +07:00
Mohamed Boudra 9c7d4ace65 fix(server): reject oversized provider stdout before parsing (#7960)
Admit raw UTF-8 line bytes before accumulation and fail the provider session explicitly on overflow. Share line framing so fragmented JSONL input no longer repeatedly scans and copies the complete prefix.
2026-10-27 09:56:31 +07:00
Mohamed Boudra 8d971876bf fix(omp): keep transcript replay responsive (#7948)
* fix(omp): keep transcript replay responsive

Index transcript branches in bounded asynchronous chunks and retain payloads only for the active chain, so large histories do not block unrelated daemon requests.

* test(omp): enforce responsive history replay in CI

Use a smaller structured transcript and a short selected chain to expose whole-file parsing without making large-output GC part of the CI gate. Disable unrelated provider discovery and route the regression through the existing integration job.

* test(ci): include OMP replay in the routing contract
2026-10-27 09:56:27 +07:00
Mohamed Boudra fc0cbbb323 fix(server): keep workspace reconciliation responsive on slow paths (#7941)
* fix(server): keep workspace reconciliation responsive on slow paths

Bound asynchronous directory inspection without releasing timed-out filesystem slots. Preserve unavailable records and make workspace archival atomic and idempotent so concurrent user archival cannot be repeated by a scan.

* fix(server): wait for healthy reconciliation inspection capacity
2026-10-27 09:56:21 +07:00
david828019-ossandQTom 64b0afe010 fix(claude): identify SDK processes with the Paseo entrypoint (#7976)
Co-authored-by: QTom <qt@truesightai.com>
2026-10-27 09:38:49 +07:00
Mohamed Boudra 10bc10c99f Let plugins give specific agents tools (#7938)
* feat(plugins): attach tools to individual agent sessions

* feat(plugins): reopen idle agent sessions after hook changes

Defer automatic plugin changes until the next idle prompt so running turns keep steering. Expose session reload separately from snapshot refresh in the SDK.

* docs(plugins): show how to attach tools to agents

* test(agent): update permission follow-up adapter
2026-10-27 09:23:50 +07:00
odoo-mvds 65ce4b590a fix(app): remove restored agent tabs from foreign workspaces (#7956)
A persisted explicit pin outlives archive recovery because its agent never joins the unrelated workspace directory. Drop positively foreign pins after hydration and let existing reconciliation remove only the misplaced view. Preserve unknown ownership and legitimate historical opens.

Adapts the repair proposed by kaicot in getpaseo/paseo#6901 to current main, with persisted-store and real-browser regression evidence. Refs getpaseo/paseo#6900.
2026-10-26 17:54:25 +00:00
paseo-bot[bot]andliyang 4f9d9d6de2 Keep ACP terminal output from stalling daemon requests (#7953)
* fix(acp): trim terminal output in linear time

* test(acp): reproduce terminal output stalls with a larger workload

The 128 KiB workload completes inside the regression deadline on faster
machines even with quadratic trimming. One MiB exceeds that deadline on
the old implementation while the bounded UTF-8 suffix implementation
finishes promptly.

Co-authored-by: liyang <liyang@msh.team>

---------

Co-authored-by: liyang <liyang@msh.team>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-26 17:04:42 +00:00
Mohamed Boudra 1f3266e2ba test(server): remove connected-host delivery timing assumptions (#7961)
CLI startup is not bounded by the recipient quiet window. Exercise the three delivery sources in separate windows and retain exact timeline and durable receipt assertions. Await permission-finish cleanup after dispatch acknowledgment using the integration cleanup budget.
2026-10-26 23:56:19 +07:00
Mohamed Boudra 28834a91ae fix(server): tolerate brief Windows atomic replace failures (#7955)
A receipt could remain pending after provider delivery when Windows rejected the completed-state rename with EPERM. Retry transient replace errors within a one-second budget while retaining the original file and cleanup semantics.
2026-10-26 23:13:25 +07:00
Simon Gurcke e3bbed3c73 fix(pi): queue prompts until manual compaction finishes (#7848)
* fix(pi): wait for manual compaction before sending prompts

* test(pi): simplify compaction regressions and stabilize live smoke
2026-10-26 14:02:44 +00:00
181 changed files with 9963 additions and 2324 deletions
+3
View File
@@ -432,6 +432,9 @@ Server entries register lifecycle observers with `server.on()` and request trans
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
executable definitions stay in the plugin process.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -1,3 +1,4 @@
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "../support/fixtures";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
@@ -274,3 +275,123 @@ test.describe("Settings toggle tab regression", () => {
}
});
});
async function selectSendingOption(
page: import("@playwright/test").Page,
setting: string,
option: string,
) {
await page.getByRole("button", { name: new RegExp(`^${setting}:`) }).click();
await page.getByRole("menuitem", { name: option, exact: true }).click();
await expect(
page.getByRole("button", { name: `${setting}: ${option}`, exact: true }),
).toBeVisible();
}
async function sendDraftWithShortcut(
page: import("@playwright/test").Page,
draft: string,
shortcut: string,
) {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill(draft);
await composer.press(shortcut);
await expect(composer).toHaveValue("");
await expect(page.getByText(draft, { exact: true })).toBeVisible();
}
test("send shortcut selection persists and controls the composer", async ({ page }, testInfo) => {
test.setTimeout(180_000);
const workspace = await seedWorkspace({ repoPrefix: "send-shortcut-" });
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
const agent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Send shortcut",
});
await openAgentRouteAndExpectFocused({
page,
serverId: getServerId(),
workspaceId: workspace.workspaceId,
agentId: agent.id,
});
await openComposerTab(page, agent.id);
const composer = page.getByRole("textbox", { name: "Message agent..." });
await test.step("Enter sends by default", async () => {
await sendDraftWithShortcut(page, "Default Enter submission", "Enter");
});
await test.step("choose the explicit modifier shortcut and retain it after reload", async () => {
await pressSettingsToggleShortcut(page);
await expect(
page.getByRole("button", { name: "Send shortcut: Enter", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Send shortcut: Enter", exact: true }).click();
await expect(page.getByRole("menuitem")).toHaveText(["Enter", `${modifierLabel}+Enter`]);
// Let the menu opening animation settle for visual QA.
await page.waitForTimeout(300);
await page.screenshot({ path: testInfo.outputPath("send-shortcut-selector.png") });
await page.getByRole("menuitem", { name: `${modifierLabel}+Enter`, exact: true }).click();
await page.reload();
await expect(
page.getByRole("button", { name: `Send shortcut: ${modifierLabel}+Enter`, exact: true }),
).toBeVisible();
await pressSettingsToggleShortcut(page);
await expect(composer).toBeVisible();
});
await test.step("Enter and Shift+Enter keep editing; modifier Enter sends", async () => {
await composer.fill("First line");
await composer.press("Enter");
await expect(composer).toHaveValue("First line\n");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("First line\n\n");
await sendDraftWithShortcut(page, "Sent with Control", "Control+Enter");
await sendDraftWithShortcut(page, "Sent with Command", "Meta+Enter");
});
await test.step("switch back to Enter without changing the default send behavior", async () => {
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", "Enter");
await pressSettingsToggleShortcut(page);
await composer.fill("Shift still edits");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("Shift still edits\n");
await sendDraftWithShortcut(page, "Restored Enter submission", "Enter");
});
} finally {
await workspace.cleanup();
}
});
for (const behavior of ["Steer", "Interrupt", "Queue"] as const) {
test(`modifier send shortcut follows ${behavior} while an agent runs`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "running-send-shortcut-",
title: "Running send shortcut",
model: "one-minute-stream",
});
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", `${modifierLabel}+Enter`);
await selectSendingOption(page, "Default send", behavior);
await pressSettingsToggleShortcut(page);
await sendDraftWithShortcut(page, "Stay running while I write a follow-up", "Control+Enter");
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill("Follow-up");
await composer.press("Enter");
await expect(composer).toHaveValue("Follow-up\n");
await composer.press("Control+Enter");
await expect(composer).toHaveValue("");
await expect(page.getByText("Follow-up", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Send queued message" })).toHaveCount(
behavior === "Queue" ? 1 : 0,
);
} finally {
await agent.cleanup();
}
});
}
@@ -11,7 +11,10 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
import {
expectAgentAbsentFromWorkspaceLayout,
seedForeignPinnedAgentTab,
} from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -105,6 +108,83 @@ async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("removes a foreign pinned agent when restoring a saved workspace layout", async ({
page,
}, testInfo) => {
const serverId = getServerId();
const firstWorkspace = await seedWorkspace({
repoPrefix: "workspace-owner-",
title: "Original workspace",
});
const secondWorkspace = await seedWorkspace({
repoPrefix: "workspace-restored-",
title: "Restored workspace",
});
try {
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
cwd: firstWorkspace.repoPath,
workspaceId: firstWorkspace.workspaceId,
title: "Original workspace agent",
});
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
cwd: secondWorkspace.repoPath,
workspaceId: secondWorkspace.workspaceId,
title: "Restored workspace agent",
});
await gotoAppShell(page);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await seedForeignPinnedAgentTab(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName,
});
await testInfo.attach("hydrated-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("hydrated-workspace.png") }),
contentType: "image/png",
});
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await testInfo.attach("restored-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("restored-workspace.png") }),
contentType: "image/png",
});
await switchWorkspaceViaSidebar({
page,
serverId,
workspaceId: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
const agents = await firstWorkspace.client.fetchAgents();
expect(
agents.entries.find(({ agent }) => agent.id === firstAgent.id)?.agent.workspaceId,
).toBe(firstWorkspace.workspaceId);
expect(
agents.entries.find(({ agent }) => agent.id === secondAgent.id)?.agent.workspaceId,
).toBe(secondWorkspace.workspaceId);
await testInfo.attach("original-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("original-workspace.png") }),
contentType: "image/png",
});
} finally {
await secondWorkspace.cleanup();
await firstWorkspace.cleanup();
}
});
test("keeps an offline notification on the agent route until the host connects", async ({
page,
}) => {
@@ -1,12 +1,47 @@
import { expect, type Page } from "@playwright/test";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import { openTabInLayoutFocused } from "../../../src/stores/workspace-layout-actions";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceTab,
} from "../../../src/workspace-tabs/model";
import { getServerId } from "./server-id";
export async function seedForeignPinnedAgentTab(
page: Page,
input: { serverId: string; workspaceId: string; agentId: string },
): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (raw === null) throw new Error("Workspace layout was not persisted");
const stored = z
.object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number().optional() })
.parse(JSON.parse(raw));
const key = buildWorkspaceTabPersistenceKey(input);
const layout = key ? stored.state.layoutByWorkspace[key] : null;
if (!key || !layout) throw new Error(`No persisted layout for workspace ${input.workspaceId}`);
const opened = openTabInLayoutFocused({
layout,
target: { kind: "agent", agentId: input.agentId },
now: 1,
placement: { mode: "focused" },
explorerSidebarPaneId: stored.state.explorerSidebarPaneIdByWorkspace?.[key] ?? null,
});
if (!opened) throw new Error("Failed to seed foreign agent tab");
stored.state = WorkspaceLayoutPersistedStateSchema.parse({
...stored.state,
layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: opened.layout },
pinnedAgentIdsByWorkspace: {
...stored.state.pinnedAgentIdsByWorkspace,
[key]: [...(stored.state.pinnedAgentIdsByWorkspace?.[key] ?? []), input.agentId],
},
});
await page.evaluate(
(value) => localStorage.setItem("workspace-layout-state", value),
JSON.stringify(stored),
);
}
/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
+1
View File
@@ -2495,6 +2495,7 @@ function ComposerContentImpl({
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={activeSendBehavior}
sendShortcut={appSettings.sendShortcut}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
+34 -4
View File
@@ -1,3 +1,4 @@
import type { SendShortcut } from "@/hooks/use-settings";
import {
useComposerEditingSession,
useComposerFullscreen,
@@ -88,6 +89,7 @@ import {
resolveAlternateSendAction,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveMessageInputEnterAction,
resolveRetainedSelection,
runAlternateSendAction,
runDefaultSendAction,
@@ -96,6 +98,11 @@ import {
} from "./state";
const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]];
const MOD_ENTER_SEND_KEYS: ShortcutKey[][] = [["mod", "Enter"]];
function resolveSendKeys(sendShortcut: SendShortcut): ShortcutKey[][] {
return sendShortcut === "mod-enter" ? MOD_ENTER_SEND_KEYS : DEFAULT_SEND_KEYS;
}
const COMPOSER_INPUT_DATASET = { composerInput: "" } as const;
export interface AttachmentMenuItem {
@@ -167,6 +174,7 @@ export interface MessageInputProps {
* running. "interrupt" and "steer" send immediately, "queue" queues. Required so the default
* lives only in DEFAULT_CLIENT_SETTINGS. */
defaultSendBehavior: "interrupt" | "steer" | "queue";
sendShortcut: SendShortcut;
/** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void;
/** Optional handler used when submit button is in loading state. */
@@ -402,6 +410,9 @@ interface DesktopKeyPressContext {
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
input: ComposerKeyPressEvent["input"];
submitOnEnter: boolean;
sendShortcut: SendShortcut;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
isSubmitDisabled: boolean;
isSubmitLoading: boolean;
disabled: boolean;
@@ -428,12 +439,21 @@ function handleDesktopKeyPressImpl(
}
if (event.nativeEvent.key !== "Enter") return;
if (!ctx.submitOnEnter) return;
if (shiftKey) return;
const action = resolveMessageInputEnterAction({
submitOnEnter: ctx.submitOnEnter,
sendShortcut: ctx.sendShortcut,
shiftKey: shiftKey ?? false,
metaKey: metaKey ?? false,
ctrlKey: ctrlKey ?? false,
isAgentRunning: ctx.isAgentRunning,
onQueue: ctx.onQueue,
});
if (action === null) return;
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
event.preventDefault();
if (metaKey || ctrlKey) {
if (action === "alternate-send") {
ctx.handleAlternateSendAction();
return;
}
@@ -1112,6 +1132,7 @@ interface ResolvedMessageInputProps {
voiceAgentId: string | undefined;
isAgentRunning: boolean;
defaultSendBehavior: "interrupt" | "steer" | "queue";
sendShortcut: SendShortcut;
onQueue: ((payload: MessagePayload) => void) | undefined;
onSubmitLoadingPress: (() => void) | undefined;
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
@@ -1159,6 +1180,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
voiceAgentId: props.voiceAgentId,
isAgentRunning: props.isAgentRunning ?? false,
defaultSendBehavior: props.defaultSendBehavior,
sendShortcut: props.sendShortcut,
onQueue: props.onQueue,
onSubmitLoadingPress: props.onSubmitLoadingPress,
onKeyPressCallback: props.onKeyPress,
@@ -1236,6 +1258,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
voiceAgentId,
isAgentRunning,
defaultSendBehavior,
sendShortcut,
onQueue,
onSubmitLoadingPress,
onKeyPressCallback,
@@ -1755,6 +1778,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
selectionRef.current,
),
submitOnEnter: shouldSubmitOnEnter,
sendShortcut,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
@@ -1766,6 +1792,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
shouldHandleWebKeyPress,
onKeyPressCallback,
shouldSubmitOnEnter,
sendShortcut,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
@@ -1980,7 +2009,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
submitLabel={submitLabel}
submitButtonTestID={submitButtonTestID}
buttonIconSize={buttonIconSize}
sendKeys={DEFAULT_SEND_KEYS}
sendKeys={resolveSendKeys(sendShortcut)}
sendTooltipLabel={sendTooltipLabel}
/>
),
@@ -2001,6 +2030,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
submitLabel,
submitButtonTestID,
buttonIconSize,
sendShortcut,
sendTooltipLabel,
],
);
@@ -2,10 +2,12 @@ import { describe, expect, it, vi } from "vitest";
import {
applyDictationTranscript,
computeCanStartDictation,
type MessageInputEnterContext,
resolveActiveSendBehavior,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveGlyphRailOffset,
resolveMessageInputEnterAction,
resolveRetainedSelection,
resolveAlternateSendAction,
runAlternateSendAction,
@@ -320,6 +322,212 @@ describe("composer send behavior", () => {
});
});
describe("resolveMessageInputEnterAction", () => {
function enterContext(
overrides: Partial<MessageInputEnterContext> = {},
): MessageInputEnterContext {
return {
submitOnEnter: true,
sendShortcut: "enter",
shiftKey: false,
metaKey: false,
ctrlKey: false,
isAgentRunning: false,
onQueue: () => undefined,
...overrides,
};
}
it("returns null for plain, Shift, and modifier Enter when submit on enter is disabled", () => {
expect(resolveMessageInputEnterAction(enterContext({ submitOnEnter: false }))).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, shiftKey: true })),
).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, metaKey: true })),
).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, ctrlKey: true })),
).toBeNull();
});
it("still returns null when modifier Enter sends and submit on enter is disabled", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", submitOnEnter: false, metaKey: true }),
),
).toBeNull();
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", submitOnEnter: false }),
),
).toBeNull();
});
it("sends on plain Enter when Enter sends", () => {
expect(resolveMessageInputEnterAction(enterContext())).toBe("default-send");
});
it("returns null for Shift+Enter when Enter sends", () => {
expect(resolveMessageInputEnterAction(enterContext({ shiftKey: true }))).toBeNull();
});
it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter so Shift keeps its newline", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ shiftKey: true, metaKey: true, isAgentRunning: true }),
),
).toBeNull();
expect(
resolveMessageInputEnterAction(
enterContext({ shiftKey: true, ctrlKey: true, isAgentRunning: true }),
),
).toBeNull();
});
it("sends on Cmd+Enter and Ctrl+Enter while idle even with a queue handler", () => {
expect(resolveMessageInputEnterAction(enterContext({ metaKey: true }))).toBe("default-send");
expect(resolveMessageInputEnterAction(enterContext({ ctrlKey: true }))).toBe("default-send");
});
it("queues on Cmd+Enter and Ctrl+Enter while the agent is running with a queue handler", () => {
expect(
resolveMessageInputEnterAction(enterContext({ metaKey: true, isAgentRunning: true })),
).toBe("alternate-send");
expect(
resolveMessageInputEnterAction(enterContext({ ctrlKey: true, isAgentRunning: true })),
).toBe("alternate-send");
});
it("sends on Cmd+Enter and Ctrl+Enter while running when no queue handler exists", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ metaKey: true, isAgentRunning: true, onQueue: undefined }),
),
).toBe("default-send");
expect(
resolveMessageInputEnterAction(
enterContext({ ctrlKey: true, isAgentRunning: true, onQueue: undefined }),
),
).toBe("default-send");
});
it("returns null for plain and Shift+Enter when modifier Enter sends", () => {
expect(resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter" }))).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", shiftKey: true })),
).toBeNull();
});
it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter when modifier Enter sends", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", shiftKey: true, metaKey: true }),
),
).toBeNull();
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", shiftKey: true, ctrlKey: true }),
),
).toBeNull();
});
it("sends on Cmd+Enter and Ctrl+Enter while idle when modifier Enter sends", () => {
expect(
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", metaKey: true })),
).toBe("default-send");
expect(
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", ctrlKey: true })),
).toBe("default-send");
});
it("uses the default action on modifier Enter while running when modifier Enter sends", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }),
),
).toBe("default-send");
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", ctrlKey: true, isAgentRunning: true }),
),
).toBe("default-send");
});
describe("composed with the send actions", () => {
function actions() {
const calls: string[] = [];
return {
calls,
handleSendMessage: () => calls.push("send"),
handleQueueMessage: () => calls.push("queue"),
onQueue: () => undefined,
};
}
function resolveCommandEnterToSendModifierEnter() {
return resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }),
);
}
it("queues through the default send action when queue behavior is selected and the agent is running", () => {
const sendAction = actions();
expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send");
runDefaultSendAction({
defaultSendBehavior: "queue",
isAgentRunning: true,
onQueue: sendAction.onQueue,
handleSendMessage: sendAction.handleSendMessage,
handleQueueMessage: sendAction.handleQueueMessage,
});
expect(sendAction.calls).toEqual(["queue"]);
});
it("sends through the default send action when queue behavior is selected and the agent is idle", () => {
const sendAction = actions();
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: false }),
),
).toBe("default-send");
runDefaultSendAction({
defaultSendBehavior: "queue",
isAgentRunning: false,
onQueue: sendAction.onQueue,
handleSendMessage: sendAction.handleSendMessage,
handleQueueMessage: sendAction.handleQueueMessage,
});
expect(sendAction.calls).toEqual(["send"]);
});
it.each([{ defaultSendBehavior: "steer" }, { defaultSendBehavior: "interrupt" }] as const)(
"sends through the default send action when $defaultSendBehavior behavior is selected and the agent is running",
({ defaultSendBehavior }) => {
const sendAction = actions();
expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send");
runDefaultSendAction({
defaultSendBehavior,
isAgentRunning: true,
onQueue: sendAction.onQueue,
handleSendMessage: sendAction.handleSendMessage,
handleQueueMessage: sendAction.handleQueueMessage,
});
expect(sendAction.calls).toEqual(["send"]);
},
);
});
});
describe("stopRealtimeVoice", () => {
it("keeps voice mode active when the running agent refuses cancellation", async () => {
const cancellationError = new Error("active run cancellation was not acknowledged");
+24
View File
@@ -1,3 +1,4 @@
import type { SendShortcut } from "@/hooks/use-settings";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import type { MessagePayload, TextReplacement } from "@/composer/types";
@@ -151,6 +152,29 @@ export function runAlternateSendAction(ctx: SendActionContext): void {
runDefaultSendAction(ctx);
}
export type MessageInputEnterAction = "alternate-send" | "default-send";
export interface MessageInputEnterContext {
submitOnEnter: boolean;
sendShortcut: SendShortcut;
shiftKey: boolean;
metaKey: boolean;
ctrlKey: boolean;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
}
export function resolveMessageInputEnterAction(
ctx: MessageInputEnterContext,
): MessageInputEnterAction | null {
if (!ctx.submitOnEnter) return null;
if (ctx.shiftKey) return null;
const hasModifier = ctx.metaKey || ctx.ctrlKey;
if (ctx.sendShortcut === "mod-enter") return hasModifier ? "default-send" : null;
if (hasModifier && ctx.isAgentRunning && ctx.onQueue) return "alternate-send";
return "default-send";
}
export function runMessageInputKeyboardAction(
action: MessageInputKeyboardActionKind,
actions: MessageInputKeyboardActions,
@@ -49,6 +49,7 @@ import {
type KeyValueStorage,
type ReleaseChannel,
type SendBehavior,
type SendShortcut,
type ServiceUrlBehavior,
type Settings,
type SidebarWorkspaceTrailing,
@@ -93,6 +94,7 @@ export type {
KeyValueStorage,
ReleaseChannel,
SendBehavior,
SendShortcut,
ServiceUrlBehavior,
Settings,
SettingsDeps,
@@ -227,6 +227,39 @@ describe("loadAppSettingsFromStorage", () => {
expect(result.chatOutlineEnabled).toBe(false);
});
it("uses Enter to send by default", async () => {
const deps = makeDeps();
const result = await loadAppSettingsFromStorage(deps);
expect(result.sendShortcut).toBe("enter");
expect(DEFAULT_CLIENT_SETTINGS.sendShortcut).toBe("enter");
});
it("loads the modifier Enter send preference", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "mod-enter" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.sendShortcut).toBe("mod-enter");
});
it("falls back to Enter when the stored send shortcut is invalid", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "yes" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.sendShortcut).toBe("enter");
});
it("defaults sidebar navigation items to an empty preference list", async () => {
const deps = makeDeps();
@@ -33,6 +33,8 @@ import { migrateAppSettings } from "./migrations";
export { APP_SETTINGS_KEY } from "./keys";
export const APP_SETTINGS_QUERY_KEY = ["app-settings"];
export type SendShortcut = "enter" | "mod-enter";
export type SendBehavior = ActiveTurnBehavior | "queue";
export type ReleaseChannel = "stable" | "beta";
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
@@ -104,6 +106,7 @@ export interface AppSettings {
chatDisplay: ChatDisplay;
chatOutlineEnabled: boolean;
vimKeybindings: boolean;
sendShortcut: SendShortcut;
filePanelAssociations: Record<string, string>;
/** Desktop-only preferences for implicit opens into the ordinary side pane. */
openInSidePane: OpenInSidePanePreferences;
@@ -160,6 +163,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
chatDisplay: "collapseTurns",
chatOutlineEnabled: true,
vimKeybindings: false,
sendShortcut: "enter",
filePanelAssociations: {},
openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
pullRequestOpenLocation: "explorer",
@@ -264,6 +268,7 @@ const StoredAppSettingsSchema = z
compactToolCalls: z.boolean().optional().catch(undefined),
chatOutlineEnabled: z.boolean().catch(true),
vimKeybindings: z.boolean().catch(false),
sendShortcut: z.enum(["enter", "mod-enter"]).catch("enter"),
filePanelAssociations: z.record(z.string(), z.string()).catch({}),
openInSidePane: z
.object({
+13
View File
@@ -2369,12 +2369,25 @@ export const ar: TranslationResources = {
"عند تشغيل الوكيل، يوجّه Enter الجولة النشطة. يضيف Command/Ctrl+Enter إلى قائمة الانتظار.",
queue: "عند تشغيل الوكيل، يضيف Enter إلى قائمة الانتظار. Command/Ctrl+Enter يرسل.",
},
commandEnterDescriptions: {
interrupt: "عند تشغيل الوكيل، يوقف {{modifier}}+Enter التشغيل. يُدرج Enter سطرًا جديدًا.",
steer: "عند تشغيل الوكيل، يوجّه {{modifier}}+Enter الجولة النشطة. يُدرج Enter سطرًا جديدًا.",
queue:
"عند تشغيل الوكيل، يضيف {{modifier}}+Enter إلى قائمة الانتظار. يُدرج Enter سطرًا جديدًا.",
},
options: {
interrupt: "مقاطعة",
steer: "توجيه",
queue: "طابور",
},
},
sendShortcut: {
label: "اختصار الإرسال",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "بسأل",
+14
View File
@@ -2457,12 +2457,26 @@ export const en = {
"When the agent is running, Enter steers the active turn. Command/Ctrl+Enter queues.",
queue: "When the agent is running, Enter queues. Command/Ctrl+Enter submits.",
},
commandEnterDescriptions: {
interrupt:
"When the agent is running, {{modifier}}+Enter interrupts. Enter inserts a new line.",
steer:
"When the agent is running, {{modifier}}+Enter steers the active turn. Enter inserts a new line.",
queue: "When the agent is running, {{modifier}}+Enter queues. Enter inserts a new line.",
},
options: {
interrupt: "Interrupt",
steer: "Steer",
queue: "Queue",
},
},
sendShortcut: {
label: "Send shortcut",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Ask",
+15
View File
@@ -2384,12 +2384,27 @@ export const es: TranslationResources = {
queue:
"Cuando el agente se está ejecutando, Enter pone en cola. Command/Ctrl+Enter envía.",
},
commandEnterDescriptions: {
interrupt:
"Cuando el agente se está ejecutando, {{modifier}}+Enter interrumpe. Enter inserta una línea nueva.",
steer:
"Cuando el agente se está ejecutando, {{modifier}}+Enter dirige el turno activo. Enter inserta una línea nueva.",
queue:
"Cuando el agente se está ejecutando, {{modifier}}+Enter pone en cola. Enter inserta una línea nueva.",
},
options: {
interrupt: "Interrumpir",
steer: "Dirigir",
queue: "Cola",
},
},
sendShortcut: {
label: "Atajo de envío",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Preguntar",
+15
View File
@@ -2383,12 +2383,27 @@ export const fr: TranslationResources = {
queue:
"Quand l’agent est en cours d’exécution, Entrée met en file d’attente. Cmd/Ctrl+Entrée envoie.",
},
commandEnterDescriptions: {
interrupt:
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée interrompt. Entrée insère une nouvelle ligne.",
steer:
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée oriente le tour actif. Entrée insère une nouvelle ligne.",
queue:
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée met en file d'attente. Entrée insère une nouvelle ligne.",
},
options: {
interrupt: "Interrompre",
steer: "Réorienter",
queue: "File d’attente",
},
},
sendShortcut: {
label: "Raccourci d’envoi",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Demander",
+13
View File
@@ -2353,12 +2353,25 @@ export const ja: TranslationResources = {
"エージェント実行中、Enterで現在のターンに指示を送ります。Command/Ctrl+Enterでキューに追加。",
queue: "エージェント実行中、Enterでキューに追加します。Command/Ctrl+Enterで送信。",
},
commandEnterDescriptions: {
interrupt: "エージェント実行中、{{modifier}}+Enterで中断します。Enterで改行します。",
steer:
"エージェント実行中、{{modifier}}+Enterで現在のターンに指示を送ります。Enterで改行します。",
queue: "エージェント実行中、{{modifier}}+Enterでキューに追加します。Enterで改行します。",
},
options: {
interrupt: "中断",
steer: "指示",
queue: "キュー",
},
},
sendShortcut: {
label: "送信ショートカット",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "確認する",
+15
View File
@@ -2344,12 +2344,27 @@ export const ko: TranslationResources = {
queue:
"에이전트가 실행 중일 때 Enter는 대기열에 추가합니다. Command/Ctrl+Enter는 제출합니다.",
},
commandEnterDescriptions: {
interrupt:
"에이전트가 실행 중일 때 {{modifier}}+Enter는 중단합니다. Enter는 줄바꿈합니다.",
steer:
"에이전트가 실행 중일 때 {{modifier}}+Enter는 현재 턴에 지시합니다. Enter는 줄바꿈합니다.",
queue:
"에이전트가 실행 중일 때 {{modifier}}+Enter는 대기열에 추가합니다. Enter는 줄바꿈합니다.",
},
options: {
interrupt: "중단",
steer: "지시",
queue: "대기열",
},
},
sendShortcut: {
label: "전송 단축키",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "물어보기",
+15
View File
@@ -2367,12 +2367,27 @@ export const ptBR: TranslationResources = {
"Quando o agente está em execução, Enter orienta o turno ativo. Command/Ctrl+Enter enfileira.",
queue: "Quando o agente está em execução, Enter enfileira. Command/Ctrl+Enter envia.",
},
commandEnterDescriptions: {
interrupt:
"Quando o agente está em execução, {{modifier}}+Enter interrompe. Enter insere uma nova linha.",
steer:
"Quando o agente está em execução, {{modifier}}+Enter orienta o turno ativo. Enter insere uma nova linha.",
queue:
"Quando o agente está em execução, {{modifier}}+Enter enfileira. Enter insere uma nova linha.",
},
options: {
interrupt: "Interromper",
steer: "Orientar",
queue: "Fila",
},
},
sendShortcut: {
label: "Atalho de envio",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Perguntar",
+15
View File
@@ -2370,12 +2370,27 @@ export const ru: TranslationResources = {
queue:
"Когда агент работает, Enter ставит сообщение в очередь, а Command/Ctrl+Enter отправляет его.",
},
commandEnterDescriptions: {
interrupt:
"Когда агент работает, {{modifier}}+Enter прерывает его работу, а Enter вставляет новую строку.",
steer:
"Когда агент работает, {{modifier}}+Enter дополняет текущий ответ новым указанием, а Enter вставляет новую строку.",
queue:
"Когда агент работает, {{modifier}}+Enter ставит сообщение в очередь, а Enter вставляет новую строку.",
},
options: {
interrupt: "Прерывать",
steer: "Направить",
queue: "Поставить в очередь",
},
},
sendShortcut: {
label: "Сочетание клавиш для отправки",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Спрашивать",
+12
View File
@@ -2308,12 +2308,24 @@ export const zhCN: TranslationResources = {
steer: "Agent 运行时,Enter 会引导当前回合。Command/Ctrl+Enter 会排队。",
queue: "Agent 运行时,Enter 会排队。Command/Ctrl+Enter 会提交。",
},
commandEnterDescriptions: {
interrupt: "Agent 运行时,{{modifier}}+Enter 会中断。Enter 会插入换行。",
steer: "Agent 运行时,{{modifier}}+Enter 会引导当前回合。Enter 会插入换行。",
queue: "Agent 运行时,{{modifier}}+Enter 会排队。Enter 会插入换行。",
},
options: {
interrupt: "中断",
steer: "引导",
queue: "排队",
},
},
sendShortcut: {
label: "发送快捷键",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "询问",
@@ -1,13 +1,17 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { SettingsCard, SettingsSection, SettingsSelect } from "@/components/settings";
import { useAppSettings, type SendBehavior } from "@/hooks/use-settings";
import { useAppSettings, type SendShortcut, type SendBehavior } from "@/hooks/use-settings";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { getShortcutOs } from "@/utils/shortcut-platform";
const SEND_BEHAVIORS: readonly SendBehavior[] = ["interrupt", "steer", "queue"];
export function SendingSection() {
const { t } = useTranslation();
const { settings, updateSettings } = useAppSettings();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const modifier = getShortcutOs() === "mac" ? "⌘" : "Ctrl";
const options = useMemo(
() =>
SEND_BEHAVIORS.map((value) => ({
@@ -20,12 +24,37 @@ export function SendingSection() {
(sendBehavior: SendBehavior) => void updateSettings({ sendBehavior }),
[updateSettings],
);
const shortcutOptions = useMemo(
() =>
(["enter", "mod-enter"] as const).map((value) => ({
value,
label: t(`settings.general.sendShortcut.options.${value}`, { modifier }),
})),
[t, modifier],
);
const changeSendShortcut = useCallback(
(sendShortcut: SendShortcut) => void updateSettings({ sendShortcut }),
[updateSettings],
);
return (
<SettingsSection title={t("settings.general.sending")}>
<SettingsCard>
{shortcutsAvailable ? (
<SettingsSelect
label={t("settings.general.sendShortcut.label")}
value={settings.sendShortcut}
options={shortcutOptions}
onValueChange={changeSendShortcut}
/>
) : null}
<SettingsSelect
label={t("settings.general.defaultSend.label")}
hint={t(`settings.general.defaultSend.descriptions.${settings.sendBehavior}`)}
hint={t(
shortcutsAvailable && settings.sendShortcut === "mod-enter"
? `settings.general.defaultSend.commandEnterDescriptions.${settings.sendBehavior}`
: `settings.general.defaultSend.descriptions.${settings.sendBehavior}`,
{ modifier },
)}
value={settings.sendBehavior}
options={options}
onValueChange={change}
@@ -271,6 +271,7 @@ export interface WorkspaceTabSnapshot {
terminalsHydrated: boolean;
activeAgentIds: Iterable<string>;
autoOpenAgentIds: Iterable<string>;
foreignAgentIds?: Iterable<string>;
knownTerminalIds?: Iterable<string>;
standaloneTerminalIds: Iterable<string>;
hasActivePendingTerminalCreate?: boolean;
@@ -2443,13 +2444,16 @@ export function reconcileWorkspaceTabs(
const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []);
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds);
// An explicit open owns the tab until the agent joins the active directory.
// From then on it follows the normal server archive lifecycle. Detail/cache
// hydration never decides whether the user's target is allowed to stay open.
const foreignAgentIds = normalizeStringSet(snapshot.foreignAgentIds ?? []);
// Explicit opens survive absent directory entries, but not known foreign ownership.
// Once an agent joins this workspace's active directory, normal archive rules apply.
const pinnedAgentIds = new Set(
[...(state.pinnedAgentIds ?? [])].filter(
(agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId),
),
[...(state.pinnedAgentIds ?? [])].filter((agentId) => {
if (!snapshot.agentsHydrated) {
return true;
}
return !activeAgentIds.has(agentId) && !foreignAgentIds.has(agentId);
}),
);
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
const knownTerminalIds = snapshot.knownTerminalIds
@@ -1,6 +1,7 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, it } from "vitest";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { createWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
buildWorkspaceTabSnapshot,
createWorkspaceAgentVisibilitySelector,
@@ -63,6 +64,118 @@ function makeAgent(input: {
const WORKSPACE_ID = "ws-1";
describe("workspace agent visibility", () => {
it("removes a restored pinned tab from an unrelated workspace once its owner is known", async () => {
const values = new Map<string, string>();
const storage = {
getItem: async (key: string) => values.get(key) ?? null,
setItem: async (key: string, value: string) => {
values.set(key, value);
},
removeItem: async (key: string) => {
values.delete(key);
},
};
const original = createWorkspaceLayoutStore(undefined, storage);
await original.persist.rehydrate();
const agent = makeAgent({ id: "restored-agent", cwd: "/project-a", workspaceId: "ws-a" });
for (const workspaceId of ["ws-a", "ws-b"]) {
original.getState().openTab({
workspaceKey: `srv:${workspaceId}`,
target: { kind: "agent", agentId: agent.id },
intent: "reveal",
pin: true,
});
}
const restored = createWorkspaceLayoutStore(undefined, storage);
await restored.persist.rehydrate();
const sessionAgents = new Map([[agent.id, agent]]);
const beforeHydration = buildWorkspaceTabSnapshot({
agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId: "ws-b" }),
agentsHydrated: false,
terminalsHydrated: true,
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingTerminalCreate: false,
hasActivePendingDraftCreate: false,
});
restored.getState().reconcileTabs("srv:ws-b", beforeHydration);
expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toEqual(new Set([agent.id]));
for (const workspaceId of ["ws-a", "ws-b"]) {
restored.getState().reconcileTabs(
`srv:${workspaceId}`,
buildWorkspaceTabSnapshot({
agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId }),
agentsHydrated: true,
terminalsHydrated: true,
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingTerminalCreate: false,
hasActivePendingDraftCreate: false,
}),
);
}
expect(
restored
.getState()
.getWorkspaceTabs("srv:ws-a")
.filter((tab) => tab.target.kind === "agent")
.map((tab) => tab.target),
).toEqual([{ kind: "agent", agentId: agent.id }]);
expect(
restored
.getState()
.getWorkspaceTabs("srv:ws-b")
.filter((tab) => tab.target.kind === "agent"),
).toEqual([]);
expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toBeUndefined();
});
it("uses directory ownership before historical details when identifying foreign agents", () => {
const own = makeAgent({ id: "own", cwd: "/repo", workspaceId: WORKSPACE_ID });
const foreign = makeAgent({ id: "foreign", cwd: "/repo", workspaceId: "ws-2" });
const unknown = makeAgent({ id: "unknown", cwd: "/repo" });
const result = deriveWorkspaceAgentVisibility({
workspaceId: WORKSPACE_ID,
sessionAgents: new Map([
[own.id, own],
[foreign.id, foreign],
[unknown.id, unknown],
]),
agentDetails: new Map([
[own.id, { ...own, workspaceId: "ws-2" }],
[foreign.id, { ...foreign, workspaceId: WORKSPACE_ID }],
[unknown.id, { ...unknown, workspaceId: "ws-2" }],
]),
});
expect(result.activeAgentIds).toEqual(new Set([own.id]));
expect(result.foreignAgentIds).toEqual(new Set([foreign.id]));
});
it("recognizes foreign historical ownership without making archived or unknown agents foreign", () => {
const archived = makeAgent({
id: "archived",
cwd: "/repo",
workspaceId: WORKSPACE_ID,
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
});
const foreign = { ...archived, id: "foreign", workspaceId: "ws-2" };
const unknown = { ...archived, id: "unknown", workspaceId: undefined };
const result = deriveWorkspaceAgentVisibility({
workspaceId: WORKSPACE_ID,
sessionAgents: new Map(),
agentDetails: new Map([
[archived.id, archived],
[foreign.id, foreign],
[unknown.id, unknown],
]),
});
expect(result.activeAgentIds).toEqual(new Set());
expect(result.foreignAgentIds).toEqual(new Set([foreign.id]));
});
it("keeps subagents active while excluding them from auto-open", () => {
const parent = makeAgent({
id: "parent-agent",
@@ -86,6 +199,7 @@ describe("workspace agent visibility", () => {
expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"]));
expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"]));
expect(result.foreignAgentIds).toEqual(new Set());
});
it("excludes archived subagents from active and auto-open", () => {
@@ -295,6 +409,7 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.foreignAgentIds).toEqual(new Set(["other-ws-agent"]));
});
it("excludes agents without a workspaceId", () => {
@@ -308,12 +423,14 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.foreignAgentIds).toEqual(new Set<string>());
});
it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {
const agentVisibility = {
activeAgentIds: new Set(["active-agent"]),
autoOpenAgentIds: new Set(["root-agent"]),
foreignAgentIds: new Set(["foreign-agent"]),
};
expect(
@@ -331,6 +448,7 @@ describe("workspace agent visibility", () => {
terminalsHydrated: true,
activeAgentIds: agentVisibility.activeAgentIds,
autoOpenAgentIds: agentVisibility.autoOpenAgentIds,
foreignAgentIds: agentVisibility.foreignAgentIds,
knownTerminalIds: ["terminal-1", "script-terminal"],
standaloneTerminalIds: ["terminal-1"],
hasActivePendingTerminalCreate: false,
@@ -469,6 +587,24 @@ describe("workspace agent visibility", () => {
expect(after.activeAgentIds).toEqual(new Set([child.id]));
expect(after.autoOpenAgentIds).toEqual(new Set([child.id]));
});
it("recomputes when only a previously unknown agent's foreign ownership becomes known", () => {
seedSession(new Map([[parent.id, parent]]));
const select = createWorkspaceAgentVisibilitySelector({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
const before = select(useSessionStore.getState());
useSessionStore
.getState()
.setAgentDetails(SERVER_ID, new Map([[otherWorkspace.id, otherWorkspace]]));
const after = select(useSessionStore.getState());
expect(after).not.toBe(before);
expect(after.activeAgentIds).toEqual(before.activeAgentIds);
expect(after.autoOpenAgentIds).toEqual(before.autoOpenAgentIds);
expect(after.foreignAgentIds).toEqual(new Set([otherWorkspace.id]));
});
});
describe("workspaceAgentVisibilityEqual", () => {
@@ -476,10 +612,12 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set(["c"]),
};
const b = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set(["c"]),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
});
@@ -488,10 +626,12 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set(["a"]),
autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set<string>(),
};
const b = {
activeAgentIds: new Set(["b"]),
autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set<string>(),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
});
@@ -500,10 +640,12 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set<string>(),
};
const b = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["b"]),
foreignAgentIds: new Set<string>(),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
});
@@ -512,10 +654,12 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
foreignAgentIds: new Set<string>(),
};
const b = {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
foreignAgentIds: new Set<string>(),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
});
@@ -6,10 +6,7 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
export interface WorkspaceAgentVisibility {
activeAgentIds: Set<string>;
autoOpenAgentIds: Set<string>;
}
function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean {
return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId;
foreignAgentIds: Set<string>;
}
export function deriveWorkspaceAgentVisibility(input: {
@@ -19,17 +16,23 @@ export function deriveWorkspaceAgentVisibility(input: {
}): WorkspaceAgentVisibility {
const { sessionAgents, agentDetails } = input;
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if ((!sessionAgents && !agentDetails) || !workspaceId) {
if (!workspaceId) {
return {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
foreignAgentIds: new Set<string>(),
};
}
const activeAgentIds = new Set<string>();
const autoOpenAgentIds = new Set<string>();
const foreignAgentIds = new Set<string>();
for (const agent of sessionAgents?.values() ?? []) {
if (!agentBelongsToWorkspace(agent, workspaceId)) {
const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId);
if (ownerId !== workspaceId) {
if (ownerId) {
foreignAgentIds.add(agent.id);
}
continue;
}
if (!agent.archivedAt) {
@@ -42,7 +45,16 @@ export function deriveWorkspaceAgentVisibility(input: {
}
}
}
return { activeAgentIds, autoOpenAgentIds };
for (const agent of agentDetails?.values() ?? []) {
if (sessionAgents?.has(agent.id)) {
continue;
}
const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId);
if (ownerId && ownerId !== workspaceId) {
foreignAgentIds.add(agent.id);
}
}
return { activeAgentIds, autoOpenAgentIds, foreignAgentIds };
}
interface AgentMapsSnapshot {
@@ -99,6 +111,7 @@ export function buildWorkspaceTabSnapshot(input: {
terminalsHydrated: input.terminalsHydrated,
activeAgentIds: input.agentVisibility.activeAgentIds,
autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds,
foreignAgentIds: input.agentVisibility.foreignAgentIds,
knownTerminalIds: input.knownTerminalIds,
standaloneTerminalIds: input.standaloneTerminalIds,
hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate,
@@ -113,7 +126,8 @@ export function workspaceAgentVisibilityEqual(
return (
a === b ||
(setsEqual(a.activeAgentIds, b.activeAgentIds) &&
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds))
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) &&
setsEqual(a.foreignAgentIds, b.foreignAgentIds))
);
}
+63
View File
@@ -1770,3 +1770,66 @@ test("qualified parents preserve foreign identity without resolving an equal loc
await client.close();
expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" });
});
test("agent reload reopens through the daemon RPC then returns the observed snapshot; refresh remains a read", async () => {
const { client, ws } = await connectClient();
try {
const agent = client.agents.ref("agent_sdk");
const snapshot = createAgent();
const reloaded = agent.reload({ requestId: "reload-sdk" });
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "refresh_agent_request",
agentId: "agent_sdk",
requestId: "reload-sdk",
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_refreshed",
requestId: "reload-sdk",
agentId: "agent_sdk",
timelineSize: 0,
},
}),
);
await expect
.poll(() => parseSentSessionMessage(ws.sent.at(-1)).type)
.toBe("fetch_agent_request");
const fetched = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "fetch_agent_response",
payload: {
requestId: fetched.requestId,
agentId: "agent_sdk",
agent: snapshot,
project: null,
error: null,
},
}),
);
expect(await reloaded).toEqual({ agent: snapshot, project: null });
expect(agent.current()).toEqual(snapshot);
const read = agent.refresh("snapshot-read");
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "fetch_agent_request",
requestId: "snapshot-read",
});
ws.message(
sessionMessage({
type: "fetch_agent_response",
payload: {
requestId: "snapshot-read",
agentId: "agent_sdk",
agent: snapshot,
project: null,
error: null,
},
}),
);
expect(await read).toEqual({ agent: snapshot, project: null });
} finally {
await client.close();
}
});
+11
View File
@@ -369,6 +369,11 @@ export interface PaseoAgentHandle {
readonly timeline: PaseoAgentTimelineHandle;
current(): PaseoAgent | null;
refresh(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
/**
* Reopens the provider session with current configuration, rerunning session-open hooks.
* Interrupts a running turn and returns the refreshed snapshot.
*/
reload(options?: { requestId?: string }): Promise<PaseoAgentRefetchResult | null>;
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise<void>;
/** Sends a prompt and resolves when that turn finishes or needs attention. */
@@ -981,6 +986,12 @@ function createAgentHandleFactory(
current = result?.agent ?? null;
return result;
},
reload: async (options) => {
await daemonClient.refreshAgent(id, options?.requestId);
const result = await daemonClient.fetchAgent({ agentId: id });
current = result?.agent ?? null;
return result;
},
send: async (text, options) => {
await daemonClient.sendAgentMessage(id, text, options);
},
@@ -1,4 +1,6 @@
import { describe, it, expect } from "vitest";
import { setImmediate } from "node:timers/promises";
import { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "../budgeted-highlighter.js";
import type { HighlightToken } from "../types.js";
import { highlightCode, highlightCodeInSteps, highlightLine } from "../highlighter.js";
@@ -420,3 +422,55 @@ describe("highlightLine", () => {
expect(tokens).toEqual([{ text: "hello", style: null }]);
});
});
describe("highlightCodeWithBudget", () => {
it("yields to IO while producing byte-identical output below the source budget", async () => {
const code = "const value = Math.max(42, 7);\n".repeat(3_000);
const expected = JSON.stringify(highlightCode(code, "file.ts"));
let ticks = 0;
let longestGap = 0;
let lastTick = performance.now();
const timer = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - lastTick);
lastTick = now;
ticks++;
}, 1);
try {
const actual = await highlightCodeWithBudget(code, "file.ts", {
yieldToEventLoop: setImmediate,
});
await new Promise((resolve) => setTimeout(resolve, 5));
expect(ticks).toBeGreaterThan(1);
expect(longestGap).toBeLessThan(50);
expect(JSON.stringify(actual)).toBe(expected);
} finally {
clearInterval(timer);
}
});
it("budgets UTF-8 bytes before parsing, including multibyte text", async () => {
expect(
await highlightCodeWithBudget("é".repeat(HIGHLIGHT_MAX_SOURCE_BYTES / 2 + 1), "file.ts", {
yieldToEventLoop: setImmediate,
}),
).toBeNull();
expect(
await highlightCodeWithBudget("a".repeat(HIGHLIGHT_MAX_SOURCE_BYTES + 1), "file.ts", {
yieldToEventLoop: setImmediate,
}),
).toBeNull();
});
it("stops a parser that is cancelled between real event-loop turns", async () => {
const controller = new AbortController();
const pending = highlightCodeWithBudget(
"const value = Math.max(42, 7);\n".repeat(3_000),
"file.ts",
{ yieldToEventLoop: setImmediate, signal: controller.signal },
);
await setImmediate();
controller.abort();
expect(await pending).toBeNull();
});
});
@@ -0,0 +1,49 @@
import { highlightCodeInSteps } from "./highlighter.js";
import type { HighlightToken } from "./types.js";
// Full source, rather than patch size, bounds parser and token allocation work.
// Each token consumes a source code unit or an empty line: at most the byte cap + 1 records.
export const HIGHLIGHT_MAX_SOURCE_BYTES = 768 * 1024;
const SLICE_MS = 4;
/**
* Returns null when source exceeds the budget or the request is cancelled.
* The host supplies a real event-loop yield (setImmediate on the daemon).
* Existing synchronous highlighting remains available for its existing consumers.
*/
export async function highlightCodeWithBudget(
code: string,
filename: string,
options: { yieldToEventLoop: () => Promise<void>; signal?: { readonly aborted: boolean } },
): Promise<HighlightToken[][] | null> {
if (options.signal?.aborted || exceedsSourceBudget(code)) return null;
const steps = highlightCodeInSteps(code, filename);
let sliceStarted = Date.now();
try {
for (;;) {
if (options.signal?.aborted) return null;
const step = steps.next();
if (step.done) return step.value;
if (Date.now() - sliceStarted >= SLICE_MS) {
await options.yieldToEventLoop();
sliceStarted = Date.now();
}
}
} finally {
steps.return([]);
}
}
function exceedsSourceBudget(code: string): boolean {
if (code.length > HIGHLIGHT_MAX_SOURCE_BYTES) return true;
let bytes = 0;
for (const char of code) {
const point = char.codePointAt(0)!;
if (point < 0x80) bytes++;
else if (point < 0x800) bytes += 2;
else if (point < 0x10000) bytes += 3;
else bytes += 4;
if (bytes > HIGHLIGHT_MAX_SOURCE_BYTES) return true;
}
return false;
}
+1
View File
@@ -15,3 +15,4 @@ export {
isSyntaxThemeId,
resolveSyntaxColors,
} from "./themes.js";
export { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "./budgeted-highlighter.js";
+57 -1
View File
@@ -92,7 +92,12 @@ function typeErrors(code: string): string[] {
}
describe("plugin SDK import boundaries", () => {
it("publishes process capabilities without their Windows implementation helpers", () => {
expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]);
expect(Object.keys(server).sort()).toEqual([
"defineAgentTool",
"execCommand",
"spawnProcess",
"terminateProcess",
]);
});
it("classifies every published entry", () => {
@@ -209,3 +214,54 @@ describe("plugin panel SDK boundary", () => {
).toEqual([]);
});
});
describe("plugin agent tools SDK boundary", () => {
it("lets a session hook attach typed tools without exposing their transport", () => {
expect(
typeErrors(`
import { z } from "zod";
import { defineAgentTool, type PluginServerContext, type AgentToolResult, type AgentToolContext, type AgentToolDefinition } from "@getpaseo/plugin/server";
const tool: AgentToolDefinition = defineAgentTool({
name: "create_issue",
description: "Create an issue",
input: z.object({ title: z.string(), count: z.string().transform(Number) }),
async execute(input, { agent, paseo, signal }) {
const count: number = input.count;
// @ts-expect-error input is inferred from the schema
input.missing;
return { text: agent.id + input.title, structuredContent: { count } };
},
});
export default function contribute(server: PluginServerContext) {
server.before("agent.session_open", ({ request }) => {
const title: string | null = request.title;
// @ts-expect-error labels are read-only
request.labels.x = "changed";
if (request.labels.x !== "y") return;
return { ...request, tools: [...request.tools, tool] };
});
return () => {};
}
`),
).toEqual([]);
});
});
describe("agent session reload SDK boundary", () => {
it("exposes reload separately from snapshot refresh", () => {
expect(
typeErrors(`
import { type PluginServerContext } from "@getpaseo/plugin/server";
import { type PaseoAgentRefetchResult } from "@getpaseo/client";
async function reload(server: PluginServerContext, paseo: import("@getpaseo/client").PaseoApi) {
const agent = paseo.agents.ref("chosen-agent");
const read: PaseoAgentRefetchResult | null = await agent.refresh("read-snapshot");
const reopened: PaseoAgentRefetchResult | null = await agent.reload({ requestId: "reload-session" });
await agent.reload();
// @ts-expect-error reload options can grow without a positional signature change
await agent.reload("reload-session");
}
`),
).toEqual([]);
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { PaseoApi } from "@getpaseo/client";
import type { z } from "zod";
import type { PluginHookAgent } from "./lifecycle.js";
export interface AgentToolContext {
agent: PluginHookAgent;
paseo: PaseoApi;
signal: AbortSignal;
}
export interface AgentToolResult {
text?: string;
structuredContent?: unknown;
isError?: boolean;
}
export interface AgentToolDefinition {
name: string;
title?: string;
description: string;
input: z.ZodType;
execute(input: unknown, context: AgentToolContext): AgentToolResult | Promise<AgentToolResult>;
}
export function defineAgentTool<Input extends z.ZodType>(tool: {
name: string;
title?: string;
description: string;
input: Input;
execute(
input: z.output<Input>,
context: AgentToolContext,
): AgentToolResult | Promise<AgentToolResult>;
}): AgentToolDefinition {
if (!/^[a-z][a-z0-9_]*$/.test(tool.name) || tool.name.length > 64) {
throw new Error(`Invalid agent tool name: ${tool.name}`);
}
return tool;
}
+3
View File
@@ -24,3 +24,6 @@ export type {
} from "./lifecycle.js";
export { spawnProcess, execCommand, terminateProcess } from "./process.js";
export { defineAgentTool } from "./agent-tools.js";
export type { AgentToolDefinition, AgentToolResult, AgentToolContext } from "./agent-tools.js";
+4
View File
@@ -1,3 +1,4 @@
import type { AgentToolDefinition } from "./agent-tools.js";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
@@ -36,6 +37,9 @@ export interface PluginSessionOpenRequest {
cwd: string;
reason: "create" | "resume" | "refresh" | "import";
purpose: "interactive" | "history";
readonly title: string | null;
readonly labels: Readonly<Record<string, string>>;
tools: AgentToolDefinition[];
env: Record<string, string>;
}
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
+116 -11
View File
@@ -1,3 +1,5 @@
import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js";
import type { AgentToolResult } from "@getpaseo/plugin/server";
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { projectTimelineRows } from "./timeline-projection.js";
@@ -333,6 +335,7 @@ export interface AgentManagerOptions {
terminalManager?: TerminalManager | null;
mcpBaseUrl?: string;
mcpAuthToken?: string;
pluginAgentTools?: PluginAgentToolsRuntime;
paseoToolsEnabled?: boolean;
paseoToolCatalogFactory?: PaseoToolCatalogFactory;
resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined;
@@ -766,6 +769,10 @@ export class AgentManager {
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null;
private readonly mcpAuthToken: string | null;
private sessionOpenGeneration = 0;
private readonly sessionOpenGenerations = new Map<string, number>();
private readonly attachedPluginTools = new Map<string, AttachedAgentTool[]>();
private readonly pluginAgentTools: PluginAgentToolsRuntime | undefined;
private paseoToolsEnabled = true;
private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null;
private readonly paseoToolPolicies = new Map<string, ProviderPaseoToolsPolicy | undefined>();
@@ -783,6 +790,7 @@ export class AgentManager {
constructor(options: AgentManagerOptions) {
this.pluginLifecycle = options.pluginLifecycle;
this.pluginAgentTools = options.pluginAgentTools;
this.timelineStore = options.timelineStore ?? new InMemoryAgentTimelineStore();
this.idFactory = options.idFactory ?? (() => randomUUID());
this.registry = options?.registry;
@@ -887,6 +895,62 @@ export class AgentManager {
this.paseoToolCatalogFactory = factory;
}
invalidatePluginSessions(): void {
this.sessionOpenGeneration++;
}
async reloadStalePluginSession(agentId: string): Promise<void> {
if (!this.needsPluginSessionReload(agentId)) return;
await this.trackAgentRegistrationOperation(
this.runLifecycleMutation(agentId, async () => {
if (!this.needsPluginSessionReload(agentId)) return;
await this.reloadAgentSessionInternal(agentId);
}),
);
}
private needsPluginSessionReload(agentId: string): boolean {
const agent = this.getAgent(agentId);
return (
agent !== null &&
agent.lifecycle !== "closed" &&
this.sessionOpenGenerations.get(agentId) !== this.sessionOpenGeneration &&
!this.hasInFlightRun(agentId)
);
}
getPluginTools(agentId: string): readonly AttachedAgentTool[] {
return (this.attachedPluginTools.get(agentId) ?? []).filter((tool) =>
this.pluginAgentTools?.isToolPluginAvailable(tool.pluginId, tool.pluginInstanceId),
);
}
async executePluginTool(
tool: AttachedAgentTool,
agentId: string,
input: unknown,
signal?: AbortSignal,
): Promise<AgentToolResult> {
const agent = this.getAgent(agentId);
if (!agent || agent.lifecycle === "closed") throw new Error(`Agent is not live: ${agentId}`);
if (!this.pluginAgentTools) throw new Error("Plugin tools are unavailable");
const record = await this.registry?.get(agentId);
return this.pluginAgentTools.callTool(
tool.pluginId,
tool.pluginInstanceId,
describeHookAgent({ ...agent, title: record?.title ?? agent.config.title }),
tool.name,
input,
signal,
);
}
private detachPluginTools(agentId: string): void {
this.attachedPluginTools.delete(agentId);
this.sessionOpenGenerations.delete(agentId);
this.pluginAgentTools?.detachTools(agentId);
}
getPaseoToolPolicy(agentId: string): ProviderPaseoToolsPolicy | undefined {
return this.paseoToolPolicies.get(agentId);
}
@@ -1312,7 +1376,13 @@ export class AgentManager {
storedConfig.cwd,
paseoToolPolicy,
options?.env,
{ reason: "create", purpose: "interactive", workspaceId: options.workspaceId ?? null },
{
reason: "create",
purpose: "interactive",
workspaceId: options.workspaceId ?? null,
title: options.initialTitle ?? storedConfig.title ?? null,
labels: options.labels ?? {},
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const createOptions = this.buildCreateSessionOptions(options);
@@ -1434,6 +1504,8 @@ export class AgentManager {
reason: "resume",
purpose,
workspaceId: options?.workspaceId ?? null,
title: record?.title ?? mergedConfig.title ?? null,
labels: record?.labels ?? options?.labels ?? {},
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
@@ -1491,7 +1563,12 @@ export class AgentManager {
storedConfig.cwd,
paseoToolPolicy,
undefined,
{ reason: "import", purpose: "interactive", workspaceId: input.workspaceId },
{
reason: "import",
purpose: "interactive",
workspaceId: input.workspaceId,
labels: input.labels ?? {},
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const imported = await client.importSession(
@@ -1586,7 +1663,13 @@ export class AgentManager {
storedConfig.cwd,
paseoToolPolicy,
undefined,
{ reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId },
{
reason: "refresh",
purpose: "interactive",
workspaceId: existing.workspaceId,
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
labels: existing.labels,
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
if (
@@ -1650,6 +1733,7 @@ export class AgentManager {
throw error;
} finally {
if (!handedToRegistration) {
this.detachPluginTools(agentId);
if (hadPreviousPaseoToolPolicy) {
this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy);
} else {
@@ -1857,6 +1941,7 @@ export class AgentManager {
} catch (error) {
persistError = error;
}
this.detachPluginTools(agentId);
this.emitClosedAgent(closedAgent, { persist: false });
this.logger.trace(
{
@@ -3940,6 +4025,7 @@ export class AgentManager {
private discardRetainedAgentState(agentId: string): void {
this.timelineStore.delete(agentId);
this.paseoToolPolicies.delete(agentId);
this.detachPluginTools(agentId);
for (const event of this.providerSubagents.deleteParent(agentId)) {
this.dispatch({ type: "provider_subagent", event });
}
@@ -5426,10 +5512,15 @@ export class AgentManager {
reason: PluginSessionOpenRequest["reason"];
purpose: PluginSessionOpenRequest["purpose"];
workspaceId?: string | null;
title?: string | null;
labels?: Record<string, string>;
},
): Promise<AgentLaunchContext> {
// A plugin changing during this open must still make the resulting session stale.
const generation = this.sessionOpenGeneration;
this.detachPluginTools(agentId);
if (this.pluginLifecycle) {
const request: PluginSessionOpenRequest = {
const request = {
agentId,
provider: client.provider,
cwd,
@@ -5437,9 +5528,14 @@ export class AgentManager {
reason: opening?.reason ?? "resume",
purpose: opening?.purpose ?? "interactive",
env: { ...env },
title: opening?.title ?? null,
labels: opening?.labels ?? {},
tools: [],
};
const transformed = await this.pluginLifecycle.before("agent.session_open", request);
env = transformed.env;
if (request.purpose === "interactive")
this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]);
}
const context: AgentLaunchContext = {
agentId,
@@ -5449,17 +5545,26 @@ export class AgentManager {
PASEO_AGENT_CWD: cwd,
},
};
if (
const useNativeTools =
this.paseoToolsEnabled &&
isPaseoToolPolicyEnabled(paseoToolPolicy) &&
client.capabilities.supportsNativePaseoTools &&
this.paseoToolCatalogFactory
client.capabilities.supportsNativePaseoTools;
if (
this.paseoToolCatalogFactory &&
(useNativeTools || this.getPluginTools(agentId).length > 0)
) {
context.paseoTools = await this.paseoToolCatalogFactory({
callerAgentId: agentId,
paseoToolPolicy,
});
try {
const catalog = await this.paseoToolCatalogFactory({
callerAgentId: agentId,
paseoToolPolicy,
});
if (useNativeTools) context.paseoTools = catalog;
} catch (error) {
this.detachPluginTools(agentId);
throw error;
}
}
this.sessionOpenGenerations.set(agentId, generation);
return context;
}
@@ -15,6 +15,7 @@ export type AgentRunController = Pick<
| "getAgent"
| "tryRunOutOfBand"
| "hasInFlightRun"
| "reloadStalePluginSession"
| "replaceAgentRun"
| "steerOrReplaceActiveTurn"
| "streamAgent"
@@ -115,6 +116,7 @@ export async function startAgentRun(
return { disposition: "out_of_band" };
}
try {
await agentManager.reloadStalePluginSession(agentId);
return await startAgentRunInner(agentManager, agentId, prompt, logger, options);
} catch (error) {
if (!isStaleProviderSessionError(error)) throw error;
@@ -1,4 +1,6 @@
import { describe, expect, it } from "vitest";
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
describe("InMemoryAgentTimelineStore", () => {
@@ -177,7 +179,10 @@ describe("projected sequence ownership", () => {
store.append("a", { type: "assistant_message", text: "Answer" });
store.append("a", { ...tool, status: "completed" });
store.initialize("b", { rows: store.getRows("a") });
expect(store.getLastItem("b")).toMatchObject({ type: "tool_call", status: "completed" });
expect(store.getLastAssistantMessage("b")).toBe("Answer");
expect(store.append("b", { type: "user_message", text: "next" }).seq).toBe(4);
expect(store.getLastItem("b")).toEqual({ type: "user_message", text: "next" });
expect(store.fetch("b").rows.map((row) => row.seqStart)).toEqual([1, 2, 4]);
});
it("returns the last projected assistant message without joining different messages", () => {
@@ -211,3 +216,151 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
expect(tail.rows.map((row) => row.seqStart)).toEqual([1, 2, 3, 5]);
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
});
it("serves tail 200 from 100,000 retained rows without stalling the loop", async () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("large");
for (let index = 0; index < 100_000; index++) {
store.append("large", { type: "user_message", text: String(index) });
}
// Generation and its GC are outside the measured request window.
await new Promise<void>((resolve) => setImmediate(resolve));
const started = performance.now();
const probe = new Promise<number>((resolve) =>
setImmediate(() => resolve(performance.now() - started)),
);
let page = store.fetch("large", { limit: 200 });
for (let repeat = 1; repeat < 8; repeat++) page = store.fetch("large", { limit: 200 });
const duration = performance.now() - started;
const loopGap = await probe;
console.info({ duration, loopGap });
expect(page.rows).toHaveLength(200);
expect(page.startSeq).toBe(99_801);
expect(page.endSeq).toBe(100_000);
// CI runners can pause for scheduling/GC; the local per-request 2 ms gate
// is measured separately. Eight requests amplify the old full-history work.
expect(duration).toBeLessThan(50);
expect(loopGap).toBeLessThan(50);
});
it("matches the previous reprojecting selector on seeded randomized timelines", () => {
let seed = 0x76aabbcc;
const random = (bound: number) => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return Math.floor((seed / 0x1_0000_0000) * bound);
};
for (let scenario = 0; scenario < 100; scenario++) {
const store = new InMemoryAgentTimelineStore();
store.initialize("a", { epoch: "e" });
for (let seq = 1; seq <= 100; seq++) {
const identity = String(random(8));
const text = String(seq);
const items: AgentTimelineItem[] = [
{ type: "user_message", text },
{ type: "assistant_message", text },
{ type: "assistant_message", text, messageId: identity },
{ type: "reasoning", text },
{
type: "tool_call",
callId: identity,
name: "shell",
status: "running",
error: null,
detail: { type: "plain_text", label: text },
},
{
type: "plugin",
id: identity,
pluginId: "test",
kind: "state",
version: 1,
data: { text },
},
];
store.append("a", items[random(items.length)], { turnId: String(random(3)) });
}
// Include rehydration of already-projected rows, with non-monotonic seqEnd.
store.initialize("b", { rows: store.getRows("a"), epoch: "e" });
for (const agentId of ["a", "b"]) {
assertSelectionMatchesReference(store, agentId);
store.append(agentId, { type: "assistant_message", text: "later" });
assertSelectionMatchesReference(store, agentId);
}
}
});
function assertSelectionMatchesReference(store: InMemoryAgentTimelineStore, agentId: string): void {
for (const direction of ["tail", "before", "after"] as const) {
for (const limit of [0, 1, 7, 200]) {
for (const cursorSeq of [0, 1, 25, 50, 99, 100, 150]) {
const actual = store.fetch(agentId, {
direction,
limit,
cursor: { epoch: "e", seq: cursorSeq },
});
const expected = selectReferenceProjectedTimelinePage({
rows: store.getRows(agentId),
bounds: actual.window,
direction,
limit,
cursorSeq,
});
expect({
...expected,
entries: undefined,
rows: expected.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)),
}).toEqual({
entries: undefined,
rows: actual.rows,
startSeq: actual.startSeq,
endSeq: actual.endSeq,
hasOlder: actual.hasOlder,
hasNewer: actual.hasNewer,
});
}
}
}
}
it("continues a scroll across appends and lifecycle collapse without losing source coverage", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("a", { epoch: "e" });
const tool: AgentTimelineItem = {
type: "tool_call",
callId: "t",
name: "shell",
status: "running",
error: null,
detail: { type: "plain_text", label: "work" },
};
store.append("a", tool);
for (let seq = 2; seq <= 5; seq++) store.append("a", { type: "user_message", text: String(seq) });
const first = store.fetch("a", { direction: "after", cursor: { epoch: "e", seq: 0 }, limit: 2 });
expect(first.endSeq).toBe(2);
store.append("a", { ...tool, status: "completed" });
store.append("a", { type: "assistant_message", text: "Done" });
expect(first.rows[0].item).toMatchObject({ status: "running" });
const second = store.fetch("a", {
direction: "after",
cursor: { epoch: "e", seq: first.endSeq! },
limit: 2,
});
expect(second.rows.map((row) => row.seqStart)).toEqual([3, 4]);
expect(second.endSeq).toBe(4);
const third = store.fetch("a", {
direction: "after",
cursor: { epoch: "e", seq: second.endSeq! },
limit: 2,
});
expect(third.rows.map((row) => row.seqStart)).toEqual([1, 5]);
expect(third.rows[0].item).toMatchObject({ status: "completed" });
expect(third.endSeq).toBe(6);
const last = store.fetch("a", {
direction: "after",
cursor: { epoch: "e", seq: third.endSeq! },
limit: 2,
});
expect(last.rows.map((row) => row.item)).toEqual([{ type: "assistant_message", text: "Done" }]);
expect(last.endSeq).toBe(7);
expect(last.hasNewer).toBe(false);
});
@@ -1,9 +1,5 @@
import { randomUUID } from "node:crypto";
import {
TimelineProjection,
selectProjectedTimelinePage,
type ProjectedTimelineRow,
} from "./timeline-projection.js";
import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type {
AgentTimelineFetchOptions,
@@ -43,8 +39,7 @@ export class InMemoryAgentTimelineStore {
? options.rows.map(cloneRow)
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
const nextSeq = rows.reduce((next, row) => Math.max(next, row.seq + 1), options?.nextSeq ?? 1);
const projection = new TimelineProjection();
for (const row of rows) projection.append(row);
const projection = new TimelineProjection(rows);
this.states.set(agentId, {
epoch: options?.epoch ?? randomUUID(),
projection,
@@ -97,18 +92,17 @@ export class InMemoryAgentTimelineStore {
const state = this.requireState(agentId);
const direction = options?.direction ?? "tail";
const cursor = options?.cursor;
const rows = state.projection.getRows();
const hasRows = state.minSeq !== 0;
const window = { minSeq: state.minSeq, maxSeq: state.nextSeq - 1, nextSeq: state.nextSeq };
const staleCursor = cursor !== undefined && cursor.epoch !== state.epoch;
const gap =
!staleCursor &&
direction === "after" &&
cursor !== undefined &&
rows.length > 0 &&
hasRows &&
cursor.seq < state.minSeq - 1;
const reset = staleCursor || gap;
const page = selectProjectedTimelinePage({
rows,
const page = state.projection.selectPage({
bounds: window,
direction: reset ? "tail" : direction,
cursorSeq: cursor?.seq,
@@ -150,14 +144,11 @@ export class InMemoryAgentTimelineStore {
getLastItem(agentId: string): AgentTimelineItem | null {
const state = this.requireState(agentId);
return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null;
return state.projection.getLastItem(state.nextSeq - 1);
}
getLastAssistantMessage(agentId: string): string | null {
const row = this.requireState(agentId)
.projection.getRows()
.findLast((candidate) => candidate.item.type === "assistant_message");
return row?.item.type === "assistant_message" ? row.item.text : null;
return this.requireState(agentId).projection.getLastAssistantMessage();
}
private requireState(agentId: string): AgentTimelineState {
@@ -69,6 +69,7 @@ test("session create forwards clientMessageId to the initial prompt run options"
getAgent: vi.fn(() => snapshot),
tryRunOutOfBand: vi.fn(() => false),
hasInFlightRun: vi.fn(() => false),
reloadStalePluginSession: async () => {},
streamAgent,
waitForAgentRunStart: vi.fn(async () => undefined),
} as unknown as Parameters<typeof createAgentCommand>[0]["agentManager"],
@@ -270,6 +270,8 @@ function buildAgentManagerSpies() {
hydrateTimelineFromProvider: vi.fn().mockResolvedValue(undefined),
appendTimelineItem: vi.fn().mockResolvedValue(undefined),
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
getPluginTools: () => [],
reloadStalePluginSession: async () => {},
hasInFlightRun: vi.fn().mockReturnValue(false),
steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }),
tryRunOutOfBand: vi.fn().mockReturnValue(false),
@@ -617,6 +619,10 @@ class FakeBrowserToolsBroker {
}
class BoundaryAgentManagerFake {
public getPluginTools(): [] {
return [];
}
private readonly agent = createManagedAgent({
id: "agent-1",
cwd: REPO_CWD,
@@ -99,16 +99,17 @@ afterEach(() => {
});
describe("mutable provider config owner", () => {
test("publishes provider changes after commit and emits nothing on rollback", () => {
test("publishes provider changes after commit and emits nothing on rollback", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, mutableConfig({ version: 1 }));
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: { codex: { enabled: true } },
providerOverrides: CONTROLLED_PROVIDERS,
extraClients: { codex: controlledCodexClient(async () => catalog("initial")) },
});
const cwd = path.resolve("/tmp/provider-config-owner");
manager.getSnapshot(cwd);
await manager.refreshSnapshotForCwd({ cwd });
const events: string[] = [];
manager.on("change", ({ current }) => events.push(current.cwd));
let agentManagerState = manager.getAgentManagerProviderState();
@@ -125,7 +126,7 @@ describe("mutable provider config owner", () => {
});
try {
store.patch({ providers: { codex: { enabled: false } } });
await store.patch({ providers: { codex: { enabled: false } } });
expect(events).toEqual([cwd]);
expect(agentManagerState.providerDefinitions.codex).toMatchObject({ enabled: false });
@@ -133,7 +134,7 @@ describe("mutable provider config owner", () => {
const unsubscribeFailure = store.onApply(() => {
throw new Error("later owner failed");
});
expect(() => store.patch({ providers: { codex: { enabled: true } } })).toThrow(
await expect(store.patch({ providers: { codex: { enabled: true } } })).rejects.toThrow(
"later owner failed",
);
unsubscribeFailure();
@@ -150,7 +151,7 @@ describe("mutable provider config owner", () => {
}
});
test("does no provider work for unrelated CORS, Git, and app URL reloads", () => {
test("does no provider work for unrelated CORS, Git, and app URL reloads", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
tempDirs.push(paseoHome);
const initial: PersistedConfig = {
@@ -174,9 +175,10 @@ describe("mutable provider config owner", () => {
});
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: { codex: { enabled: true } },
providerOverrides: CONTROLLED_PROVIDERS,
extraClients: { codex: controlledCodexClient(async () => catalog("initial")) },
});
manager.getSnapshot("/tmp/provider-config-owner");
await manager.refreshSnapshotForCwd({ cwd: "/tmp/provider-config-owner" });
const events: string[] = [];
manager.on("change", ({ current }) => events.push(current.cwd));
let registryUpdates = 0;
@@ -200,7 +202,7 @@ describe("mutable provider config owner", () => {
};
writeFileSync(configPath, `${JSON.stringify(reloaded, null, 2)}\n`, "utf-8");
expect(store.reload().appliedPaths).toEqual([
expect((await store.reload()).appliedPaths).toEqual([
"app.baseUrl",
"daemon.cors.allowedOrigins",
"daemon.git.maxProcessConcurrency",
@@ -245,7 +247,7 @@ describe("mutable provider config owner", () => {
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1));
expect(events).toEqual([]);
store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } });
await store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } });
expect(events).toEqual(["loading:none"]);
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(2));
@@ -311,9 +313,9 @@ describe("mutable provider config owner", () => {
expect(events).toEqual([]);
const before = manager.getSnapshot(cwd);
expect(() =>
await expect(
store.patch({ providers: { codex: { enabled: true, label: "Rejected Codex" } } }),
).toThrow("later owner failed");
).rejects.toThrow("later owner failed");
expect(events).toEqual([]);
expect(observedDuringApply).toEqual([{ label: "Codex", snapshot: before }]);
expect(observedDuringApply[0]!.snapshot).toBe(before);
@@ -40,6 +40,8 @@ class FakePermissionAgentManager {
return undefined;
}
async reloadStalePluginSession(): Promise<void> {}
hasInFlightRun(): boolean {
return this.hasRunInFlight;
}
@@ -310,7 +310,7 @@ describe("PluginAgentClientRegistry", () => {
});
const own = { nested: { replace: "agent" } };
const agent = await manager.createAgent(
{ provider: "plugin-direct", cwd: "/tmp", providerOptions: own },
{ provider: "plugin-direct", cwd: tmpdir(), providerOptions: own },
undefined,
{ workspaceId: undefined },
);
@@ -811,6 +811,46 @@ describe("ACPAgentSession terminal tools", () => {
truncated: false,
});
});
test("trims large terminal output without rescanning the retained suffix", async () => {
const session = createSession();
const terminal = await session.createTerminal({
sessionId: "session-1",
command: process.execPath,
args: ["-e", 'process.stdout.write("x".repeat(1024 * 1024))'],
cwd: process.cwd(),
outputByteLimit: 1024,
});
await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId });
await expect(
session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }),
).resolves.toEqual({
output: "x".repeat(1024),
truncated: true,
exitStatus: { exitCode: 0, signal: null },
});
}, 1000);
test("trims terminal output at a UTF-8 character boundary", async () => {
const session = createSession();
const terminal = await session.createTerminal({
sessionId: "session-1",
command: process.execPath,
args: ["-e", 'process.stdout.write("😀ab")'],
cwd: process.cwd(),
outputByteLimit: 3,
});
await session.waitForTerminalExit({ sessionId: "session-1", terminalId: terminal.terminalId });
await expect(
session.terminalOutput({ sessionId: "session-1", terminalId: terminal.terminalId }),
).resolves.toEqual({
output: "ab",
truncated: true,
exitStatus: { exitCode: 0, signal: null },
});
});
});
describe("mapACPUsage", () => {
@@ -4093,10 +4093,16 @@ function appendTerminalOutput(entry: TerminalEntry, chunk: string): void {
if (!limit) {
return;
}
while (Buffer.byteLength(entry.output, "utf8") > limit && entry.output.length > 0) {
entry.output = entry.output.slice(1);
entry.truncated = true;
const output = Buffer.from(entry.output, "utf8");
if (output.length <= limit) {
return;
}
let start = output.length - limit;
while (start < output.length && (output[start] & 0xc0) === 0x80) {
start += 1;
}
entry.output = output.subarray(start).toString("utf8");
entry.truncated = true;
}
function readRecord(value: unknown): Record<string, unknown> | null {
@@ -1,10 +1,11 @@
import { once } from "node:events";
import type { Query } from "@anthropic-ai/claude-agent-sdk";
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import type { AgentLaunchContext } from "../../agent-sdk-types.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryInput } from "./query.js";
import { claudeQuery, type ClaudeQueryInput } from "./query.js";
function createQueryMock(events: unknown[]): Query {
let index = 0;
@@ -29,6 +30,48 @@ function createQueryMock(events: unknown[]): Query {
}
describe("Claude SDK env", () => {
test.each(["node", "bun", process.execPath])(
"identifies the spawned %s process as Paseo after clearing parent session env",
async (command) => {
let output = "";
let exited = Promise.resolve<unknown[]>([]);
const query = claudeQuery(
{ prompt: "", options: {} },
{
runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } },
launchEnv: { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" },
queryFactory: ({ options }) => {
if (!options.spawnClaudeCodeProcess) {
throw new Error("Missing Claude process launcher");
}
const child = options.spawnClaudeCodeProcess({
command,
args: [
"-e",
"process.stdout.write(JSON.stringify({ entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT, parentSession: process.env.CLAUDECODE ?? null }))",
],
cwd: process.cwd(),
env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: "sdk-ts", CLAUDECODE: "1" },
signal: new AbortController().signal,
});
child.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
exited = once(child, "close");
return createQueryMock([]);
},
},
);
try {
expect(await exited).toEqual([0, null]);
expect(JSON.parse(output)).toEqual({ entrypoint: "Paseo", parentSession: null });
} finally {
query.close();
}
},
);
test("forwards launch-context env through Claude process env", async () => {
let capturedEnv: Record<string, string | undefined> | undefined;
let capturedPerTaskStopAffordance: boolean | undefined;
@@ -1,9 +1,13 @@
import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { open } from "node:fs/promises";
import { performance } from "node:perf_hooks";
import * as os from "node:os";
import * as path from "node:path";
import pino from "pino";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js";
import type { ClaudeRewindSdk } from "./rewind.js";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { withClaudeReplayHistory } from "./transcript-history.js";
@@ -32,7 +36,10 @@ describe("ClaudeAgentSession persisted history load", () => {
let configDir: string;
let records: LogRecord[];
async function resume(queryFactory?: ClaudeQueryFactory): Promise<AgentSession> {
async function resume(
queryFactory?: ClaudeQueryFactory,
rewindSdk?: ClaudeRewindSdk,
): Promise<AgentSession> {
const client = new ClaudeAgentClient({
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
queryFactory:
@@ -42,10 +49,36 @@ describe("ClaudeAgentSession persisted history load", () => {
}),
resolveBinary: async () => process.execPath,
resolveVersion: async () => "2.1.220",
rewindSdk,
});
return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd });
}
function replyFactory(text: string, messageId = "live-reply"): ClaudeQueryFactory {
return (() => {
const messages = [
{ type: "assistant", uuid: messageId, message: { content: text } },
{ type: "result", subtype: "success", total_cost_usd: 0 },
];
return {
async next() {
const value = messages.shift();
return value ? { done: false, value } : { done: true, value: undefined };
},
close() {},
async return() {
return { done: true, value: undefined };
},
async supportedCommands() {
return [];
},
[Symbol.asyncIterator]() {
return this;
},
};
}) as ClaudeQueryFactory;
}
function transcriptPath(): string {
return path.join(claudeProjectDirSync(cwd, { configDir }), `${SESSION_ID}.jsonl`);
}
@@ -65,6 +98,288 @@ describe("ClaudeAgentSession persisted history load", () => {
rmSync(tempRoot, { recursive: true, force: true });
});
test("serves unrelated health requests while resuming 32 MiB of ordered history", async () => {
const startedAt = performance.now();
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
const file = await open(transcriptPath(), "w");
const text = "x".repeat(16 * 1024);
let bytes = 0;
let count = 0;
try {
while (bytes < 32 * 1024 * 1024) {
const line =
JSON.stringify({ type: "assistant", message: { content: `${count}:${text}` } }) + "\n";
bytes += Buffer.byteLength(line);
await file.write(line);
count++;
}
} finally {
await file.close();
}
const daemon = await createTestPaseoDaemon();
let lastTick = performance.now();
let longestGap = 0;
const probe = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - lastTick);
lastTick = now;
}, 2);
let loading = true;
let answeredDuringLoad = false;
const health = fetch(`http://127.0.0.1:${daemon.port}/api/health`).then(async (response) => {
expect(response.status).toBe(200);
await response.text();
answeredDuringLoad = loading;
return undefined;
});
let session: AgentSession | undefined;
try {
session = await resume(replyFactory("live after large resume"));
let index = 0;
for await (const event of session.streamHistory()) {
expect(event).toEqual({
type: "timeline",
provider: "claude",
timestamp: undefined,
item: { type: "assistant_message", text: `${index}:${text}` },
});
index++;
}
loading = false;
await health;
await new Promise((resolve) => setTimeout(resolve, 5));
console.info(
JSON.stringify({
bytes,
count,
longestGap,
answeredDuringLoad,
elapsedMs: performance.now() - startedAt,
}),
);
expect(index).toBe(count);
expect(longestGap).toBeLessThan(50);
expect(answeredDuringLoad).toBe(true);
const live = await collectSessionTurnEvents(session, "follow up");
expect(
live
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([
{ type: "assistant_message", messageId: "live-reply", text: "live after large resume" },
]);
} finally {
clearInterval(probe);
await session?.close();
await daemon.close();
}
}, 60_000);
test("rebinds ordered history before returning from rewind and accepts a live turn", async () => {
const entries = [
{ type: "user", uuid: "u1", message: { content: "first" } },
{ type: "assistant", uuid: "a1", message: { content: "first reply" } },
{ type: "user", uuid: "u2", message: { content: "second" } },
{ type: "assistant", uuid: "a2", message: { content: "second reply" } },
];
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n"));
const forkId = "rebound-history";
const forkFile = path.join(path.dirname(transcriptPath()), `${forkId}.jsonl`);
writeFileSync(
forkFile,
entries
.slice(0, 2)
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
const forks: unknown[] = [];
const late = {
type: "assistant",
uuid: "retired-reply",
message: { content: "retired query" },
};
let pendingRead: ((value: unknown) => void) | undefined;
let releaseRetirement!: () => void;
const retirementGate = new Promise<void>((resolve) => {
releaseRetirement = resolve;
});
let retirementStarted!: () => void;
const retiring = new Promise<void>((resolve) => {
retirementStarted = resolve;
});
let firstQuery = true;
let retired = false;
const queryFactory = (() => {
if (!firstQuery) return replyFactory("live after rebind")({} as never);
firstQuery = false;
const messages = [
{ type: "assistant", uuid: "before-rebind", message: { content: "before rebind" } },
{ type: "result", subtype: "success", total_cost_usd: 0 },
];
return {
async next() {
const value = messages.shift();
if (value) return { done: false, value };
if (retired) return { done: true, value: undefined };
return new Promise((resolve) => {
pendingRead = resolve;
});
},
close() {
retired = true;
pendingRead?.({ done: false, value: late });
},
async return() {
retirementStarted();
await retirementGate;
return { done: true, value: undefined };
},
async supportedCommands() {
return [];
},
[Symbol.asyncIterator]() {
return this;
},
};
}) as ClaudeQueryFactory;
const session = await resume(queryFactory, {
async forkSession(sessionId, options) {
forks.push({ sessionId, ...options });
return { sessionId: forkId };
},
});
try {
const original = await Array.fromAsync(session.streamHistory());
expect(original).toHaveLength(4);
await collectSessionTurnEvents(session, "before rewind");
const emitted: AgentStreamEvent[] = [];
const subscription = session.subscribe((event) => emitted.push(event));
let rewindDone = false;
const rewind = (async () => {
await session.revertConversation!({ messageId: "u2" });
rewindDone = true;
})();
await retiring;
let historyDone = false;
const reading = (async () => {
const history = await Array.fromAsync(session.streamHistory());
historyDone = true;
return history;
})();
const readingAgain = Array.fromAsync(session.streamHistory());
await Promise.resolve();
expect(rewindDone).toBe(false);
expect(historyDone).toBe(false);
releaseRetirement();
await rewind;
subscription();
expect(retired).toBe(true);
expect(
emitted.filter(
(event) => event.type === "timeline" && event.item.type === "assistant_message",
),
).toEqual([
{
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: `Claude switched to a new session: ${SESSION_ID} -> ${forkId}`,
},
},
]);
expect(forks).toEqual([
{ sessionId: SESSION_ID, upToMessageId: "a1", transcriptPath: transcriptPath() },
]);
expect(session.id).toBe(forkId);
const [history, secondRead] = await Promise.all([reading, readingAgain]);
expect(history).toEqual(original.slice(0, 2));
expect(secondRead).toEqual([]);
const live = await collectSessionTurnEvents(session, "continue the fork");
expect(
live
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([
{ type: "assistant_message", messageId: "live-reply", text: "live after rebind" },
]);
} finally {
releaseRetirement();
await session.close();
}
});
test("reports a failed rewind without poisoning readiness when query retirement throws", async () => {
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(
transcriptPath(),
[
{ type: "user", uuid: "u1", message: { content: "first" } },
{ type: "assistant", uuid: "a1", message: { content: "reply" } },
{ type: "user", uuid: "u2", message: { content: "second" } },
]
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
let firstQuery = true;
let finishRead: ((value: unknown) => void) | undefined;
const queryFactory = ((input) => {
const query = replyFactory(
firstQuery ? "before retirement" : "still available",
firstQuery ? "before-retirement" : "live-reply",
)(input);
if (!firstQuery) return query;
firstQuery = false;
let reads = 0;
return {
...query,
async next() {
if (++reads <= 2) return query.next();
return new Promise((resolve) => {
finishRead = resolve;
});
},
close() {
finishRead?.({ done: true, value: undefined });
throw new Error("retirement failed");
},
[Symbol.asyncIterator]() {
return this;
},
};
}) as ClaudeQueryFactory;
const session = await resume(queryFactory, {
async forkSession() {
return { sessionId: "failed-rebind" };
},
});
try {
await Array.fromAsync(session.streamHistory());
await collectSessionTurnEvents(session, "before rewind");
await expect(session.revertConversation!({ messageId: "u2" })).rejects.toThrow(
"retirement failed",
);
expect(records).toContainEqual(
expect.objectContaining({
level: pino.levels.values.warn,
msg: "Failed to rebind Claude session",
sessionId: "failed-rebind",
err: expect.objectContaining({ message: "retirement failed" }),
}),
);
expect(session.id).toBe(SESSION_ID);
const live = await collectSessionTurnEvents(session, "after failed rebind");
expect(
live
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([{ type: "assistant_message", messageId: "live-reply", text: "still available" }]);
} finally {
await session.close();
}
});
test.each(["file_path", "files"])(
"replays %s without probing targets and preserves live existence checks",
async (field) => {
@@ -300,21 +615,21 @@ describe("ClaudeAgentSession persisted history load", () => {
);
});
test("loads readable history when the temporary directory is unavailable", () => {
test("loads readable history when the temporary directory is unavailable", async () => {
const file = transcriptPath();
const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } };
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify(entry));
const unavailable = path.join(tempRoot, "unavailable-tmp");
for (const variable of ["TMPDIR", "TEMP", "TMP"]) vi.stubEnv(variable, unavailable);
withClaudeReplayHistory(file, (history) => {
expect([...history.parentEntries]).toEqual([entry]);
await withClaudeReplayHistory(file, async (history) => {
expect(await Array.fromAsync(history.parentEntries)).toEqual([entry]);
});
});
test.each(["parent", "child", "workflow"])(
"preserves indexed %s history when its transcript disappears between passes",
(target) => {
async (target) => {
const parent = transcriptPath();
const sessionDirectory = parent.slice(0, -".jsonl".length);
const child = path.join(sessionDirectory, "subagents", "agent-child.jsonl");
@@ -334,14 +649,16 @@ describe("ClaudeAgentSession persisted history load", () => {
writeFileSync(parent, JSON.stringify(parentEntry));
writeFileSync(child, JSON.stringify(childEntry));
writeFileSync(workflow, JSON.stringify(childEntry));
withClaudeReplayHistory(parent, (history) => {
await withClaudeReplayHistory(parent, async (history) => {
rmSync({ parent, child, workflow }[target]);
// Ownership facts and visible payloads are separate passes over the same source.
for (let pass = 0; pass < 2; pass++) {
expect([...history.parentEntries]).toEqual([parentEntry]);
expect(await Array.fromAsync(history.parentEntries)).toEqual([parentEntry]);
expect(history.subagents).toHaveLength(1);
expect([...history.subagents[0]!.entries]).toEqual([childEntry]);
expect([...history.workflowEntriesByRunId.get("wf_test")!]).toEqual([childEntry]);
expect(await Array.fromAsync(history.subagents[0]!.entries)).toEqual([childEntry]);
expect(await Array.fromAsync(history.workflowEntriesByRunId.get("wf_test")!)).toEqual([
childEntry,
]);
}
});
},
@@ -13,8 +13,8 @@ const MATERIALIZED_PNG_PATH_PATTERN =
/paseo-attachments(?:-[^\\/]+)?[\\/](?:[^\\/]+[\\/])?[0-9a-f]{64}\.png$/;
interface ClaudeImageTestSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
convertHistoryEntry(entry: unknown): AgentTimelineItem[];
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
convertHistoryEntry(entry: unknown): Promise<AgentTimelineItem[]>;
}
async function createSession(): Promise<ClaudeImageTestSession> {
@@ -157,7 +157,7 @@ describe("Claude tool_result image rendering", () => {
test("emits the image as assistant markdown and keeps base64 out of the live tool output", async () => {
const session = await createSession();
const events = session.translateMessageToEvents(imageToolResultUserMessage());
const events = await session.translateMessageToEvents(imageToolResultUserMessage());
const timelineItems = events
.filter((event) => event.type === "timeline")
@@ -181,7 +181,7 @@ describe("Claude tool_result image rendering", () => {
test("replays the image as assistant markdown through history conversion", async () => {
const session = await createSession();
const items = session.convertHistoryEntry(imageToolResultHistoryEntry());
const items = await session.convertHistoryEntry(imageToolResultHistoryEntry());
const [imageMessage, ...extraImages] = imageMessages(items);
expect(extraImages).toEqual([]);
@@ -202,7 +202,7 @@ describe("Claude tool_result image rendering", () => {
test("keeps base64 out of an errored tool_result that carries an image", async () => {
const session = await createSession();
const events = session.translateMessageToEvents(erroredImageToolResultUserMessage());
const events = await session.translateMessageToEvents(erroredImageToolResultUserMessage());
const timelineItems = events
.filter((event) => event.type === "timeline")
@@ -226,7 +226,7 @@ describe("Claude tool_result image rendering", () => {
test("emits one image message per image block in a multi-image tool_result", async () => {
const session = await createSession();
const events = session.translateMessageToEvents(multiImageToolResultUserMessage());
const events = await session.translateMessageToEvents(multiImageToolResultUserMessage());
const timelineItems = events
.filter((event) => event.type === "timeline")
@@ -32,7 +32,7 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
close(): Promise<void>;
}
@@ -53,7 +53,7 @@ afterEach(() => {
});
describe("convertClaudeHistoryEntry", () => {
test("maps user tool results to timeline items", () => {
test("maps user tool results to timeline items", async () => {
const toolUseId = "toolu_test";
const entry = {
type: "user",
@@ -79,14 +79,14 @@ describe("convertClaudeHistoryEntry", () => {
];
const mapBlocks = vi.fn().mockReturnValue(stubTimeline);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual(stubTimeline);
expect(mapBlocks).toHaveBeenCalledTimes(1);
expect(Array.isArray(mapBlocks.mock.calls[0][0])).toBe(true);
});
test("replays persisted Claude tool results as completed tool calls", () => {
test("replays persisted Claude tool results as completed tool calls", async () => {
const entry = {
type: "user",
message: {
@@ -124,12 +124,12 @@ describe("convertClaudeHistoryEntry", () => {
const mapPersistedToolResultBlocks = (): AgentTimelineItem[] => completedToolCall;
expect(convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual(
expect(await convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual(
completedToolCall,
);
});
test("returns user messages when no tool blocks exist", () => {
test("returns user messages when no tool blocks exist", async () => {
const entry = {
type: "user",
message: {
@@ -138,7 +138,7 @@ describe("convertClaudeHistoryEntry", () => {
},
};
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
{
type: "user_message",
text: "Run npm test",
@@ -146,7 +146,7 @@ describe("convertClaudeHistoryEntry", () => {
]);
});
test("converts compact boundary metadata variants", () => {
test("converts compact boundary metadata variants", async () => {
const fixtures = [
{
entry: {
@@ -175,7 +175,7 @@ describe("convertClaudeHistoryEntry", () => {
] as const;
for (const fixture of fixtures) {
expect(convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([
expect(await convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([
{
type: "compaction",
status: "completed",
@@ -186,7 +186,7 @@ describe("convertClaudeHistoryEntry", () => {
}
});
test("skips synthetic user entries", () => {
test("skips synthetic user entries", async () => {
const entry = {
type: "user",
isSynthetic: true,
@@ -197,13 +197,13 @@ describe("convertClaudeHistoryEntry", () => {
};
const mapBlocks = vi.fn().mockReturnValue([]);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual([]);
expect(mapBlocks).not.toHaveBeenCalled();
});
test("skips meta user entries from Claude skill loading", () => {
test("skips meta user entries from Claude skill loading", async () => {
const entry = {
type: "user",
isMeta: true,
@@ -219,13 +219,13 @@ describe("convertClaudeHistoryEntry", () => {
};
const mapBlocks = vi.fn().mockReturnValue([]);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual([]);
expect(mapBlocks).not.toHaveBeenCalled();
});
test("skips interrupt placeholder transcript noise", () => {
test("skips interrupt placeholder transcript noise", async () => {
const interruptEntry = {
type: "user",
message: {
@@ -246,11 +246,11 @@ describe("convertClaudeHistoryEntry", () => {
.fn()
.mockReturnValue([{ type: "assistant_message", text: "No response requested." }]);
expect(convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
});
test("skips <local-command-stdout> messages (model switch, /context, etc.)", () => {
test("skips <local-command-stdout> messages (model switch, /context, etc.)", async () => {
// Real entries from Claude Code JSONL history files
const modelSwitch = {
type: "user",
@@ -305,24 +305,24 @@ describe("convertClaudeHistoryEntry", () => {
const mapBlocks = vi.fn().mockReturnValue([]);
expect(convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]);
// Real user messages must NOT be filtered
const realMessage = {
type: "user",
message: { role: "user", content: "fix the bug in auth.ts" },
};
expect(convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
expect(await convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
{ type: "user_message", text: "fix the bug in auth.ts" },
]);
});
test("maps task notifications to synthetic tool calls", () => {
test("maps task notifications to synthetic tool calls", async () => {
const entry = {
type: "system",
subtype: "task_notification",
@@ -333,7 +333,7 @@ describe("convertClaudeHistoryEntry", () => {
output_file: "/tmp/bg-fail-1.txt",
};
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
{
type: "tool_call",
callId: "task_notification_task-note-system-1",
@@ -357,7 +357,7 @@ describe("convertClaudeHistoryEntry", () => {
]);
});
test("maps queue-operation task notifications to synthetic tool calls", () => {
test("maps queue-operation task notifications to synthetic tool calls", async () => {
const entry = {
type: "queue-operation",
operation: "enqueue",
@@ -372,7 +372,7 @@ describe("convertClaudeHistoryEntry", () => {
].join("\n"),
};
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
{
type: "tool_call",
callId: "task_notification_task-note-queue-1",
@@ -396,7 +396,7 @@ describe("convertClaudeHistoryEntry", () => {
]);
});
test("passes assistant content blocks through to the mapper", () => {
test("passes assistant content blocks through to the mapper", async () => {
const entry = {
type: "assistant",
message: {
@@ -414,7 +414,7 @@ describe("convertClaudeHistoryEntry", () => {
];
const mapBlocks = vi.fn().mockReturnValue(mappedTimeline);
expect(convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline);
expect(await convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline);
expect(mapBlocks).toHaveBeenCalledWith(entry.message.content);
});
});
@@ -1208,7 +1208,7 @@ describe("ClaudeAgentSession features", () => {
input: Record<string, unknown>,
options: Record<string, unknown>,
): Promise<PermissionResult>;
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
};
try {
@@ -1248,7 +1248,7 @@ describe("ClaudeAgentSession features", () => {
),
).resolves.toMatchObject({ behavior: "deny", interrupt: undefined });
internal.translateMessageToEvents({
await internal.translateMessageToEvents({
type: "command_lifecycle",
command_uuid: steerUuid,
state: "started",
@@ -2395,7 +2395,7 @@ describe("ClaudeAgentSession context window usage", () => {
test("emits canonical task snapshots from Claude TaskCreate results", async () => {
const session = await createSessionForTest();
try {
session.translateMessageToEvents({
await session.translateMessageToEvents({
type: "assistant",
message: {
content: [
@@ -2409,7 +2409,7 @@ describe("ClaudeAgentSession context window usage", () => {
},
} as unknown as SDKMessage);
const events = session.translateMessageToEvents({
const events = await session.translateMessageToEvents({
type: "user",
message: {
content: [{ type: "tool_result", tool_use_id: "task-create-1", content: "created" }],
@@ -3611,9 +3611,9 @@ describe("ClaudeAgentSession context window usage", () => {
]);
try {
const compactEvents = (session as unknown as TestClaudeSession).translateMessageToEvents(
createCompactBoundary(),
);
const compactEvents = await (
session as unknown as TestClaudeSession
).translateMessageToEvents(createCompactBoundary());
expect(compactEvents).toContainEqual(
expect.objectContaining({
type: "usage_updated",
@@ -3799,7 +3799,7 @@ describe("ClaudeAgentSession context window usage", () => {
test("result.result is surfaced as an assistant message when no model output was produced", async () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
const events = await session.translateMessageToEvents({
type: "result",
subtype: "success",
result: "Unknown command: /foo-doesnt-exist",
@@ -3834,7 +3834,7 @@ describe("ClaudeAgentSession context window usage", () => {
test("result.result is not duplicated when the model produced output during the turn", async () => {
const session = await createSessionForTest();
const events = session.translateMessageToEvents({
const events = await session.translateMessageToEvents({
type: "result",
subtype: "success",
result: "Here is the answer.",
@@ -1,3 +1,4 @@
import { setImmediate } from "node:timers/promises";
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
import { validateProviderOptions } from "../../provider-options.js";
import type { ChildProcess } from "node:child_process";
@@ -82,7 +83,7 @@ import {
type ClaudeRewindSdk,
} from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js";
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPath } from "./project-dir.js";
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
import {
isProviderImageMarkdown,
@@ -1634,7 +1635,7 @@ export class ClaudeAgentClient implements AgentClient {
cwd: merged.cwd,
};
const claudeConfig = this.assertConfig(mergedConfig);
return new ClaudeAgentSession(claudeConfig, {
const session = new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
handle,
@@ -1645,6 +1646,8 @@ export class ClaudeAgentClient implements AgentClient {
resolveBinary: this.resolveBinary,
rewindSdk: this.rewindSdk,
});
await session.ensureHistoryReady();
return session;
}
async getCatalogCacheKey(_options: FetchCatalogOptions): Promise<string> {
@@ -2329,6 +2332,9 @@ class ClaudeAgentSession implements AgentSession {
{ type: "provider_subagent" }
>[] = [];
private historyPending = false;
// Readiness never rejects; history/rebind failures are logged at their owning boundary.
private historyReady: Promise<void> | null = null;
private historySessionId: string | null = null;
private replayingHistory = false;
private turnState: TurnState = "idle";
private nextTurnOrdinal = 1;
@@ -2382,7 +2388,7 @@ class ClaudeAgentSession implements AgentSession {
}
this.claudeSessionId = handle.sessionId;
this.persistence = handle;
this.loadPersistedHistory(handle.sessionId);
this.historySessionId = handle.sessionId;
} else {
this.claudeSessionId = null;
this.persistence = null;
@@ -2462,6 +2468,7 @@ class ClaudeAgentSession implements AgentSession {
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<{ turnId: string }> {
await this.ensureHistoryReady();
if (this.closed) {
throw new Error("Claude session is closed");
}
@@ -2539,7 +2546,11 @@ class ClaudeAgentSession implements AgentSession {
this.input.push(sdkMessage);
setTimeout(() => {
if (this.activeForegroundTurnId === turnId) {
this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId);
void this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId).catch(
(error: unknown) => {
this.logger.warn({ err: error }, "Failed to emit submitted Claude message");
},
);
}
}, 0);
} catch (error) {
@@ -2632,6 +2643,7 @@ class ClaudeAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
await this.ensureHistoryReady();
if (
!this.historyPending ||
(this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0)
@@ -2643,7 +2655,9 @@ class ClaudeAgentSession implements AgentSession {
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
let replayed = 0;
for (const entry of history) {
if (++replayed % 32 === 0) await setImmediate();
yield {
type: "timeline",
item: entry.item,
@@ -2961,6 +2975,7 @@ class ClaudeAgentSession implements AgentSession {
"provider.claude.session_close.start",
);
this.closed = true;
await this.ensureHistoryReady();
this.rejectAllPendingPermissions(new Error("Claude session closed"));
this.cancelCurrentTurn?.();
this.subscribers.clear();
@@ -3001,7 +3016,7 @@ class ClaudeAgentSession implements AgentSession {
// (see `claude --help`), so the SDK's persistSession=false is silently dropped
// in stream-json mode. Sweep the transcript ourselves so ephemeral runs
// (metadata generator, branch-name generator) don't show up as resumable.
const historyPath = this.resolveHistoryPath(this.claudeSessionId);
const historyPath = await this.resolveHistoryPath(this.claudeSessionId);
if (historyPath) {
try {
await promises.rm(historyPath, { force: true });
@@ -3045,6 +3060,7 @@ class ClaudeAgentSession implements AgentSession {
}
async revertConversation(input: { messageId: string }): Promise<void> {
await this.ensureHistoryReady();
const target = this.resolveConversationRewindTarget(input.messageId);
if (target.kind === "fresh-session") {
this.startFreshConversationSession();
@@ -3054,16 +3070,15 @@ class ClaudeAgentSession implements AgentSession {
await revertClaudeConversation({
sdk: this.rewindSdk,
sessionId: currentSessionId,
transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null,
transcriptPath: currentSessionId ? await this.resolveHistoryPath(currentSessionId) : null,
messageId: target.messageId,
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
setSessionId: (sessionId) => {
this.rebindConversationSession(sessionId);
},
setSessionId: (sessionId) => this.rebindConversationSession(sessionId),
});
}
async revertFiles(input: { messageId: string }): Promise<void> {
await this.ensureHistoryReady();
const messageId = await this.resolveClaudeMessageId(input.messageId);
await revertClaudeFiles({
query: await this.ensureQuery(),
@@ -3238,38 +3253,52 @@ class ClaudeAgentSession implements AgentSession {
return candidates;
}
private rebindConversationSession(sessionId: string): void {
const oldSessionId = this.claudeSessionId;
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = null;
this.persistence = null;
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = true;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
this.rewindTurnAnchors.length = 0;
this.taskState.reset();
this.loadPersistedHistory(sessionId);
if (oldSessionId && oldSessionId !== sessionId) {
this.dispatchEvents([
{
type: "timeline",
provider: "claude",
item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
},
{
type: "thread_started",
provider: "claude",
sessionId,
},
]);
}
private rebindConversationSession(sessionId: string): Promise<void> {
const previousReady = this.ensureHistoryReady();
const rebind = (async () => {
await previousReady;
await this.retireQuery();
if (this.closed) return;
const oldSessionId = this.claudeSessionId;
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = null;
this.persistence = null;
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = true;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
this.rewindTurnAnchors.length = 0;
this.taskState.reset();
this.historySessionId = sessionId;
await this.loadPersistedHistory(sessionId);
if (this.closed) return;
if (oldSessionId && oldSessionId !== sessionId) {
this.dispatchEvents([
{
type: "timeline",
provider: "claude",
item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
},
{
type: "thread_started",
provider: "claude",
sessionId,
},
]);
}
})();
this.historyReady = rebind.catch((error) => {
this.logger.warn({ err: error, sessionId }, "Failed to rebind Claude session");
});
return rebind;
}
private startFreshConversationSession(): void {
this.historySessionId = null;
this.historyReady = Promise.resolve();
const sessionId = randomUUID();
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = sessionId;
@@ -3390,7 +3419,43 @@ class ClaudeAgentSession implements AgentSession {
return { kind: "fresh-session" };
}
private async retireQuery(): Promise<void> {
const oldQuery = this.query;
if (!oldQuery) return;
const oldInput = this.input;
// Null out query/input BEFORE awaiting the old iterator's return so the
// old pump sees this.query !== activeQuery and skips failActiveTurns.
this.query = null;
this.input = null;
this.queryPumpPromise = null;
this.queryRestartNeeded = false;
// Ending the input retires the process on purpose. Detach first so its
// exit is not reported as a crash.
const retiredChild = this.childProcess;
this.childProcess = null;
if (retiredChild) this.failRunningRuntimeTasks();
oldInput?.end();
oldQuery.close?.();
try {
await oldQuery.return?.();
} catch {
/* ignore */
}
// Tree-kill the old process tree now that the SDK has cleaned up.
// If we skip this, MCP children of the previous claude process can
// survive as orphans when the session spawns a replacement query.
if (retiredChild) {
await terminateWithTreeKill(retiredChild, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
}).catch(() => {
/* process may already be dead */
});
}
}
private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> {
await this.ensureHistoryReady();
// Controls use the running turn's query. Pending launch settings take effect
// after that turn finishes, when its active query handle has been cleared.
if (this.query && (!this.queryRestartNeeded || this.activeForegroundQuery === this.query)) {
@@ -3398,37 +3463,7 @@ class ClaudeAgentSession implements AgentSession {
}
if (this.queryRestartNeeded && this.query) {
const oldQuery = this.query;
const oldInput = this.input;
// Null out query/input BEFORE awaiting the old iterator's return so the
// old pump sees this.query !== activeQuery and skips failActiveTurns.
this.query = null;
this.input = null;
this.queryPumpPromise = null;
this.queryRestartNeeded = false;
// Ending the input retires the process on purpose. Detach first so its
// exit is not reported as a crash.
const retiredChild = this.childProcess;
this.childProcess = null;
if (retiredChild) this.failRunningRuntimeTasks();
oldInput?.end();
oldQuery.close?.();
try {
await oldQuery.return?.();
} catch {
/* ignore */
}
// Tree-kill the old process tree now that the SDK has cleaned up.
// If we skip this, MCP children of the previous claude process can
// survive as orphans when the session spawns a replacement query.
if (retiredChild) {
await terminateWithTreeKill(retiredChild, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
}).catch(() => {
/* process may already be dead */
});
}
await this.retireQuery();
}
// Preserve claudeSessionId across query recreation so buildOptions() passes
@@ -4095,12 +4130,14 @@ class ClaudeAgentSession implements AgentSession {
);
};
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
if (!this.ownsQuery(activeQuery)) return true;
this.processingSdkMessages++;
try {
this.runtimeSafety.observe(message);
logRawMessage(message);
consecutiveInterruptAbortRecoveries = 0;
if (await this.handleMissingResumedConversation(message, activeQuery)) return true;
if (!this.ownsQuery(activeQuery)) return true;
await this.routeSdkMessageFromPump(message);
return false;
} finally {
@@ -4221,6 +4258,10 @@ class ClaudeAgentSession implements AgentSession {
return isClaudeTurnInit(message) && !this.activeForegroundTurnId;
}
private ownsQuery(query: Query): boolean {
return !this.closed && this.query === query;
}
private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> {
if (this.consumeSessionStateChange(message)) {
return;
@@ -4295,10 +4336,17 @@ class ClaudeAgentSession implements AgentSession {
messageIdHint: string | null,
turnId: string | null,
): Promise<AgentStreamEvent[]> {
const messageEvents = this.translateMessageToEvents(message, {
const query = this.query;
const messageEvents = await this.translateMessageToEvents(message, {
suppressAssistantText: true,
suppressReasoning: true,
});
if (
this.closed ||
this.query !== query ||
(this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null) !== turnId
)
return [];
const assistantTimelineEvents = readClaudeParentToolUseId(message)
? []
: this.timelineAssembler
@@ -4443,13 +4491,13 @@ class ClaudeAgentSession implements AgentSession {
return withdrewAll;
}
private translateMessageToEvents(
private async translateMessageToEvents(
message: SDKMessage,
options?: {
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): AgentStreamEvent[] {
): Promise<AgentStreamEvent[]> {
const parentToolUseId = readClaudeParentToolUseId(message);
if (parentToolUseId) {
return this.translateSidechainFrameToEvents(message, parentToolUseId);
@@ -4497,11 +4545,11 @@ class ClaudeAgentSession implements AgentSession {
this.appendSystemMessageEvents(message, events);
break;
case "user":
this.appendUserMessageEvents(message, events);
await this.appendUserMessageEvents(message, events);
this.appendSidechainResultEvents(message, events);
break;
case "assistant": {
const timelineItems = this.mapBlocksToTimeline(message.message.content, {
const timelineItems = await this.mapBlocksToTimeline(message.message.content, {
suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false,
});
@@ -4512,7 +4560,7 @@ class ClaudeAgentSession implements AgentSession {
break;
}
case "stream_event":
this.appendStreamEventEvents(message, events, options);
await this.appendStreamEventEvents(message, events, options);
break;
case "result":
this.appendResultEvents(message, events);
@@ -4614,13 +4662,14 @@ class ClaudeAgentSession implements AgentSession {
}
}
private emitSubmittedUserMessage(
private async emitSubmittedUserMessage(
message: Extract<SDKMessage, { type: "user" }>,
turnId: string,
clientMessageId?: string,
): void {
): Promise<void> {
const events: AgentStreamEvent[] = [];
this.appendUserMessageEvents(message, events);
await this.appendUserMessageEvents(message, events);
if (this.activeForegroundTurnId !== turnId) return;
if (events.length === 0) {
return;
}
@@ -4750,10 +4799,10 @@ class ClaudeAgentSession implements AgentSession {
}
}
private appendUserMessageEvents(
private async appendUserMessageEvents(
message: Extract<SDKMessage, { type: "user" }>,
events: AgentStreamEvent[],
): void {
): Promise<void> {
if (isSyntheticUserEntry(message)) {
return;
}
@@ -4792,7 +4841,7 @@ class ClaudeAgentSession implements AgentSession {
return;
}
if (Array.isArray(content)) {
this.appendUserContentArrayEvents(content, messageId, events);
await this.appendUserContentArrayEvents(content, messageId, events);
}
}
@@ -4818,12 +4867,12 @@ class ClaudeAgentSession implements AgentSession {
events.push({ type: "timeline", item, provider: "claude" });
}
private appendUserContentArrayEvents(
private async appendUserContentArrayEvents(
content: ReadonlyArray<unknown>,
messageId: string | undefined,
events: AgentStreamEvent[],
): void {
const timelineItems = this.mapBlocksToTimeline(content, {
): Promise<void> {
const timelineItems = await this.mapBlocksToTimeline(content, {
textMessageType: "user_message",
});
for (const item of timelineItems) {
@@ -4839,16 +4888,16 @@ class ClaudeAgentSession implements AgentSession {
}
}
private appendStreamEventEvents(
private async appendStreamEventEvents(
message: Extract<SDKMessage, { type: "stream_event" }>,
events: AgentStreamEvent[],
options: { suppressAssistantText?: boolean; suppressReasoning?: boolean } | undefined,
): void {
): Promise<void> {
const usageUpdatedEvent = this.contextUsage.buildStreamUsageEvent(message.event);
if (usageUpdatedEvent) {
events.push(usageUpdatedEvent);
}
const timelineItems = this.mapPartialEvent(message.event, {
const timelineItems = await this.mapPartialEvent(message.event, {
suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false,
});
@@ -5272,22 +5321,31 @@ class ClaudeAgentSession implements AgentSession {
}
}
private loadPersistedHistory(sessionId: string): void {
ensureHistoryReady(): Promise<void> {
if (!this.historyReady) {
this.historyReady = this.historySessionId
? this.loadPersistedHistory(this.historySessionId)
: Promise.resolve();
}
return this.historyReady;
}
private async loadPersistedHistory(sessionId: string): Promise<void> {
let historyPath: string | null = null;
this.replayingHistory = true;
try {
this.taskState.reset();
historyPath = this.resolveHistoryPath(sessionId);
if (!historyPath || !fs.existsSync(historyPath)) {
historyPath = await this.resolveHistoryPath(sessionId);
if (!historyPath || !(await transcriptExists(historyPath))) {
this.logger.info(
{ sessionId, cwd: this.config.cwd, historyPath },
"No Claude transcript to load history from",
);
return;
}
withClaudeReplayHistory(historyPath, (history) => {
const replay = this.ingestPersistedSidechains(history);
this.ingestPersistedHistory(history.parentEntries, replay);
await withClaudeReplayHistory(historyPath, async (history) => {
const replay = await this.ingestPersistedSidechains(history);
await this.ingestPersistedHistory(history.parentEntries, replay);
});
} catch (error) {
this.logger.warn(
@@ -5299,13 +5357,13 @@ class ClaudeAgentSession implements AgentSession {
}
}
private ingestPersistedHistory(
entries: Iterable<ClaudeHistoryEntry>,
private async ingestPersistedHistory(
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
replay: ClaudeReplayOwnership,
): void {
): Promise<void> {
const timeline: PersistedTimelineEntry[] = [];
for (const entry of entries) {
this.ingestPersistedHistoryEntry(entry, timeline, replay);
for await (const entry of entries) {
await this.ingestPersistedHistoryEntry(entry, timeline, replay);
}
if (timeline.length > 0) {
@@ -5314,27 +5372,33 @@ class ClaudeAgentSession implements AgentSession {
}
}
private ingestPersistedSidechains(history: ClaudeReplayHistory): ClaudeReplayOwnership {
const parentFacts = readClaudeReplayParentFacts(history.parentEntries);
private async ingestPersistedSidechains(
history: ClaudeReplayHistory,
): Promise<ClaudeReplayOwnership> {
const parentFacts = await readClaudeReplayParentFacts(history.parentEntries);
// Replay produces the same observations the live task protocol produces, then folds them
// with the same function, so identity and status are derived once for both paths.
const subagentReplay = observeReplaySubagents({
subagents: history.subagents.map((subagent) => ({
const subagents = [];
for (const subagent of history.subagents) {
subagents.push({
...subagent,
parentFacts: readClaudeReplayParentFacts(subagent.entries),
})),
parentFacts: await readClaudeReplayParentFacts(subagent.entries),
});
}
const subagentReplay = await observeReplaySubagents({
subagents,
parent: parentFacts,
convertEntry: (entry) => this.convertHistoryEntry(entry),
});
const observations = [
...subagentReplay.observations,
...observeReplayWorkflows({
...(await observeReplayWorkflows({
workflows: history.workflows,
parentEntries: history.parentEntries,
entriesByRunId: history.workflowEntriesByRunId,
convertEntry: (entry) => this.convertHistoryEntry(entry),
}),
})),
];
const restoredProviderSubagentIds = new Set(
observations
@@ -5359,11 +5423,11 @@ class ClaudeAgentSession implements AgentSession {
return replay;
}
private ingestPersistedHistoryEntry(
private async ingestPersistedHistoryEntry(
entry: ClaudeHistoryEntry,
timeline: PersistedTimelineEntry[],
replay: ClaudeReplayOwnership,
): void {
): Promise<void> {
if (entry.isSidechain) {
return;
}
@@ -5377,7 +5441,7 @@ class ClaudeAgentSession implements AgentSession {
? replay.toolOwners.get(notificationToolUseId)
: undefined;
if (notificationOwner) {
for (const item of this.convertHistoryEntry(entry)) {
for (const item of await this.convertHistoryEntry(entry)) {
this.persistedProviderSubagentEvents.push({
type: "provider_subagent",
provider: "claude",
@@ -5394,7 +5458,7 @@ class ClaudeAgentSession implements AgentSession {
const taskSnapshot = this.taskState.observe(entry);
const items = [
...(taskSnapshot ? [taskSnapshot] : []),
...this.convertHistoryEntry(entry).map((item) =>
...(await this.convertHistoryEntry(entry)).map((item) =>
labelReplayedSubagentCard(item, replay.subagentToolCalls),
),
];
@@ -5421,20 +5485,20 @@ class ClaudeAgentSession implements AgentSession {
}
}
private resolveHistoryPath(sessionId: string): string | null {
private async resolveHistoryPath(sessionId: string): Promise<string | null> {
const cwd = this.config.cwd;
if (!cwd) return null;
return claudeTranscriptPathSync({
return claudeTranscriptPath({
cwd,
sessionId,
configDir: claudeConfigDir(this.harnessEnvironment),
});
}
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)).map(
limitAgentTimelineItemContent,
);
private async convertHistoryEntry(entry: ClaudeHistoryEntry): Promise<AgentTimelineItem[]> {
return (
await convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content))
).map(limitAgentTimelineItemContent);
}
// Maps Claude content blocks into AgentTimelineItems.
@@ -5449,14 +5513,14 @@ class ClaudeAgentSession implements AgentSession {
//
// NOTE: convertClaudeHistoryEntry uses extractUserMessageText directly instead of this function
// for user entries. Both paths must produce equivalent user_message items.
private mapBlocksToTimeline(
private async mapBlocksToTimeline(
content: string | ReadonlyArray<unknown>,
options?: {
textMessageType?: "assistant_message" | "user_message";
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): AgentTimelineItem[] {
): Promise<AgentTimelineItem[]> {
const textMessageType = options?.textMessageType ?? "assistant_message";
const suppressText =
textMessageType === "assistant_message" && (options?.suppressAssistantText ?? false);
@@ -5483,7 +5547,7 @@ class ClaudeAgentSession implements AgentSession {
if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) {
continue;
}
this.mapBlockToTimeline(block, {
await this.mapBlockToTimeline(block, {
items,
userTextParts,
textMessageType,
@@ -5528,7 +5592,7 @@ class ClaudeAgentSession implements AgentSession {
}
}
private mapBlockToTimeline(
private async mapBlockToTimeline(
block: ClaudeContentChunk,
context: {
items: AgentTimelineItem[];
@@ -5537,7 +5601,7 @@ class ClaudeAgentSession implements AgentSession {
suppressText: boolean;
suppressReasoning: boolean;
},
): void {
): Promise<void> {
switch (block.type) {
case "text":
case "text_delta":
@@ -5561,7 +5625,7 @@ class ClaudeAgentSession implements AgentSession {
case "code_execution_tool_result":
case "bash_code_execution_tool_result":
case "text_editor_code_execution_tool_result":
this.handleToolResult(block, context.items);
await this.handleToolResult(block, context.items);
break;
default:
break;
@@ -5607,7 +5671,10 @@ class ClaudeAgentSession implements AgentSession {
);
}
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
private async handleToolResult(
block: ClaudeContentChunk,
items: AgentTimelineItem[],
): Promise<void> {
const entry =
typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined;
const blockToolName = typeof block.tool_name === "string" ? block.tool_name : undefined;
@@ -5640,7 +5707,7 @@ class ClaudeAgentSession implements AgentSession {
this.rememberSettledSubagentToolCall(settledToolCall, entry);
for (const image of images) {
const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, {
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
});
if (imageItem) {
@@ -5864,13 +5931,13 @@ class ClaudeAgentSession implements AgentSession {
return false;
}
private mapPartialEvent(
private async mapPartialEvent(
event: SDKPartialAssistantMessage["event"],
options?: {
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): AgentTimelineItem[] {
): Promise<AgentTimelineItem[]> {
if (this.updatePartialEventToolState(event)) {
return [];
}
@@ -5878,14 +5945,14 @@ class ClaudeAgentSession implements AgentSession {
switch (event.type) {
case "content_block_start":
return isClaudeContentChunk(event.content_block)
? this.mapBlocksToTimeline([event.content_block], {
? await this.mapBlocksToTimeline([event.content_block], {
suppressAssistantText: options?.suppressAssistantText,
suppressReasoning: options?.suppressReasoning,
})
: [];
case "content_block_delta":
return isClaudeContentChunk(event.delta)
? this.mapBlocksToTimeline([event.delta], {
? await this.mapBlocksToTimeline([event.delta], {
suppressAssistantText: options?.suppressAssistantText,
suppressReasoning: options?.suppressReasoning,
})
@@ -6191,13 +6258,13 @@ function labelReplayedSubagentCard(
* source consumes. The agentId scrape is kept only as a fallback for sessions recorded before
* Claude Code wrote the meta sidecar.
*/
function readClaudeReplayParentFacts(
entries: Iterable<ClaudeHistoryEntry>,
): ClaudeReplayParentFacts {
async function readClaudeReplayParentFacts(
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
): Promise<ClaudeReplayParentFacts> {
const toolCalls = new Map<string, { title?: string; description?: string }>();
const linksByAgentId = new Map<string, { toolCallId: string; failed: boolean }>();
const outcomes = new Map<string, { failed: boolean }>();
for (const entry of entries) {
for await (const entry of entries) {
const content = toObjectRecord(entry.message)?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -6248,12 +6315,14 @@ interface ClaudeHistoryEntry {
[key: string]: unknown;
}
function mapAssistantHistoryBlocksWithMessageId(
async function mapAssistantHistoryBlocksWithMessageId(
entry: ClaudeHistoryEntry,
content: string | ClaudeContentChunk[],
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
): AgentTimelineItem[] {
const items = mapBlocks(content);
mapBlocks: (
content: string | ClaudeContentChunk[],
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
): Promise<AgentTimelineItem[]> {
const items = await mapBlocks(content);
const assistantMessageId =
typeof entry.uuid === "string" && entry.uuid.length > 0 ? entry.uuid : null;
if (!assistantMessageId) {
@@ -6321,10 +6390,12 @@ function isProviderImageMessage(item: AgentTimelineItem): boolean {
return item.type === "assistant_message" && isProviderImageMarkdown(item.text);
}
export function convertClaudeHistoryEntry(
export async function convertClaudeHistoryEntry(
entry: ClaudeHistoryEntry,
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
): AgentTimelineItem[] {
mapBlocks: (
content: string | ClaudeContentChunk[],
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
): Promise<AgentTimelineItem[]> {
const preamble = convertClaudeHistoryEntryPreamble(entry);
if ("shortCircuit" in preamble) {
return preamble.shortCircuit;
@@ -6363,7 +6434,7 @@ export function convertClaudeHistoryEntry(
}
if (hasToolBlock && normalizedBlocks) {
const mapped = mapBlocks(normalizedBlocks);
const mapped = await mapBlocks(normalizedBlocks);
if (entry.type === "user") {
// tool_result handling (handleToolResult) emits image markdown as an assistant_message
// alongside the tool_call. User-entry text blocks also map to assistant_message in this path
@@ -6377,7 +6448,7 @@ export function convertClaudeHistoryEntry(
}
if (entry.type === "assistant" && contentValue) {
return mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
return await mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
}
return timeline;
@@ -6729,7 +6800,7 @@ async function readClaudeRenamedSessionTitle(input: {
}): Promise<string | null> {
let content: string;
try {
content = await fsPromises.readFile(claudeTranscriptPathSync(input), "utf8");
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
} catch (error) {
// A session with no transcript on disk has no rename to carry over. Every
// other read failure is a real fault and stays explicit.
@@ -6829,3 +6900,13 @@ function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle |
state: record.state as ClaudeCommandLifecycle["state"],
};
}
async function transcriptExists(file: string): Promise<boolean> {
try {
await fs.promises.access(file);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
@@ -1,5 +1,5 @@
import { existsSync, readdirSync, realpathSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { realpathSync } from "node:fs";
import { realpath, access, readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -35,21 +35,21 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti
// one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the
// cwd under another name (a Windows binary under WSL). Returns the expected path when no
// transcript exists anywhere.
export function claudeTranscriptPathSync(input: {
export async function claudeTranscriptPath(input: {
cwd: string;
sessionId: string;
configDir?: string;
}): string {
}): Promise<string> {
const options = { configDir: input.configDir };
const fileName = `${input.sessionId}.jsonl`;
const expected = join(claudeProjectDirSync(input.cwd, options), fileName);
if (existsSync(expected)) {
const expected = join(await claudeProjectDir(input.cwd, options), fileName);
if (await exists(expected)) {
return expected;
}
const projectsRoot = join(resolveConfigDir(options), "projects");
let projectDirs: string[];
try {
projectDirs = readdirSync(projectsRoot);
projectDirs = await readdir(projectsRoot);
} catch (error) {
if (isMissingDirectoryError(error)) {
return expected;
@@ -58,13 +58,23 @@ export function claudeTranscriptPathSync(input: {
}
for (const projectDir of projectDirs) {
const candidate = join(projectsRoot, projectDir, fileName);
if (existsSync(candidate)) {
if (await exists(candidate)) {
return candidate;
}
}
return expected;
}
async function exists(file: string): Promise<boolean> {
try {
await access(file);
return true;
} catch (error) {
if (isMissingDirectoryError(error)) return false;
throw error;
}
}
function isMissingDirectoryError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | null)?.code;
return code === "ENOENT" || code === "ENOTDIR";
@@ -2,11 +2,10 @@ import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:chi
import { query, type Options, type Query, type SpawnOptions } from "@anthropic-ai/claude-agent-sdk";
import {
createProviderEnv,
createProviderEnvSpec,
type ProviderRuntimeSettings,
} from "../../provider-launch-config.js";
import { buildSelfNodeCommand } from "../../../paseo-env.js";
import { buildSelfNodeCommand, createExternalProcessEnv } from "../../../paseo-env.js";
import { spawnProcess } from "../../../../utils/spawn.js";
// Keep the raw SDK query import in this module only. Claude process launch behavior
@@ -74,11 +73,9 @@ function applyRuntimeSettingsToClaudeOptions(
runtimeSettings,
overlays: [launchEnv],
});
const providerEnv = createProviderEnv({
baseEnv: spawnOptions.env,
runtimeSettings,
overlays: [launchEnv],
});
// Set our identity after sanitization clears the parent session's entrypoint.
providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = "Paseo";
const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay);
const selfNodeCommand = isDefaultRuntime
? buildSelfNodeCommand(resolved.args, providerEnv)
: null;
@@ -76,7 +76,7 @@ export async function revertClaudeConversation(input: {
transcriptPath: string | null;
messageId: string;
resolveMessageId?: (messageId: string) => string | Promise<string>;
setSessionId: (sessionId: string) => void;
setSessionId: (sessionId: string) => void | Promise<void>;
}): Promise<void> {
if (!input.sessionId || !input.transcriptPath) {
throw new Error("Claude session is not ready for rewind");
@@ -86,7 +86,7 @@ export async function revertClaudeConversation(input: {
upToMessageId: messageId,
transcriptPath: input.transcriptPath,
});
input.setSessionId(fork.sessionId);
await input.setSessionId(fork.sessionId);
}
export async function revertClaudeFiles(input: {
@@ -108,7 +108,7 @@ export async function revertClaudeConversationAndFiles(input: {
transcriptPath: string | null;
messageId: string;
resolveMessageId?: (messageId: string) => string | Promise<string>;
setSessionId: (sessionId: string) => void;
setSessionId: (sessionId: string) => void | Promise<void>;
}): Promise<void> {
await revertClaudeFiles({
query: input.query,
@@ -1,3 +1,9 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
} from "../../provider-image-output.js";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { describe, expect, it } from "vitest";
@@ -45,7 +51,7 @@ function applyToStore(observations: SubagentObservation[]): ProviderSubagentDesc
}
describe("parseClaudeSubagentMeta", () => {
it("reads the real sidecar shape", () => {
it("reads the real sidecar shape", async () => {
expect(
parseClaudeSubagentMeta(
'{"agentType":"general-purpose","description":"Reply banana","toolUseId":"toolu_013i","spawnDepth":1}',
@@ -68,7 +74,7 @@ describe("parseClaudeSubagentMeta", () => {
expect(parseClaudeSubagentMeta(contents)).toBeNull();
});
it("keeps the good fields when one field has the wrong type", () => {
it("keeps the good fields when one field has the wrong type", async () => {
expect(parseClaudeSubagentMeta('{"agentType":"Explore","toolUseId":42}')).toEqual({
agentType: "Explore",
description: undefined,
@@ -79,12 +85,14 @@ describe("parseClaudeSubagentMeta", () => {
});
describe("observeReplaySubagents", () => {
it("links a subagent to its Task call through meta.toolUseId", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
it("links a subagent to its Task call through meta.toolUseId", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations[0]).toMatchObject({
kind: "declared",
@@ -95,31 +103,33 @@ describe("observeReplaySubagents", () => {
});
});
it("replays descendants beneath the sidechain that declared their Task", () => {
it("replays descendants beneath the sidechain that declared their Task", async () => {
const nestedToolUseId = "toolu_012rzYnFZA";
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 },
entries: [],
parentFacts: {
toolCalls: new Map([
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 },
entries: [],
parentFacts: {
toolCalls: new Map([
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
},
},
},
{
agentId: "a6acb4b898",
meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 },
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
{
agentId: "a6acb4b898",
meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 },
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations).toContainEqual(
expect.objectContaining({
@@ -130,89 +140,143 @@ describe("observeReplaySubagents", () => {
);
});
it("keeps a subagent recorded before spawnDepth existed", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
it("keeps a subagent recorded before spawnDepth existed", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID });
});
it("drops a meta-linked subagent when the parent never declared its Task", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" },
entries: [],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations;
it("drops a meta-linked subagent when the parent never declared its Task", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" },
entries: [],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations;
expect(observations).toEqual([]);
});
it("falls back to the scraped link when there is no meta file", () => {
it("falls back to the scraped link when there is no meta file", async () => {
const parent: ClaudeReplayParentFacts = {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]),
outcomesByToolCallId: new Map(),
};
const [declared, ...rest] = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent,
convertEntry: () => [],
}).observations;
const [declared, ...rest] = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent,
convertEntry: () => [],
})
).observations;
expect(declared).toMatchObject({ id: TOOL_USE_ID, toolCallId: TOOL_USE_ID, title: "Explore" });
expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" });
});
it("drops an unlinkable subagent", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: emptyParent(),
convertEntry: () => [],
}).observations;
it("drops an unlinkable subagent", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: emptyParent(),
convertEntry: () => [],
})
).observations;
expect(observations).toEqual([]);
});
it("drops a terminal subagent the parent has no Task call for", () => {
it("drops a terminal subagent the parent has no Task call for", async () => {
// A legacy grandchild: its toolUseId names a Task call made inside a sibling's transcript, so
// no tool_result for it can ever reach the parent. Left running, it never stops.
const descriptor = applyToStore(
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations,
(
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toBeNull();
});
it("drops a terminal skill-spawned subagent that has no link at all", () => {
it("drops a terminal skill-spawned subagent that has no link at all", async () => {
// A /code-review run: no Task tool_use exists to name it, so its sidecar carries only
// agentType and its id appears nowhere in the parent transcript.
const descriptor = applyToStore(
observeReplaySubagents({
(
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { agentType: "general-purpose" },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toBeNull();
});
it("reports a scraped link's failure as failed", async () => {
const descriptor = applyToStore(
(
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]),
outcomesByToolCallId: new Map(),
},
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
});
it("drops a non-terminal linkless subagent", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
@@ -221,111 +285,81 @@ describe("observeReplaySubagents", () => {
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
message: { stop_reason: null },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations,
);
expect(descriptor).toBeNull();
});
it("reports a scraped link's failure as failed", () => {
const descriptor = applyToStore(
observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]),
outcomesByToolCallId: new Map(),
},
convertEntry: () => [],
}).observations,
);
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
});
it("drops a non-terminal linkless subagent", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { agentType: "general-purpose" },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: null },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations;
})
).observations;
expect(observations).toEqual([]);
});
it("finishes a terminal Task child when the parent has not recorded its outcome", () => {
it("finishes a terminal Task child when the parent has not recorded its outcome", async () => {
const descriptor = applyToStore(
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
}).observations,
(
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toMatchObject({ status: "completed" });
});
it("leaves a subagent with no recorded outcome running", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
}).observations;
it("leaves a subagent with no recorded outcome running", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
})
).observations;
expect(observations.some((o) => o.kind === "status")).toBe(false);
});
it("reports a failed outcome as failed", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(true),
convertEntry: () => [],
}).observations;
it("reports a failed outcome as failed", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(true),
convertEntry: () => [],
})
).observations;
expect(observations.at(-1)).toMatchObject({ kind: "status", status: "failed" });
});
it("carries entry timestamps onto timeline observations", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [{ type: "reasoning", text: "thinking" }],
}).observations;
it("carries entry timestamps onto timeline observations", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [{ type: "reasoning", text: "thinking" }],
})
).observations;
expect(observations.find((observation) => observation.kind === "timeline")).toMatchObject({
kind: "timeline",
@@ -336,18 +370,20 @@ describe("observeReplaySubagents", () => {
});
describe("replay runtime", () => {
it("reads the model and effort off the subagent's own transcript", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
it("reads the model and effort off the subagent's own transcript", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations.find((o) => o.kind === "subtitle")).toEqual({
kind: "subtitle",
@@ -356,49 +392,55 @@ describe("replay runtime", () => {
});
});
it("takes the last observed values when the model changes mid-transcript", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } },
{ type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } },
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
it("takes the last observed values when the model changes mid-transcript", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } },
{ type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } },
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
subtitle: "general-purpose · Sonnet 5 · Low",
});
});
it("reports nothing when the transcript never names a model or effort", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
it("reports nothing when the transcript never names a model or effort", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations.some((o) => o.kind === "subtitle")).toBe(false);
});
it("passes through a model the manifest does not know", () => {
it("passes through a model the manifest does not know", async () => {
// Claude Code is an Anthropic-compatible client; subagents legitimately report other models.
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", message: { model: "glm-5.1" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", message: { model: "glm-5.1" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
subtitle: "general-purpose · glm-5.1",
});
@@ -406,20 +448,24 @@ describe("replay runtime", () => {
});
describe("replay usage", () => {
function subtitleOf(entries: ClaudeReplayEntry[]): SubagentObservation | undefined {
return observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations.find((observation) => observation.kind === "subtitle");
async function subtitleOf(
entries: ClaudeReplayEntry[],
): Promise<SubagentObservation | undefined> {
return (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations.find((observation) => observation.kind === "subtitle");
}
it("derives the counters Claude Code itself reports, from a real transcript's shape", () => {
it("derives the counters Claude Code itself reports, from a real transcript's shape", async () => {
// Shape and numbers taken from an on-disk subagent transcript. Claude Code finalizes a
// subagent from the LAST assistant entry's usage, so the earlier, smaller turn must not win
// and must not be added in.
expect(
subtitleOf([
await subtitleOf([
{
type: "assistant",
timestamp: "2026-07-23T23:18:43.023Z",
@@ -462,9 +508,9 @@ describe("replay usage", () => {
});
});
it("formats tokens for a child that only answered", () => {
it("formats tokens for a child that only answered", async () => {
expect(
subtitleOf([
await subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -479,12 +525,12 @@ describe("replay usage", () => {
).toMatchObject({ subtitle: "general-purpose · 16.4k tokens" });
});
it("emits nothing for a transcript with no entries", () => {
expect(subtitleOf([])).toBeUndefined();
it("emits nothing for a transcript with no entries", async () => {
expect(await subtitleOf([])).toBeUndefined();
});
it("keeps duration out of the compact subtitle", () => {
const observation = subtitleOf([
it("keeps duration out of the compact subtitle", async () => {
const observation = await subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -494,8 +540,8 @@ describe("replay usage", () => {
expect(observation).toMatchObject({ subtitle: "general-purpose · 10 tokens" });
});
it("omits totalTokens when no entry carries a usage block", () => {
const observation = subtitleOf([
it("omits totalTokens when no entry carries a usage block", async () => {
const observation = await subtitleOf([
{ type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", message: { content: [] } },
{
type: "assistant",
@@ -506,9 +552,9 @@ describe("replay usage", () => {
expect(observation).toBeUndefined();
});
it("keeps the last usage-bearing entry when the final assistant entry has none", () => {
it("keeps the last usage-bearing entry when the final assistant entry has none", async () => {
expect(
subtitleOf([
await subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -519,34 +565,36 @@ describe("replay usage", () => {
).toMatchObject({ subtitle: "general-purpose · 1k tokens" });
});
it("only reports counters the transcript actually shows", () => {
it("only reports counters the transcript actually shows", async () => {
// A user-only transcript says nothing about cost: no assistant turn, so no tool count either.
const observation = subtitleOf([
const observation = await subtitleOf([
{ type: "user", timestamp: "2026-07-27T16:42:14.698Z", message: { content: "prompt" } },
{ type: "user", timestamp: "2026-07-27T16:42:20.000Z", message: { content: "more" } },
]);
expect(observation).toBeUndefined();
});
it("lands the complete provider-owned subtitle on the descriptor", () => {
it("lands the complete provider-owned subtitle on the descriptor", async () => {
const store = new ProviderSubagentStore();
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
message: { content: [], usage: { input_tokens: 7 } },
},
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
message: { content: [], usage: { input_tokens: 7 } },
},
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
for (const event of foldSubagentObservations(observations)) {
store.apply("parent", "claude", event);
}
@@ -559,7 +607,7 @@ describe("live and replay agree", () => {
// The property the design exists to guarantee: one session, two sources, one descriptor.
// This is inexpressible against the inference implementation, where the paths share no
// vocabulary and each derives identity and status its own way.
it("produces identical descriptor state from the wire and from disk", () => {
it("produces identical descriptor state from the wire and from disk", async () => {
const live = new ClaudeTaskProtocolSource();
const liveObservations = [
...live.observe({
@@ -579,21 +627,23 @@ describe("live and replay agree", () => {
} as unknown as SDKMessage),
];
const replayObservations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: {
toolUseId: TOOL_USE_ID,
agentType: "general-purpose",
description: "Summarize hover and unistyles docs",
const replayObservations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: {
toolUseId: TOOL_USE_ID,
agentType: "general-purpose",
description: "Summarize hover and unistyles docs",
},
entries: [],
},
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
const fromWire = applyToStore(liveObservations);
const fromDisk = applyToStore(replayObservations);
@@ -616,3 +666,34 @@ describe("live and replay agree", () => {
});
});
});
it("awaits image materialization before publishing replay observations", async () => {
const bytes = Buffer.from("async replay image fixture");
const convertEntry = async () => {
const item = await renderProviderImageOutputAsAssistantMarkdown(
{ data: bytes.toString("base64"), mimeType: "image/png" },
{ materialize: materializeProviderImage },
);
return item ? [item] : [];
};
const { observations } = await observeReplaySubagents({
parent: parentWithTaskCall(),
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", message: { content: [] } }],
},
],
convertEntry,
});
const image = observations.find(
(observation) =>
observation.kind === "timeline" && observation.item.type === "assistant_message",
);
if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message")
throw new Error("Expected replay image");
const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1];
expect(source).toBeTruthy();
expect(await readFile(fileURLToPath(source!))).toEqual(bytes);
});
@@ -130,7 +130,7 @@ function readUsage(entry: ClaudeReplayEntry): ClaudeSubagentUsage | null {
export interface ClaudeReplaySubagentInput {
agentId: string;
meta: ClaudeSubagentMeta | null;
entries: Iterable<ClaudeReplayEntry>;
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
/** Task declarations made by this subagent, used to attach its direct children. */
parentFacts?: ClaudeReplayParentFacts;
}
@@ -250,13 +250,13 @@ function observeSubtitle(
};
}
function observeSubagent(
async function observeSubagent(
subagent: ClaudeReplaySubagentInput,
parent: ClaudeReplayParentFacts,
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[],
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
toolOwners: Map<string, string>,
parentSubagentId?: string,
): SubagentObservation[] {
): Promise<SubagentObservation[]> {
const link = resolveParentLink(subagent, parent);
if (!link) return [];
const toolCall = parent.toolCalls.get(link.toolCallId);
@@ -269,7 +269,7 @@ function observeSubagent(
let childTerminalStatus: ProviderSubagentStatus | null = null;
// Consume each source once for presentation and timeline; keep no raw transcript records.
for (const entry of subagent.entries) {
for await (const entry of subagent.entries) {
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
if (!seenEntry) firstTimestamp = timestamp;
seenEntry = true;
@@ -280,7 +280,7 @@ function observeSubagent(
childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null;
}
recordReplayToolOwners(toolOwners, entry, link.id);
for (const item of convertEntry(entry)) {
for (const item of await convertEntry(entry)) {
timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) });
}
}
@@ -320,11 +320,11 @@ function recordReplayToolOwners(
}
}
export function observeReplaySubagents(input: {
export async function observeReplaySubagents(input: {
subagents: readonly ClaudeReplaySubagentInput[];
parent: ClaudeReplayParentFacts;
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> } {
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> {
const observations: SubagentObservation[] = [];
const toolOwners = new Map<string, string>();
const unresolved = [...input.subagents].sort(
@@ -346,7 +346,7 @@ export function observeReplaySubagents(input: {
const { ownerId, parent, link } = resolved;
// Only proven descendants may own notifications; ambient sidecars cannot claim them.
for (const observation of observeSubagent(
for (const observation of await observeSubagent(
subagent,
parent,
input.convertEntry,
@@ -1,3 +1,9 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
} from "../../provider-image-output.js";
import { describe, expect, it } from "vitest";
import { ProviderSubagentStore } from "../../../provider-subagents/store.js";
@@ -35,7 +41,7 @@ function parentEntries(
}
describe("Claude workflow replay", () => {
it("parses the persisted run summary observed from Claude Code 2.1.220", () => {
it("parses the persisted run summary observed from Claude Code 2.1.220", async () => {
expect(
parseClaudeWorkflowRun(
JSON.stringify({
@@ -62,8 +68,8 @@ describe("Claude workflow replay", () => {
});
});
it("rebuilds the same generic completed descriptor through the parent Workflow call", () => {
const observations = observeReplayWorkflows({
it("rebuilds the same generic completed descriptor through the parent Workflow call", async () => {
const observations = await observeReplayWorkflows({
workflows: [
{
runId: RUN_ID,
@@ -91,8 +97,8 @@ describe("Claude workflow replay", () => {
});
});
it("rebuilds a workflow when Claude persists its result as structured text blocks", () => {
const observations = observeReplayWorkflows({
it("rebuilds a workflow when Claude persists its result as structured text blocks", async () => {
const observations = await observeReplayWorkflows({
workflows: [{ runId: RUN_ID, summary: "Structured result workflow", status: "completed" }],
parentEntries: parentEntries([
{
@@ -114,7 +120,7 @@ describe("Claude workflow replay", () => {
});
});
it("replays the workflow result without exposing its script or child prompt", () => {
it("replays the workflow result without exposing its script or child prompt", async () => {
const workflow = parseClaudeWorkflowRun(
JSON.stringify({
runId: RUN_ID,
@@ -128,7 +134,7 @@ describe("Claude workflow replay", () => {
expect(workflow).not.toBeNull();
if (!workflow) throw new Error("expected workflow");
const observations = observeReplayWorkflows({
const observations = await observeReplayWorkflows({
workflows: [workflow],
parentEntries: parentEntries(),
});
@@ -145,9 +151,9 @@ describe("Claude workflow replay", () => {
expect(JSON.stringify(observations)).not.toContain("SECRET CHILD PROMPT");
});
it("does not duplicate a result already present in the child transcript", () => {
it("does not duplicate a result already present in the child transcript", async () => {
const report = "What Paseo is\nA local-first coding-agent environment.";
const observations = observeReplayWorkflows({
const observations = await observeReplayWorkflows({
workflows: [
{ runId: RUN_ID, summary: "Inspect Paseo", status: "completed", result: { report } },
],
@@ -166,8 +172,8 @@ describe("Claude workflow replay", () => {
).toHaveLength(1);
});
it("fails a nonterminal persisted run because its former runtime cannot update it", () => {
const observations = observeReplayWorkflows({
it("fails a nonterminal persisted run because its former runtime cannot update it", async () => {
const observations = await observeReplayWorkflows({
workflows: [{ runId: RUN_ID, summary: "Interrupted workflow", status: "running" }],
parentEntries: parentEntries(),
});
@@ -179,9 +185,9 @@ describe("Claude workflow replay", () => {
});
});
it("drops a run that no parent Workflow tool call declared", () => {
it("drops a run that no parent Workflow tool call declared", async () => {
expect(
observeReplayWorkflows({
await observeReplayWorkflows({
workflows: [{ runId: RUN_ID, status: "completed" }],
parentEntries: [],
}),
@@ -195,3 +201,29 @@ describe("Claude workflow replay", () => {
},
);
});
it("awaits image materialization before publishing replay observations", async () => {
const bytes = Buffer.from("async replay image fixture");
const convertEntry = async () => {
const item = await renderProviderImageOutputAsAssistantMarkdown(
{ data: bytes.toString("base64"), mimeType: "image/png" },
{ materialize: materializeProviderImage },
);
return item ? [item] : [];
};
const observations = await observeReplayWorkflows({
workflows: [{ runId: RUN_ID, status: "completed" }],
parentEntries: parentEntries(),
entriesByRunId: new Map([[RUN_ID, [{ type: "assistant", message: { content: [] } }]]]),
convertEntry,
});
const image = observations.find(
(observation) =>
observation.kind === "timeline" && observation.item.type === "assistant_message",
);
if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message")
throw new Error("Expected replay image");
const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1];
expect(source).toBeTruthy();
expect(await readFile(fileURLToPath(source!))).toEqual(bytes);
});
@@ -45,14 +45,17 @@ export function parseClaudeWorkflowRun(contents: string): ClaudeWorkflowRun | nu
* provider runtime was recreated, so replay terminalizes it as failed instead of resurrecting a
* row that can never receive another update.
*/
export function observeReplayWorkflows(input: {
export async function observeReplayWorkflows(input: {
workflows: readonly ClaudeWorkflowRun[];
parentEntries: Iterable<ClaudeWorkflowParentEntry>;
parentEntries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>;
/** Repeatable entries in chronological order, supplied by the transcript reader. */
entriesByRunId?: ReadonlyMap<string, Iterable<ClaudeReplayEntry>>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): SubagentObservation[] {
const toolCallIdByRunId = readWorkflowLinks(input.parentEntries);
entriesByRunId?: ReadonlyMap<
string,
Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>
>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
}): Promise<SubagentObservation[]> {
const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries);
const observations: SubagentObservation[] = [];
for (const workflow of input.workflows) {
@@ -79,7 +82,7 @@ export function observeReplayWorkflows(input: {
});
}
for (const observation of observeWorkflowTimeline({
for (const observation of await observeWorkflowTimeline({
id,
result: workflow.result,
entries: input.entriesByRunId?.get(workflow.runId) ?? [],
@@ -115,19 +118,19 @@ export function observeReplayWorkflows(input: {
return observations;
}
function observeWorkflowTimeline(input: {
async function observeWorkflowTimeline(input: {
id: string;
result: unknown;
entries: Iterable<ClaudeReplayEntry>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
finishedAt?: string;
}): SubagentObservation[] {
}): Promise<SubagentObservation[]> {
const observations: SubagentObservation[] = [];
const replayedAssistantText = new Set<string>();
for (const entry of input.entries) {
for await (const entry of input.entries) {
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
const items = input.convertEntry?.(entry) ?? [];
const items = (await input.convertEntry?.(entry)) ?? [];
for (const item of items) {
if (item.type === "assistant_message") replayedAssistantText.add(item.text.trim());
observations.push({
@@ -150,11 +153,13 @@ function observeWorkflowTimeline(input: {
return observations;
}
function readWorkflowLinks(entries: Iterable<ClaudeWorkflowParentEntry>): Map<string, string> {
async function readWorkflowLinks(
entries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>,
): Promise<Map<string, string>> {
const workflowToolCallIds = new Set<string>();
const toolCallIdByRunId = new Map<string, string>();
for (const entry of entries) {
for await (const entry of entries) {
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -165,7 +170,7 @@ function readWorkflowLinks(entries: Iterable<ClaudeWorkflowParentEntry>): Map<st
}
}
for (const entry of entries) {
for await (const entry of entries) {
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -221,9 +221,11 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
/** Normalize notification identity for one transcript traversal. Queue bookkeeping and delivery
* are separate records of the same occurrence; each source counts repeated identical bodies
* independently so the second event is not merged with the first. */
export function* assignTaskNotificationReplayIds<T>(entries: Iterable<T>): Generator<T> {
export async function* assignTaskNotificationReplayIds<T>(
entries: AsyncIterable<T>,
): AsyncGenerator<T> {
const occurrences = new Map<string, Map<string, number>>();
for (const entry of entries) {
for await (const entry of entries) {
const record = TaskNotificationHistoryRecordSchema.safeParse(entry);
if (!record.success) {
yield entry;
@@ -1,5 +1,6 @@
import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js";
import fs from "node:fs";
import fs, { type FileHandle } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import path from "node:path";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import {
@@ -24,11 +25,11 @@ interface RecordLocation {
}
/** Chunk in bytes so UTF-8 and multi-chunk records are decoded once, at a line boundary. */
function* locatedRecords(
async function* locatedRecords(
file: string,
fd: number,
): Generator<{ entry: TranscriptEntry; location: RecordLocation }> {
const stat = fs.fstatSync(fd);
fd: FileHandle,
): AsyncGenerator<{ entry: TranscriptEntry; location: RecordLocation }> {
const stat = await fd.stat();
// Opening a directory succeeds on some platforms; it must never look like empty history.
if (stat.isDirectory()) {
throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${file}'`), {
@@ -43,7 +44,7 @@ function* locatedRecords(
let length = 0;
while (remaining > 0) {
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining));
const count = fs.readSync(fd, buffer, 0, buffer.length, null);
const { bytesRead: count } = await fd.read(buffer, 0, buffer.length, null);
if (count === 0) break;
remaining -= count;
let start = 0;
@@ -64,6 +65,8 @@ function* locatedRecords(
if (entry) yield { entry, location };
start = end + 1;
}
// Bound parsing work as well as IO; microtasks alone do not service timers or sockets.
await setImmediate();
if (start < count) {
pieces.push(buffer.subarray(start, count));
length += count - start;
@@ -85,20 +88,25 @@ function* locatedRecords(
/** Indexed sources parse only their own records using descriptors owned by this replay. */
function indexedRecords(
locations: readonly RecordLocation[],
descriptors: ReadonlyMap<string, number>,
): Iterable<TranscriptEntry> {
descriptors: ReadonlyMap<string, FileHandle>,
): AsyncIterable<TranscriptEntry> {
return {
*[Symbol.iterator]() {
async *[Symbol.asyncIterator]() {
yield* assignTaskNotificationReplayIds(readEntries());
},
};
function* readEntries(): Generator<TranscriptEntry> {
async function* readEntries(): AsyncGenerator<TranscriptEntry> {
for (const location of locations) {
const fd = descriptors.get(location.file)!;
const buffer = Buffer.allocUnsafe(location.length);
let read = 0;
while (read < buffer.length) {
const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read);
const { bytesRead: count } = await fd.read(
buffer,
read,
buffer.length - read,
location.offset + read,
);
if (count === 0)
throw new Error(`Claude transcript was truncated during replay: ${location.file}`);
read += count;
@@ -127,9 +135,9 @@ function parseRecord(line: string): TranscriptEntry | null {
}
}
function readOptionalFile(file: string): string | null {
async function readOptionalFile(file: string): Promise<string | null> {
try {
return fs.readFileSync(file, "utf8");
return await fs.readFile(file, "utf8");
} catch {
return null;
}
@@ -138,31 +146,40 @@ function readOptionalFile(file: string): string | null {
/** Discover sources without retaining transcript contents. Unrelated sidechains stay excluded
* by the replay ownership resolver; workflow children retain their separate run ownership. */
export interface ClaudeReplayHistory {
parentEntries: Iterable<TranscriptEntry>;
parentEntries: AsyncIterable<TranscriptEntry>;
subagents: {
agentId: string;
meta: ClaudeSubagentMeta | null;
entries: Iterable<TranscriptEntry>;
entries: AsyncIterable<TranscriptEntry>;
}[];
workflows: ClaudeWorkflowRun[];
workflowEntriesByRunId: Map<string, Iterable<TranscriptEntry>>;
workflowEntriesByRunId: Map<string, AsyncIterable<TranscriptEntry>>;
}
/** Own descriptors through every pass so unlink or rename cannot invalidate discovered history.
* Sources must be consumed before the callback returns. No transcript copies are needed. */
export function withClaudeReplayHistory<T>(
export async function withClaudeReplayHistory<T>(
historyPath: string,
consume: (history: ClaudeReplayHistory) => T,
): T {
const descriptors = new Map<string, number>();
consume: (history: ClaudeReplayHistory) => Promise<T>,
): Promise<T> {
const descriptors = new Map<string, FileHandle>();
try {
return consume(indexHistory(historyPath, descriptors));
return await consume(await indexHistory(historyPath, descriptors));
} finally {
for (const fd of descriptors.values()) fs.closeSync(fd);
await closeReplaySources(descriptors.values());
}
}
function indexHistory(historyPath: string, descriptors: Map<string, number>): ClaudeReplayHistory {
async function closeReplaySources(descriptors: Iterable<FileHandle>): Promise<void> {
const results = await Promise.allSettled([...descriptors].map((fd) => fd.close()));
const failure = results.find((result) => result.status === "rejected");
if (failure) throw failure.reason;
}
async function indexHistory(
historyPath: string,
descriptors: Map<string, FileHandle>,
): Promise<ClaudeReplayHistory> {
const sessionDirectory = path.join(
path.dirname(historyPath),
path.basename(historyPath, ".jsonl"),
@@ -174,8 +191,7 @@ function indexHistory(historyPath: string, descriptors: Map<string, number>): Cl
const directories = [sidechainDirectory];
while (directories.length) {
const directory = directories.pop()!;
if (!fs.existsSync(directory)) continue;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
for (const entry of await optionalDirectory(directory)) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) {
directories.push(file);
@@ -194,23 +210,23 @@ function indexHistory(historyPath: string, descriptors: Map<string, number>): Cl
}
const agentId = /^agent-(.+)\.meta\.json$/.exec(entry.name)?.[1];
if (!agentId) continue;
const contents = readOptionalFile(file);
const contents = await readOptionalFile(file);
const meta = contents === null ? null : parseClaudeSubagentMeta(contents);
if (meta) metaByAgentId.set(agentId, meta);
}
}
const parentLocations: RecordLocation[] = [];
const locationsByAgentId = new Map<string, RecordLocation[]>();
function scan(
async function scan(
file: string,
visit: (entry: TranscriptEntry, location: RecordLocation) => void,
): void {
const fd = fs.openSync(file, "r");
): Promise<void> {
const fd = await fs.open(file, "r");
descriptors.set(file, fd);
for (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
for await (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
}
for (const file of sidechainFiles) {
scan(file, (entry, location) => {
await scan(file, (entry, location) => {
if (file === historyPath && entry.isSidechain !== true) parentLocations.push(location);
if (entry.isSidechain !== true || typeof entry.agentId !== "string") return;
const locations = locationsByAgentId.get(entry.agentId) ?? [];
@@ -218,11 +234,11 @@ function indexHistory(historyPath: string, descriptors: Map<string, number>): Cl
locationsByAgentId.set(entry.agentId, locations);
});
}
const workflowEntriesByRunId = new Map<string, Iterable<TranscriptEntry>>();
const workflowEntriesByRunId = new Map<string, AsyncIterable<TranscriptEntry>>();
for (const [runId, files] of workflowFiles) {
const locations: RecordLocation[] = [];
for (const file of files)
scan(file, (entry, location) => {
await scan(file, (entry, location) => {
if (entry.type !== "user" || isToolResult(entry)) locations.push(location);
});
locations.sort((a, b) => {
@@ -240,21 +256,28 @@ function indexHistory(historyPath: string, descriptors: Map<string, number>): Cl
meta: metaByAgentId.get(agentId) ?? null,
entries: indexedRecords(locations, descriptors),
})),
workflows: readWorkflows(sessionDirectory),
workflows: await readWorkflows(sessionDirectory),
workflowEntriesByRunId,
};
}
function readWorkflows(sessionDirectory: string): ClaudeWorkflowRun[] {
async function readWorkflows(sessionDirectory: string): Promise<ClaudeWorkflowRun[]> {
const workflows: ClaudeWorkflowRun[] = [];
const workflowDirectory = path.join(sessionDirectory, "workflows");
if (fs.existsSync(workflowDirectory)) {
for (const entry of fs.readdirSync(workflowDirectory, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
const contents = readOptionalFile(path.join(workflowDirectory, entry.name));
const workflow = contents === null ? null : parseClaudeWorkflowRun(contents);
if (workflow) workflows.push(workflow);
}
for (const entry of await optionalDirectory(workflowDirectory)) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
const contents = await readOptionalFile(path.join(workflowDirectory, entry.name));
const workflow = contents === null ? null : parseClaudeWorkflowRun(contents);
if (workflow) workflows.push(workflow);
}
return workflows;
}
async function optionalDirectory(directory: string) {
try {
return await fs.readdir(directory, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
}
File diff suppressed because it is too large Load Diff
@@ -1939,12 +1939,12 @@ function codexImageOutputFromResult(result: unknown): ProviderImageOutput | null
};
}
function mapCodexThreadImageItem(
async function mapCodexThreadImageItem(
normalizedType: string,
normalizedItem: Record<string, unknown>,
): AgentTimelineItem | null {
): Promise<AgentTimelineItem | null> {
if (normalizedType === "imageView") {
return renderProviderImageOutputAsAssistantMarkdown({
return await renderProviderImageOutputAsAssistantMarkdown({
path: firstStringField(normalizedItem, ["path"]),
});
}
@@ -1960,7 +1960,7 @@ function mapCodexThreadImageItem(
const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]);
const result = codexImageOutputFromResult(normalizedItem.result);
return renderProviderImageOutputAsAssistantMarkdown(
return await renderProviderImageOutputAsAssistantMarkdown(
{
path: savedPath ?? result?.path ?? null,
url: result?.url ?? null,
@@ -1982,10 +1982,10 @@ function mapCodexAgentMessage(item: Record<string, unknown>): AgentTimelineItem
};
}
export function threadItemToTimeline(
export async function threadItemToTimeline(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): AgentTimelineItem | null {
): Promise<AgentTimelineItem | null> {
const itemRecord = toObjectRecord(item);
if (!itemRecord) return null;
const includeUserMessage = options?.includeUserMessage ?? true;
@@ -1999,7 +1999,7 @@ export function threadItemToTimeline(
: itemRecord;
if (normalizedType === "imageView" || normalizedType === "imageGeneration") {
return mapCodexThreadImageItem(normalizedType, normalizedItem);
return await mapCodexThreadImageItem(normalizedType, normalizedItem);
}
if (normalizedType && CODEX_TOOL_THREAD_ITEM_TYPES.has(normalizedType)) {
return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
@@ -2024,7 +2024,7 @@ export function threadItemToTimeline(
}
}
function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] {
async function mcpToolResultImagesToTimeline(item: unknown): Promise<AgentTimelineItem[]> {
const itemRecord = toObjectRecord(item);
if (!itemRecord) {
return [];
@@ -2037,24 +2037,25 @@ function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] {
}
const { images } = splitCodexMcpToolResultImages(itemRecord.result);
return images
.map((image) =>
renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
}),
)
.filter((timelineItem): timelineItem is AgentTimelineItem => timelineItem !== null);
const items: AgentTimelineItem[] = [];
for (const image of images) {
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
});
if (imageItem) items.push(imageItem);
}
return items;
}
function threadItemToTimelineEntries(
async function threadItemToTimelineEntries(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): AgentTimelineItem[] {
const timelineItem = threadItemToTimeline(item, options);
): Promise<AgentTimelineItem[]> {
const timelineItem = await threadItemToTimeline(item, options);
if (!timelineItem) {
return [];
}
return [timelineItem, ...mcpToolResultImagesToTimeline(item)];
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
}
const CodexThreadReadResponseSchema = z
@@ -2103,7 +2104,7 @@ async function loadCodexThreadHistoryTimeline(params: {
historicalSubAgentActivity.agentThreadId,
);
if (existingIndex !== undefined) {
const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd });
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
updateHistoricalSubAgentActivity(
timeline,
existingIndex,
@@ -2116,7 +2117,7 @@ async function loadCodexThreadHistoryTimeline(params: {
continue;
}
}
for (const timelineItem of threadItemToTimelineEntries(item, { cwd: params.cwd })) {
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
const timestamp =
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
const settledTimelineItem =
@@ -3290,10 +3291,12 @@ export async function codexAppServerTurnInputFromPrompt(
}
if (block.type === "image") {
try {
const filePath = materializeProviderImage({
data: block.data,
mimeType: block.mimeType,
}).path;
const filePath = (
await materializeProviderImage({
data: block.data,
mimeType: block.mimeType,
})
).path;
output.push({ type: "localImage", path: filePath });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -4370,6 +4373,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.dismissPendingPlanApprovals("Dismissed by a new prompt");
try {
await this.pendingNotifications;
await this.connect();
if (!this.client) {
throw new Error("Codex client not initialized");
@@ -5437,7 +5441,21 @@ export class CodexAppServerAgentSession implements AgentSession {
return `codex-turn-${this.nextTurnOrdinal++}`;
}
private handleNotification(method: string, params: unknown): void {
private pendingNotifications: Promise<void> = Promise.resolve();
private handleNotification(method: string, params: unknown): Promise<void> {
// An image must exist before its event, and completion must follow that event.
// Transport callbacks cannot await: the session owns their ordered drain.
const next = this.pendingNotifications.then(() => {
return this.closed ? undefined : this.processNotification(method, params);
});
this.pendingNotifications = next.catch((error: unknown) => {
this.handleUnexpectedTermination(error instanceof Error ? error : new Error(String(error)));
});
return this.pendingNotifications;
}
private async processNotification(method: string, params: unknown): Promise<void> {
const notificationParams = toObjectRecord(params);
if (method === "serverRequest/resolved" && typeof notificationParams?.requestId === "number") {
const requestId = this.mcpElicitationPermissionIds.get(notificationParams.requestId);
@@ -5467,13 +5485,16 @@ export class CodexAppServerAgentSession implements AgentSession {
return;
}
if (route.kind === "sub_agent") {
this.dispatchSubAgentNotification(parsed, route.callId);
await this.dispatchSubAgentNotification(parsed, route.callId);
return;
}
this.dispatchParsedNotification(parsed);
await this.dispatchParsedNotification(parsed);
}
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
private async dispatchSubAgentNotification(
parsed: ParsedCodexNotification,
callId: string,
): Promise<void> {
switch (parsed.kind) {
case "thread_started":
this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
@@ -5484,7 +5505,7 @@ export class CodexAppServerAgentSession implements AgentSession {
case "reasoning_delta":
case "item_started":
case "item_completed":
this.dispatchParsedNotification(parsed);
await this.dispatchParsedNotification(parsed);
return;
case "exec_command_output_delta":
case "file_change_output_delta":
@@ -5510,7 +5531,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private dispatchParsedNotification(parsed: ParsedCodexNotification): void {
private async dispatchParsedNotification(parsed: ParsedCodexNotification): Promise<void> {
if (isCodexDeltaNotification(parsed)) {
this.handleCodexDeltaNotification(parsed);
return;
@@ -5555,10 +5576,10 @@ export class CodexAppServerAgentSession implements AgentSession {
this.handlePatchApplyCompletedNotification(parsed);
return;
case "item_completed":
this.handleItemCompletedNotification(parsed);
await this.handleItemCompletedNotification(parsed);
return;
case "item_started":
this.handleItemStartedNotification(parsed);
await this.handleItemStartedNotification(parsed);
return;
case "invalid_payload":
this.warnInvalidNotificationPayload(parsed.method, parsed.params);
@@ -5600,7 +5621,7 @@ export class CodexAppServerAgentSession implements AgentSession {
pending.push(parsed);
}
private replayPendingSubAgentNotifications(threadIds: readonly string[]): void {
private async replayPendingSubAgentNotifications(threadIds: readonly string[]): Promise<void> {
for (const threadId of threadIds) {
const pending = this.pendingSubAgentNotificationsByThreadId.get(threadId);
if (!pending) {
@@ -5612,7 +5633,7 @@ export class CodexAppServerAgentSession implements AgentSession {
continue;
}
for (const parsed of pending) {
this.dispatchSubAgentNotification(parsed, callId);
await this.dispatchSubAgentNotification(parsed, callId);
}
}
}
@@ -6578,9 +6599,13 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private handleItemCompletedNotification(
private ownsNotificationTurn(turnId: string | null): boolean {
return !this.closed && this.activeForegroundTurnId === turnId;
}
private async handleItemCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
): void {
): Promise<void> {
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
// and canonical `item/*`. Render ordinary items only from the canonical
// channel, but accept a legacy-only child announcement so it can establish
@@ -6590,20 +6615,23 @@ export class CodexAppServerAgentSession implements AgentSession {
}
this.receiveAsyncQuestion(parsed.threadId, parsed.item);
if (this.isUserMessageItem(parsed.item)) {
this.handleUserMessageItem(parsed);
await this.handleUserMessageItem(parsed);
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (this.handleCompletedSpecialItem(parsed, childSubAgentCallId)) {
return;
}
const timelineItem = threadItemToTimeline(parsed.item, {
const foregroundTurnId = this.activeForegroundTurnId;
const timelineItem = await threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
if (!timelineItem) {
return;
}
const imageItems = await mcpToolResultImagesToTimeline(parsed.item);
if (!this.ownsNotificationTurn(foregroundTurnId)) return;
const registeredChildThreadIds =
timelineItem.type === "tool_call"
? this.registerSubAgentToolCall({
@@ -6613,12 +6641,11 @@ export class CodexAppServerAgentSession implements AgentSession {
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
})
: [];
const imageItems = mcpToolResultImagesToTimeline(parsed.item);
if (childSubAgentCallId) {
this.emitCompletedProviderSubagentItem(parsed, timelineItem);
this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem);
this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
const normalizedItemType = normalizeCodexThreadItemType(
@@ -6626,7 +6653,7 @@ export class CodexAppServerAgentSession implements AgentSession {
);
const itemId = parsed.item.id;
if (this.shouldSkipCompletedThreadItem(timelineItem, normalizedItemType, itemId)) {
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
if (this.consumeStreamedTextCompletion(timelineItem, itemId)) {
@@ -6637,7 +6664,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emittedItemCompletedIds.add(itemId);
this.emittedItemStartedIds.delete(itemId);
}
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
@@ -6666,7 +6693,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
}
private consumeStreamedTextCompletion(
@@ -6738,14 +6765,14 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private handleItemStartedNotification(
private async handleItemStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
): void {
): Promise<void> {
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return;
}
if (this.isUserMessageItem(parsed.item)) {
this.handleUserMessageItem(parsed);
await this.handleUserMessageItem(parsed);
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
@@ -6767,7 +6794,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.handleRegisteredSubAgentActivity(parsed.item)) {
return;
}
const timelineItem = threadItemToTimeline(parsed.item, {
const timelineItem = await threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
@@ -6786,7 +6813,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
}
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
const normalizedItemType = normalizeCodexThreadItemType(
@@ -6810,14 +6837,14 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
}
private handleUserMessageItem(
private async handleUserMessageItem(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): void {
): Promise<void> {
const itemId = parsed.item.id;
const timelineItem = threadItemToTimeline(parsed.item, {
const timelineItem = await threadItemToTimeline(parsed.item, {
includeUserMessage: true,
cwd: this.config.cwd ?? null,
});
@@ -183,3 +183,29 @@ describe("Codex app-server transport", () => {
codex.child.stdin.end();
});
});
describe("Codex stdout byte admission", () => {
test("fails pending requests and the session before joining an oversized line", async () => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const terminations: Error[] = [];
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
const request = client.request("thread/resume", {}, 1000).catch((error: Error) => error);
const chunk = Buffer.alloc(64 * 1024, "x");
child.stdout.write('{"id":1,"result":"');
for (let index = 0; index < 2048; index++) child.stdout.write(chunk);
const error = await request;
expect(error).toMatchObject({
name: "ProviderStreamLimitError",
code: "PROVIDER_STREAM_LINE_TOO_LARGE",
limitBytes: 128 * 1024 * 1024,
});
expect(terminations).toEqual([error]);
expect(client.hasPendingRequests()).toBe(false);
child.stdout.write('"}\n{"id":1,"result":"late"}\n');
await expect(client.request("model/list")).rejects.toThrow("closed");
await client.dispose();
child.stdout.end();
child.stderr.end();
});
});
@@ -1,10 +1,15 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import { StringDecoder } from "node:string_decoder";
import type { Logger } from "pino";
import { z } from "zod";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import { ProviderLineReader, ProviderStreamLimitError } from "../jsonl-frame-decoder.js";
// A measured long-thread resume was 19.75 MB; preserve the existing 32 MiB fixture
// with 4x margin for longer threads. This is provider stdout, not the client protocol.
export const CODEX_PROVIDER_LINE_BYTES = 128 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
const APP_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const APP_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
@@ -167,39 +172,6 @@ function readProviderTurnId(params: unknown): string | undefined {
return isRecord(turn) && typeof turn.id === "string" ? turn.id : undefined;
}
/**
* Splits stdout into lines on "\n" only. `node:readline` additionally treats U+2028 and U+2029
* as line terminators, but Codex emits those characters raw inside JSON strings, so readline
* would cut a single JSON-RPC message into pieces that fail to parse.
*/
function createNewlineLineReader(
input: NodeJS.ReadableStream,
onLine: (line: string) => void,
): { close(): void } {
const decoder = new StringDecoder("utf8");
// Text after the last "\n", kept as parts and joined once its line ends, so a large line
// arriving in many chunks is scanned and copied once instead of once per chunk.
let unterminated: string[] = [];
const onData = (chunk: Buffer | string): void => {
const text = typeof chunk === "string" ? chunk : decoder.write(chunk);
let lineStart = 0;
let index = text.indexOf("\n");
while (index !== -1) {
unterminated.push(text.slice(lineStart, index));
const line = unterminated.join("");
unterminated = [];
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
lineStart = index + 1;
index = text.indexOf("\n", lineStart);
}
if (lineStart < text.length) {
unterminated.push(text.slice(lineStart));
}
};
input.on("data", onData);
return { close: () => void input.off("data", onData) };
}
export class CodexAppServerClient {
private readonly stdoutLineReader: { close(): void };
private activeProviderRequests = 0;
@@ -216,11 +188,24 @@ export class CodexAppServerClient {
private readonly logger: Logger,
private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}),
) {
this.stdoutLineReader = createNewlineLineReader(child.stdout, (line) => {
void this.handleLine(line).catch((error) => {
this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line");
});
});
// Split only on LF: U+2028/U+2029 are valid raw characters inside Codex JSON.
const reader = new ProviderLineReader(
CODEX_PROVIDER_LINE_BYTES,
(line) => {
void this.handleLine(line).catch((error) => {
this.logger.warn({ error, line }, "Failed to handle Codex app-server stdout line");
});
},
(error) => this.handleStreamLimit(error),
);
const onData = (chunk: Buffer | string) => reader.write(chunk);
child.stdout.on("data", onData);
this.stdoutLineReader = {
close: () => {
reader.close();
child.stdout.off("data", onData);
},
};
child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
@@ -318,6 +303,15 @@ export class CodexAppServerClient {
}
}
private handleStreamLimit(failure: ProviderStreamLimitError): void {
// A rejected response/notification cannot be replayed safely. Fail the session,
// including live turns with no pending RPC, rather than dropping a stream unit.
this.handleUnexpectedTermination(failure);
void this.dispose().catch((err) => {
this.logger.warn({ err }, "Failed to terminate oversized Codex stdout producer");
});
}
private handleUnexpectedTermination(error: Error): void {
if (this.disposed) {
return;
@@ -1,3 +1,5 @@
import { performance } from "node:perf_hooks";
import { setTimeout as delay } from "node:timers/promises";
import { describe, expect, it } from "vitest";
import { JsonlFrameDecoder, type JsonlFrameProblem } from "./jsonl-frame-decoder.js";
@@ -36,6 +38,16 @@ describe("JsonlFrameDecoder", () => {
expect(problems).toEqual([]);
});
it("preserves UTF-8 characters split across raw stdout buffers", () => {
const { decoder, frames, problems } = decoderHarness();
const bytes = Buffer.from('{"type":"notice","text":"é🙂"}\n');
const split = bytes.indexOf(Buffer.from("🙂")) + 1;
decoder.write(bytes.subarray(0, split));
decoder.write(bytes.subarray(split));
expect(frames).toEqual([{ type: "notice", text: "é🙂" }]);
expect(problems).toEqual([]);
});
it("reassembles a large ordered v2 frame", () => {
const { decoder, frames, problems } = decoderHarness();
const frame = { type: "models", value: "x".repeat(1024 * 1024) };
@@ -55,3 +67,57 @@ describe("JsonlFrameDecoder", () => {
expect(problems).toEqual(["out-of-order-chunk"]);
});
});
describe("provider line admission", () => {
it("keeps the loop responsive while accumulating a fragmented provider response", async () => {
const { decoder, frames, problems } = decoderHarness();
const part = "x".repeat(512);
let last = performance.now();
let longestGap = 0;
const probe = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - last);
last = now;
}, 1);
try {
await delay(5);
decoder.write('{"type":"notice","text":"');
// 1 MiB is enough to expose the former rescan of every accumulated prefix.
for (let index = 0; index < 2048; index++) decoder.write(part);
decoder.write('"}\n');
await delay(5);
expect(frames).toEqual([{ type: "notice", text: part.repeat(2048) }]);
expect(problems).toEqual([]);
expect(longestGap).toBeLessThan(50);
} finally {
clearInterval(probe);
}
});
it("rejects an unterminated over-cap UTF-8 line and stops accepting frames", () => {
const frames: Record<string, unknown>[] = [];
const failures: Array<{ problem: string; detail: unknown }> = [];
const decoder = new JsonlFrameDecoder({
frame: (frame) => frames.push(frame),
problem: (problem, detail) => failures.push({ problem, detail }),
});
// 64 MiB of UTF-8, only 32 Mi UTF-16 code units. No newline or JSON parse needed.
const chunk = "é".repeat(32 * 1024);
for (let index = 0; index < 1024; index++) decoder.write(chunk);
expect(failures).toEqual([]);
decoder.write("é");
expect(failures).toEqual([
{
problem: "line-too-large",
detail: expect.objectContaining({
name: "ProviderStreamLimitError",
code: "PROVIDER_STREAM_LINE_TOO_LARGE",
limitBytes: 64 * 1024 * 1024,
}),
},
]);
decoder.write('\n{"type":"ready"}\n');
expect(frames).toEqual([]);
expect(failures).toHaveLength(1);
});
});
@@ -1,3 +1,60 @@
// Provider-process stdout only; these are not app/daemon wire limits.
export const JSONL_PROVIDER_LINE_BYTES = 64 * 1024 * 1024;
export class ProviderStreamLimitError extends Error {
readonly code = "PROVIDER_STREAM_LINE_TOO_LARGE";
constructor(readonly limitBytes: number) {
super(
`Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`,
);
this.name = "ProviderStreamLimitError";
}
}
/** Admits raw UTF-8 bytes before retaining, decoding or joining a complete line. */
export class ProviderLineReader {
private parts: Buffer[] = [];
private bytes = 0;
private stopped = false;
constructor(
private readonly limitBytes: number,
private readonly onLine: (line: string) => void,
private readonly onLimit: (error: ProviderStreamLimitError) => void,
) {}
write(chunk: Buffer | string): void {
if (this.stopped) return;
const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
let start = 0;
while (start < buffer.byteLength && !this.stopped) {
const newline = buffer.indexOf(10, start);
const end = newline === -1 ? buffer.byteLength : newline;
const length = end - start;
if (length > this.limitBytes - this.bytes) {
this.close();
this.onLimit(new ProviderStreamLimitError(this.limitBytes));
return;
}
if (length) this.parts.push(buffer.subarray(start, end));
this.bytes += length;
if (newline === -1) return;
const line = Buffer.concat(this.parts, this.bytes).toString("utf8");
this.parts = [];
this.bytes = 0;
this.onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
start = newline + 1;
}
}
close(): void {
this.stopped = true;
this.parts = [];
this.bytes = 0;
}
}
const V2_FRAME_BYTES = 1024 * 1024;
const V2_REASSEMBLED_BYTES = 64 * 1024 * 1024;
const V2_CHUNK_BYTES = 256 * 1024;
@@ -5,6 +62,7 @@ const V2_BASE64_CHARS = Math.ceil(V2_CHUNK_BYTES / 3) * 4;
const V2_MAX_CHUNKS = Math.ceil(V2_REASSEMBLED_BYTES / V2_CHUNK_BYTES);
export type JsonlFrameProblem =
| "line-too-large"
| "invalid-json"
| "invalid-chunk"
| "invalid-base64"
@@ -78,7 +136,7 @@ function chunkBytes(data: string): Buffer | null {
}
export class JsonlFrameDecoder {
private lineBuffer = "";
private readonly lines: ProviderLineReader;
private assembly: ChunkAssembly | null = null;
constructor(
@@ -86,17 +144,18 @@ export class JsonlFrameDecoder {
frame(message: Record<string, unknown>): void;
problem(problem: JsonlFrameProblem, detail?: unknown): void;
},
) {}
) {
this.lines = new ProviderLineReader(
JSONL_PROVIDER_LINE_BYTES,
(line) => {
if (line.trim()) this.consumeLine(line);
},
(error) => this.drop("line-too-large", error),
);
}
write(text: string): void {
this.lineBuffer += text;
let newline = this.lineBuffer.indexOf("\n");
while (newline !== -1) {
const line = this.lineBuffer.slice(0, newline).replace(/\r$/, "");
this.lineBuffer = this.lineBuffer.slice(newline + 1);
if (line.trim()) this.consumeLine(line);
newline = this.lineBuffer.indexOf("\n");
}
write(chunk: Buffer | string): void {
this.lines.write(chunk);
}
private consumeLine(line: string): void {
@@ -471,3 +471,25 @@ describe("JsonlRpcProcess", () => {
}
});
});
describe("JSONL stdout byte admission", () => {
test("fails pending requests and publishes the typed failure to live-turn consumers", async () => {
const child = createInMemoryChildProcess();
const process = startProcess({ child });
const exit = new Promise<JsonlRpcExit>((resolve) => process.onExit(resolve));
const request = process.request({ type: "hang" }, 1000).catch((error: Error) => error);
const chunk = Buffer.alloc(64 * 1024, "x");
child.stdout.write('{"type":"notice","text":"');
for (let index = 0; index < 1024; index++) child.stdout.write(chunk);
const error = await request;
expect(error).toMatchObject({
name: "ProviderStreamLimitError",
code: "PROVIDER_STREAM_LINE_TOO_LARGE",
limitBytes: 64 * 1024 * 1024,
});
expect((await exit).error).toBe(error);
await process.close();
child.stdout.end();
child.stderr.end();
});
});
@@ -3,7 +3,7 @@ import type { Logger } from "pino";
import { spawnProcess } from "../../../utils/spawn.js";
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
import { JsonlFrameDecoder } from "./jsonl-frame-decoder.js";
import { JsonlFrameDecoder, ProviderStreamLimitError } from "./jsonl-frame-decoder.js";
export { supportsJsonlRpcProtocolV2 } from "./jsonl-frame-decoder.js";
/** Default wall-clock timeout for control-plane / short RPC calls. */
@@ -81,6 +81,7 @@ export class JsonlRpcProcess {
private stderrBuffer = "";
private nextRequestId = 1;
private disposed = false;
private streamFailure: ProviderStreamLimitError | null = null;
private exited = false;
private closing: Promise<void> | null = null;
private readonly frameDecoder: JsonlFrameDecoder;
@@ -90,6 +91,14 @@ export class JsonlRpcProcess {
this.frameDecoder = new JsonlFrameDecoder({
frame: (message) => this.dispatchFrame(message),
problem: (problem, detail) => {
if (problem === "line-too-large" && detail instanceof ProviderStreamLimitError) {
// Stop the producer: silently skipping a response or turn event loses state.
this.streamFailure = detail;
void this.close(detail).catch((err) => {
this.options.logger.warn({ err }, `Failed to close ${this.diagnosticName}`);
});
return;
}
this.options.logger.warn(
{ problem, detail },
`Ignoring invalid ${this.diagnosticName} frame`,
@@ -98,7 +107,7 @@ export class JsonlRpcProcess {
});
this.child = (options.spawn ?? spawnJsonlRpcProcess)(options.launch);
this.child.stdout.on("data", (chunk) => {
this.handleStdoutChunk(chunk.toString());
this.handleStdoutChunk(chunk);
});
this.child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
@@ -114,9 +123,11 @@ export class JsonlRpcProcess {
});
this.child.on("exit", (code, signal) => {
this.exited = true;
const error = new Error(
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
const error =
this.streamFailure ??
new Error(
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
const exit = { code, signal, error };
for (const subscriber of this.exitSubscribers) {
subscriber(exit);
@@ -254,7 +265,7 @@ export class JsonlRpcProcess {
}
}
private handleStdoutChunk(chunk: string): void {
private handleStdoutChunk(chunk: Buffer | string): void {
this.frameDecoder.write(chunk);
}
@@ -1133,6 +1133,17 @@ describe("OMP agent client and session", () => {
messageId: "assistant-history",
},
]);
await expect(
omp.runPrompt("continue after resume", "resumed follow-up complete"),
).resolves.toMatchObject({
finalText: "resumed follow-up complete",
});
expect(omp.timeline()).toContainEqual(
expect.objectContaining({
type: "assistant_message",
text: "resumed follow-up complete",
}),
);
expect(omp.usageSession()).toMatchObject({ provider: "omp", sessionKey: expect.any(String) });
await omp.close();
expect(omp.usageSession()).toBeNull();
@@ -1332,13 +1343,14 @@ describe("OMP agent client and session", () => {
expect(omp.runtimeLaunches()).toHaveLength(1);
});
test("closes a replacement process if the session closes during relaunch", async () => {
test("rejects a pending recovery start and closes its processes when the session closes", async () => {
const omp = new OmpHarness();
await omp.start();
omp.processExit("OMP RPC process exited with code 1 and signal null");
const turn = omp.startTurnDetached("continue");
const rejected = expect(turn).rejects.toThrow("closed");
await omp.close();
await turn;
await rejected;
await waitForImmediate();
expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true);
});
@@ -1627,3 +1639,93 @@ test.each([
expect(await session.canStopRuntime!()).toBe(expected);
},
);
test.each([false, true])(
"image prompt keeps OMP's vision capability contract (vision: %s)",
async (vision) => {
const runtime = new FakeOmp();
runtime.queueSessionSetup((session) => {
session.state = {
...session.state,
model: {
provider: "test",
id: "image-model",
input: vision ? ["text", "image"] : ["text"],
},
};
});
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
onTestFinished(() => session.close());
const bytes = Buffer.from("OMP attachment fixture");
const first = session.startTurn([
{ type: "text", text: "Describe this image." },
{ type: "image", data: bytes.toString("base64"), mimeType: "image/png" },
]);
await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active");
await first;
const [prompt] = runtime.latestSession().prompts;
expect(prompt.imageCount).toBe(vision ? 1 : 0);
if (vision) expect(prompt.message).toBe("Describe this image.");
else {
const imagePath = prompt.message.match(/\[Image available at: (.+)\]/)?.[1];
expect(imagePath).toBeTruthy();
const { readFile, rm } = await import("node:fs/promises");
expect(await readFile(imagePath!)).toEqual(bytes);
await rm(imagePath!);
}
},
);
test("interrupting OMP during image conversion cancels the admitted turn and permits a follow-up", async () => {
const runtime = new FakeOmp();
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
onTestFinished(() => session.close());
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
let admitted = false;
const first = session
.startTurn([
{
type: "image",
data: Buffer.alloc(16 * 1024 * 1024, 53).toString("base64"),
mimeType: "image/png",
},
])
.then((result) => {
admitted = true;
return result;
});
await waitForImmediate();
expect(admitted).toBe(false);
expect(runtime.latestSession().prompts).toHaveLength(0);
await session.interrupt();
const followup = await session.startTurn("follow-up after image interrupt");
const original = await first;
expect(events).toContainEqual(
expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }),
);
expect(followup.turnId).not.toBe(original.turnId);
expect(runtime.latestSession().prompts).toEqual([
{ message: "follow-up after image interrupt", imageCount: 0 },
]);
});
test("closing OMP during image conversion rejects its pending start", async () => {
const runtime = new FakeOmp();
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
const first = session.startTurn([
{
type: "image",
data: Buffer.alloc(16 * 1024 * 1024, 54).toString("base64"),
mimeType: "image/png",
},
]);
const rejected = expect(first).rejects.toThrow("closed");
await waitForImmediate();
expect(runtime.latestSession().prompts).toHaveLength(0);
await session.close();
await rejected;
});
@@ -317,9 +317,9 @@ function ompModelSupportsImageInput(model: OmpModel | null | undefined): boolean
return model?.input?.includes("image") === true;
}
function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string {
async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise<string> {
try {
const materialized = materializeProviderImage({
const materialized = await materializeProviderImage({
data: image.data,
mimeType: image.mimeType,
});
@@ -329,10 +329,10 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str
}
}
function convertPromptInput(
async function convertPromptInput(
prompt: AgentPromptInput,
options: { model: OmpModel | null | undefined },
): OmpPromptPayload {
): Promise<OmpPromptPayload> {
if (typeof prompt === "string") {
return { text: prompt };
}
@@ -355,7 +355,7 @@ function convertPromptInput(
mimeType: block.mimeType,
});
} else {
textParts.push(renderTextOnlyImageHint(block));
textParts.push(await renderTextOnlyImageHint(block));
}
continue;
}
@@ -868,11 +868,9 @@ export class OmpAgentSession implements AgentSession {
throw new Error("An OMP turn is already active");
}
const payload = convertPromptInput(prompt, { model: this.state.model });
const turnId = randomUUID();
this.live = true;
this.activeTurnId = turnId;
this.rememberClientMessage(options?.clientMessageId ?? null, payload.text);
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.activeTurnStarted = false;
@@ -880,6 +878,10 @@ export class OmpAgentSession implements AgentSession {
this.activeTurnHasUserMessage = false;
this.activePromptRequestId = null;
this.clearNoTurnBuffers();
const payload = await convertPromptInput(prompt, { model: this.state.model });
if (this.closed) throw new Error("OMP session is closed");
if (this.activeTurnId !== turnId) return { turnId };
this.rememberClientMessage(options?.clientMessageId ?? null, payload.text);
this.activeNoTurnPromptText = payload.text;
this.usagePoller.startTurn();
if (announceTurn) {
@@ -946,7 +948,9 @@ export class OmpAgentSession implements AgentSession {
): Promise<SteerResult> {
if (this.closed || this.activeTurnId !== options.expectedTurnId)
return { status: "unavailable" };
const payload = convertPromptInput(prompt, { model: this.state.model });
const payload = await convertPromptInput(prompt, { model: this.state.model });
if (this.closed || this.activeTurnId !== options.expectedTurnId)
return { status: "unavailable" };
if (this.parseSlashCommandInput(payload.text)) return { status: "unavailable" };
const submission = this.rememberClientMessage(options.clientMessageId ?? null, payload.text);
try {
@@ -1,4 +1,5 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
@@ -8,7 +9,7 @@ import { streamOmpCoreHistory, type OmpCapturedUserMessageEntry } from "./messag
import type { OmpAgentMessage } from "./rpc-types.js";
import { FakeOmp } from "./test-utils/fake-omp.js";
import { OMP_HISTORY_MAPPER_HOOKS } from "./history-hooks.js";
import { readOmpHistoryTodoState, streamOmpHistory } from "./history.js";
import { readActiveOmpEntryChain, readOmpHistoryTodoState, streamOmpHistory } from "./history.js";
async function collectHistory(
messages: OmpAgentMessage[],
@@ -1245,3 +1246,53 @@ describe("OMP history mapper", () => {
);
});
});
test("indexes out-of-order branches, duplicate IDs and cycles before selecting history", async () => {
const directory = await mkdtemp(join(tmpdir(), "omp-history-branches-"));
const sessionFile = join(directory, "session.jsonl");
const root = { id: "root", parentId: null };
const earlyLeaf = {
id: "early",
parentId: "root",
message: { role: "user", content: "你好🌍".repeat(15000) },
};
const branch = { id: "branch", parentId: "root" };
const active = { id: "active", parentId: "branch" };
const duplicate = {
id: "branch",
parentId: "root",
message: { role: "user", content: "replacement" },
};
try {
await writeFile(
sessionFile,
[earlyLeaf, active, root, branch, duplicate]
.map((entry) => JSON.stringify(entry))
.join("\n") + '\n{"id":',
);
// The final record is a branch ancestor, not the active leaf. Latest duplicate wins
// when following parents; the invalid tail contributes neither a leaf nor a parent.
await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual([root, duplicate, active]);
await expect(readActiveOmpEntryChain(sessionFile, "early")).resolves.toEqual([root, earlyLeaf]);
await expect(readActiveOmpEntryChain(sessionFile, "missing")).resolves.toEqual([
root,
duplicate,
active,
]);
const cycle = [
{ id: "a", parentId: "b" },
{ id: "b", parentId: "a" },
];
await writeFile(sessionFile, cycle.map((entry) => JSON.stringify(entry)).join("\n"));
await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual(cycle);
await writeFile(sessionFile, "null\n{}\ninvalid\n");
await expect(readActiveOmpEntryChain(sessionFile)).resolves.toEqual([]);
await expect(readActiveOmpEntryChain(directory)).rejects.toMatchObject({ code: "EISDIR" });
await expect(readActiveOmpEntryChain(join(directory, "missing"))).rejects.toMatchObject({
code: "ENOENT",
});
await expect(readOmpHistoryTodoState(join(directory, "missing"))).resolves.toBeNull();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
@@ -0,0 +1,104 @@
import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
import { mkdtemp, open, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { performance } from "node:perf_hooks";
import { setTimeout as delay } from "node:timers/promises";
import { expect, test } from "vitest";
import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js";
import { DaemonClient } from "../../../test-utils/daemon-client.js";
import { readActiveOmpEntryChain } from "./history.js";
test("replays selected OMP history without blocking the event loop", async () => {
const directory = await mkdtemp(join(tmpdir(), "omp-history-budget-"));
const sessionFile = join(directory, "session.jsonl");
const started = performance.now();
try {
const daemon = await createTestPaseoDaemon({
// This regression exercises file replay and daemon requests, not executable discovery.
providerOverrides: Object.fromEntries(
BUILTIN_PROVIDER_IDS.map((id) => [id, { enabled: false }]),
),
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.13.2",
});
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
await client.getDaemonStatus();
const file = await open(sessionFile, "w");
// Structured message blocks expose document-sized parsing at a modest byte size.
// Replay an earlier active entry so inactive payload retention cannot drive the probe.
const text = Array.from({ length: 64 }, () => ({ type: "text", text: "x" }));
let bytes = 0;
let count = 0;
try {
while (bytes < 28 * 1024 * 1024) {
const line =
JSON.stringify({
type: "message",
id: `row-${count}`,
parentId: count ? `row-${count - 1}` : null,
message: { role: "user", content: text },
}) + "\n";
bytes += Buffer.byteLength(line);
await file.write(line);
count++;
}
} finally {
await file.close();
}
let last = performance.now();
let longestGap = 0;
const probe = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - last);
last = now;
}, 5);
try {
const replayState = { finished: false };
let repliesDuringReplay = 0;
const replay = readActiveOmpEntryChain(sessionFile, "row-0").finally(() => {
replayState.finished = true;
});
// A real WebSocket RPC served by the isolated daemon on the same event loop.
const requests = (async () => {
while (!replayState.finished) {
await client.getDaemonStatus();
if (!replayState.finished) repliesDuringReplay++;
await delay(10);
}
})();
const [entries] = await Promise.all([replay, requests]);
await delay(10);
console.log(
JSON.stringify({
bytes,
count,
longestGap,
repliesDuringReplay,
generationAndLoadMs: performance.now() - started,
}),
);
expect(entries).toEqual([
{
type: "message",
id: "row-0",
parentId: null,
message: { role: "user", content: text },
},
]);
expect(longestGap).toBeLessThan(50);
expect(repliesDuringReplay).toBeGreaterThan(0);
} finally {
clearInterval(probe);
}
} finally {
await client.close().finally(() => daemon.close());
}
} finally {
await rm(directory, { recursive: true, force: true });
}
}, 30000);
@@ -1,4 +1,5 @@
import { readFile } from "node:fs/promises";
import { open, type FileHandle } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import { basename, extname, join } from "node:path";
import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
@@ -96,7 +97,9 @@ export async function* streamOmpHistory(input: {
const messages: OmpAgentMessage[] = [];
const replayEntries: OmpReplayEntry[] = [];
const userEntries: OmpCapturedUserMessageEntry[] = [];
let prepared = 0;
for (const entry of entries) {
if (++prepared % 32 === 0) await setImmediate();
if (entry.type === "compaction") {
replayEntries.push({ entry, item: mapCompactionEntry(entry) });
continue;
@@ -115,7 +118,9 @@ export async function* streamOmpHistory(input: {
OMP_HISTORY_MAPPER_HOOKS,
input.bridgedTools,
);
let replayed = 0;
for (const replayEntry of replayEntries) {
if (++replayed % 32 === 0) await setImmediate();
const timestamp = normalizeProviderReplayTimestamp(replayEntry.entry.timestamp);
const events: AgentStreamEvent[] =
"item" in replayEntry
@@ -329,34 +334,139 @@ function stripExtension(filePath: string): string {
return extension ? filePath.slice(0, -extension.length) : filePath;
}
export async function readActiveOmpEntryChain(
sessionFile: string,
activeEntryId?: string,
): Promise<OmpSessionEntry[]> {
const content = await readFile(sessionFile, "utf8");
const entries = content.split("\n").flatMap((line) => {
if (!line.trim()) return [];
try {
const value = JSON.parse(line) as OmpSessionEntry;
return value && typeof value === "object" && typeof value.id === "string" ? [value] : [];
} catch {
return [];
interface OmpEntryLocation {
id: string;
parentId?: string | null;
offset: number;
}
// Yield after each bounded IO chunk, including chunks containing only tiny or invalid records.
// Keep bytes until a complete line so multi-byte UTF-8 characters survive chunk boundaries.
async function* readOmpLines(
file: FileHandle,
size: number,
): AsyncGenerator<{
line: Buffer;
offset: number;
}> {
const chunkSize = 64 * 1024;
let fragments: Buffer[] = [];
let lineOffset = 0;
for (let position = 0; position < size; ) {
const chunk = Buffer.allocUnsafe(Math.min(chunkSize, size - position));
const { bytesRead } = await file.read(chunk, 0, chunk.length, position);
if (!bytesRead) break;
let start = 0;
for (let index = 0; index < bytesRead; index++) {
if (chunk[index] !== 10) continue;
const tail = chunk.subarray(start, index);
const line = fragments.length ? Buffer.concat([...fragments, tail]) : tail;
yield { line, offset: lineOffset };
fragments = [];
start = index + 1;
lineOffset = position + start;
}
});
if (entries.length === 0) return [];
const byId = new Map(entries.map((entry) => [entry.id!, entry]));
const parentIds = new Set(entries.flatMap((entry) => (entry.parentId ? [entry.parentId] : [])));
const leaves = entries.filter((entry) => !parentIds.has(entry.id!));
let current: OmpSessionEntry | undefined =
(activeEntryId ? byId.get(activeEntryId) : undefined) ?? leaves.at(-1) ?? entries.at(-1);
const chain: OmpSessionEntry[] = [];
if (start < bytesRead) fragments.push(chunk.subarray(start, bytesRead));
position += bytesRead;
await setImmediate();
}
if (fragments.length) yield { line: Buffer.concat(fragments), offset: lineOffset };
}
function parseOmpEntry(line: Buffer): OmpSessionEntry | null {
try {
const value: unknown = JSON.parse(line.toString("utf8"));
return value && typeof value === "object" && typeof Reflect.get(value, "id") === "string"
? (value as OmpSessionEntry)
: null;
} catch {
// OMP can leave a partial last record; malformed records have always been ignored.
return null;
}
}
async function selectOmpChain(
locations: readonly OmpEntryLocation[],
byId: ReadonlyMap<string, OmpEntryLocation>,
parentIds: ReadonlySet<string>,
activeEntryId?: string,
): Promise<OmpEntryLocation[]> {
let current = activeEntryId ? byId.get(activeEntryId) : undefined;
if (!current) {
for (let index = locations.length - 1; index >= 0; index--) {
const location = locations[index]!;
if (!parentIds.has(location.id)) {
current = location;
break;
}
if (index % 256 === 0) await setImmediate();
}
current ??= locations.at(-1);
}
const chain: OmpEntryLocation[] = [];
const seen = new Set<string>();
while (current?.id && !seen.has(current.id)) {
chain.push(current);
seen.add(current.id);
current = current.parentId ? byId.get(current.parentId) : undefined;
if (chain.length % 256 === 0) await setImmediate();
}
return chain;
}
export async function readActiveOmpEntryChain(
sessionFile: string,
activeEntryId?: string,
): Promise<OmpSessionEntry[]> {
const file = await open(sessionFile, "r");
try {
const stat = await file.stat();
if (stat.isDirectory()) {
throw Object.assign(
new Error(`EISDIR: illegal operation on a directory, read '${sessionFile}'`),
{
code: "EISDIR",
},
);
}
// One descriptor and a fixed byte boundary exclude appends made while replay is yielding.
// Retain graph metadata only: abandoned branches must not retain their message payloads.
const locations: OmpEntryLocation[] = [];
const byId = new Map<string, OmpEntryLocation>();
const parentIds = new Set<string>();
for await (const { line, offset } of readOmpLines(file, stat.size)) {
const entry = parseOmpEntry(line);
if (!entry) continue;
const location = { id: entry.id!, parentId: entry.parentId, offset };
locations.push(location);
byId.set(location.id, location);
if (location.parentId) parentIds.add(location.parentId);
}
const chain = await selectOmpChain(locations, byId, parentIds, activeEntryId);
if (chain.length === 0) return [];
const selected = new Map<number, number>();
for (let index = 0; index < chain.length; index++) {
selected.set(chain[index]!.offset, chain.length - index - 1);
if (index % 256 === 0) await setImmediate();
}
const entries: OmpSessionEntry[] = [];
for await (const { line, offset } of readOmpLines(file, stat.size)) {
const index = selected.get(offset);
if (index === undefined) continue;
const entry = parseOmpEntry(line);
// Truncation in place invalidates the indexed snapshot; never publish a partial chain.
if (!entry || entry.id !== chain[chain.length - index - 1]!.id) {
throw new Error(`OMP transcript changed during replay: ${sessionFile}`);
}
entries[index] = entry;
selected.delete(offset);
if (selected.size === 0) break;
}
if (selected.size) throw new Error(`OMP transcript changed during replay: ${sessionFile}`);
return entries;
} finally {
await file.close();
}
return chain.toReversed();
}
function mapEntryMessage(entry: OmpSessionEntry): OmpAgentMessage | null {
@@ -56,6 +56,7 @@ test("bridge preserves tool failure messages and classifies invalid requests as
getDaemonTcpHost: null,
serviceProxyPublicBaseUrl: null,
resolveScriptHealth: null,
getGlobalServicePorts: async () => undefined,
logger,
emit: () => {},
publishStatusUpdate: () => {},
@@ -1,3 +1,4 @@
import { collectTimelineSnapshot } from "./test-utils/v2-timeline.js";
import type { SessionMessageAssistant, SessionMessageUser } from "@opencode/client";
import { V2Timeline } from "./v2/timeline.js";
import { describe, expect, it } from "vitest";
@@ -1560,7 +1561,7 @@ describe("translateOpenCodeEvent", () => {
});
describe("OpenCode v2 history reconciliation", () => {
it("keeps client message identity on live echoes but omits it from history", () => {
it("keeps client message identity on live echoes but omits it from history", async () => {
const user: SessionMessageUser = {
id: "native-message",
type: "user",
@@ -1568,13 +1569,13 @@ describe("OpenCode v2 history reconciliation", () => {
time: { created: 1 },
metadata: { paseoClientMessageId: "client-message" },
};
expect(new V2Timeline().messages([user])).toMatchObject([
expect(await collectTimelineSnapshot(new V2Timeline(), [user])).toMatchObject([
{ item: { clientMessageId: "client-message", messageId: "native-message" } },
]);
expect(new V2Timeline(false).messages([user])).toMatchObject([
expect(await collectTimelineSnapshot(new V2Timeline(false), [user])).toMatchObject([
{ item: { type: "user_message", messageId: "native-message" } },
]);
const [event] = new V2Timeline(false).messages([user]);
const [event] = await collectTimelineSnapshot(new V2Timeline(false), [user]);
expect(event && "item" in event && event.item).not.toHaveProperty("clientMessageId");
});
function assistant(text: string): SessionMessageAssistant {
@@ -1587,31 +1588,35 @@ describe("OpenCode v2 history reconciliation", () => {
content: [{ type: "text", text }],
};
}
it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", () => {
it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", async () => {
const timeline = new V2Timeline();
const text = (messages: ReturnType<V2Timeline["messages"]>) =>
const text = (messages: Awaited<ReturnType<typeof collectTimelineSnapshot>>) =>
messages.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message"
? [event.item.text]
: [],
);
expect(text(timeline.messages([assistant("hello")]))).toEqual(["hello"]);
expect(timeline.messages([assistant("hel")])).toEqual([]);
expect(text(timeline.messages([assistant("hello world")]))).toEqual([" world"]);
expect(timeline.messages([assistant("hello world")])).toEqual([]);
expect(text(await collectTimelineSnapshot(timeline, [assistant("hello")]))).toEqual(["hello"]);
expect(await collectTimelineSnapshot(timeline, [assistant("hel")])).toEqual([]);
expect(text(await collectTimelineSnapshot(timeline, [assistant("hello world")]))).toEqual([
" world",
]);
expect(await collectTimelineSnapshot(timeline, [assistant("hello world")])).toEqual([]);
});
it("keeps reasoning separate from assistant output", () => {
it("keeps reasoning separate from assistant output", async () => {
const timeline = new V2Timeline();
const message = assistant("answer");
message.content.unshift({ type: "reasoning", text: "thinking" });
expect(
timeline.messages([message]).map((event) => (event.type === "timeline" ? event.item : null)),
(await collectTimelineSnapshot(timeline, [message])).map((event) =>
event.type === "timeline" ? event.item : null,
),
).toEqual([
{ type: "reasoning", text: "thinking" },
{ type: "assistant_message", text: "answer", messageId: "message" },
]);
});
it("emits tool state transitions once and keeps their call identity", () => {
it("emits tool state transitions once and keeps their call identity", async () => {
const timeline = new V2Timeline();
const message = assistant("");
message.content = [
@@ -1623,7 +1628,7 @@ describe("OpenCode v2 history reconciliation", () => {
state: { status: "running", input: { command: "pwd" }, metadata: {} },
},
];
expect(timeline.messages([message])).toMatchObject([
expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([
{ item: { type: "tool_call", callId: "call", status: "running" } },
]);
message.content = [
@@ -1639,9 +1644,9 @@ describe("OpenCode v2 history reconciliation", () => {
},
},
];
expect(timeline.messages([message])).toMatchObject([
expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([
{ item: { type: "tool_call", callId: "call", status: "completed" } },
]);
expect(timeline.messages([message])).toEqual([]);
expect(await collectTimelineSnapshot(timeline, [message])).toEqual([]);
});
});
@@ -1,3 +1,4 @@
import { collectTimelineSnapshot } from "./v2-timeline.js";
import { TimelineProjection } from "../../../timeline-projection.js";
import { V2Timeline } from "../v2/timeline.js";
@@ -21,16 +22,18 @@ for (let index = 0; index < chunks; index++) {
const event =
mode === "delta"
? timeline.delta({ ...part, delta })
: timeline.messages([
{
id: "answer",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
time: { created: 2 },
content: [{ type: "reasoning", text }],
},
])[0];
: (
await collectTimelineSnapshot(timeline, [
{
id: "answer",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
time: { created: 2 },
content: [{ type: "reasoning", text }],
},
])
)[0];
if (event?.type !== "timeline") throw new Error("Expected a timeline event");
projection.append({
seq: index + 1,
@@ -0,0 +1,15 @@
import type { SessionMessageInfo } from "@opencode/client";
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import type { V2Timeline } from "../v2/timeline.js";
// Snapshot assertions without a live stream can collect the publisher's events.
export async function collectTimelineSnapshot(
timeline: V2Timeline,
messages: SessionMessageInfo[],
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
await timeline.messages(messages, (event) => {
events.push(event);
});
return events;
}
@@ -92,13 +92,13 @@ export class SessionChildren {
this.childStates.set(info.id, signature);
this.options.emit({ type: "provider_subagent", provider: "opencode", event: presentation });
}
for (const event of timeline.messages(await messages(this.options.client(), info.id))) {
await timeline.messages(await messages(this.options.client(), info.id), (event) => {
if (event.type === "timeline")
this.options.emit({
type: "provider_subagent",
provider: "opencode",
event: { type: "timeline", id: info.id, item: event.item, timestamp: event.timestamp },
});
}
});
}
}
@@ -131,7 +131,7 @@ export class OpenCodeV2Session implements AgentSession {
await this.configureConnection();
const location = { directory: this.config.cwd };
this.modes = modesFromV2((await this.client.agent.list({ location })).data);
this.timeline.messages(await messages(this.client, this.id));
await this.timeline.messages(await messages(this.client, this.id), () => {});
await this.startStream();
await this.children.reconcile(this.id);
}
@@ -261,7 +261,11 @@ export class OpenCodeV2Session implements AgentSession {
async *streamHistory() {
await this.reconnectIfExited();
const history = new V2Timeline(false);
yield* history.messages(await messages(this.client, this.id));
const events: AgentStreamEvent[] = [];
await history.messages(await messages(this.client, this.id), (event) => {
events.push(event);
});
yield* events;
}
async getRuntimeInfo() {
await this.reconnectIfExited();
@@ -417,7 +421,7 @@ export class OpenCodeV2Session implements AgentSession {
]);
this.info = info;
this.history = history;
for (const event of this.timeline.messages(history)) this.emitTimeline(event);
await this.timeline.messages(history, (event) => this.emitTimeline(event));
await this.permissions.reconcile(this.id);
}
private async reconcile() {
@@ -1,3 +1,4 @@
import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js";
import { V2Timeline } from "./timeline.js";
import type { SessionMessageAssistant } from "@opencode/client";
import { describe, expect, test } from "vitest";
@@ -80,7 +81,7 @@ describe("OpenCode v2 token streaming", () => {
}
});
test("deduplicates snapshots before and after deltas using per-type ordinals", () => {
test("deduplicates snapshots before and after deltas using per-type ordinals", async () => {
const timeline = new V2Timeline();
const text = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const;
const reasoning = { ...text, type: "reasoning" } as const;
@@ -99,24 +100,28 @@ describe("OpenCode v2 token streaming", () => {
},
{ type: "text", text: "Hello" },
]);
expect(timeline.messages([snapshot])).toMatchObject([{ item: { text: "lo" } }]);
expect(await collectTimelineSnapshot(timeline, [snapshot])).toMatchObject([
{ item: { text: "lo" } },
]);
expect(timeline.delta({ ...text, delta: "lo" })).toBeNull();
expect(timeline.messages([snapshot])).toEqual([]);
expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]);
expect(timeline.delta({ ...text, delta: "!" })).toMatchObject({ item: { text: "!" } });
expect(timeline.messages([snapshot])).toEqual([]);
expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]);
});
test("recovers missed fragments from snapshots after reconnect", () => {
test("recovers missed fragments from snapshots after reconnect", async () => {
const timeline = new V2Timeline();
const part = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const;
timeline.startPart(part);
expect(timeline.delta({ ...part, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } });
timeline.resetStreams();
expect(timeline.delta({ ...part, delta: "!" })).toBeNull();
expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toMatchObject([
{ item: { text: "lo!" } },
]);
expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toEqual([]);
expect(
await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]),
).toMatchObject([{ item: { text: "lo!" } }]);
expect(
await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]),
).toEqual([]);
});
test("withholds streamed prose during a structured-output turn", async () => {
@@ -203,3 +208,62 @@ describe("OpenCode v2 token streaming", () => {
}
});
});
test.each(["text", "reasoning"] as const)(
"publishes snapshot %s before a live suffix during image materialization",
async (type) => {
const timeline = new V2Timeline();
const part = { assistantMessageID: "answer", type, ordinal: 0 };
timeline.startPart(part);
const emitted: AgentStreamEvent[] = [];
let settled = false;
const snapshot = timeline
.messages(
[
assistant([
{ type, text: "Hello" },
{
type: "tool",
id: "screenshot",
name: "paseo_browser_screenshot",
executed: false,
state: {
status: "completed",
input: {},
content: [
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png" },
],
metadata: {},
},
time: { created: 2, ran: 2, completed: 2 },
},
]),
],
(event) => {
emitted.push(event);
},
)
.then(() => {
settled = true;
return undefined;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(settled).toBe(false);
const delta = timeline.delta({ ...part, delta: "Hello world" });
if (delta) emitted.push(delta);
await snapshot;
const text = emitted.flatMap((event) => {
if (event.type !== "timeline") return [];
if (
type === "text" &&
event.item.type === "assistant_message" &&
!event.item.text.includes("![Image]")
)
return [event.item.text];
if (type === "reasoning" && event.item.type === "reasoning") return [event.item.text];
return [];
});
expect(text).toEqual(["Hello", " world"]);
expect(text.join("")).toBe("Hello world");
},
);
@@ -1,3 +1,4 @@
import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js";
import type { SessionMessageAssistant } from "@opencode/client";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
@@ -15,7 +16,7 @@ function assistant(content: SessionMessageAssistant["content"]): SessionMessageA
};
}
test("a completed edit carries the replaced and replacement text", () => {
test("a completed edit carries the replaced and replacement text", async () => {
// Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`.
const edit = assistant([
{
@@ -45,7 +46,7 @@ test("a completed edit carries the replaced and replacement text", () => {
},
]);
expect(new V2Timeline().messages([edit])).toMatchObject([
expect(await collectTimelineSnapshot(new V2Timeline(), [edit])).toMatchObject([
{
item: {
type: "tool_call",
@@ -57,7 +58,7 @@ test("a completed edit carries the replaced and replacement text", () => {
]);
});
test("a tool's image output is shown as an image, not as base64 in the tool output", () => {
test("a tool's image output is shown as an image, not as base64 in the tool output", async () => {
const png =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
const screenshot = assistant([
@@ -80,7 +81,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp
]);
const timeline = new V2Timeline();
const events = timeline.messages([screenshot]);
const events = await collectTimelineSnapshot(timeline, [screenshot]);
expect(JSON.stringify(events)).not.toContain(png);
expect(events.map((event) => event.type === "timeline" && event.item)).toMatchObject([
@@ -95,7 +96,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp
text: expect.stringMatching(/^\n\n!\[Image\]\(file:\/\/.*paseo-attachments.*\.png\)\n\n$/),
},
]);
expect(timeline.messages([screenshot])).toEqual([]);
expect(await collectTimelineSnapshot(timeline, [screenshot])).toEqual([]);
});
test("a surrogate pair split across deltas streams back intact", () => {
@@ -55,18 +55,32 @@ export class V2Timeline {
return { type: "timeline", provider: "opencode", item, timestamp: new Date().toISOString() };
}
messages(messages: SessionMessageInfo[]): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
private pendingMessages: Promise<unknown> = Promise.resolve();
// Publish each cursor advance in the same synchronous step. Buffering snapshot
// events until image IO finishes lets live deltas overtake already committed text.
messages(
messages: SessionMessageInfo[],
publish: (event: AgentStreamEvent) => void,
): Promise<void> {
const result = this.pendingMessages.then(() => this.publishMessages(messages, publish));
this.pendingMessages = result.catch(() => {});
return result;
}
private async publishMessages(
messages: SessionMessageInfo[],
publish: (event: AgentStreamEvent) => void,
): Promise<void> {
const state: TimelineState = { structured: false, accepted: false };
for (const message of messages) {
const timestamp = new Date(message.time.created).toISOString();
const push = (item: AgentTimelineItem) =>
events.push({ type: "timeline", provider: "opencode", item, timestamp });
publish({ type: "timeline", provider: "opencode", item, timestamp });
if (message.type === "user") this.userMessage(message, state, push);
else if (message.type === "assistant") this.assistantMessage(message, state, push);
else if (message.type === "assistant") await this.assistantMessage(message, state, push);
else if (message.type === "compaction") this.compactionMessage(message, push);
}
return events;
}
private userMessage(
@@ -89,7 +103,7 @@ export class V2Timeline {
});
}
private assistantMessage(
private async assistantMessage(
message: Extract<SessionMessageInfo, { type: "assistant" }>,
state: TimelineState,
push: (item: AgentTimelineItem) => void,
@@ -99,16 +113,16 @@ export class V2Timeline {
// paths address the same cursor entry.
let textOrdinal = 0;
let reasoningOrdinal = 0;
message.content.forEach((part, partIndex) => {
for (const [partIndex, part] of message.content.entries()) {
if (part.type === "tool" && part.name === STRUCTURED_OUTPUT_TOOL) {
this.structuredOutputPart(message.id, partIndex, part, state, push);
} else if (part.type === "text" || part.type === "reasoning") {
const ordinal = part.type === "text" ? textOrdinal++ : reasoningOrdinal++;
this.textPart(message.id, part, ordinal, state, push);
} else {
this.toolPart(message.id, partIndex, part, push);
await this.toolPart(message.id, partIndex, part, push);
}
});
}
}
private structuredOutputPart(
@@ -156,7 +170,7 @@ export class V2Timeline {
else push({ type: "reasoning", text: suffix });
}
private toolPart(
private async toolPart(
messageID: string,
partIndex: number,
part: Extract<
@@ -174,16 +188,16 @@ export class V2Timeline {
if (item) push(item);
// Images follow their tool call as markdown, as Claude and Codex emit them,
// so base64 never reaches the tool output.
images.forEach((image, index) => {
for (const [index, image] of images.entries()) {
const imageKey = `${key}:image:${index}`;
if (this.content.has(imageKey)) return;
if (this.content.has(imageKey)) continue;
this.content.set(imageKey, "");
const imageItem = renderProviderImageOutputAsAssistantMarkdown(
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(
{ url: image.uri, mimeType: image.mime },
{ materialize: materializeProviderImage },
);
if (imageItem) push(imageItem);
});
}
}
private compactionMessage(
@@ -3063,6 +3063,81 @@ describe("PiRpcAgentClient", () => {
]);
});
test.each([
{ outcome: "successful", error: null },
{ outcome: "failed", error: new Error("summarizer failed") },
])("waits for $outcome manual compaction before sending a new prompt", async ({ error }) => {
const { pi, session, events } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
fakeSession.compactError = error;
const compactionFinished = Promise.withResolvers<void>();
fakeSession.compactCompletion = compactionFinished.promise;
const handler = session.tryHandleOutOfBand("/compact");
const compaction = handler!.run({ emit: () => {} });
const { turnId } = await session.startTurn("continue after compaction");
await flushTurnScheduling();
expect(fakeSession.compactRequests).toEqual([{}]);
expect(fakeSession.prompts).toEqual([]);
compactionFinished.resolve();
await compaction;
await flushTurnScheduling();
expect(fakeSession.prompts).toEqual([{ message: "continue after compaction", imageCount: 0 }]);
fakeSession.finishTurn();
await events.nextTurnCompletion();
expect(events.turnLifecycleEvents()).toContainEqual({ type: "turn_completed", turnId });
expect(events.eventTypes()).not.toContain("turn_failed");
});
test.each(["interrupt", "close"] as const)(
"does not send a prompt waiting on compaction after %s",
async (action) => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
const compactionFinished = Promise.withResolvers<void>();
fakeSession.compactCompletion = compactionFinished.promise;
const handler = session.tryHandleOutOfBand("/compact");
const compaction = handler!.run({ emit: () => {} });
await session.startTurn("do not send this prompt");
await session[action]();
compactionFinished.resolve();
await compaction;
await flushTurnScheduling();
expect(fakeSession.prompts).toEqual([]);
},
);
test("does not send a prompt when compaction finishes during interruption", async () => {
const { pi, session, events } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
const compactionFinished = Promise.withResolvers<void>();
fakeSession.compactCompletion = compactionFinished.promise;
const clearQueueFinished = Promise.withResolvers<void>();
fakeSession.clearQueueCompletion = clearQueueFinished.promise;
const handler = session.tryHandleOutOfBand("/compact");
const compaction = handler!.run({ emit: () => {} });
const { turnId } = await session.startTurn("do not send this prompt");
const interruption = session.interrupt();
compactionFinished.resolve();
await compaction;
await flushTurnScheduling();
expect(fakeSession.controlRequests).toEqual(["clear_queue"]);
expect(fakeSession.prompts).toEqual([]);
clearQueueFinished.resolve();
await interruption;
expect(events.turnLifecycleEvents()).toEqual([{ type: "turn_canceled", turnId }]);
expect(fakeSession.prompts).toEqual([]);
});
test("closes Pi compact loading marker when RPC rejects after compaction starts", async () => {
const { pi, session } = await createSession();
const fakeSession = pi.latestSession();
@@ -3508,3 +3583,63 @@ test("pi runtime safety protects queued replies and runtime-owned activity", asy
native.getStateError = new Error("Unable to prove idle");
await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle");
});
test("reserves the Pi turn while an image prompt is materialized", async () => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const first = session.startTurn([
{ type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" },
]);
await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active");
await first;
expect(pi.latestSession().prompts).toHaveLength(1);
});
test("interrupting Pi during image conversion cancels the admitted turn and permits a follow-up", async () => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
let admitted = false;
const first = session
.startTurn([
{
type: "image",
data: Buffer.alloc(16 * 1024 * 1024, 51).toString("base64"),
mimeType: "image/png",
},
])
.then((result) => {
admitted = true;
return result;
});
await waitForImmediate();
expect(admitted).toBe(false);
expect(pi.latestSession().prompts).toHaveLength(0);
await session.interrupt();
const followup = await session.startTurn("follow-up after image interrupt");
const original = await first;
expect(events).toContainEqual(
expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }),
);
expect(followup.turnId).not.toBe(original.turnId);
expect(pi.latestSession().prompts).toEqual([
{ message: "follow-up after image interrupt", imageCount: 0 },
]);
});
test("closing Pi during image conversion rejects its pending start", async () => {
const { pi, session } = await createSession();
const first = session.startTurn([
{
type: "image",
data: Buffer.alloc(16 * 1024 * 1024, 52).toString("base64"),
mimeType: "image/png",
},
]);
const rejected = expect(first).rejects.toThrow("closed");
await waitForImmediate();
expect(pi.latestSession().prompts).toHaveLength(0);
await session.close();
await rejected;
});
@@ -367,9 +367,9 @@ function piModelSupportsImageInput(model: PiModel | null | undefined): boolean {
return model?.input?.includes("image") === true;
}
function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string {
async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise<string> {
try {
const materialized = materializeProviderImage({
const materialized = await materializeProviderImage({
data: image.data,
mimeType: image.mimeType,
});
@@ -379,10 +379,10 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str
}
}
function convertPromptInput(
async function convertPromptInput(
prompt: AgentPromptInput,
options: { model: PiModel | null | undefined },
): PiPromptPayload {
): Promise<PiPromptPayload> {
if (typeof prompt === "string") {
return { text: prompt };
}
@@ -405,7 +405,7 @@ function convertPromptInput(
mimeType: block.mimeType,
});
} else {
textParts.push(renderTextOnlyImageHint(block));
textParts.push(await renderTextOnlyImageHint(block));
}
continue;
}
@@ -1189,6 +1189,7 @@ export class PiRpcAgentSession implements AgentSession {
private readonly contextUserEntries: PiCapturedEntry[] = [];
private readonly treeUserEntriesById = new Map<string, PiCapturedEntry>();
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
private compactionPromise: Promise<void> | null = null;
private outOfBandCompactionEmit: ((event: AgentStreamEvent) => void) | null = null;
private outOfBandCompactionStarted = false;
private outOfBandCompactionCompleted = false;
@@ -1279,7 +1280,6 @@ export class PiRpcAgentSession implements AgentSession {
throw new Error("A Pi turn is already active");
}
const payload = convertPromptInput(prompt, { model: this.state.model });
const turnId = randomUUID();
this.activeTurnId = turnId;
this.usagePoller.startTurn();
@@ -1291,11 +1291,23 @@ export class PiRpcAgentSession implements AgentSession {
this.activePromptRequestId = null;
this.pendingSteerSubmissions.length = 0;
this.clearNoTurnBuffers();
const payload = await convertPromptInput(prompt, { model: this.state.model });
if (this.closed) throw new Error("Pi session is closed");
if (this.activeTurnId !== turnId) return { turnId };
this.activeNoTurnPromptText = payload.text;
const shouldProbeForNoTurnPrompt = this.parseSlashCommandInput(payload.text) !== null;
void (async () => {
try {
if (this.compactionPromise) {
// Pi RPC rejects prompts until the manual compaction request settles.
await this.compactionPromise.catch(() => undefined);
const turnCanceled =
this.closed || this.activeTurnId !== turnId || this.interruptingTurn?.turnId === turnId;
if (turnCanceled) {
return;
}
}
const ack = await this.runtimeSession.prompt(payload.text, payload.images);
this.activePromptRequestId = ack.requestId ?? null;
const correlatedResult = ack.requestId
@@ -1353,7 +1365,9 @@ export class PiRpcAgentSession implements AgentSession {
if (this.closed || this.activeTurnId !== options.expectedTurnId) {
return { status: "unavailable" };
}
const payload = convertPromptInput(prompt, { model: this.state.model });
const payload = await convertPromptInput(prompt, { model: this.state.model });
if (this.closed || this.activeTurnId !== options.expectedTurnId)
return { status: "unavailable" };
// Pi rejects steer RPCs that are extension commands, so slash inputs keep the
// interrupt-and-replace fallback where they can run directly.
if (this.parseSlashCommandInput(payload.text)) {
@@ -1804,7 +1818,8 @@ export class PiRpcAgentSession implements AgentSession {
this.outOfBandCompactionStarted = false;
this.outOfBandCompactionCompleted = false;
try {
await this.runtimeSession.compact(customInstructions);
this.compactionPromise = this.runtimeSession.compact(customInstructions);
await this.compactionPromise;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (
@@ -1830,6 +1845,7 @@ export class PiRpcAgentSession implements AgentSession {
},
});
} finally {
this.compactionPromise = null;
if (this.outOfBandCompactionEmit === emit && !this.outOfBandCompactionStarted) {
this.outOfBandCompactionEmit = null;
this.outOfBandCompactionStarted = false;
@@ -108,6 +108,7 @@ export class FakePiSession implements PiRuntimeSession {
steerError: Error | null = null;
readonly controlRequests: string[] = [];
clearQueueError: Error | null = null;
clearQueueCompletion: Promise<void> | null = null;
readonly compactRequests: Array<{ customInstructions?: string }> = [];
readonly setAutoCompactionRequests: boolean[] = [];
readonly subagentSubscriptionRequests: FakePiSubagentSubscriptionLevel[] = [];
@@ -140,6 +141,7 @@ export class FakePiSession implements PiRuntimeSession {
subagentSubscriptionError: Error | null = null;
setSessionNameError: Error | null = null;
compactError: Error | null = null;
compactCompletion: Promise<void> | null = null;
emitCompactEnd = true;
getStateError: Error | null = null;
getSessionStatsError: Error | null = null;
@@ -233,6 +235,7 @@ export class FakePiSession implements PiRuntimeSession {
async clearQueue(): Promise<void> {
this.controlRequests.push("clear_queue");
await this.clearQueueCompletion;
if (this.clearQueueError) {
throw this.clearQueueError;
}
@@ -241,6 +244,7 @@ export class FakePiSession implements PiRuntimeSession {
async compact(customInstructions?: string): Promise<void> {
this.compactRequests.push(customInstructions === undefined ? {} : { customInstructions });
this.emit({ type: "compaction_start", reason: "manual" });
await this.compactCompletion;
if (this.emitCompactEnd) {
this.emit({ type: "compaction_end", reason: "manual" });
}
@@ -5,8 +5,8 @@ import { describe, expect, test } from "vitest";
import { materializeProviderImage } from "./provider-image-output.js";
describe.skipIf(process.platform === "win32")("materializeProviderImage", () => {
test("writes image attachments under a private temp directory", () => {
const materialized = materializeProviderImage({
test("writes image attachments under a private temp directory", async () => {
const materialized = await materializeProviderImage({
data: "YWJjMTIz",
mimeType: "image/png",
});
@@ -1,10 +1,11 @@
import { existsSync, rmSync } from "node:fs";
import { existsSync, rmSync, statSync, readFileSync } from "node:fs";
import path from "node:path";
import MarkdownIt from "markdown-it";
import { projectTimelineRows } from "../timeline-projection.js";
import { describe, expect, test } from "vitest";
import {
MAX_PROVIDER_IMAGE_BYTES,
isProviderImageMarkdown,
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
@@ -12,8 +13,8 @@ import {
const HASH = "a".repeat(64);
function renderImageMarkdown(imagePath: string): string {
const item = renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
async function renderImageMarkdown(imagePath: string): Promise<string> {
const item = await renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
if (!item || item.type !== "assistant_message") {
throw new Error("Expected provider image output to render as assistant markdown.");
}
@@ -40,8 +41,8 @@ describe("isProviderImageMarkdown", () => {
).toBe(true);
});
test("emits Windows file paths as file URIs", () => {
const markdown = renderImageMarkdown(
test("emits Windows file paths as file URIs", async () => {
const markdown = await renderImageMarkdown(
`C:\\Users\\me\\AppData\\Local\\Temp\\paseo-attachments\\${HASH}.png`,
);
@@ -51,22 +52,24 @@ describe("isProviderImageMarkdown", () => {
expect(isProviderImageMarkdown(markdown)).toBe(true);
});
test("emits POSIX file paths with spaces as valid file URI markdown", () => {
const markdown = renderImageMarkdown("/home/user/Projects/Project With Spaces/screenshot.png");
test("emits POSIX file paths with spaces as valid file URI markdown", async () => {
const markdown = await renderImageMarkdown(
"/home/user/Projects/Project With Spaces/screenshot.png",
);
expect(markdown).toBe(
"\n\n![Image](file:///home/user/Projects/Project%20With%20Spaces/screenshot.png)\n\n",
);
});
test("encodes URI-significant characters in POSIX file paths", () => {
const markdown = renderImageMarkdown("/tmp/screenshot#1?draft.png");
test("encodes URI-significant characters in POSIX file paths", async () => {
const markdown = await renderImageMarkdown("/tmp/screenshot#1?draft.png");
expect(markdown).toBe("\n\n![Image](file:///tmp/screenshot%231%3Fdraft.png)\n\n");
});
test("preserves double-leading slashes in POSIX file paths", () => {
const markdown = renderImageMarkdown("//tmp/screenshot#1.png");
test("preserves double-leading slashes in POSIX file paths", async () => {
const markdown = await renderImageMarkdown("//tmp/screenshot#1.png");
expect(markdown).toBe("\n\n![Image](file:////tmp/screenshot%231.png)\n\n");
});
@@ -78,8 +81,8 @@ describe("isProviderImageMarkdown", () => {
"\\\\?\\UNC\\server\\share\\shot?draft.png",
"file://server/share/shot%3Fdraft.png",
],
])("encodes %s image paths as file URIs", (_label, imagePath, expectedSource) => {
expect(renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`);
])("encodes %s image paths as file URIs", async (_label, imagePath, expectedSource) => {
expect(await renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`);
});
test("rejects user-authored markdown that is not a materialized attachment", () => {
@@ -92,8 +95,8 @@ describe("isProviderImageMarkdown", () => {
});
describe("materializeProviderImage", () => {
test("recreates the private temp directory if the cached directory is removed", () => {
const first = materializeProviderImage({
test("recreates the private temp directory if the cached directory is removed", async () => {
const first = await materializeProviderImage({
data: "YWJjMTIz",
mimeType: "image/png",
});
@@ -102,7 +105,7 @@ describe("materializeProviderImage", () => {
rmSync(firstDir, { recursive: true, force: true });
const second = materializeProviderImage({
const second = await materializeProviderImage({
data: "ZGVmNDU2",
mimeType: "image/png",
});
@@ -117,8 +120,8 @@ describe("materializeProviderImage", () => {
});
describe("provider image paragraph boundaries", () => {
test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", (ending) => {
const image = renderProviderImageOutputAsAssistantMarkdown({
test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", async (ending) => {
const image = await renderProviderImageOutputAsAssistantMarkdown({
url: "https://example.com/chart.png",
});
if (!image) throw new Error("Expected image output");
@@ -152,10 +155,10 @@ describe("provider image paragraph boundaries", () => {
});
});
test("materialized image output retains paragraph boundaries and attachment recognition", () => {
const item = renderProviderImageOutputAsAssistantMarkdown(
test("materialized image output retains paragraph boundaries and attachment recognition", async () => {
const item = await renderProviderImageOutputAsAssistantMarkdown(
{ data: "YWJjMTIz", mimeType: "image/png" },
{ materialize: () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) },
{ materialize: async () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) },
);
expect(item).toEqual({
type: "assistant_message",
@@ -164,3 +167,76 @@ test("materialized image output retains paragraph boundaries and attachment reco
if (!item || item.type !== "assistant_message") throw new Error("Expected image output");
expect(isProviderImageMarkdown(item.text)).toBe(true);
});
test("repeated content is reused without rewriting the attachment", async () => {
const image = {
data: Buffer.from("dedupe regression").toString("base64"),
mimeType: "image/png",
};
const first = await materializeProviderImage(image);
const before = statSync(first.path, { bigint: true });
await new Promise((resolve) => setTimeout(resolve, 20));
const second = await materializeProviderImage(image);
expect(second.path).toBe(first.path);
expect(statSync(second.path, { bigint: true }).mtimeNs).toBe(before.mtimeNs);
});
test("64 MiB materialization leaves the event loop responsive and preserves bytes", async () => {
const bytes = Buffer.alloc(64 * 1024 * 1024, 37);
const data = bytes.toString("base64");
let previous = performance.now();
let longest = 0;
const probe = setInterval(() => {
const now = performance.now();
longest = Math.max(longest, now - previous);
previous = now;
}, 1);
try {
const result = await materializeProviderImage({ data, mimeType: "image/png" });
await new Promise((resolve) => setTimeout(resolve, 5));
console.log(`64 MiB attachment longest loop gap: ${longest.toFixed(2)} ms`);
expect(longest).toBeLessThan(50);
expect(readFileSync(result.path).equals(bytes)).toBe(true);
rmSync(result.path, { force: true });
} finally {
clearInterval(probe);
}
});
test("preserves base64 decoding across whitespace and chunk boundaries", async () => {
const bytes = Buffer.alloc(512 * 1024, 123);
const base64 = bytes.toString("base64");
const data = base64.match(/.{1,77}/g)!.join("\n");
const result = await materializeProviderImage({ data, mimeType: "image/png" });
expect(readFileSync(result.path).equals(bytes)).toBe(true);
});
test("rejects oversized materialization before work and leaves the queue usable", async () => {
const data = "A".repeat(Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4 + 260);
await expect(materializeProviderImage({ data, mimeType: "image/png" })).rejects.toThrow(
"exceeds",
);
const omitted = await renderProviderImageOutputAsAssistantMarkdown(
{ data, mimeType: "image/png" },
{ materialize: materializeProviderImage },
);
expect(omitted).toEqual({
type: "assistant_message",
text: "Image output was omitted because it was not available as a file path or URL.",
});
const image = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
expect(readFileSync(image.path).toString()).toBe("abc123");
});
test("simultaneous requests for the same content share the published file", async () => {
const image = {
data: Buffer.from("concurrent dedupe fixture").toString("base64"),
mimeType: "image/png",
};
const results = await Promise.all([
materializeProviderImage(image),
materializeProviderImage(image),
]);
expect(results[0]).toEqual(results[1]);
expect(readFileSync(results[0].path).toString()).toBe("concurrent dedupe fixture");
});
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import * as fsSync from "node:fs";
import * as fs from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import os from "node:os";
import path from "node:path";
@@ -23,32 +24,26 @@ const PRIVATE_ATTACHMENT_DIR_MODE = 0o700;
const MATERIALIZED_IMAGE_FILE_MODE = 0o600;
let materializedImageAttachmentDir: string | null = null;
// Serialize materializations: one decoder and one file operation at a time, including
// concurrent history replay and live turns. A rejected operation must not poison the queue.
let pendingMaterialization: Promise<unknown> = Promise.resolve();
function canReuseMaterializedImageAttachmentDir(dir: string): boolean {
try {
const stats = fsSync.lstatSync(dir);
if (!stats.isDirectory()) {
return false;
async function getMaterializedImageAttachmentDir(): Promise<string> {
if (materializedImageAttachmentDir) {
try {
const stats = await fs.lstat(materializedImageAttachmentDir);
if (stats.isDirectory()) {
await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
return materializedImageAttachmentDir;
}
} catch {
/* Temp storage may have been removed between turns. */
}
fsSync.chmodSync(dir, PRIVATE_ATTACHMENT_DIR_MODE);
return true;
} catch {
return false;
}
}
function getMaterializedImageAttachmentDir(): string {
if (
materializedImageAttachmentDir &&
canReuseMaterializedImageAttachmentDir(materializedImageAttachmentDir)
) {
return materializedImageAttachmentDir;
}
materializedImageAttachmentDir = fsSync.mkdtempSync(
materializedImageAttachmentDir = await fs.mkdtemp(
path.join(os.tmpdir(), PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX),
);
fsSync.chmodSync(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
return materializedImageAttachmentDir;
}
@@ -71,32 +66,92 @@ function getImageExtension(mimeType: string): string {
}
}
function normalizeImageData(mimeType: string, data: string): { mimeType: string; data: string } {
if (data.startsWith("data:")) {
const match = data.match(/^data:([^;]+);base64,(.*)$/);
if (match) {
return { mimeType: match[1], data: match[2] };
}
}
return { mimeType, data };
}
// 128 MiB admits the measured 64 MiB fixture with headroom while bounding disk
// and decoding per image. This is materialization admission, not a wire schema limit.
export const MAX_PROVIDER_IMAGE_BYTES = 128 * 1024 * 1024;
const BASE64_CHUNK_CHARACTERS = 256 * 1024; // Multiple of four; decode units stay aligned.
// Filenames are a content hash of the bytes so re-materializing the same image
// within a process reuses the existing temp file instead of leaking a fresh one
// for repeated image blocks or history replay.
export function materializeProviderImage(image: {
data: string;
mimeType: string | null;
}): MaterializedProviderImage {
const attachmentsDir = getMaterializedImageAttachmentDir();
const normalized = normalizeImageData(image.mimeType ?? "image/png", image.data);
const bytes = Buffer.from(normalized.data, "base64");
const extension = getImageExtension(normalized.mimeType);
const hash = createHash("sha256").update(bytes).digest("hex");
const filePath = path.join(attachmentsDir, `${hash}.${extension}`);
fsSync.writeFileSync(filePath, bytes, { mode: MATERIALIZED_IMAGE_FILE_MODE });
fsSync.chmodSync(filePath, MATERIALIZED_IMAGE_FILE_MODE);
return { path: filePath };
}): Promise<MaterializedProviderImage> {
const result = pendingMaterialization.then(() => materializeImage(image));
pendingMaterialization = result.catch(() => {});
return result;
}
async function materializeImage(image: {
data: string;
mimeType: string | null;
}): Promise<MaterializedProviderImage> {
const maxEncodedCharacters = Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4;
// Check length before inspecting/copying the payload, including a bounded data URI header.
if (image.data.length > maxEncodedCharacters + 256) {
throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`);
}
let data = image.data;
let mimeType = image.mimeType ?? "image/png";
if (data.startsWith("data:")) {
const separator = data.indexOf(";base64,");
if (separator >= 0 && separator < 256) {
mimeType = data.slice(5, separator);
data = data.slice(separator + 8);
}
}
if (data.length > maxEncodedCharacters) {
throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`);
}
const hash = createHash("sha256");
for await (const bytes of decodeImage(data)) hash.update(bytes);
const dir = await getMaterializedImageAttachmentDir();
const filePath = path.join(dir, `${hash.digest("hex")}.${getImageExtension(mimeType)}`);
try {
await fs.stat(filePath);
return { path: filePath };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const staging = await fs.mkdtemp(path.join(dir, ".pending-"));
const stagingPath = path.join(staging, "image");
try {
await fs.chmod(staging, PRIVATE_ATTACHMENT_DIR_MODE);
const file = await fs.open(stagingPath, "wx", MATERIALIZED_IMAGE_FILE_MODE);
try {
await file.chmod(MATERIALIZED_IMAGE_FILE_MODE);
// Decode again only for a new hash. This bounds retained memory to one chunk
// and performs no writes at all for repeated content, including history replay.
for await (const bytes of decodeImage(data)) await file.writeFile(bytes);
} finally {
await file.close();
}
await fs.rename(stagingPath, filePath);
return { path: filePath };
} finally {
await fs.rm(staging, { recursive: true, force: true });
}
}
async function* decodeImage(data: string): AsyncGenerator<Buffer> {
let carry = "";
let decodedBytes = 0;
for (let offset = 0; offset < data.length; offset += BASE64_CHUNK_CHARACTERS) {
// Buffer.from ignores non-base64 characters. Carry incomplete quartets so
// line-wrapped payloads decode identically across chunk boundaries.
const chunk =
carry +
data.slice(offset, offset + BASE64_CHUNK_CHARACTERS).replace(/[^A-Za-z0-9+/_=-]/g, "");
const finalChunk = offset + BASE64_CHUNK_CHARACTERS >= data.length || chunk.includes("=");
const end = finalChunk ? chunk.length : chunk.length - (chunk.length % 4);
const bytes = Buffer.from(chunk.slice(0, end), "base64");
carry = chunk.slice(end);
decodedBytes += bytes.length;
if (decodedBytes > MAX_PROVIDER_IMAGE_BYTES)
throw new Error("Image attachment exceeds size limit");
yield bytes;
await setImmediate();
if (finalChunk) break;
}
}
// Recognizes markdown rendered for a materialized provider image: its source is a content-hashed
@@ -112,7 +167,10 @@ export function isProviderImageMarkdown(text: string): boolean {
}
interface RenderProviderImageOutputOptions {
materialize?: (image: { data: string; mimeType: string | null }) => MaterializedProviderImage;
materialize?: (image: {
data: string;
mimeType: string | null;
}) => Promise<MaterializedProviderImage>;
}
function nonEmptyString(value: string | null | undefined): string | null {
@@ -169,10 +227,10 @@ function escapeMarkdownImageSource(value: string): string {
return markdownImageSource(value).replace(/\\/g, "\\\\").replace(/\)/g, "\\)");
}
export function renderProviderImageOutputAsAssistantMarkdown(
export async function renderProviderImageOutputAsAssistantMarkdown(
image: ProviderImageOutput,
options: RenderProviderImageOutputOptions = {},
): AgentTimelineItem | null {
): Promise<AgentTimelineItem | null> {
const source = nonEmptyString(image.path) ?? nonEmptyString(image.url);
if (source && !isDataImageSource(source)) {
const altText = escapeMarkdownImageAlt(nonEmptyString(image.altText) ?? "Image");
@@ -191,7 +249,7 @@ export function renderProviderImageOutputAsAssistantMarkdown(
let materialized: MaterializedProviderImage | null = null;
try {
materialized = options.materialize
? options.materialize({
? await options.materialize({
data,
mimeType: nonEmptyString(image.mimeType),
})
@@ -0,0 +1,249 @@
// Frozen pre-index page selection: differential oracle for retained projection tests.
import type { AgentTimelineRow } from "../agent-timeline-store-types.js";
import {
TimelineProjection,
projectTimelineRows,
type TimelineProjectionEntry,
type ProjectedTimelineRow,
type TimelineLimitDirection,
type ProjectedTimelinePageSelection,
} from "../timeline-projection.js";
function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): TimelineProjectionEntry[] {
return rows.map((row) => {
if ("seqStart" in row) return { ...(row as ProjectedTimelineRow) };
return {
item: row.item,
timestamp: row.timestamp,
...(row.turnId ? { turnId: row.turnId } : {}),
...(row.providerMessageId ? { providerMessageId: row.providerMessageId } : {}),
seqStart: row.seq,
seqEnd: row.seq,
sourceSeqRanges: [{ startSeq: row.seq, endSeq: row.seq }],
collapsed: [],
};
});
}
function getTimelineBounds(
rows: readonly AgentTimelineRow[],
): { minSeq: number; maxSeq: number } | null {
const first = rows[0];
const last = rows[rows.length - 1];
if (!first || !last) {
return null;
}
const entries = makeCanonicalEntries(rows);
return {
minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity),
maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0),
};
}
function firstSourceSeqInRange(
entry: TimelineProjectionEntry,
startSeq: number,
endSeq: number,
): number | null {
for (const range of entry.sourceSeqRanges) {
const firstSeq = Math.max(range.startSeq, startSeq);
if (firstSeq <= Math.min(range.endSeq, endSeq)) return firstSeq;
}
return null;
}
interface ProjectedEntryCandidate {
entry: TimelineProjectionEntry;
index: number;
firstSourceSeq: number;
}
function selectProjectedEntriesAfter(input: {
entries: readonly TimelineProjectionEntry[];
startSeq: number;
maxSeq: number;
limit: number;
}): { entries: TimelineProjectionEntry[]; endSeq: number | null } {
const eligible = input.entries
.map((entry, index) => ({
entry,
index,
firstSourceSeq: firstSourceSeqInRange(entry, input.startSeq, input.maxSeq),
}))
.filter((candidate): candidate is ProjectedEntryCandidate => candidate.firstSourceSeq !== null)
.sort((left, right) => left.firstSourceSeq - right.firstSourceSeq || left.index - right.index);
const selected = input.limit === 0 ? eligible : eligible.slice(0, input.limit);
const selectedEntries = selected
.sort((left, right) => left.index - right.index)
.map((candidate) => candidate.entry);
if (selectedEntries.length === 0) return { entries: [], endSeq: null };
const selectedRanges = selectedEntries
.flatMap((entry) => entry.sourceSeqRanges)
.sort((left, right) => left.startSeq - right.startSeq || left.endSeq - right.endSeq);
// Wide projected entries can include future, discontiguous source ranges. The
// page cursor advances only through source rows covered without a gap.
let endSeq = input.startSeq - 1;
for (const range of selectedRanges) {
if (range.endSeq <= endSeq) continue;
if (range.startSeq > endSeq + 1) break;
endSeq = Math.min(range.endSeq, input.maxSeq);
}
return {
entries: selectedEntries,
endSeq: endSeq >= input.startSeq ? endSeq : null,
};
}
function selectProjectedEntriesBefore(input: {
entries: readonly TimelineProjectionEntry[];
endSeq: number;
limit: number;
}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } {
// Older history follows projected display order. Lifecycle updates can move an
// entry's seqEnd without moving its display anchor, so seqStart keeps the full
// projected item on exactly one backward page.
const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq);
const selected =
input.limit === 0 || input.limit >= eligible.length
? eligible
: eligible.slice(eligible.length - input.limit);
return {
entries: selected,
startSeq: selected[0]?.seqStart ?? null,
hasOlder: selected.length < eligible.length,
};
}
function selectProjectedEntriesTail(
projectedAll: TimelineProjectionEntry[],
limit: number,
bounds: { minSeq: number; maxSeq: number },
): ProjectedTimelinePageSelection {
let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit);
// Include earlier display anchors with updates in the selected source window,
// so a tail certifies contiguous coverage even with interleaved tool updates.
for (let i = start - 1; i >= 0; i--) {
if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i;
}
const selected = projectedAll.slice(start);
return {
entries: selected,
startSeq: selected[0]?.seqStart ?? null,
endSeq: bounds.maxSeq,
hasOlder: start > 0,
hasNewer: false,
};
}
export function selectReferenceProjectedTimelinePage(input: {
rows: readonly AgentTimelineRow[];
bounds?: { minSeq: number; maxSeq: number };
direction: TimelineLimitDirection;
cursorSeq?: number;
limit?: number;
}): ProjectedTimelinePageSelection {
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
const bounds = input.bounds ?? getTimelineBounds(input.rows);
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
if (!bounds) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
};
}
if (projectedAll.length === 0) {
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: cursorSeq >= bounds.minSeq,
hasNewer: cursorSeq < bounds.maxSeq,
};
}
if (input.direction === "before") {
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: cursorSeq > bounds.minSeq,
hasNewer: cursorSeq <= bounds.maxSeq,
};
}
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
};
}
if (input.direction === "tail") {
return selectProjectedEntriesTail(projectedAll, limit, bounds);
}
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
const selected = selectProjectedEntriesAfter({
entries: projectedAll,
startSeq,
maxSeq: bounds.maxSeq,
limit,
});
return {
entries: selected.entries,
startSeq: selected.endSeq === null ? null : startSeq,
endSeq: selected.endSeq,
hasOlder: startSeq > bounds.minSeq,
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
};
}
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
if (endSeq < bounds.minSeq) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: endSeq < bounds.maxSeq,
};
}
const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit });
return {
entries: selected.entries,
startSeq: selected.startSeq,
endSeq,
hasOlder: selected.hasOlder,
hasNewer: endSeq < bounds.maxSeq,
};
}
// Existing selection fixtures exercise the retained owner through its public API.
export function selectProjectedTimelinePage(
input: Parameters<typeof selectReferenceProjectedTimelinePage>[0],
) {
const projection = new TimelineProjection(input.rows);
const bounds = input.bounds ?? getTimelineBounds(input.rows) ?? { minSeq: 0, maxSeq: 0 };
const page = projection.selectPage({ ...input, bounds });
return {
...page,
entries: page.entries.map((row) => {
const { seq: _seq, ...entry } = row as ProjectedTimelineRow;
return entry;
}),
};
}
@@ -1,9 +1,9 @@
import { selectProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
import { describe, expect, test } from "vitest";
import type { AgentTimelineRow } from "./agent-manager.js";
import {
projectTimelineRows,
selectProjectedTimelinePage,
selectTimelineWindowByProjectedLimit,
} from "./timeline-projection.js";
@@ -295,12 +295,43 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
/** A retained item. `seq` is its latest source event, not its display anchor. */
export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry;
interface ProjectedTimelinePageOptions {
bounds: { minSeq: number; maxSeq: number };
direction: TimelineLimitDirection;
cursorSeq?: number;
limit?: number;
}
/** Owns projected state; source events are consumed, never retained. */
export class TimelineProjection {
private readonly rows: ProjectedTimelineRow[] = [];
private readonly identities = new Map<string, number>();
private lastAssistantRowIndex: number | null = null;
// Disjoint source-coverage spans in display order. A tail starting inside a
// span must include its first anchor to cover interleaved lifecycle updates.
private readonly spans: { start: number; endSeq: number }[] = [];
private readonly sourceRanges: { startSeq: number; endSeq: number; rowIndex: number }[] = [];
constructor(rows: readonly AgentTimelineRow[] = []) {
for (const row of rows) this.appendProjected(row);
for (const [rowIndex, row] of this.rows.entries()) {
for (const range of row.sourceSeqRanges) this.sourceRanges.push({ ...range, rowIndex });
}
this.sourceRanges.sort((a, b) => a.startSeq - b.startSeq);
}
append(row: AgentTimelineRow): void {
const rowIndex = this.appendProjected(row);
const last = this.sourceRanges.at(-1);
if (last && last.rowIndex === rowIndex && last.endSeq + 1 === row.seq) {
last.endSeq = row.seq;
} else {
this.sourceRanges.push({ startSeq: row.seq, endSeq: row.seq, rowIndex });
}
}
private appendProjected(row: AgentTimelineRow): number {
const entry = makeCanonicalEntries([row])[0];
const identity = timelineItemIdentity(row.item);
const index = identity === null ? undefined : this.identities.get(identity);
@@ -308,7 +339,8 @@ export class TimelineProjection {
const merged = existing ? mergeIdentityEntries(existing, entry) : null;
if (merged && index !== undefined) {
this.rows[index] = { ...merged, seq: merged.seqEnd };
return;
this.extendSpan(index);
return index;
}
const previous = this.rows.at(-1);
const adjacent = previous
@@ -317,16 +349,159 @@ export class TimelineProjection {
if (previous && adjacent.length === 1) {
const mergedText = adjacent[0];
this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd };
return;
this.extendSpan(this.rows.length - 1);
return this.rows.length - 1;
}
if (identity !== null) this.identities.set(identity, this.rows.length);
if (entry.item.type === "assistant_message") this.lastAssistantRowIndex = this.rows.length;
this.rows.push({ ...entry, seq: entry.seqEnd });
const last = this.spans.at(-1);
if (last && last.endSeq >= entry.seqStart) {
last.endSeq = Math.max(last.endSeq, entry.seqEnd);
} else {
this.spans.push({ start: this.rows.length - 1, endSeq: entry.seqEnd });
}
return this.rows.length - 1;
}
private spanIndex(rowIndex: number): number {
let low = 0;
let high = this.spans.length;
while (low + 1 < high) {
const middle = (low + high) >>> 1;
if (this.spans[middle].start <= rowIndex) low = middle;
else high = middle;
}
return low;
}
private extendSpan(rowIndex: number): void {
const index = this.spanIndex(rowIndex);
const span = this.spans[index];
span.endSeq = Math.max(span.endSeq, this.rows[rowIndex].seqEnd);
let end = index + 1;
while (end < this.spans.length && this.rows[this.spans[end].start].seqStart <= span.endSeq) {
span.endSeq = Math.max(span.endSeq, this.spans[end].endSeq);
end++;
}
this.spans.splice(index + 1, end - index - 1);
}
getRows(): ProjectedTimelineRow[] {
return this.rows.map((row) => ({ ...row }));
}
selectPage(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
if (this.rows.length === 0) {
const cursor =
input.cursorSeq ??
(input.direction === "before" ? input.bounds.maxSeq + 1 : input.bounds.minSeq - 1);
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder:
input.direction === "after"
? cursor >= input.bounds.minSeq
: input.direction === "before" && cursor > input.bounds.minSeq,
hasNewer:
input.direction === "after"
? cursor < input.bounds.maxSeq
: input.direction === "before" && cursor <= input.bounds.maxSeq,
};
}
switch (input.direction) {
case "tail":
return this.selectTail(input);
case "after":
return this.selectAfter(input);
case "before":
return this.selectBefore(input);
}
}
private selectTail(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
const limit = Math.max(0, Math.floor(input.limit ?? 0));
const requestedStart = limit === 0 ? 0 : Math.max(0, this.rows.length - limit);
const start = this.spans[this.spanIndex(requestedStart)].start;
return {
entries: this.rows.slice(start),
startSeq: this.rows[start].seqStart,
endSeq: input.bounds.maxSeq,
hasOlder: start > 0,
hasNewer: false,
};
}
private selectAfter(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
const startSeq = Math.max(
input.bounds.minSeq,
(input.cursorSeq ?? input.bounds.minSeq - 1) + 1,
);
let low = 0;
let high = this.sourceRanges.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (this.sourceRanges[middle].endSeq < startSeq) low = middle + 1;
else high = middle;
}
const indexes = new Set<number>();
const limit = Math.max(0, Math.floor(input.limit ?? 0));
for (let i = low; i < this.sourceRanges.length; i++) {
const range = this.sourceRanges[i];
if (range.startSeq > input.bounds.maxSeq) break;
indexes.add(range.rowIndex);
if (limit > 0 && indexes.size === limit) break;
}
const entries = [...indexes].sort((a, b) => a - b).map((index) => this.rows[index]);
const selected = selectProjectedEntriesAfter({
entries,
startSeq,
maxSeq: input.bounds.maxSeq,
limit,
});
return {
entries: selected.entries,
startSeq: selected.endSeq === null ? null : startSeq,
endSeq: selected.endSeq,
hasOlder: startSeq > input.bounds.minSeq,
hasNewer: selected.endSeq !== null && selected.endSeq < input.bounds.maxSeq,
};
}
private selectBefore(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
const endSeq = Math.min(input.bounds.maxSeq, (input.cursorSeq ?? input.bounds.maxSeq + 1) - 1);
if (endSeq >= input.bounds.minSeq) {
let low = 0;
let high = this.rows.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (this.rows[middle].seqStart <= endSeq) low = middle + 1;
else high = middle;
}
const limit = Math.max(0, Math.floor(input.limit ?? 0));
const start = limit === 0 ? 0 : Math.max(0, low - limit);
return {
entries: this.rows.slice(start, low),
startSeq: this.rows[start]?.seqStart ?? null,
endSeq,
hasOlder: start > 0,
hasNewer: endSeq < input.bounds.maxSeq,
};
}
return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true };
}
getLastItem(seq: number): AgentTimelineItem | null {
const last = this.sourceRanges.at(-1);
return last?.endSeq === seq ? this.rows[last.rowIndex].item : null;
}
getLastAssistantMessage(): string | null {
const row = this.lastAssistantRowIndex === null ? null : this.rows[this.lastAssistantRowIndex];
return row?.item.type === "assistant_message" ? row.item.text : null;
}
enrichSubmittedUserMessage(
clientMessageId: string,
providerMessageId: string,
@@ -444,21 +619,6 @@ export function selectTimelineWindowByProjectedLimit(input: {
};
}
function getTimelineBounds(
rows: readonly AgentTimelineRow[],
): { minSeq: number; maxSeq: number } | null {
const first = rows[0];
const last = rows[rows.length - 1];
if (!first || !last) {
return null;
}
const entries = makeCanonicalEntries(rows);
return {
minSeq: entries.reduce((min, e) => Math.min(min, e.seqStart), Infinity),
maxSeq: entries.reduce((max, e) => Math.max(max, e.seqEnd), 0),
};
}
function firstSourceSeqInRange(
entry: TimelineProjectionEntry,
startSeq: number,
@@ -515,142 +675,6 @@ function selectProjectedEntriesAfter(input: {
};
}
function selectProjectedEntriesBefore(input: {
entries: readonly TimelineProjectionEntry[];
endSeq: number;
limit: number;
}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } {
// Older history follows projected display order. Lifecycle updates can move an
// entry's seqEnd without moving its display anchor, so seqStart keeps the full
// projected item on exactly one backward page.
const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq);
const selected =
input.limit === 0 || input.limit >= eligible.length
? eligible
: eligible.slice(eligible.length - input.limit);
return {
entries: selected,
startSeq: selected[0]?.seqStart ?? null,
hasOlder: selected.length < eligible.length,
};
}
function selectProjectedEntriesTail(
projectedAll: TimelineProjectionEntry[],
limit: number,
bounds: { minSeq: number; maxSeq: number },
): ProjectedTimelinePageSelection {
let start = limit === 0 ? 0 : Math.max(0, projectedAll.length - limit);
// Include earlier display anchors with updates in the selected source window,
// so a tail certifies contiguous coverage even with interleaved tool updates.
for (let i = start - 1; i >= 0; i--) {
if (projectedAll[i].seqEnd >= projectedAll[start].seqStart) start = i;
}
const selected = projectedAll.slice(start);
return {
entries: selected,
startSeq: selected[0]?.seqStart ?? null,
endSeq: bounds.maxSeq,
hasOlder: start > 0,
hasNewer: false,
};
}
export function selectProjectedTimelinePage(input: {
rows: readonly AgentTimelineRow[];
bounds?: { minSeq: number; maxSeq: number };
direction: TimelineLimitDirection;
cursorSeq?: number;
limit?: number;
}): ProjectedTimelinePageSelection {
const limit = input.limit === undefined ? 0 : Math.max(0, Math.floor(input.limit));
const bounds = input.bounds ?? getTimelineBounds(input.rows);
const projectedAll = projectTimelineRows({ rows: input.rows, mode: "projected" });
if (!bounds) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
};
}
if (projectedAll.length === 0) {
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: cursorSeq >= bounds.minSeq,
hasNewer: cursorSeq < bounds.maxSeq,
};
}
if (input.direction === "before") {
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: cursorSeq > bounds.minSeq,
hasNewer: cursorSeq <= bounds.maxSeq,
};
}
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
};
}
if (input.direction === "tail") {
return selectProjectedEntriesTail(projectedAll, limit, bounds);
}
if (input.direction === "after") {
const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1;
const startSeq = Math.max(bounds.minSeq, cursorSeq + 1);
const selected = selectProjectedEntriesAfter({
entries: projectedAll,
startSeq,
maxSeq: bounds.maxSeq,
limit,
});
return {
entries: selected.entries,
startSeq: selected.endSeq === null ? null : startSeq,
endSeq: selected.endSeq,
hasOlder: startSeq > bounds.minSeq,
hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq,
};
}
const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1;
const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1);
if (endSeq < bounds.minSeq) {
return {
entries: [],
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: endSeq < bounds.maxSeq,
};
}
const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit });
return {
entries: selected.entries,
startSeq: selected.startSeq,
endSeq,
hasOlder: selected.hasOlder,
hasNewer: endSeq < bounds.maxSeq,
};
}
/**
* Apply a projected-count limit to a flat AgentTimelineItem[] without seq metadata.
* Used by callers that only have items in hand (e.g. MCP tools reading
@@ -1,3 +1,4 @@
import { registerPluginTools } from "./plugin-tools.js";
import type { FinishSubscriptions, FinishRegistration } from "../finish-subscriptions/index.js";
import type { AgentMessageDelivery } from "../agent-messages/index.js";
import {
@@ -704,6 +705,7 @@ function createToolCatalog(
}
const localHostOperations = new Map<string, HostOperation>();
const tools = new Map<string, PaseoToolDefinition>();
const builtinNames = new Set<string>();
const hostTools = new Map([
["create_agent", "createAgent"],
["create_workspace", "createWorkspace"],
@@ -738,6 +740,7 @@ function createToolCatalog(
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Tool handlers are schema-validated at registration boundaries.
handler: (input: any, context: PaseoToolExecutionContext) => Promise<PaseoToolResult>,
) => {
builtinNames.add(name);
if (!isPaseoToolEnabled(options.paseoToolPolicy, name)) {
return;
}
@@ -3272,6 +3275,14 @@ function createToolCatalog(
},
);
registerPluginTools({
agentManager,
callerAgentId,
scope,
tools,
builtinNames,
policy: options.paseoToolPolicy,
});
return toCatalog();
}
@@ -0,0 +1,40 @@
import { z } from "zod";
import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config";
import type { AgentManager } from "../agent-manager.js";
import { isPaseoToolEnabled } from "../paseo-tool-policy.js";
import type { PaseoToolDefinition } from "./types.js";
import { addModelVisibleStructuredContent } from "./paseo-tool-serialization.js";
export function registerPluginTools(options: {
agentManager: AgentManager;
callerAgentId?: string;
scope: "agent" | "top-level";
tools: Map<string, PaseoToolDefinition>;
builtinNames: Set<string>;
policy?: ProviderPaseoToolsPolicy;
}): void {
const { agentManager, callerAgentId, scope, tools, builtinNames, policy } = options;
if (scope !== "agent" || !callerAgentId) return;
for (const tool of agentManager.getPluginTools(callerAgentId)) {
if (builtinNames.has(tool.name)) {
throw new Error(
`Agent tool ${tool.name} from plugin ${tool.pluginId} collides with Paseo tool ${tool.name}`,
);
}
if (!isPaseoToolEnabled(policy, tool.name)) continue;
tools.set(tool.name, {
name: tool.name,
title: tool.title,
description: tool.description,
inputSchema: z.fromJSONSchema(tool.inputSchema),
async handler(input, { signal }) {
const result = await agentManager.executePluginTool(tool, callerAgentId, input, signal);
return addModelVisibleStructuredContent({
content: result.text === undefined ? [] : [{ type: "text", text: result.text }],
structuredContent: result.structuredContent,
isError: result.isError,
});
},
});
}
}
@@ -0,0 +1,147 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, it } from "vitest";
import { writeFileAtomic, writeJsonFileAtomic } from "./atomic-file.js";
let directory: string;
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-atomic-file-"));
});
afterEach(async () => {
await fs.rm(directory, { recursive: true, force: true });
});
it.each(["EPERM", "EACCES", "EBUSY"])(
"commits a completed receipt after transient Windows %s replace failures",
async (code) => {
const file = path.join(directory, "receipt.json");
await writeJsonFileAtomic(file, { state: "pending" });
let failures = 3;
let elapsed = 0;
await writeJsonFileAtomic(
file,
{ state: "completed" },
{
platform: "win32",
now: () => elapsed,
sleep: async (milliseconds) => {
elapsed += milliseconds;
},
rename: async (source, destination) => {
if (failures-- > 0) {
expect(JSON.parse(await fs.readFile(destination, "utf8"))).toEqual({
state: "pending",
});
expect(JSON.parse(await fs.readFile(source, "utf8"))).toEqual({ state: "completed" });
throw Object.assign(new Error("destination held open"), { code });
}
await fs.rename(source, destination);
},
},
);
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "completed" });
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
expect(elapsed).toBeGreaterThan(0);
},
);
it.each(["EPERM", "EACCES", "EBUSY"])(
"preserves the previous file and removes the temp file when Windows %s persists",
async (code) => {
const file = path.join(directory, "receipt.json");
await writeJsonFileAtomic(file, { state: "pending" });
const error = Object.assign(new Error("destination held open"), { code });
const waits: number[] = [];
let elapsed = 0;
const attempts: number[] = [];
await expect(
writeJsonFileAtomic(
file,
{ state: "completed" },
{
platform: "win32",
now: () => elapsed,
sleep: async (milliseconds) => {
waits.push(milliseconds);
elapsed += milliseconds;
},
rename: async () => {
attempts.push(elapsed);
throw error;
},
},
),
).rejects.toBe(error);
expect(elapsed).toBe(1000);
expect(waits.slice(0, 5)).toEqual([10, 20, 40, 80, 100]);
expect(Math.max(...waits)).toBe(100);
expect(attempts.every((time) => time < 1000)).toBe(true);
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
},
);
it.each([
["linux", "EPERM"],
["darwin", "EACCES"],
["linux", "EBUSY"],
["win32", "ENOENT"],
["win32", "ENOSPC"],
["win32", "EXDEV"],
] as const)("does not retry %s %s failures", async (platform, code) => {
const file = path.join(directory, "receipt.json");
await writeJsonFileAtomic(file, { state: "pending" });
const error = Object.assign(new Error("rename failed"), { code });
let attempts = 0;
const waits: number[] = [];
await expect(
writeFileAtomic(file, "replacement", {
platform,
now: () => 0,
sleep: async (milliseconds) => {
waits.push(milliseconds);
},
rename: async () => {
attempts++;
throw error;
},
}),
).rejects.toBe(error);
expect(attempts).toBe(1);
expect(waits).toEqual([]);
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
});
it("does not retry after a rename consumes the Windows retry budget", async () => {
const file = path.join(directory, "receipt.json");
const error = Object.assign(new Error("slow rename failed"), { code: "EPERM" });
let elapsed = 0;
const waits: number[] = [];
await expect(
writeFileAtomic(file, "replacement", {
platform: "win32",
now: () => elapsed,
sleep: async (milliseconds) => {
waits.push(milliseconds);
},
rename: async () => {
elapsed += 1000;
throw error;
},
}),
).rejects.toBe(error);
expect(waits).toEqual([]);
expect(await fs.readdir(directory)).toEqual([]);
});
it("creates parent directories and replaces binary and JSON files through the default runtime", async () => {
const file = path.join(directory, "nested", "file");
const bytes = Buffer.from([0, 255, 128, 1]);
await writeFileAtomic(file, bytes);
expect(await fs.readFile(file)).toEqual(bytes);
await writeJsonFileAtomic(file, { state: "completed" });
expect(await fs.readFile(file, "utf8")).toBe('{\n "state": "completed"\n}');
expect(await fs.readdir(path.dirname(file))).toEqual(["file"]);
});
+54 -3
View File
@@ -1,10 +1,26 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
interface AtomicFileRuntime {
platform: NodeJS.Platform;
rename: (source: string, destination: string) => Promise<void>;
now: () => number;
sleep: (milliseconds: number) => Promise<void>;
}
const runtime: AtomicFileRuntime = {
platform: process.platform,
rename: fs.rename,
now: () => performance.now(),
sleep,
};
export async function writeFileAtomic(
filePath: string,
data: string | NodeJS.ArrayBufferView,
operations: AtomicFileRuntime = runtime,
): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const tempPath = path.join(
@@ -13,13 +29,48 @@ export async function writeFileAtomic(
);
try {
await fs.writeFile(tempPath, data, "utf8");
await fs.rename(tempPath, filePath);
await replaceFile(tempPath, filePath, operations);
} catch (error) {
await fs.rm(tempPath, { force: true });
throw error;
}
}
export async function writeJsonFileAtomic(filePath: string, value: unknown): Promise<void> {
await writeFileAtomic(filePath, JSON.stringify(value, null, 2));
async function replaceFile(
source: string,
destination: string,
operations: AtomicFileRuntime,
): Promise<void> {
// Windows readers/scanners can briefly deny replacement. Yield with backoff,
// but keep interactive persistence failures bounded to a one-second retry budget.
const deadline = operations.now() + 1000;
let delay = 10;
for (;;) {
try {
await operations.rename(source, destination);
return;
} catch (error) {
if (
operations.platform !== "win32" ||
!(error instanceof Error) ||
!("code" in error) ||
!["EPERM", "EACCES", "EBUSY"].includes(String(error.code))
) {
throw error;
}
const remaining = deadline - operations.now();
if (remaining <= 0) throw error;
await operations.sleep(Math.min(delay, remaining));
if (operations.now() >= deadline) throw error;
delay = Math.min(delay * 2, 100);
}
}
}
export async function writeJsonFileAtomic(
filePath: string,
value: unknown,
operations: AtomicFileRuntime = runtime,
): Promise<void> {
await writeFileAtomic(filePath, JSON.stringify(value, null, 2), operations);
}
+6 -6
View File
@@ -141,11 +141,11 @@ function liveCleanupHost() {
test("bootstrap's host toggle prevents collection live, persists, and leaves other hosts enabled", async () => {
const host = liveCleanupHost();
const other = liveCleanupHost();
host.store.patch({ idleRuntimeCleanup: false });
await host.store.patch({ idleRuntimeCleanup: false });
expect(loadPersistedConfig(host.home).agents?.idleRuntimeCleanup).toBe(false);
expect((await host.sweep()).collected).toEqual([]);
expect((await other.sweep()).collected).toHaveLength(1);
host.store.patch({ idleRuntimeCleanup: true });
await host.store.patch({ idleRuntimeCleanup: true });
host.protect(true);
expect((await host.sweep()).collected).toEqual([]);
host.protect(false);
@@ -160,7 +160,7 @@ test("bootstrap's config-file reload binding disables and enables eligible colle
path.join(host.home, "config.json"),
JSON.stringify({ version: 1, agents: { idleRuntimeCleanup: enabled } }),
);
expect(host.store.reload()).toEqual({
expect(await host.store.reload()).toEqual({
appliedPaths: ["agents.idleRuntimeCleanup"],
restartRequiredPaths: [],
overrideControlledPaths: [],
@@ -173,12 +173,12 @@ test.each([false, true])(
"failed config apply restores prior idle cleanup behavior (previous: %s)",
async (previous) => {
const host = liveCleanupHost();
host.store.patch({ idleRuntimeCleanup: previous });
await host.store.patch({ idleRuntimeCleanup: previous });
const persistedBefore = loadPersistedConfig(host.home);
const refuse = host.store.onApply(() => {
throw new Error("Another live owner refused");
});
expect(() => host.store.patch({ idleRuntimeCleanup: !previous })).toThrow(
await expect(host.store.patch({ idleRuntimeCleanup: !previous })).rejects.toThrow(
"Another live owner refused",
);
expect(host.store.get().idleRuntimeCleanup).toBe(previous);
@@ -191,6 +191,6 @@ test.each([false, true])(
test("disposing bootstrap's binding stops configuration delivery", async () => {
const host = liveCleanupHost();
host.unsubscribe();
host.store.patch({ idleRuntimeCleanup: false });
await host.store.patch({ idleRuntimeCleanup: false });
expect((await host.sweep()).collected).toHaveLength(1);
});
+18 -7
View File
@@ -161,7 +161,11 @@ import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js";
import { createOrchestrationSkills } from "./orchestration-skills/index.js";
import { resolveConfigFromPersisted, type CliConfigOverrides } from "./config.js";
import {
resolveReloadableConfig,
type ResolvedDaemonSettings,
type CliConfigOverrides,
} from "./config.js";
import { resolvePaseoToolPolicy } from "./agent/paseo-tool-policy.js";
import { BrowserToolsBroker } from "./browser-tools/broker.js";
import { DaemonConfigBrowserToolsPolicy } from "./browser-tools/policy.js";
@@ -196,7 +200,7 @@ import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import { loadPersistedConfig, type PersistedConfig } from "./persisted-config.js";
import { type PersistedConfig } from "./persisted-config.js";
import { createServiceProxySubsystem, type ServiceProxySubsystem } from "./service-proxy.js";
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
import { ScriptHealthMonitor } from "./script-health-monitor.js";
@@ -551,7 +555,7 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri
return config.trustedProxies ?? ["loopback"];
}
function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfig["relay"] {
function createInitialRelayConfig(config: ResolvedDaemonSettings): MutableDaemonConfig["relay"] {
return {
enabled: config.relayEnabled ?? true,
requireProtectedSecurity: config.relayRequireProtectedSecurity ?? false,
@@ -562,7 +566,7 @@ function resolveReloadEnv(config: PaseoDaemonConfig): NodeJS.ProcessEnv {
return config.configReload?.env ?? process.env;
}
function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig {
function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): MutableDaemonConfig {
const providers = config.providerOverrides ?? {};
const initialConfig: MutableDaemonConfig = {
@@ -638,14 +642,14 @@ export async function createPaseoDaemon(
}),
reloadSource: {
resolve: (persisted) => {
const reloaded = resolveConfigFromPersisted(config.paseoHome, persisted, {
const reloaded = resolveReloadableConfig(persisted, {
env: resolveReloadEnv(config),
cli: config.configReload?.cli,
relayEnabledFallback: config.configReload?.relayEnabledFallback,
});
return {
mutable: createInitialMutableDaemonConfig(reloaded),
overrideControlledPaths: reloaded.configReload?.overrideControlledPaths ?? [],
overrideControlledPaths: reloaded.overrideControlledPaths,
};
},
},
@@ -995,6 +999,7 @@ export async function createPaseoDaemon(
const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState();
const agentManager = new AgentManager({
pluginLifecycle: pluginRuntime,
pluginAgentTools: pluginRuntime,
clients: initialAgentManagerState.clients,
providerDefinitions: initialAgentManagerState.providerDefinitions,
registry: agentStorage,
@@ -1015,6 +1020,10 @@ export async function createPaseoDaemon(
const unsubscribePluginProviders =
pluginRuntime.subscribeProviderRegistrations(syncPluginProviders);
const unsubscribePluginSessionChanges = pluginRuntime.subscribeSessionOpenChanges(() => {
agentManager.invalidatePluginSessions();
});
const detachAgentStoragePersistence = attachAgentStoragePersistence(
logger,
agentManager,
@@ -1536,7 +1545,7 @@ export async function createPaseoDaemon(
spawnWorkspaceScript,
assertAutomationAllowed: (workspaceId) =>
assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, workspaceId),
globalServicePorts: loadPersistedConfig(config.paseoHome).worktrees?.servicePorts,
getGlobalServicePorts: () => daemonConfigStore.readServicePorts(),
}),
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
@@ -1914,6 +1923,7 @@ export async function createPaseoDaemon(
localCredential = null;
await deleteLocalCredential(config.paseoHome);
unsubscribePluginProviders();
unsubscribePluginSessionChanges();
await pluginRuntime.stopAllPlugins().catch(() => undefined);
await serviceProxy.stopStandalone().catch(() => undefined);
await agentProviderRuntime.shutdown().catch(() => undefined);
@@ -1933,6 +1943,7 @@ export async function createPaseoDaemon(
// that is still open. Plugins themselves are stopped once every session
// they serve has been closed, further down.
unsubscribePluginProviders();
unsubscribePluginSessionChanges();
await hubRelationships.stop();
workspaceReconciliation.dispose();
scriptHealthMonitor.stop();
@@ -132,3 +132,22 @@ test.each(["base", "uncommitted"] as const)(
expect(await read("next", "next-after")).toEqual({ diff: "next-after" });
},
);
test("superseding a diff cancels its highlighting while identical readers share work", async () => {
const cache = new CheckoutDiffCache(() => 0);
const release = Promise.withResolvers<{ diff: string }>();
let activeSignal: AbortSignal | undefined;
const load = (signal: AbortSignal) => {
activeSignal = signal;
return release.promise;
};
const first = cache.read({ cwd: "repo", compare: { mode: "base" }, load });
await Promise.resolve();
expect(activeSignal).toBeDefined();
expect(activeSignal!.aborted).toBe(false);
expect(cache.read({ cwd: "repo", compare: { mode: "base" }, load })).toBe(first);
cache.invalidate("repo", "base");
expect(activeSignal!.aborted).toBe(true);
release.resolve({ diff: "old snapshot" });
await first;
});
@@ -4,6 +4,7 @@ import type { CheckoutDiffCompare, CheckoutDiffResult } from "../utils/checkout-
interface ActiveRead {
promise: Promise<CheckoutDiffResult>;
invalidated: boolean;
controller: AbortController;
}
interface CachedDiff {
@@ -17,7 +18,7 @@ interface CheckoutDiffReadInput {
compare: CheckoutDiffCompare;
force?: boolean;
reason?: string;
load: () => Promise<CheckoutDiffResult>;
load: (signal: AbortSignal) => Promise<CheckoutDiffResult>;
}
/** Completed payloads are bounded; active work survives invalidation and eviction. */
@@ -51,6 +52,7 @@ export class CheckoutDiffCache {
if (pending) {
if (force) {
pending.invalidated = true;
pending.controller.abort();
this.values.delete(key);
}
if (!pending.invalidated) return pending.promise;
@@ -66,8 +68,9 @@ export class CheckoutDiffCache {
// Defer load until ownership is installed, including synchronous invalidations.
const read: ActiveRead = {
invalidated: false,
controller: new AbortController(),
promise: Promise.resolve()
.then(load)
.then(() => load(read.controller.signal))
.then((value) => {
// Never repopulate the cache with a snapshot invalidated during its build.
if (!read.invalidated)
@@ -86,7 +89,10 @@ export class CheckoutDiffCache {
if (cachedCwd !== cwd || (cachedMode !== mode && cachedMode !== "all")) continue;
this.values.delete(key);
const pending = this.active.get(key);
if (pending) pending.invalidated = true;
if (pending) {
pending.invalidated = true;
pending.controller.abort();
}
}
}
}

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