Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andcoolwind abf9a6ff77 fix(agent): carry launch environment preservation onto current main
Include the merged Windows receipt persistence and integration synchronization fixes so the restart fix can be verified on current CI.

Co-authored-by: coolwind <45729206+coolwindX@users.noreply.github.com>
2026-10-26 17:11:07 +00:00
coolwind ae153721c8 fix(agent): preserve launch env across daemon restarts 2026-10-25 12:48:59 +00:00
178 changed files with 2427 additions and 9576 deletions
-3
View File
@@ -432,9 +432,6 @@ 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.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

@@ -1,4 +1,3 @@
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";
@@ -275,123 +274,3 @@ 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,10 +11,7 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
import {
expectAgentAbsentFromWorkspaceLayout,
seedForeignPinnedAgentTab,
} from "../support/helpers/workspace-layout";
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -108,83 +105,6 @@ 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,47 +1,12 @@
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,7 +2495,6 @@ function ComposerContentImpl({
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={activeSendBehavior}
sendShortcut={appSettings.sendShortcut}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
+4 -34
View File
@@ -1,4 +1,3 @@
import type { SendShortcut } from "@/hooks/use-settings";
import {
useComposerEditingSession,
useComposerFullscreen,
@@ -89,7 +88,6 @@ import {
resolveAlternateSendAction,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveMessageInputEnterAction,
resolveRetainedSelection,
runAlternateSendAction,
runDefaultSendAction,
@@ -98,11 +96,6 @@ 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 {
@@ -174,7 +167,6 @@ 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. */
@@ -410,9 +402,6 @@ 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;
@@ -439,21 +428,12 @@ function handleDesktopKeyPressImpl(
}
if (event.nativeEvent.key !== "Enter") 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.submitOnEnter) return;
if (shiftKey) return;
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
event.preventDefault();
if (action === "alternate-send") {
if (metaKey || ctrlKey) {
ctx.handleAlternateSendAction();
return;
}
@@ -1132,7 +1112,6 @@ 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;
@@ -1180,7 +1159,6 @@ 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,
@@ -1258,7 +1236,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
voiceAgentId,
isAgentRunning,
defaultSendBehavior,
sendShortcut,
onQueue,
onSubmitLoadingPress,
onKeyPressCallback,
@@ -1778,9 +1755,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
selectionRef.current,
),
submitOnEnter: shouldSubmitOnEnter,
sendShortcut,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
@@ -1792,9 +1766,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
shouldHandleWebKeyPress,
onKeyPressCallback,
shouldSubmitOnEnter,
sendShortcut,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
@@ -2009,7 +1980,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
submitLabel={submitLabel}
submitButtonTestID={submitButtonTestID}
buttonIconSize={buttonIconSize}
sendKeys={resolveSendKeys(sendShortcut)}
sendKeys={DEFAULT_SEND_KEYS}
sendTooltipLabel={sendTooltipLabel}
/>
),
@@ -2030,7 +2001,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
submitLabel,
submitButtonTestID,
buttonIconSize,
sendShortcut,
sendTooltipLabel,
],
);
@@ -2,12 +2,10 @@ import { describe, expect, it, vi } from "vitest";
import {
applyDictationTranscript,
computeCanStartDictation,
type MessageInputEnterContext,
resolveActiveSendBehavior,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveGlyphRailOffset,
resolveMessageInputEnterAction,
resolveRetainedSelection,
resolveAlternateSendAction,
runAlternateSendAction,
@@ -322,212 +320,6 @@ 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,4 +1,3 @@
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";
@@ -152,29 +151,6 @@ 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,7 +49,6 @@ import {
type KeyValueStorage,
type ReleaseChannel,
type SendBehavior,
type SendShortcut,
type ServiceUrlBehavior,
type Settings,
type SidebarWorkspaceTrailing,
@@ -94,7 +93,6 @@ export type {
KeyValueStorage,
ReleaseChannel,
SendBehavior,
SendShortcut,
ServiceUrlBehavior,
Settings,
SettingsDeps,
@@ -227,39 +227,6 @@ 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,8 +33,6 @@ 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";
@@ -106,7 +104,6 @@ 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;
@@ -163,7 +160,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
chatDisplay: "collapseTurns",
chatOutlineEnabled: true,
vimKeybindings: false,
sendShortcut: "enter",
filePanelAssociations: {},
openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
pullRequestOpenLocation: "explorer",
@@ -268,7 +264,6 @@ 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,25 +2369,12 @@ 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,26 +2457,12 @@ 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,27 +2384,12 @@ 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,27 +2383,12 @@ 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,25 +2353,12 @@ 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,27 +2344,12 @@ 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,27 +2367,12 @@ 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,27 +2370,12 @@ 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,24 +2308,12 @@ 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,17 +1,13 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { SettingsCard, SettingsSection, SettingsSelect } from "@/components/settings";
import { useAppSettings, type SendShortcut, type SendBehavior } from "@/hooks/use-settings";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useAppSettings, type SendBehavior } from "@/hooks/use-settings";
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) => ({
@@ -24,37 +20,12 @@ 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(
shortcutsAvailable && settings.sendShortcut === "mod-enter"
? `settings.general.defaultSend.commandEnterDescriptions.${settings.sendBehavior}`
: `settings.general.defaultSend.descriptions.${settings.sendBehavior}`,
{ modifier },
)}
hint={t(`settings.general.defaultSend.descriptions.${settings.sendBehavior}`)}
value={settings.sendBehavior}
options={options}
onValueChange={change}
@@ -271,7 +271,6 @@ export interface WorkspaceTabSnapshot {
terminalsHydrated: boolean;
activeAgentIds: Iterable<string>;
autoOpenAgentIds: Iterable<string>;
foreignAgentIds?: Iterable<string>;
knownTerminalIds?: Iterable<string>;
standaloneTerminalIds: Iterable<string>;
hasActivePendingTerminalCreate?: boolean;
@@ -2444,16 +2443,13 @@ export function reconcileWorkspaceTabs(
const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []);
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds);
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.
// 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 pinnedAgentIds = new Set(
[...(state.pinnedAgentIds ?? [])].filter((agentId) => {
if (!snapshot.agentsHydrated) {
return true;
}
return !activeAgentIds.has(agentId) && !foreignAgentIds.has(agentId);
}),
[...(state.pinnedAgentIds ?? [])].filter(
(agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId),
),
);
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
const knownTerminalIds = snapshot.knownTerminalIds
@@ -1,7 +1,6 @@
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,
@@ -64,118 +63,6 @@ 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",
@@ -199,7 +86,6 @@ 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", () => {
@@ -409,7 +295,6 @@ 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", () => {
@@ -423,14 +308,12 @@ 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(
@@ -448,7 +331,6 @@ 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,
@@ -587,24 +469,6 @@ 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", () => {
@@ -612,12 +476,10 @@ 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);
});
@@ -626,12 +488,10 @@ 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);
});
@@ -640,12 +500,10 @@ 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);
});
@@ -654,12 +512,10 @@ 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,7 +6,10 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
export interface WorkspaceAgentVisibility {
activeAgentIds: Set<string>;
autoOpenAgentIds: Set<string>;
foreignAgentIds: Set<string>;
}
function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean {
return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId;
}
export function deriveWorkspaceAgentVisibility(input: {
@@ -16,23 +19,17 @@ export function deriveWorkspaceAgentVisibility(input: {
}): WorkspaceAgentVisibility {
const { sessionAgents, agentDetails } = input;
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if (!workspaceId) {
if ((!sessionAgents && !agentDetails) || !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() ?? []) {
const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId);
if (ownerId !== workspaceId) {
if (ownerId) {
foreignAgentIds.add(agent.id);
}
if (!agentBelongsToWorkspace(agent, workspaceId)) {
continue;
}
if (!agent.archivedAt) {
@@ -45,16 +42,7 @@ export function deriveWorkspaceAgentVisibility(input: {
}
}
}
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 };
return { activeAgentIds, autoOpenAgentIds };
}
interface AgentMapsSnapshot {
@@ -111,7 +99,6 @@ 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,
@@ -126,8 +113,7 @@ export function workspaceAgentVisibilityEqual(
return (
a === b ||
(setsEqual(a.activeAgentIds, b.activeAgentIds) &&
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) &&
setsEqual(a.foreignAgentIds, b.foreignAgentIds))
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds))
);
}
-63
View File
@@ -1770,66 +1770,3 @@ 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,11 +369,6 @@ 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. */
@@ -986,12 +981,6 @@ 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,6 +1,4 @@
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";
@@ -422,55 +420,3 @@ 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();
});
});
@@ -1,49 +0,0 @@
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,4 +15,3 @@ export {
isSyntaxThemeId,
resolveSyntaxColors,
} from "./themes.js";
export { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "./budgeted-highlighter.js";
+1 -57
View File
@@ -92,12 +92,7 @@ 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([
"defineAgentTool",
"execCommand",
"spawnProcess",
"terminateProcess",
]);
expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]);
});
it("classifies every published entry", () => {
@@ -214,54 +209,3 @@ 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
@@ -1,39 +0,0 @@
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,6 +24,3 @@ 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,4 +1,3 @@
import type { AgentToolDefinition } from "./agent-tools.js";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
@@ -37,9 +36,6 @@ 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/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/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: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",
@@ -135,6 +135,7 @@ export async function ensureAgentLoaded(
labels: record.labels,
workspaceId: record.workspaceId,
owner: record.owner,
env: record.launchEnv,
});
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
}
@@ -2869,6 +2869,134 @@ test("createAgent passes daemon launch env through the provider launch context",
});
});
test.each([
{ provider: "codex", hasHandle: true },
{ provider: "opencode", hasHandle: true },
{ provider: "codex", hasHandle: false },
{ provider: "opencode", hasHandle: false },
])(
"$provider preserves explicit launch env across reloads and storage-backed resumes (handle: $hasHandle)",
async ({ provider, hasHandle }) => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-launch-env-"));
const storagePath = join(workdir, "agents");
class LaunchEnvClient extends TestAgentClient {
lastLaunchContext: AgentLaunchContext | undefined;
override async createSession(
config: AgentSessionConfig,
context?: AgentLaunchContext,
): Promise<AgentSession> {
this.lastLaunchContext = context;
const session = await super.createSession(config);
session.describePersistence = () => ({ provider, sessionId: session.id });
return session;
}
override async resumeSession(
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
context?: AgentLaunchContext,
): Promise<AgentSession> {
this.lastLaunchContext = context;
const session = await super.resumeSession(handle, config, context);
session.describePersistence = () => ({ provider, sessionId: handle.sessionId });
return session;
}
}
const client = new LaunchEnvClient(provider);
let storage = new AgentStorage(storagePath, logger);
let manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
const env = { XDG_DATA_HOME: join(workdir, "agent-data"), CUSTOM_EMPTY: "" };
const expectedEnv = { ...env };
const agent = await manager.createAgent({ provider, cwd: workdir }, undefined, {
workspaceId: undefined,
env,
});
onTestFinished(async () => {
await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
});
env.XDG_DATA_HOME = "caller-mutated";
await manager.closeAgent(agent.id);
await manager.flush();
if (!hasHandle) {
const record = await storage.get(agent.id);
if (!record) throw new Error("Created agent was not persisted");
await storage.upsert({ ...record, persistence: null });
}
// Fresh storage and manager instances must read the environment from disk.
for (let restart = 0; restart < 2; restart += 1) {
storage = new AgentStorage(storagePath, logger);
manager = new AgentManager({ clients: { [provider]: client }, registry: storage, logger });
const resumed = await ensureAgentLoaded(agent.id, {
agentManager: manager,
agentStorage: storage,
logger,
});
expect(client.lastLaunchContext).toEqual({
agentId: agent.id,
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
});
await manager.reloadAgentSession(agent.id);
expect(client.lastLaunchContext).toEqual({
agentId: agent.id,
env: { ...expectedEnv, PASEO_AGENT_ID: agent.id, PASEO_AGENT_CWD: workdir },
});
expect(toAgentPayload(resumed)).not.toHaveProperty("launchEnv");
expect(toAgentPayload(resumed).persistence?.metadata).not.toHaveProperty("launchEnv");
await manager.closeAgent(agent.id);
await manager.flush();
}
},
);
test("session-open hooks override persisted launch env without persisting per-opening values", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-launch-env-hook-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const observedEnv: Array<Record<string, string> | undefined> = [];
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
pluginLifecycle: {
emit() {},
async before(name, request) {
if (name === "agent.create" && "env" in request) {
request.env = { ...request.env, FROM_CREATE_HOOK: "saved" };
}
if (name === "agent.session_open" && "env" in request) {
observedEnv.push({ ...request.env });
request.env = { ...request.env, XDG_DATA_HOME: "current-hook-data" };
}
return request;
},
},
});
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
env: { XDG_DATA_HOME: "original-data" },
});
onTestFinished(async () => {
await manager.closeAgent(agent.id);
await manager.flush();
rmSync(workdir, { recursive: true, force: true });
});
await manager.reloadAgentSession(agent.id);
expect(observedEnv).toEqual([
{ XDG_DATA_HOME: "original-data", FROM_CREATE_HOOK: "saved" },
{ XDG_DATA_HOME: "original-data", FROM_CREATE_HOOK: "saved" },
]);
expect((await storage.get(agent.id))?.launchEnv).toEqual({
XDG_DATA_HOME: "original-data",
FROM_CREATE_HOOK: "saved",
});
});
test("createAgent passes persistSession to provider create options", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
+23 -119
View File
@@ -1,5 +1,3 @@
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";
@@ -335,7 +333,6 @@ export interface AgentManagerOptions {
terminalManager?: TerminalManager | null;
mcpBaseUrl?: string;
mcpAuthToken?: string;
pluginAgentTools?: PluginAgentToolsRuntime;
paseoToolsEnabled?: boolean;
paseoToolCatalogFactory?: PaseoToolCatalogFactory;
resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined;
@@ -417,6 +414,8 @@ interface ManagedAgentBase {
owner?: AgentOwner;
capabilities: AgentCapabilityFlags;
config: AgentSessionConfig;
/** Explicit launch overrides, before per-opening hooks and daemon-owned variables. */
launchEnv?: Record<string, string>;
runtimeInfo?: AgentRuntimeInfo;
createdAt: Date;
updatedAt: Date;
@@ -769,10 +768,6 @@ 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>();
@@ -790,7 +785,6 @@ 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;
@@ -895,62 +889,6 @@ 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);
}
@@ -1376,13 +1314,7 @@ export class AgentManager {
storedConfig.cwd,
paseoToolPolicy,
options?.env,
{
reason: "create",
purpose: "interactive",
workspaceId: options.workspaceId ?? null,
title: options.initialTitle ?? storedConfig.title ?? null,
labels: options.labels ?? {},
},
{ reason: "create", purpose: "interactive", workspaceId: options.workspaceId ?? null },
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const createOptions = this.buildCreateSessionOptions(options);
@@ -1393,6 +1325,7 @@ export class AgentManager {
initialTitle: options.initialTitle,
workspaceId: options.workspaceId,
owner: options.owner,
launchEnv: options.env ? { ...options.env } : undefined,
historyPrimed: true,
});
if (!agent.internal) {
@@ -1484,7 +1417,7 @@ export class AgentManager {
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
mergedConfig,
resolvedAgentId,
{ purpose },
{ purpose, env: record?.launchEnv },
);
const client = this.requireClient(handle.provider);
const available = await client.isAvailable();
@@ -1499,13 +1432,11 @@ export class AgentManager {
client,
storedConfig.cwd,
paseoToolPolicy,
undefined,
record?.launchEnv,
{
reason: "resume",
purpose,
workspaceId: options?.workspaceId ?? null,
title: record?.title ?? mergedConfig.title ?? null,
labels: record?.labels ?? options?.labels ?? {},
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
@@ -1518,6 +1449,7 @@ export class AgentManager {
await this.requireExternalMcpSupport(session, storedConfig);
return this.registerSession(session, storedConfig, resolvedAgentId, {
...options,
launchEnv: record?.launchEnv,
persistence: handle,
restoring: true,
});
@@ -1563,12 +1495,7 @@ export class AgentManager {
storedConfig.cwd,
paseoToolPolicy,
undefined,
{
reason: "import",
purpose: "interactive",
workspaceId: input.workspaceId,
labels: input.labels ?? {},
},
{ reason: "import", purpose: "interactive", workspaceId: input.workspaceId },
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const imported = await client.importSession(
@@ -1654,6 +1581,7 @@ export class AgentManager {
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
refreshConfig,
agentId,
{ env: existing.launchEnv },
);
const hadPreviousPaseoToolPolicy = this.paseoToolPolicies.has(agentId);
const previousPaseoToolPolicy = this.paseoToolPolicies.get(agentId);
@@ -1662,14 +1590,8 @@ export class AgentManager {
client,
storedConfig.cwd,
paseoToolPolicy,
undefined,
{
reason: "refresh",
purpose: "interactive",
workspaceId: existing.workspaceId,
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
labels: existing.labels,
},
existing.launchEnv,
{ reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId },
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
if (
@@ -1713,6 +1635,7 @@ export class AgentManager {
labels: existing.labels,
workspaceId: existing.workspaceId,
owner: existing.owner,
launchEnv: existing.launchEnv,
createdAt: existing.createdAt,
updatedAt: existing.updatedAt,
lastUserMessageAt: existing.lastUserMessageAt,
@@ -1733,7 +1656,6 @@ export class AgentManager {
throw error;
} finally {
if (!handedToRegistration) {
this.detachPluginTools(agentId);
if (hadPreviousPaseoToolPolicy) {
this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy);
} else {
@@ -1941,7 +1863,6 @@ export class AgentManager {
} catch (error) {
persistError = error;
}
this.detachPluginTools(agentId);
this.emitClosedAgent(closedAgent, { persist: false });
this.logger.trace(
{
@@ -3753,6 +3674,7 @@ export class AgentManager {
publishWhenReady?: boolean;
workspaceId?: string;
owner?: AgentOwner;
launchEnv?: Record<string, string>;
},
): Promise<ManagedAgent> {
let registered = false;
@@ -3931,6 +3853,7 @@ export class AgentManager {
persistence?: AgentPersistenceHandle;
workspaceId?: string;
owner?: AgentOwner;
launchEnv?: Record<string, string>;
}
| undefined;
}): ActiveManagedAgent {
@@ -3941,6 +3864,7 @@ export class AgentManager {
cwd: config.cwd,
workspaceId: options?.workspaceId,
owner: options?.owner,
launchEnv: options?.launchEnv,
session,
capabilities: session.capabilities,
config,
@@ -4025,7 +3949,6 @@ 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 });
}
@@ -5512,15 +5435,10 @@ 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 = {
const request: PluginSessionOpenRequest = {
agentId,
provider: client.provider,
cwd,
@@ -5528,14 +5446,9 @@ 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,
@@ -5545,26 +5458,17 @@ export class AgentManager {
PASEO_AGENT_CWD: cwd,
},
};
const useNativeTools =
if (
this.paseoToolsEnabled &&
isPaseoToolPolicyEnabled(paseoToolPolicy) &&
client.capabilities.supportsNativePaseoTools;
if (
this.paseoToolCatalogFactory &&
(useNativeTools || this.getPluginTools(agentId).length > 0)
client.capabilities.supportsNativePaseoTools &&
this.paseoToolCatalogFactory
) {
try {
const catalog = await this.paseoToolCatalogFactory({
callerAgentId: agentId,
paseoToolPolicy,
});
if (useNativeTools) context.paseoTools = catalog;
} catch (error) {
this.detachPluginTools(agentId);
throw error;
}
context.paseoTools = await this.paseoToolCatalogFactory({
callerAgentId: agentId,
paseoToolPolicy,
});
}
this.sessionOpenGenerations.set(agentId, generation);
return context;
}
@@ -95,6 +95,7 @@ export function toStoredAgentRecord(
lastStatus: agent.lifecycle,
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null,
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
runtimeInfo,
features: normalizeFeatures(agent.features),
persistence,
@@ -15,7 +15,6 @@ export type AgentRunController = Pick<
| "getAgent"
| "tryRunOutOfBand"
| "hasInFlightRun"
| "reloadStalePluginSession"
| "replaceAgentRun"
| "steerOrReplaceActiveTurn"
| "streamAgent"
@@ -116,7 +115,6 @@ 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;
@@ -56,6 +56,7 @@ const STORED_AGENT_SCHEMA = z.object({
lastStatus: AgentStatusSchema.default("closed"),
lastModeId: z.string().nullable().optional(),
config: SERIALIZABLE_CONFIG_SCHEMA,
launchEnv: z.record(z.string(), z.string()).optional(),
runtimeInfo: z
.object({
provider: z.string(),
@@ -1,6 +1,4 @@
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", () => {
@@ -179,10 +177,7 @@ 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", () => {
@@ -216,151 +211,3 @@ 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,5 +1,9 @@
import { randomUUID } from "node:crypto";
import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js";
import {
TimelineProjection,
selectProjectedTimelinePage,
type ProjectedTimelineRow,
} from "./timeline-projection.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type {
AgentTimelineFetchOptions,
@@ -39,7 +43,8 @@ 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(rows);
const projection = new TimelineProjection();
for (const row of rows) projection.append(row);
this.states.set(agentId, {
epoch: options?.epoch ?? randomUUID(),
projection,
@@ -92,17 +97,18 @@ export class InMemoryAgentTimelineStore {
const state = this.requireState(agentId);
const direction = options?.direction ?? "tail";
const cursor = options?.cursor;
const hasRows = state.minSeq !== 0;
const rows = state.projection.getRows();
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 &&
hasRows &&
rows.length > 0 &&
cursor.seq < state.minSeq - 1;
const reset = staleCursor || gap;
const page = state.projection.selectPage({
const page = selectProjectedTimelinePage({
rows,
bounds: window,
direction: reset ? "tail" : direction,
cursorSeq: cursor?.seq,
@@ -144,11 +150,14 @@ export class InMemoryAgentTimelineStore {
getLastItem(agentId: string): AgentTimelineItem | null {
const state = this.requireState(agentId);
return state.projection.getLastItem(state.nextSeq - 1);
return state.projection.getRows().find((row) => row.seqEnd === state.nextSeq - 1)?.item ?? null;
}
getLastAssistantMessage(agentId: string): string | null {
return this.requireState(agentId).projection.getLastAssistantMessage();
const row = this.requireState(agentId)
.projection.getRows()
.findLast((candidate) => candidate.item.type === "assistant_message");
return row?.item.type === "assistant_message" ? row.item.text : null;
}
private requireState(agentId: string): AgentTimelineState {
@@ -69,7 +69,6 @@ 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,8 +270,6 @@ 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),
@@ -619,10 +617,6 @@ class FakeBrowserToolsBroker {
}
class BoundaryAgentManagerFake {
public getPluginTools(): [] {
return [];
}
private readonly agent = createManagedAgent({
id: "agent-1",
cwd: REPO_CWD,
@@ -99,17 +99,16 @@ afterEach(() => {
});
describe("mutable provider config owner", () => {
test("publishes provider changes after commit and emits nothing on rollback", async () => {
test("publishes provider changes after commit and emits nothing on rollback", () => {
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: CONTROLLED_PROVIDERS,
extraClients: { codex: controlledCodexClient(async () => catalog("initial")) },
providerOverrides: { codex: { enabled: true } },
});
const cwd = path.resolve("/tmp/provider-config-owner");
await manager.refreshSnapshotForCwd({ cwd });
manager.getSnapshot(cwd);
const events: string[] = [];
manager.on("change", ({ current }) => events.push(current.cwd));
let agentManagerState = manager.getAgentManagerProviderState();
@@ -126,7 +125,7 @@ describe("mutable provider config owner", () => {
});
try {
await store.patch({ providers: { codex: { enabled: false } } });
store.patch({ providers: { codex: { enabled: false } } });
expect(events).toEqual([cwd]);
expect(agentManagerState.providerDefinitions.codex).toMatchObject({ enabled: false });
@@ -134,7 +133,7 @@ describe("mutable provider config owner", () => {
const unsubscribeFailure = store.onApply(() => {
throw new Error("later owner failed");
});
await expect(store.patch({ providers: { codex: { enabled: true } } })).rejects.toThrow(
expect(() => store.patch({ providers: { codex: { enabled: true } } })).toThrow(
"later owner failed",
);
unsubscribeFailure();
@@ -151,7 +150,7 @@ describe("mutable provider config owner", () => {
}
});
test("does no provider work for unrelated CORS, Git, and app URL reloads", async () => {
test("does no provider work for unrelated CORS, Git, and app URL reloads", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-provider-config-owner-"));
tempDirs.push(paseoHome);
const initial: PersistedConfig = {
@@ -175,10 +174,9 @@ describe("mutable provider config owner", () => {
});
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: CONTROLLED_PROVIDERS,
extraClients: { codex: controlledCodexClient(async () => catalog("initial")) },
providerOverrides: { codex: { enabled: true } },
});
await manager.refreshSnapshotForCwd({ cwd: "/tmp/provider-config-owner" });
manager.getSnapshot("/tmp/provider-config-owner");
const events: string[] = [];
manager.on("change", ({ current }) => events.push(current.cwd));
let registryUpdates = 0;
@@ -202,7 +200,7 @@ describe("mutable provider config owner", () => {
};
writeFileSync(configPath, `${JSON.stringify(reloaded, null, 2)}\n`, "utf-8");
expect((await store.reload()).appliedPaths).toEqual([
expect(store.reload().appliedPaths).toEqual([
"app.baseUrl",
"daemon.cors.allowedOrigins",
"daemon.git.maxProcessConcurrency",
@@ -247,7 +245,7 @@ describe("mutable provider config owner", () => {
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(1));
expect(events).toEqual([]);
await store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } });
store.patch({ providers: { codex: { enabled: true, label: "Reloaded Codex" } } });
expect(events).toEqual(["loading:none"]);
await vi.waitFor(() => expect(catalogResolvers).toHaveLength(2));
@@ -313,9 +311,9 @@ describe("mutable provider config owner", () => {
expect(events).toEqual([]);
const before = manager.getSnapshot(cwd);
await expect(
expect(() =>
store.patch({ providers: { codex: { enabled: true, label: "Rejected Codex" } } }),
).rejects.toThrow("later owner failed");
).toThrow("later owner failed");
expect(events).toEqual([]);
expect(observedDuringApply).toEqual([{ label: "Codex", snapshot: before }]);
expect(observedDuringApply[0]!.snapshot).toBe(before);
@@ -40,8 +40,6 @@ 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: tmpdir(), providerOptions: own },
{ provider: "plugin-direct", cwd: "/tmp", providerOptions: own },
undefined,
{ workspaceId: undefined },
);
@@ -1,11 +1,10 @@
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 { claudeQuery, type ClaudeQueryInput } from "./query.js";
import type { ClaudeQueryInput } from "./query.js";
function createQueryMock(events: unknown[]): Query {
let index = 0;
@@ -30,48 +29,6 @@ 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,13 +1,9 @@
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";
@@ -36,10 +32,7 @@ describe("ClaudeAgentSession persisted history load", () => {
let configDir: string;
let records: LogRecord[];
async function resume(
queryFactory?: ClaudeQueryFactory,
rewindSdk?: ClaudeRewindSdk,
): Promise<AgentSession> {
async function resume(queryFactory?: ClaudeQueryFactory): Promise<AgentSession> {
const client = new ClaudeAgentClient({
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
queryFactory:
@@ -49,36 +42,10 @@ 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`);
}
@@ -98,288 +65,6 @@ 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) => {
@@ -615,21 +300,21 @@ describe("ClaudeAgentSession persisted history load", () => {
);
});
test("loads readable history when the temporary directory is unavailable", async () => {
test("loads readable history when the temporary directory is unavailable", () => {
const file = transcriptPath();
const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } };
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify(entry));
const unavailable = path.join(tempRoot, "unavailable-tmp");
for (const variable of ["TMPDIR", "TEMP", "TMP"]) vi.stubEnv(variable, unavailable);
await withClaudeReplayHistory(file, async (history) => {
expect(await Array.fromAsync(history.parentEntries)).toEqual([entry]);
withClaudeReplayHistory(file, (history) => {
expect([...history.parentEntries]).toEqual([entry]);
});
});
test.each(["parent", "child", "workflow"])(
"preserves indexed %s history when its transcript disappears between passes",
async (target) => {
(target) => {
const parent = transcriptPath();
const sessionDirectory = parent.slice(0, -".jsonl".length);
const child = path.join(sessionDirectory, "subagents", "agent-child.jsonl");
@@ -649,16 +334,14 @@ describe("ClaudeAgentSession persisted history load", () => {
writeFileSync(parent, JSON.stringify(parentEntry));
writeFileSync(child, JSON.stringify(childEntry));
writeFileSync(workflow, JSON.stringify(childEntry));
await withClaudeReplayHistory(parent, async (history) => {
withClaudeReplayHistory(parent, (history) => {
rmSync({ parent, child, workflow }[target]);
// Ownership facts and visible payloads are separate passes over the same source.
for (let pass = 0; pass < 2; pass++) {
expect(await Array.fromAsync(history.parentEntries)).toEqual([parentEntry]);
expect([...history.parentEntries]).toEqual([parentEntry]);
expect(history.subagents).toHaveLength(1);
expect(await Array.fromAsync(history.subagents[0]!.entries)).toEqual([childEntry]);
expect(await Array.fromAsync(history.workflowEntriesByRunId.get("wf_test")!)).toEqual([
childEntry,
]);
expect([...history.subagents[0]!.entries]).toEqual([childEntry]);
expect([...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): Promise<AgentStreamEvent[]>;
convertHistoryEntry(entry: unknown): Promise<AgentTimelineItem[]>;
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
convertHistoryEntry(entry: unknown): 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 = await session.translateMessageToEvents(imageToolResultUserMessage());
const events = 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 = await session.convertHistoryEntry(imageToolResultHistoryEntry());
const items = 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 = await session.translateMessageToEvents(erroredImageToolResultUserMessage());
const events = 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 = await session.translateMessageToEvents(multiImageToolResultUserMessage());
const events = 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): Promise<AgentStreamEvent[]>;
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
close(): Promise<void>;
}
@@ -53,7 +53,7 @@ afterEach(() => {
});
describe("convertClaudeHistoryEntry", () => {
test("maps user tool results to timeline items", async () => {
test("maps user tool results to timeline items", () => {
const toolUseId = "toolu_test";
const entry = {
type: "user",
@@ -79,14 +79,14 @@ describe("convertClaudeHistoryEntry", () => {
];
const mapBlocks = vi.fn().mockReturnValue(stubTimeline);
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
const result = 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", async () => {
test("replays persisted Claude tool results as completed tool calls", () => {
const entry = {
type: "user",
message: {
@@ -124,12 +124,12 @@ describe("convertClaudeHistoryEntry", () => {
const mapPersistedToolResultBlocks = (): AgentTimelineItem[] => completedToolCall;
expect(await convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual(
expect(convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual(
completedToolCall,
);
});
test("returns user messages when no tool blocks exist", async () => {
test("returns user messages when no tool blocks exist", () => {
const entry = {
type: "user",
message: {
@@ -138,7 +138,7 @@ describe("convertClaudeHistoryEntry", () => {
},
};
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
{
type: "user_message",
text: "Run npm test",
@@ -146,7 +146,7 @@ describe("convertClaudeHistoryEntry", () => {
]);
});
test("converts compact boundary metadata variants", async () => {
test("converts compact boundary metadata variants", () => {
const fixtures = [
{
entry: {
@@ -175,7 +175,7 @@ describe("convertClaudeHistoryEntry", () => {
] as const;
for (const fixture of fixtures) {
expect(await convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([
expect(convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([
{
type: "compaction",
status: "completed",
@@ -186,7 +186,7 @@ describe("convertClaudeHistoryEntry", () => {
}
});
test("skips synthetic user entries", async () => {
test("skips synthetic user entries", () => {
const entry = {
type: "user",
isSynthetic: true,
@@ -197,13 +197,13 @@ describe("convertClaudeHistoryEntry", () => {
};
const mapBlocks = vi.fn().mockReturnValue([]);
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual([]);
expect(mapBlocks).not.toHaveBeenCalled();
});
test("skips meta user entries from Claude skill loading", async () => {
test("skips meta user entries from Claude skill loading", () => {
const entry = {
type: "user",
isMeta: true,
@@ -219,13 +219,13 @@ describe("convertClaudeHistoryEntry", () => {
};
const mapBlocks = vi.fn().mockReturnValue([]);
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual([]);
expect(mapBlocks).not.toHaveBeenCalled();
});
test("skips interrupt placeholder transcript noise", async () => {
test("skips interrupt placeholder transcript noise", () => {
const interruptEntry = {
type: "user",
message: {
@@ -246,11 +246,11 @@ describe("convertClaudeHistoryEntry", () => {
.fn()
.mockReturnValue([{ type: "assistant_message", text: "No response requested." }]);
expect(await convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
});
test("skips <local-command-stdout> messages (model switch, /context, etc.)", async () => {
test("skips <local-command-stdout> messages (model switch, /context, etc.)", () => {
// Real entries from Claude Code JSONL history files
const modelSwitch = {
type: "user",
@@ -305,24 +305,24 @@ describe("convertClaudeHistoryEntry", () => {
const mapBlocks = vi.fn().mockReturnValue([]);
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([]);
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([]);
// Real user messages must NOT be filtered
const realMessage = {
type: "user",
message: { role: "user", content: "fix the bug in auth.ts" },
};
expect(await convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
expect(convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
{ type: "user_message", text: "fix the bug in auth.ts" },
]);
});
test("maps task notifications to synthetic tool calls", async () => {
test("maps task notifications to synthetic tool calls", () => {
const entry = {
type: "system",
subtype: "task_notification",
@@ -333,7 +333,7 @@ describe("convertClaudeHistoryEntry", () => {
output_file: "/tmp/bg-fail-1.txt",
};
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
expect(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", async () => {
test("maps queue-operation task notifications to synthetic tool calls", () => {
const entry = {
type: "queue-operation",
operation: "enqueue",
@@ -372,7 +372,7 @@ describe("convertClaudeHistoryEntry", () => {
].join("\n"),
};
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
expect(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", async () => {
test("passes assistant content blocks through to the mapper", () => {
const entry = {
type: "assistant",
message: {
@@ -414,7 +414,7 @@ describe("convertClaudeHistoryEntry", () => {
];
const mapBlocks = vi.fn().mockReturnValue(mappedTimeline);
expect(await convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline);
expect(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): Promise<AgentStreamEvent[]>;
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
};
try {
@@ -1248,7 +1248,7 @@ describe("ClaudeAgentSession features", () => {
),
).resolves.toMatchObject({ behavior: "deny", interrupt: undefined });
await internal.translateMessageToEvents({
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 {
await session.translateMessageToEvents({
session.translateMessageToEvents({
type: "assistant",
message: {
content: [
@@ -2409,7 +2409,7 @@ describe("ClaudeAgentSession context window usage", () => {
},
} as unknown as SDKMessage);
const events = await session.translateMessageToEvents({
const events = 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 = await (
session as unknown as TestClaudeSession
).translateMessageToEvents(createCompactBoundary());
const compactEvents = (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 = await session.translateMessageToEvents({
const events = 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 = await session.translateMessageToEvents({
const events = session.translateMessageToEvents({
type: "result",
subtype: "success",
result: "Here is the answer.",
@@ -1,4 +1,3 @@
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";
@@ -83,7 +82,7 @@ import {
type ClaudeRewindSdk,
} from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPath } from "./project-dir.js";
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js";
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
import {
isProviderImageMarkdown,
@@ -1635,7 +1634,7 @@ export class ClaudeAgentClient implements AgentClient {
cwd: merged.cwd,
};
const claudeConfig = this.assertConfig(mergedConfig);
const session = new ClaudeAgentSession(claudeConfig, {
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
handle,
@@ -1646,8 +1645,6 @@ export class ClaudeAgentClient implements AgentClient {
resolveBinary: this.resolveBinary,
rewindSdk: this.rewindSdk,
});
await session.ensureHistoryReady();
return session;
}
async getCatalogCacheKey(_options: FetchCatalogOptions): Promise<string> {
@@ -2332,9 +2329,6 @@ 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;
@@ -2388,7 +2382,7 @@ class ClaudeAgentSession implements AgentSession {
}
this.claudeSessionId = handle.sessionId;
this.persistence = handle;
this.historySessionId = handle.sessionId;
this.loadPersistedHistory(handle.sessionId);
} else {
this.claudeSessionId = null;
this.persistence = null;
@@ -2468,7 +2462,6 @@ class ClaudeAgentSession implements AgentSession {
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<{ turnId: string }> {
await this.ensureHistoryReady();
if (this.closed) {
throw new Error("Claude session is closed");
}
@@ -2546,11 +2539,7 @@ class ClaudeAgentSession implements AgentSession {
this.input.push(sdkMessage);
setTimeout(() => {
if (this.activeForegroundTurnId === turnId) {
void this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId).catch(
(error: unknown) => {
this.logger.warn({ err: error }, "Failed to emit submitted Claude message");
},
);
this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId);
}
}, 0);
} catch (error) {
@@ -2643,7 +2632,6 @@ class ClaudeAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
await this.ensureHistoryReady();
if (
!this.historyPending ||
(this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0)
@@ -2655,9 +2643,7 @@ 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,
@@ -2975,7 +2961,6 @@ 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();
@@ -3016,7 +3001,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 = await this.resolveHistoryPath(this.claudeSessionId);
const historyPath = this.resolveHistoryPath(this.claudeSessionId);
if (historyPath) {
try {
await promises.rm(historyPath, { force: true });
@@ -3060,7 +3045,6 @@ 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();
@@ -3070,15 +3054,16 @@ class ClaudeAgentSession implements AgentSession {
await revertClaudeConversation({
sdk: this.rewindSdk,
sessionId: currentSessionId,
transcriptPath: currentSessionId ? await this.resolveHistoryPath(currentSessionId) : null,
transcriptPath: currentSessionId ? 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(),
@@ -3253,52 +3238,38 @@ class ClaudeAgentSession implements AgentSession {
return candidates;
}
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 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 startFreshConversationSession(): void {
this.historySessionId = null;
this.historyReady = Promise.resolve();
const sessionId = randomUUID();
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = sessionId;
@@ -3419,43 +3390,7 @@ 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)) {
@@ -3463,7 +3398,37 @@ class ClaudeAgentSession implements AgentSession {
}
if (this.queryRestartNeeded && this.query) {
await this.retireQuery();
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 */
});
}
}
// Preserve claudeSessionId across query recreation so buildOptions() passes
@@ -4130,14 +4095,12 @@ 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 {
@@ -4258,10 +4221,6 @@ 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;
@@ -4336,17 +4295,10 @@ class ClaudeAgentSession implements AgentSession {
messageIdHint: string | null,
turnId: string | null,
): Promise<AgentStreamEvent[]> {
const query = this.query;
const messageEvents = await this.translateMessageToEvents(message, {
const messageEvents = 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
@@ -4491,13 +4443,13 @@ class ClaudeAgentSession implements AgentSession {
return withdrewAll;
}
private async translateMessageToEvents(
private translateMessageToEvents(
message: SDKMessage,
options?: {
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): Promise<AgentStreamEvent[]> {
): AgentStreamEvent[] {
const parentToolUseId = readClaudeParentToolUseId(message);
if (parentToolUseId) {
return this.translateSidechainFrameToEvents(message, parentToolUseId);
@@ -4545,11 +4497,11 @@ class ClaudeAgentSession implements AgentSession {
this.appendSystemMessageEvents(message, events);
break;
case "user":
await this.appendUserMessageEvents(message, events);
this.appendUserMessageEvents(message, events);
this.appendSidechainResultEvents(message, events);
break;
case "assistant": {
const timelineItems = await this.mapBlocksToTimeline(message.message.content, {
const timelineItems = this.mapBlocksToTimeline(message.message.content, {
suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false,
});
@@ -4560,7 +4512,7 @@ class ClaudeAgentSession implements AgentSession {
break;
}
case "stream_event":
await this.appendStreamEventEvents(message, events, options);
this.appendStreamEventEvents(message, events, options);
break;
case "result":
this.appendResultEvents(message, events);
@@ -4662,14 +4614,13 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async emitSubmittedUserMessage(
private emitSubmittedUserMessage(
message: Extract<SDKMessage, { type: "user" }>,
turnId: string,
clientMessageId?: string,
): Promise<void> {
): void {
const events: AgentStreamEvent[] = [];
await this.appendUserMessageEvents(message, events);
if (this.activeForegroundTurnId !== turnId) return;
this.appendUserMessageEvents(message, events);
if (events.length === 0) {
return;
}
@@ -4799,10 +4750,10 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async appendUserMessageEvents(
private appendUserMessageEvents(
message: Extract<SDKMessage, { type: "user" }>,
events: AgentStreamEvent[],
): Promise<void> {
): void {
if (isSyntheticUserEntry(message)) {
return;
}
@@ -4841,7 +4792,7 @@ class ClaudeAgentSession implements AgentSession {
return;
}
if (Array.isArray(content)) {
await this.appendUserContentArrayEvents(content, messageId, events);
this.appendUserContentArrayEvents(content, messageId, events);
}
}
@@ -4867,12 +4818,12 @@ class ClaudeAgentSession implements AgentSession {
events.push({ type: "timeline", item, provider: "claude" });
}
private async appendUserContentArrayEvents(
private appendUserContentArrayEvents(
content: ReadonlyArray<unknown>,
messageId: string | undefined,
events: AgentStreamEvent[],
): Promise<void> {
const timelineItems = await this.mapBlocksToTimeline(content, {
): void {
const timelineItems = this.mapBlocksToTimeline(content, {
textMessageType: "user_message",
});
for (const item of timelineItems) {
@@ -4888,16 +4839,16 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async appendStreamEventEvents(
private appendStreamEventEvents(
message: Extract<SDKMessage, { type: "stream_event" }>,
events: AgentStreamEvent[],
options: { suppressAssistantText?: boolean; suppressReasoning?: boolean } | undefined,
): Promise<void> {
): void {
const usageUpdatedEvent = this.contextUsage.buildStreamUsageEvent(message.event);
if (usageUpdatedEvent) {
events.push(usageUpdatedEvent);
}
const timelineItems = await this.mapPartialEvent(message.event, {
const timelineItems = this.mapPartialEvent(message.event, {
suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false,
});
@@ -5321,31 +5272,22 @@ class ClaudeAgentSession implements AgentSession {
}
}
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> {
private loadPersistedHistory(sessionId: string): void {
let historyPath: string | null = null;
this.replayingHistory = true;
try {
this.taskState.reset();
historyPath = await this.resolveHistoryPath(sessionId);
if (!historyPath || !(await transcriptExists(historyPath))) {
historyPath = this.resolveHistoryPath(sessionId);
if (!historyPath || !fs.existsSync(historyPath)) {
this.logger.info(
{ sessionId, cwd: this.config.cwd, historyPath },
"No Claude transcript to load history from",
);
return;
}
await withClaudeReplayHistory(historyPath, async (history) => {
const replay = await this.ingestPersistedSidechains(history);
await this.ingestPersistedHistory(history.parentEntries, replay);
withClaudeReplayHistory(historyPath, (history) => {
const replay = this.ingestPersistedSidechains(history);
this.ingestPersistedHistory(history.parentEntries, replay);
});
} catch (error) {
this.logger.warn(
@@ -5357,13 +5299,13 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async ingestPersistedHistory(
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
private ingestPersistedHistory(
entries: Iterable<ClaudeHistoryEntry>,
replay: ClaudeReplayOwnership,
): Promise<void> {
): void {
const timeline: PersistedTimelineEntry[] = [];
for await (const entry of entries) {
await this.ingestPersistedHistoryEntry(entry, timeline, replay);
for (const entry of entries) {
this.ingestPersistedHistoryEntry(entry, timeline, replay);
}
if (timeline.length > 0) {
@@ -5372,33 +5314,27 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async ingestPersistedSidechains(
history: ClaudeReplayHistory,
): Promise<ClaudeReplayOwnership> {
const parentFacts = await readClaudeReplayParentFacts(history.parentEntries);
private ingestPersistedSidechains(history: ClaudeReplayHistory): ClaudeReplayOwnership {
const parentFacts = 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 subagents = [];
for (const subagent of history.subagents) {
subagents.push({
const subagentReplay = observeReplaySubagents({
subagents: history.subagents.map((subagent) => ({
...subagent,
parentFacts: await readClaudeReplayParentFacts(subagent.entries),
});
}
const subagentReplay = await observeReplaySubagents({
subagents,
parentFacts: readClaudeReplayParentFacts(subagent.entries),
})),
parent: parentFacts,
convertEntry: (entry) => this.convertHistoryEntry(entry),
});
const observations = [
...subagentReplay.observations,
...(await observeReplayWorkflows({
...observeReplayWorkflows({
workflows: history.workflows,
parentEntries: history.parentEntries,
entriesByRunId: history.workflowEntriesByRunId,
convertEntry: (entry) => this.convertHistoryEntry(entry),
})),
}),
];
const restoredProviderSubagentIds = new Set(
observations
@@ -5423,11 +5359,11 @@ class ClaudeAgentSession implements AgentSession {
return replay;
}
private async ingestPersistedHistoryEntry(
private ingestPersistedHistoryEntry(
entry: ClaudeHistoryEntry,
timeline: PersistedTimelineEntry[],
replay: ClaudeReplayOwnership,
): Promise<void> {
): void {
if (entry.isSidechain) {
return;
}
@@ -5441,7 +5377,7 @@ class ClaudeAgentSession implements AgentSession {
? replay.toolOwners.get(notificationToolUseId)
: undefined;
if (notificationOwner) {
for (const item of await this.convertHistoryEntry(entry)) {
for (const item of this.convertHistoryEntry(entry)) {
this.persistedProviderSubagentEvents.push({
type: "provider_subagent",
provider: "claude",
@@ -5458,7 +5394,7 @@ class ClaudeAgentSession implements AgentSession {
const taskSnapshot = this.taskState.observe(entry);
const items = [
...(taskSnapshot ? [taskSnapshot] : []),
...(await this.convertHistoryEntry(entry)).map((item) =>
...this.convertHistoryEntry(entry).map((item) =>
labelReplayedSubagentCard(item, replay.subagentToolCalls),
),
];
@@ -5485,20 +5421,20 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async resolveHistoryPath(sessionId: string): Promise<string | null> {
private resolveHistoryPath(sessionId: string): string | null {
const cwd = this.config.cwd;
if (!cwd) return null;
return claudeTranscriptPath({
return claudeTranscriptPathSync({
cwd,
sessionId,
configDir: claudeConfigDir(this.harnessEnvironment),
});
}
private async convertHistoryEntry(entry: ClaudeHistoryEntry): Promise<AgentTimelineItem[]> {
return (
await convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content))
).map(limitAgentTimelineItemContent);
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)).map(
limitAgentTimelineItemContent,
);
}
// Maps Claude content blocks into AgentTimelineItems.
@@ -5513,14 +5449,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 async mapBlocksToTimeline(
private mapBlocksToTimeline(
content: string | ReadonlyArray<unknown>,
options?: {
textMessageType?: "assistant_message" | "user_message";
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): Promise<AgentTimelineItem[]> {
): AgentTimelineItem[] {
const textMessageType = options?.textMessageType ?? "assistant_message";
const suppressText =
textMessageType === "assistant_message" && (options?.suppressAssistantText ?? false);
@@ -5547,7 +5483,7 @@ class ClaudeAgentSession implements AgentSession {
if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) {
continue;
}
await this.mapBlockToTimeline(block, {
this.mapBlockToTimeline(block, {
items,
userTextParts,
textMessageType,
@@ -5592,7 +5528,7 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async mapBlockToTimeline(
private mapBlockToTimeline(
block: ClaudeContentChunk,
context: {
items: AgentTimelineItem[];
@@ -5601,7 +5537,7 @@ class ClaudeAgentSession implements AgentSession {
suppressText: boolean;
suppressReasoning: boolean;
},
): Promise<void> {
): void {
switch (block.type) {
case "text":
case "text_delta":
@@ -5625,7 +5561,7 @@ class ClaudeAgentSession implements AgentSession {
case "code_execution_tool_result":
case "bash_code_execution_tool_result":
case "text_editor_code_execution_tool_result":
await this.handleToolResult(block, context.items);
this.handleToolResult(block, context.items);
break;
default:
break;
@@ -5671,10 +5607,7 @@ class ClaudeAgentSession implements AgentSession {
);
}
private async handleToolResult(
block: ClaudeContentChunk,
items: AgentTimelineItem[],
): Promise<void> {
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): 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;
@@ -5707,7 +5640,7 @@ class ClaudeAgentSession implements AgentSession {
this.rememberSettledSubagentToolCall(settledToolCall, entry);
for (const image of images) {
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
});
if (imageItem) {
@@ -5931,13 +5864,13 @@ class ClaudeAgentSession implements AgentSession {
return false;
}
private async mapPartialEvent(
private mapPartialEvent(
event: SDKPartialAssistantMessage["event"],
options?: {
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): Promise<AgentTimelineItem[]> {
): AgentTimelineItem[] {
if (this.updatePartialEventToolState(event)) {
return [];
}
@@ -5945,14 +5878,14 @@ class ClaudeAgentSession implements AgentSession {
switch (event.type) {
case "content_block_start":
return isClaudeContentChunk(event.content_block)
? await this.mapBlocksToTimeline([event.content_block], {
? this.mapBlocksToTimeline([event.content_block], {
suppressAssistantText: options?.suppressAssistantText,
suppressReasoning: options?.suppressReasoning,
})
: [];
case "content_block_delta":
return isClaudeContentChunk(event.delta)
? await this.mapBlocksToTimeline([event.delta], {
? this.mapBlocksToTimeline([event.delta], {
suppressAssistantText: options?.suppressAssistantText,
suppressReasoning: options?.suppressReasoning,
})
@@ -6258,13 +6191,13 @@ function labelReplayedSubagentCard(
* source consumes. The agentId scrape is kept only as a fallback for sessions recorded before
* Claude Code wrote the meta sidecar.
*/
async function readClaudeReplayParentFacts(
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
): Promise<ClaudeReplayParentFacts> {
function readClaudeReplayParentFacts(
entries: Iterable<ClaudeHistoryEntry>,
): 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 await (const entry of entries) {
for (const entry of entries) {
const content = toObjectRecord(entry.message)?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -6315,14 +6248,12 @@ interface ClaudeHistoryEntry {
[key: string]: unknown;
}
async function mapAssistantHistoryBlocksWithMessageId(
function mapAssistantHistoryBlocksWithMessageId(
entry: ClaudeHistoryEntry,
content: string | ClaudeContentChunk[],
mapBlocks: (
content: string | ClaudeContentChunk[],
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
): Promise<AgentTimelineItem[]> {
const items = await mapBlocks(content);
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
): AgentTimelineItem[] {
const items = mapBlocks(content);
const assistantMessageId =
typeof entry.uuid === "string" && entry.uuid.length > 0 ? entry.uuid : null;
if (!assistantMessageId) {
@@ -6390,12 +6321,10 @@ function isProviderImageMessage(item: AgentTimelineItem): boolean {
return item.type === "assistant_message" && isProviderImageMarkdown(item.text);
}
export async function convertClaudeHistoryEntry(
export function convertClaudeHistoryEntry(
entry: ClaudeHistoryEntry,
mapBlocks: (
content: string | ClaudeContentChunk[],
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
): Promise<AgentTimelineItem[]> {
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
): AgentTimelineItem[] {
const preamble = convertClaudeHistoryEntryPreamble(entry);
if ("shortCircuit" in preamble) {
return preamble.shortCircuit;
@@ -6434,7 +6363,7 @@ export async function convertClaudeHistoryEntry(
}
if (hasToolBlock && normalizedBlocks) {
const mapped = await mapBlocks(normalizedBlocks);
const mapped = 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
@@ -6448,7 +6377,7 @@ export async function convertClaudeHistoryEntry(
}
if (entry.type === "assistant" && contentValue) {
return await mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
return mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
}
return timeline;
@@ -6800,7 +6729,7 @@ async function readClaudeRenamedSessionTitle(input: {
}): Promise<string | null> {
let content: string;
try {
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
content = await fsPromises.readFile(claudeTranscriptPathSync(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.
@@ -6900,13 +6829,3 @@ 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 { realpathSync } from "node:fs";
import { realpath, access, readdir } from "node:fs/promises";
import { existsSync, readdirSync, realpathSync } from "node:fs";
import { realpath } 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 async function claudeTranscriptPath(input: {
export function claudeTranscriptPathSync(input: {
cwd: string;
sessionId: string;
configDir?: string;
}): Promise<string> {
}): string {
const options = { configDir: input.configDir };
const fileName = `${input.sessionId}.jsonl`;
const expected = join(await claudeProjectDir(input.cwd, options), fileName);
if (await exists(expected)) {
const expected = join(claudeProjectDirSync(input.cwd, options), fileName);
if (existsSync(expected)) {
return expected;
}
const projectsRoot = join(resolveConfigDir(options), "projects");
let projectDirs: string[];
try {
projectDirs = await readdir(projectsRoot);
projectDirs = readdirSync(projectsRoot);
} catch (error) {
if (isMissingDirectoryError(error)) {
return expected;
@@ -58,23 +58,13 @@ export async function claudeTranscriptPath(input: {
}
for (const projectDir of projectDirs) {
const candidate = join(projectsRoot, projectDir, fileName);
if (await exists(candidate)) {
if (existsSync(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,10 +2,11 @@ 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, createExternalProcessEnv } from "../../../paseo-env.js";
import { buildSelfNodeCommand } from "../../../paseo-env.js";
import { spawnProcess } from "../../../../utils/spawn.js";
// Keep the raw SDK query import in this module only. Claude process launch behavior
@@ -73,9 +74,11 @@ function applyRuntimeSettingsToClaudeOptions(
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 providerEnv = createProviderEnv({
baseEnv: spawnOptions.env,
runtimeSettings,
overlays: [launchEnv],
});
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 | Promise<void>;
setSessionId: (sessionId: string) => 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,
});
await input.setSessionId(fork.sessionId);
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 | Promise<void>;
setSessionId: (sessionId: string) => void;
}): Promise<void> {
await revertClaudeFiles({
query: input.query,
@@ -1,9 +1,3 @@
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";
@@ -51,7 +45,7 @@ function applyToStore(observations: SubagentObservation[]): ProviderSubagentDesc
}
describe("parseClaudeSubagentMeta", () => {
it("reads the real sidecar shape", async () => {
it("reads the real sidecar shape", () => {
expect(
parseClaudeSubagentMeta(
'{"agentType":"general-purpose","description":"Reply banana","toolUseId":"toolu_013i","spawnDepth":1}',
@@ -74,7 +68,7 @@ describe("parseClaudeSubagentMeta", () => {
expect(parseClaudeSubagentMeta(contents)).toBeNull();
});
it("keeps the good fields when one field has the wrong type", async () => {
it("keeps the good fields when one field has the wrong type", () => {
expect(parseClaudeSubagentMeta('{"agentType":"Explore","toolUseId":42}')).toEqual({
agentType: "Explore",
description: undefined,
@@ -85,14 +79,12 @@ describe("parseClaudeSubagentMeta", () => {
});
describe("observeReplaySubagents", () => {
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;
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;
expect(observations[0]).toMatchObject({
kind: "declared",
@@ -103,33 +95,31 @@ describe("observeReplaySubagents", () => {
});
});
it("replays descendants beneath the sidechain that declared their Task", async () => {
it("replays descendants beneath the sidechain that declared their Task", () => {
const nestedToolUseId = "toolu_012rzYnFZA";
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 }]]),
},
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 }]]),
},
{
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({
@@ -140,143 +130,89 @@ describe("observeReplaySubagents", () => {
);
});
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;
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;
expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID });
});
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;
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;
expect(observations).toEqual([]);
});
it("falls back to the scraped link when there is no meta file", async () => {
it("falls back to the scraped link when there is no meta file", () => {
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] = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent,
convertEntry: () => [],
})
).observations;
const [declared, ...rest] = 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", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: emptyParent(),
convertEntry: () => [],
})
).observations;
it("drops an unlinkable subagent", () => {
const observations = 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", async () => {
it("drops a terminal subagent the parent has no Task call for", () => {
// 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(
(
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,
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", async () => {
it("drops a terminal skill-spawned subagent that has no link at all", () => {
// 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(
(
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({
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
@@ -285,81 +221,111 @@ describe("observeReplaySubagents", () => {
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: null },
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations;
}).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;
expect(observations).toEqual([]);
});
it("finishes a terminal Task child when the parent has not recorded its outcome", async () => {
it("finishes a terminal Task child when the parent has not recorded its outcome", () => {
const descriptor = applyToStore(
(
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,
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", async () => {
const observations = (
await 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", () => {
const observations = 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", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(true),
convertEntry: () => [],
})
).observations;
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;
expect(observations.at(-1)).toMatchObject({ kind: "status", status: "failed" });
});
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;
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;
expect(observations.find((observation) => observation.kind === "timeline")).toMatchObject({
kind: "timeline",
@@ -370,20 +336,18 @@ describe("observeReplaySubagents", () => {
});
describe("replay runtime", () => {
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;
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;
expect(observations.find((o) => o.kind === "subtitle")).toEqual({
kind: "subtitle",
@@ -392,55 +356,49 @@ describe("replay runtime", () => {
});
});
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;
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;
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", async () => {
const observations = (
await 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", () => {
const observations = 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", async () => {
it("passes through a model the manifest does not know", () => {
// Claude Code is an Anthropic-compatible client; subagents legitimately report other models.
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;
const observations = 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",
});
@@ -448,24 +406,20 @@ describe("replay runtime", () => {
});
describe("replay usage", () => {
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");
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");
}
it("derives the counters Claude Code itself reports, from a real transcript's shape", async () => {
it("derives the counters Claude Code itself reports, from a real transcript's shape", () => {
// 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(
await subtitleOf([
subtitleOf([
{
type: "assistant",
timestamp: "2026-07-23T23:18:43.023Z",
@@ -508,9 +462,9 @@ describe("replay usage", () => {
});
});
it("formats tokens for a child that only answered", async () => {
it("formats tokens for a child that only answered", () => {
expect(
await subtitleOf([
subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -525,12 +479,12 @@ describe("replay usage", () => {
).toMatchObject({ subtitle: "general-purpose · 16.4k tokens" });
});
it("emits nothing for a transcript with no entries", async () => {
expect(await subtitleOf([])).toBeUndefined();
it("emits nothing for a transcript with no entries", () => {
expect(subtitleOf([])).toBeUndefined();
});
it("keeps duration out of the compact subtitle", async () => {
const observation = await subtitleOf([
it("keeps duration out of the compact subtitle", () => {
const observation = subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -540,8 +494,8 @@ describe("replay usage", () => {
expect(observation).toMatchObject({ subtitle: "general-purpose · 10 tokens" });
});
it("omits totalTokens when no entry carries a usage block", async () => {
const observation = await subtitleOf([
it("omits totalTokens when no entry carries a usage block", () => {
const observation = subtitleOf([
{ type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", message: { content: [] } },
{
type: "assistant",
@@ -552,9 +506,9 @@ describe("replay usage", () => {
expect(observation).toBeUndefined();
});
it("keeps the last usage-bearing entry when the final assistant entry has none", async () => {
it("keeps the last usage-bearing entry when the final assistant entry has none", () => {
expect(
await subtitleOf([
subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -565,36 +519,34 @@ describe("replay usage", () => {
).toMatchObject({ subtitle: "general-purpose · 1k tokens" });
});
it("only reports counters the transcript actually shows", async () => {
it("only reports counters the transcript actually shows", () => {
// A user-only transcript says nothing about cost: no assistant turn, so no tool count either.
const observation = await subtitleOf([
const observation = 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", async () => {
it("lands the complete provider-owned subtitle on the descriptor", () => {
const store = new ProviderSubagentStore();
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;
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;
for (const event of foldSubagentObservations(observations)) {
store.apply("parent", "claude", event);
}
@@ -607,7 +559,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", async () => {
it("produces identical descriptor state from the wire and from disk", () => {
const live = new ClaudeTaskProtocolSource();
const liveObservations = [
...live.observe({
@@ -627,23 +579,21 @@ describe("live and replay agree", () => {
} as unknown as SDKMessage),
];
const replayObservations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: {
toolUseId: TOOL_USE_ID,
agentType: "general-purpose",
description: "Summarize hover and unistyles docs",
},
entries: [],
const replayObservations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: {
toolUseId: TOOL_USE_ID,
agentType: "general-purpose",
description: "Summarize hover and unistyles docs",
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
const fromWire = applyToStore(liveObservations);
const fromDisk = applyToStore(replayObservations);
@@ -666,34 +616,3 @@ 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> | AsyncIterable<ClaudeReplayEntry>;
entries: Iterable<ClaudeReplayEntry>;
/** Task declarations made by this subagent, used to attach its direct children. */
parentFacts?: ClaudeReplayParentFacts;
}
@@ -250,13 +250,13 @@ function observeSubtitle(
};
}
async function observeSubagent(
function observeSubagent(
subagent: ClaudeReplaySubagentInput,
parent: ClaudeReplayParentFacts,
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[],
toolOwners: Map<string, string>,
parentSubagentId?: string,
): Promise<SubagentObservation[]> {
): SubagentObservation[] {
const link = resolveParentLink(subagent, parent);
if (!link) return [];
const toolCall = parent.toolCalls.get(link.toolCallId);
@@ -269,7 +269,7 @@ async function observeSubagent(
let childTerminalStatus: ProviderSubagentStatus | null = null;
// Consume each source once for presentation and timeline; keep no raw transcript records.
for await (const entry of subagent.entries) {
for (const entry of subagent.entries) {
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
if (!seenEntry) firstTimestamp = timestamp;
seenEntry = true;
@@ -280,7 +280,7 @@ async function observeSubagent(
childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null;
}
recordReplayToolOwners(toolOwners, entry, link.id);
for (const item of await convertEntry(entry)) {
for (const item of convertEntry(entry)) {
timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) });
}
}
@@ -320,11 +320,11 @@ function recordReplayToolOwners(
}
}
export async function observeReplaySubagents(input: {
export function observeReplaySubagents(input: {
subagents: readonly ClaudeReplaySubagentInput[];
parent: ClaudeReplayParentFacts;
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> {
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): { 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 async function observeReplaySubagents(input: {
const { ownerId, parent, link } = resolved;
// Only proven descendants may own notifications; ambient sidecars cannot claim them.
for (const observation of await observeSubagent(
for (const observation of observeSubagent(
subagent,
parent,
input.convertEntry,
@@ -1,9 +1,3 @@
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";
@@ -41,7 +35,7 @@ function parentEntries(
}
describe("Claude workflow replay", () => {
it("parses the persisted run summary observed from Claude Code 2.1.220", async () => {
it("parses the persisted run summary observed from Claude Code 2.1.220", () => {
expect(
parseClaudeWorkflowRun(
JSON.stringify({
@@ -68,8 +62,8 @@ describe("Claude workflow replay", () => {
});
});
it("rebuilds the same generic completed descriptor through the parent Workflow call", async () => {
const observations = await observeReplayWorkflows({
it("rebuilds the same generic completed descriptor through the parent Workflow call", () => {
const observations = observeReplayWorkflows({
workflows: [
{
runId: RUN_ID,
@@ -97,8 +91,8 @@ describe("Claude workflow replay", () => {
});
});
it("rebuilds a workflow when Claude persists its result as structured text blocks", async () => {
const observations = await observeReplayWorkflows({
it("rebuilds a workflow when Claude persists its result as structured text blocks", () => {
const observations = observeReplayWorkflows({
workflows: [{ runId: RUN_ID, summary: "Structured result workflow", status: "completed" }],
parentEntries: parentEntries([
{
@@ -120,7 +114,7 @@ describe("Claude workflow replay", () => {
});
});
it("replays the workflow result without exposing its script or child prompt", async () => {
it("replays the workflow result without exposing its script or child prompt", () => {
const workflow = parseClaudeWorkflowRun(
JSON.stringify({
runId: RUN_ID,
@@ -134,7 +128,7 @@ describe("Claude workflow replay", () => {
expect(workflow).not.toBeNull();
if (!workflow) throw new Error("expected workflow");
const observations = await observeReplayWorkflows({
const observations = observeReplayWorkflows({
workflows: [workflow],
parentEntries: parentEntries(),
});
@@ -151,9 +145,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", async () => {
it("does not duplicate a result already present in the child transcript", () => {
const report = "What Paseo is\nA local-first coding-agent environment.";
const observations = await observeReplayWorkflows({
const observations = observeReplayWorkflows({
workflows: [
{ runId: RUN_ID, summary: "Inspect Paseo", status: "completed", result: { report } },
],
@@ -172,8 +166,8 @@ describe("Claude workflow replay", () => {
).toHaveLength(1);
});
it("fails a nonterminal persisted run because its former runtime cannot update it", async () => {
const observations = await observeReplayWorkflows({
it("fails a nonterminal persisted run because its former runtime cannot update it", () => {
const observations = observeReplayWorkflows({
workflows: [{ runId: RUN_ID, summary: "Interrupted workflow", status: "running" }],
parentEntries: parentEntries(),
});
@@ -185,9 +179,9 @@ describe("Claude workflow replay", () => {
});
});
it("drops a run that no parent Workflow tool call declared", async () => {
it("drops a run that no parent Workflow tool call declared", () => {
expect(
await observeReplayWorkflows({
observeReplayWorkflows({
workflows: [{ runId: RUN_ID, status: "completed" }],
parentEntries: [],
}),
@@ -201,29 +195,3 @@ 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,17 +45,14 @@ 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 async function observeReplayWorkflows(input: {
export function observeReplayWorkflows(input: {
workflows: readonly ClaudeWorkflowRun[];
parentEntries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>;
parentEntries: Iterable<ClaudeWorkflowParentEntry>;
/** Repeatable entries in chronological order, supplied by the transcript reader. */
entriesByRunId?: ReadonlyMap<
string,
Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>
>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
}): Promise<SubagentObservation[]> {
const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries);
entriesByRunId?: ReadonlyMap<string, Iterable<ClaudeReplayEntry>>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): SubagentObservation[] {
const toolCallIdByRunId = readWorkflowLinks(input.parentEntries);
const observations: SubagentObservation[] = [];
for (const workflow of input.workflows) {
@@ -82,7 +79,7 @@ export async function observeReplayWorkflows(input: {
});
}
for (const observation of await observeWorkflowTimeline({
for (const observation of observeWorkflowTimeline({
id,
result: workflow.result,
entries: input.entriesByRunId?.get(workflow.runId) ?? [],
@@ -118,19 +115,19 @@ export async function observeReplayWorkflows(input: {
return observations;
}
async function observeWorkflowTimeline(input: {
function observeWorkflowTimeline(input: {
id: string;
result: unknown;
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
entries: Iterable<ClaudeReplayEntry>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
finishedAt?: string;
}): Promise<SubagentObservation[]> {
}): SubagentObservation[] {
const observations: SubagentObservation[] = [];
const replayedAssistantText = new Set<string>();
for await (const entry of input.entries) {
for (const entry of input.entries) {
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
const items = (await input.convertEntry?.(entry)) ?? [];
const items = input.convertEntry?.(entry) ?? [];
for (const item of items) {
if (item.type === "assistant_message") replayedAssistantText.add(item.text.trim());
observations.push({
@@ -153,13 +150,11 @@ async function observeWorkflowTimeline(input: {
return observations;
}
async function readWorkflowLinks(
entries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>,
): Promise<Map<string, string>> {
function readWorkflowLinks(entries: Iterable<ClaudeWorkflowParentEntry>): Map<string, string> {
const workflowToolCallIds = new Set<string>();
const toolCallIdByRunId = new Map<string, string>();
for await (const entry of entries) {
for (const entry of entries) {
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -170,7 +165,7 @@ async function readWorkflowLinks(
}
}
for await (const entry of entries) {
for (const entry of entries) {
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -221,11 +221,9 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
/** Normalize notification identity for one transcript traversal. Queue bookkeeping and delivery
* are separate records of the same occurrence; each source counts repeated identical bodies
* independently so the second event is not merged with the first. */
export async function* assignTaskNotificationReplayIds<T>(
entries: AsyncIterable<T>,
): AsyncGenerator<T> {
export function* assignTaskNotificationReplayIds<T>(entries: Iterable<T>): Generator<T> {
const occurrences = new Map<string, Map<string, number>>();
for await (const entry of entries) {
for (const entry of entries) {
const record = TaskNotificationHistoryRecordSchema.safeParse(entry);
if (!record.success) {
yield entry;
@@ -1,6 +1,5 @@
import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js";
import fs, { type FileHandle } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import fs from "node:fs";
import path from "node:path";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import {
@@ -25,11 +24,11 @@ interface RecordLocation {
}
/** Chunk in bytes so UTF-8 and multi-chunk records are decoded once, at a line boundary. */
async function* locatedRecords(
function* locatedRecords(
file: string,
fd: FileHandle,
): AsyncGenerator<{ entry: TranscriptEntry; location: RecordLocation }> {
const stat = await fd.stat();
fd: number,
): Generator<{ entry: TranscriptEntry; location: RecordLocation }> {
const stat = fs.fstatSync(fd);
// 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}'`), {
@@ -44,7 +43,7 @@ async function* locatedRecords(
let length = 0;
while (remaining > 0) {
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining));
const { bytesRead: count } = await fd.read(buffer, 0, buffer.length, null);
const count = fs.readSync(fd, buffer, 0, buffer.length, null);
if (count === 0) break;
remaining -= count;
let start = 0;
@@ -65,8 +64,6 @@ async 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;
@@ -88,25 +85,20 @@ async function* locatedRecords(
/** Indexed sources parse only their own records using descriptors owned by this replay. */
function indexedRecords(
locations: readonly RecordLocation[],
descriptors: ReadonlyMap<string, FileHandle>,
): AsyncIterable<TranscriptEntry> {
descriptors: ReadonlyMap<string, number>,
): Iterable<TranscriptEntry> {
return {
async *[Symbol.asyncIterator]() {
*[Symbol.iterator]() {
yield* assignTaskNotificationReplayIds(readEntries());
},
};
async function* readEntries(): AsyncGenerator<TranscriptEntry> {
function* readEntries(): Generator<TranscriptEntry> {
for (const location of locations) {
const fd = descriptors.get(location.file)!;
const buffer = Buffer.allocUnsafe(location.length);
let read = 0;
while (read < buffer.length) {
const { bytesRead: count } = await fd.read(
buffer,
read,
buffer.length - read,
location.offset + read,
);
const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read);
if (count === 0)
throw new Error(`Claude transcript was truncated during replay: ${location.file}`);
read += count;
@@ -135,9 +127,9 @@ function parseRecord(line: string): TranscriptEntry | null {
}
}
async function readOptionalFile(file: string): Promise<string | null> {
function readOptionalFile(file: string): string | null {
try {
return await fs.readFile(file, "utf8");
return fs.readFileSync(file, "utf8");
} catch {
return null;
}
@@ -146,40 +138,31 @@ async function readOptionalFile(file: string): Promise<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: AsyncIterable<TranscriptEntry>;
parentEntries: Iterable<TranscriptEntry>;
subagents: {
agentId: string;
meta: ClaudeSubagentMeta | null;
entries: AsyncIterable<TranscriptEntry>;
entries: Iterable<TranscriptEntry>;
}[];
workflows: ClaudeWorkflowRun[];
workflowEntriesByRunId: Map<string, AsyncIterable<TranscriptEntry>>;
workflowEntriesByRunId: Map<string, Iterable<TranscriptEntry>>;
}
/** Own descriptors through every pass so unlink or rename cannot invalidate discovered history.
* Sources must be consumed before the callback returns. No transcript copies are needed. */
export async function withClaudeReplayHistory<T>(
export function withClaudeReplayHistory<T>(
historyPath: string,
consume: (history: ClaudeReplayHistory) => Promise<T>,
): Promise<T> {
const descriptors = new Map<string, FileHandle>();
consume: (history: ClaudeReplayHistory) => T,
): T {
const descriptors = new Map<string, number>();
try {
return await consume(await indexHistory(historyPath, descriptors));
return consume(indexHistory(historyPath, descriptors));
} finally {
await closeReplaySources(descriptors.values());
for (const fd of descriptors.values()) fs.closeSync(fd);
}
}
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> {
function indexHistory(historyPath: string, descriptors: Map<string, number>): ClaudeReplayHistory {
const sessionDirectory = path.join(
path.dirname(historyPath),
path.basename(historyPath, ".jsonl"),
@@ -191,7 +174,8 @@ async function indexHistory(
const directories = [sidechainDirectory];
while (directories.length) {
const directory = directories.pop()!;
for (const entry of await optionalDirectory(directory)) {
if (!fs.existsSync(directory)) continue;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) {
directories.push(file);
@@ -210,23 +194,23 @@ async function indexHistory(
}
const agentId = /^agent-(.+)\.meta\.json$/.exec(entry.name)?.[1];
if (!agentId) continue;
const contents = await readOptionalFile(file);
const contents = readOptionalFile(file);
const meta = contents === null ? null : parseClaudeSubagentMeta(contents);
if (meta) metaByAgentId.set(agentId, meta);
}
}
const parentLocations: RecordLocation[] = [];
const locationsByAgentId = new Map<string, RecordLocation[]>();
async function scan(
function scan(
file: string,
visit: (entry: TranscriptEntry, location: RecordLocation) => void,
): Promise<void> {
const fd = await fs.open(file, "r");
): void {
const fd = fs.openSync(file, "r");
descriptors.set(file, fd);
for await (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
for (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
}
for (const file of sidechainFiles) {
await scan(file, (entry, location) => {
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) ?? [];
@@ -234,11 +218,11 @@ async function indexHistory(
locationsByAgentId.set(entry.agentId, locations);
});
}
const workflowEntriesByRunId = new Map<string, AsyncIterable<TranscriptEntry>>();
const workflowEntriesByRunId = new Map<string, Iterable<TranscriptEntry>>();
for (const [runId, files] of workflowFiles) {
const locations: RecordLocation[] = [];
for (const file of files)
await scan(file, (entry, location) => {
scan(file, (entry, location) => {
if (entry.type !== "user" || isToolResult(entry)) locations.push(location);
});
locations.sort((a, b) => {
@@ -256,28 +240,21 @@ async function indexHistory(
meta: metaByAgentId.get(agentId) ?? null,
entries: indexedRecords(locations, descriptors),
})),
workflows: await readWorkflows(sessionDirectory),
workflows: readWorkflows(sessionDirectory),
workflowEntriesByRunId,
};
}
async function readWorkflows(sessionDirectory: string): Promise<ClaudeWorkflowRun[]> {
function readWorkflows(sessionDirectory: string): ClaudeWorkflowRun[] {
const workflows: ClaudeWorkflowRun[] = [];
const workflowDirectory = path.join(sessionDirectory, "workflows");
for (const entry of await optionalDirectory(workflowDirectory)) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
const contents = await readOptionalFile(path.join(workflowDirectory, entry.name));
const workflow = contents === null ? null : parseClaudeWorkflowRun(contents);
if (workflow) workflows.push(workflow);
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);
}
}
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
};
}
async function mapCodexThreadImageItem(
function mapCodexThreadImageItem(
normalizedType: string,
normalizedItem: Record<string, unknown>,
): Promise<AgentTimelineItem | null> {
): AgentTimelineItem | null {
if (normalizedType === "imageView") {
return await renderProviderImageOutputAsAssistantMarkdown({
return renderProviderImageOutputAsAssistantMarkdown({
path: firstStringField(normalizedItem, ["path"]),
});
}
@@ -1960,7 +1960,7 @@ async function mapCodexThreadImageItem(
const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]);
const result = codexImageOutputFromResult(normalizedItem.result);
return await renderProviderImageOutputAsAssistantMarkdown(
return renderProviderImageOutputAsAssistantMarkdown(
{
path: savedPath ?? result?.path ?? null,
url: result?.url ?? null,
@@ -1982,10 +1982,10 @@ function mapCodexAgentMessage(item: Record<string, unknown>): AgentTimelineItem
};
}
export async function threadItemToTimeline(
export function threadItemToTimeline(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): Promise<AgentTimelineItem | null> {
): AgentTimelineItem | null {
const itemRecord = toObjectRecord(item);
if (!itemRecord) return null;
const includeUserMessage = options?.includeUserMessage ?? true;
@@ -1999,7 +1999,7 @@ export async function threadItemToTimeline(
: itemRecord;
if (normalizedType === "imageView" || normalizedType === "imageGeneration") {
return await mapCodexThreadImageItem(normalizedType, normalizedItem);
return mapCodexThreadImageItem(normalizedType, normalizedItem);
}
if (normalizedType && CODEX_TOOL_THREAD_ITEM_TYPES.has(normalizedType)) {
return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
@@ -2024,7 +2024,7 @@ export async function threadItemToTimeline(
}
}
async function mcpToolResultImagesToTimeline(item: unknown): Promise<AgentTimelineItem[]> {
function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] {
const itemRecord = toObjectRecord(item);
if (!itemRecord) {
return [];
@@ -2037,25 +2037,24 @@ async function mcpToolResultImagesToTimeline(item: unknown): Promise<AgentTimeli
}
const { images } = splitCodexMcpToolResultImages(itemRecord.result);
const items: AgentTimelineItem[] = [];
for (const image of images) {
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
});
if (imageItem) items.push(imageItem);
}
return items;
return images
.map((image) =>
renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
}),
)
.filter((timelineItem): timelineItem is AgentTimelineItem => timelineItem !== null);
}
async function threadItemToTimelineEntries(
function threadItemToTimelineEntries(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): Promise<AgentTimelineItem[]> {
const timelineItem = await threadItemToTimeline(item, options);
): AgentTimelineItem[] {
const timelineItem = threadItemToTimeline(item, options);
if (!timelineItem) {
return [];
}
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
return [timelineItem, ...mcpToolResultImagesToTimeline(item)];
}
const CodexThreadReadResponseSchema = z
@@ -2104,7 +2103,7 @@ async function loadCodexThreadHistoryTimeline(params: {
historicalSubAgentActivity.agentThreadId,
);
if (existingIndex !== undefined) {
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd });
updateHistoricalSubAgentActivity(
timeline,
existingIndex,
@@ -2117,7 +2116,7 @@ async function loadCodexThreadHistoryTimeline(params: {
continue;
}
}
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
for (const timelineItem of threadItemToTimelineEntries(item, { cwd: params.cwd })) {
const timestamp =
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
const settledTimelineItem =
@@ -3291,12 +3290,10 @@ export async function codexAppServerTurnInputFromPrompt(
}
if (block.type === "image") {
try {
const filePath = (
await materializeProviderImage({
data: block.data,
mimeType: block.mimeType,
})
).path;
const filePath = 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);
@@ -4373,7 +4370,6 @@ 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");
@@ -5441,21 +5437,7 @@ export class CodexAppServerAgentSession implements AgentSession {
return `codex-turn-${this.nextTurnOrdinal++}`;
}
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> {
private handleNotification(method: string, params: unknown): void {
const notificationParams = toObjectRecord(params);
if (method === "serverRequest/resolved" && typeof notificationParams?.requestId === "number") {
const requestId = this.mcpElicitationPermissionIds.get(notificationParams.requestId);
@@ -5485,16 +5467,13 @@ export class CodexAppServerAgentSession implements AgentSession {
return;
}
if (route.kind === "sub_agent") {
await this.dispatchSubAgentNotification(parsed, route.callId);
this.dispatchSubAgentNotification(parsed, route.callId);
return;
}
await this.dispatchParsedNotification(parsed);
this.dispatchParsedNotification(parsed);
}
private async dispatchSubAgentNotification(
parsed: ParsedCodexNotification,
callId: string,
): Promise<void> {
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
switch (parsed.kind) {
case "thread_started":
this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
@@ -5505,7 +5484,7 @@ export class CodexAppServerAgentSession implements AgentSession {
case "reasoning_delta":
case "item_started":
case "item_completed":
await this.dispatchParsedNotification(parsed);
this.dispatchParsedNotification(parsed);
return;
case "exec_command_output_delta":
case "file_change_output_delta":
@@ -5531,7 +5510,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private async dispatchParsedNotification(parsed: ParsedCodexNotification): Promise<void> {
private dispatchParsedNotification(parsed: ParsedCodexNotification): void {
if (isCodexDeltaNotification(parsed)) {
this.handleCodexDeltaNotification(parsed);
return;
@@ -5576,10 +5555,10 @@ export class CodexAppServerAgentSession implements AgentSession {
this.handlePatchApplyCompletedNotification(parsed);
return;
case "item_completed":
await this.handleItemCompletedNotification(parsed);
this.handleItemCompletedNotification(parsed);
return;
case "item_started":
await this.handleItemStartedNotification(parsed);
this.handleItemStartedNotification(parsed);
return;
case "invalid_payload":
this.warnInvalidNotificationPayload(parsed.method, parsed.params);
@@ -5621,7 +5600,7 @@ export class CodexAppServerAgentSession implements AgentSession {
pending.push(parsed);
}
private async replayPendingSubAgentNotifications(threadIds: readonly string[]): Promise<void> {
private replayPendingSubAgentNotifications(threadIds: readonly string[]): void {
for (const threadId of threadIds) {
const pending = this.pendingSubAgentNotificationsByThreadId.get(threadId);
if (!pending) {
@@ -5633,7 +5612,7 @@ export class CodexAppServerAgentSession implements AgentSession {
continue;
}
for (const parsed of pending) {
await this.dispatchSubAgentNotification(parsed, callId);
this.dispatchSubAgentNotification(parsed, callId);
}
}
}
@@ -6599,13 +6578,9 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private ownsNotificationTurn(turnId: string | null): boolean {
return !this.closed && this.activeForegroundTurnId === turnId;
}
private async handleItemCompletedNotification(
private handleItemCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
): Promise<void> {
): 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
@@ -6615,23 +6590,20 @@ export class CodexAppServerAgentSession implements AgentSession {
}
this.receiveAsyncQuestion(parsed.threadId, parsed.item);
if (this.isUserMessageItem(parsed.item)) {
await this.handleUserMessageItem(parsed);
this.handleUserMessageItem(parsed);
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (this.handleCompletedSpecialItem(parsed, childSubAgentCallId)) {
return;
}
const foregroundTurnId = this.activeForegroundTurnId;
const timelineItem = await threadItemToTimeline(parsed.item, {
const timelineItem = 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({
@@ -6641,11 +6613,12 @@ 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);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
const normalizedItemType = normalizeCodexThreadItemType(
@@ -6653,7 +6626,7 @@ export class CodexAppServerAgentSession implements AgentSession {
);
const itemId = parsed.item.id;
if (this.shouldSkipCompletedThreadItem(timelineItem, normalizedItemType, itemId)) {
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
if (this.consumeStreamedTextCompletion(timelineItem, itemId)) {
@@ -6664,7 +6637,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emittedItemCompletedIds.add(itemId);
this.emittedItemStartedIds.delete(itemId);
}
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
@@ -6693,7 +6666,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
}
private consumeStreamedTextCompletion(
@@ -6765,14 +6738,14 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private async handleItemStartedNotification(
private handleItemStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
): Promise<void> {
): void {
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return;
}
if (this.isUserMessageItem(parsed.item)) {
await this.handleUserMessageItem(parsed);
this.handleUserMessageItem(parsed);
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
@@ -6794,7 +6767,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.handleRegisteredSubAgentActivity(parsed.item)) {
return;
}
const timelineItem = await threadItemToTimeline(parsed.item, {
const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
@@ -6813,7 +6786,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
}
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
const normalizedItemType = normalizeCodexThreadItemType(
@@ -6837,14 +6810,14 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
}
private async handleUserMessageItem(
private handleUserMessageItem(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): Promise<void> {
): void {
const itemId = parsed.item.id;
const timelineItem = await threadItemToTimeline(parsed.item, {
const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: true,
cwd: this.config.cwd ?? null,
});
@@ -183,29 +183,3 @@ 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,15 +1,10 @@
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;
@@ -172,6 +167,39 @@ 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;
@@ -188,24 +216,11 @@ export class CodexAppServerClient {
private readonly logger: Logger,
private readonly getTraceContext: () => CodexAppServerTraceContext = () => ({}),
) {
// 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);
},
};
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");
});
});
child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
@@ -303,15 +318,6 @@ 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,5 +1,3 @@
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";
@@ -38,16 +36,6 @@ 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) };
@@ -67,57 +55,3 @@ 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,60 +1,3 @@
// 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;
@@ -62,7 +5,6 @@ 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"
@@ -136,7 +78,7 @@ function chunkBytes(data: string): Buffer | null {
}
export class JsonlFrameDecoder {
private readonly lines: ProviderLineReader;
private lineBuffer = "";
private assembly: ChunkAssembly | null = null;
constructor(
@@ -144,18 +86,17 @@ 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(chunk: Buffer | string): void {
this.lines.write(chunk);
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");
}
}
private consumeLine(line: string): void {
@@ -471,25 +471,3 @@ 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, ProviderStreamLimitError } from "./jsonl-frame-decoder.js";
import { JsonlFrameDecoder } from "./jsonl-frame-decoder.js";
export { supportsJsonlRpcProtocolV2 } from "./jsonl-frame-decoder.js";
/** Default wall-clock timeout for control-plane / short RPC calls. */
@@ -81,7 +81,6 @@ 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;
@@ -91,14 +90,6 @@ 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`,
@@ -107,7 +98,7 @@ export class JsonlRpcProcess {
});
this.child = (options.spawn ?? spawnJsonlRpcProcess)(options.launch);
this.child.stdout.on("data", (chunk) => {
this.handleStdoutChunk(chunk);
this.handleStdoutChunk(chunk.toString());
});
this.child.stderr.on("data", (chunk) => {
this.stderrBuffer += chunk.toString();
@@ -123,11 +114,9 @@ export class JsonlRpcProcess {
});
this.child.on("exit", (code, signal) => {
this.exited = true;
const error =
this.streamFailure ??
new Error(
`${this.diagnosticName} process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
);
const error = 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);
@@ -265,7 +254,7 @@ export class JsonlRpcProcess {
}
}
private handleStdoutChunk(chunk: Buffer | string): void {
private handleStdoutChunk(chunk: string): void {
this.frameDecoder.write(chunk);
}
@@ -1133,17 +1133,6 @@ 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();
@@ -1343,14 +1332,13 @@ describe("OMP agent client and session", () => {
expect(omp.runtimeLaunches()).toHaveLength(1);
});
test("rejects a pending recovery start and closes its processes when the session closes", async () => {
test("closes a replacement process if the session closes during relaunch", 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 rejected;
await turn;
await waitForImmediate();
expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true);
});
@@ -1639,93 +1627,3 @@ 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;
}
async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise<string> {
function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string {
try {
const materialized = await materializeProviderImage({
const materialized = materializeProviderImage({
data: image.data,
mimeType: image.mimeType,
});
@@ -329,10 +329,10 @@ async function renderTextOnlyImageHint(image: { data: string; mimeType: string }
}
}
async function convertPromptInput(
function convertPromptInput(
prompt: AgentPromptInput,
options: { model: OmpModel | null | undefined },
): Promise<OmpPromptPayload> {
): OmpPromptPayload {
if (typeof prompt === "string") {
return { text: prompt };
}
@@ -355,7 +355,7 @@ async function convertPromptInput(
mimeType: block.mimeType,
});
} else {
textParts.push(await renderTextOnlyImageHint(block));
textParts.push(renderTextOnlyImageHint(block));
}
continue;
}
@@ -868,9 +868,11 @@ 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;
@@ -878,10 +880,6 @@ 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) {
@@ -948,9 +946,7 @@ export class OmpAgentSession implements AgentSession {
): Promise<SteerResult> {
if (this.closed || this.activeTurnId !== options.expectedTurnId)
return { status: "unavailable" };
const payload = await convertPromptInput(prompt, { model: this.state.model });
if (this.closed || this.activeTurnId !== options.expectedTurnId)
return { status: "unavailable" };
const payload = convertPromptInput(prompt, { model: this.state.model });
if (this.parseSlashCommandInput(payload.text)) return { status: "unavailable" };
const submission = this.rememberClientMessage(options.clientMessageId ?? null, payload.text);
try {
@@ -1,5 +1,4 @@
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";
@@ -9,7 +8,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 { readActiveOmpEntryChain, readOmpHistoryTodoState, streamOmpHistory } from "./history.js";
import { readOmpHistoryTodoState, streamOmpHistory } from "./history.js";
async function collectHistory(
messages: OmpAgentMessage[],
@@ -1246,53 +1245,3 @@ 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 });
}
});
@@ -1,104 +0,0 @@
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,5 +1,4 @@
import { open, type FileHandle } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import { readFile } from "node:fs/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";
@@ -97,9 +96,7 @@ 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;
@@ -118,9 +115,7 @@ 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
@@ -334,139 +329,34 @@ function stripExtension(filePath: string): string {
return extension ? filePath.slice(0, -extension.length) : filePath;
}
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 (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>,
export async function readActiveOmpEntryChain(
sessionFile: 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();
): 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 [];
}
current ??= locations.at(-1);
}
const chain: OmpEntryLocation[] = [];
});
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[] = [];
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,7 +56,6 @@ test("bridge preserves tool failure messages and classifies invalid requests as
getDaemonTcpHost: null,
serviceProxyPublicBaseUrl: null,
resolveScriptHealth: null,
getGlobalServicePorts: async () => undefined,
logger,
emit: () => {},
publishStatusUpdate: () => {},
@@ -1,4 +1,3 @@
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";
@@ -1561,7 +1560,7 @@ describe("translateOpenCodeEvent", () => {
});
describe("OpenCode v2 history reconciliation", () => {
it("keeps client message identity on live echoes but omits it from history", async () => {
it("keeps client message identity on live echoes but omits it from history", () => {
const user: SessionMessageUser = {
id: "native-message",
type: "user",
@@ -1569,13 +1568,13 @@ describe("OpenCode v2 history reconciliation", () => {
time: { created: 1 },
metadata: { paseoClientMessageId: "client-message" },
};
expect(await collectTimelineSnapshot(new V2Timeline(), [user])).toMatchObject([
expect(new V2Timeline().messages([user])).toMatchObject([
{ item: { clientMessageId: "client-message", messageId: "native-message" } },
]);
expect(await collectTimelineSnapshot(new V2Timeline(false), [user])).toMatchObject([
expect(new V2Timeline(false).messages([user])).toMatchObject([
{ item: { type: "user_message", messageId: "native-message" } },
]);
const [event] = await collectTimelineSnapshot(new V2Timeline(false), [user]);
const [event] = new V2Timeline(false).messages([user]);
expect(event && "item" in event && event.item).not.toHaveProperty("clientMessageId");
});
function assistant(text: string): SessionMessageAssistant {
@@ -1588,35 +1587,31 @@ describe("OpenCode v2 history reconciliation", () => {
content: [{ type: "text", text }],
};
}
it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", async () => {
it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", () => {
const timeline = new V2Timeline();
const text = (messages: Awaited<ReturnType<typeof collectTimelineSnapshot>>) =>
const text = (messages: ReturnType<V2Timeline["messages"]>) =>
messages.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message"
? [event.item.text]
: [],
);
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([]);
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([]);
});
it("keeps reasoning separate from assistant output", async () => {
it("keeps reasoning separate from assistant output", () => {
const timeline = new V2Timeline();
const message = assistant("answer");
message.content.unshift({ type: "reasoning", text: "thinking" });
expect(
(await collectTimelineSnapshot(timeline, [message])).map((event) =>
event.type === "timeline" ? event.item : null,
),
timeline.messages([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", async () => {
it("emits tool state transitions once and keeps their call identity", () => {
const timeline = new V2Timeline();
const message = assistant("");
message.content = [
@@ -1628,7 +1623,7 @@ describe("OpenCode v2 history reconciliation", () => {
state: { status: "running", input: { command: "pwd" }, metadata: {} },
},
];
expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([
expect(timeline.messages([message])).toMatchObject([
{ item: { type: "tool_call", callId: "call", status: "running" } },
]);
message.content = [
@@ -1644,9 +1639,9 @@ describe("OpenCode v2 history reconciliation", () => {
},
},
];
expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([
expect(timeline.messages([message])).toMatchObject([
{ item: { type: "tool_call", callId: "call", status: "completed" } },
]);
expect(await collectTimelineSnapshot(timeline, [message])).toEqual([]);
expect(timeline.messages([message])).toEqual([]);
});
});
@@ -1,4 +1,3 @@
import { collectTimelineSnapshot } from "./v2-timeline.js";
import { TimelineProjection } from "../../../timeline-projection.js";
import { V2Timeline } from "../v2/timeline.js";
@@ -22,18 +21,16 @@ for (let index = 0; index < chunks; index++) {
const event =
mode === "delta"
? timeline.delta({ ...part, delta })
: (
await collectTimelineSnapshot(timeline, [
{
id: "answer",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
time: { created: 2 },
content: [{ type: "reasoning", text }],
},
])
)[0];
: timeline.messages([
{
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,
@@ -1,15 +0,0 @@
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 });
}
await timeline.messages(await messages(this.options.client(), info.id), (event) => {
for (const event of timeline.messages(await messages(this.options.client(), info.id))) {
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);
await this.timeline.messages(await messages(this.client, this.id), () => {});
this.timeline.messages(await messages(this.client, this.id));
await this.startStream();
await this.children.reconcile(this.id);
}
@@ -261,11 +261,7 @@ export class OpenCodeV2Session implements AgentSession {
async *streamHistory() {
await this.reconnectIfExited();
const history = new V2Timeline(false);
const events: AgentStreamEvent[] = [];
await history.messages(await messages(this.client, this.id), (event) => {
events.push(event);
});
yield* events;
yield* history.messages(await messages(this.client, this.id));
}
async getRuntimeInfo() {
await this.reconnectIfExited();
@@ -421,7 +417,7 @@ export class OpenCodeV2Session implements AgentSession {
]);
this.info = info;
this.history = history;
await this.timeline.messages(history, (event) => this.emitTimeline(event));
for (const event of this.timeline.messages(history)) this.emitTimeline(event);
await this.permissions.reconcile(this.id);
}
private async reconcile() {
@@ -1,4 +1,3 @@
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";
@@ -81,7 +80,7 @@ describe("OpenCode v2 token streaming", () => {
}
});
test("deduplicates snapshots before and after deltas using per-type ordinals", async () => {
test("deduplicates snapshots before and after deltas using per-type ordinals", () => {
const timeline = new V2Timeline();
const text = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const;
const reasoning = { ...text, type: "reasoning" } as const;
@@ -100,28 +99,24 @@ describe("OpenCode v2 token streaming", () => {
},
{ type: "text", text: "Hello" },
]);
expect(await collectTimelineSnapshot(timeline, [snapshot])).toMatchObject([
{ item: { text: "lo" } },
]);
expect(timeline.messages([snapshot])).toMatchObject([{ item: { text: "lo" } }]);
expect(timeline.delta({ ...text, delta: "lo" })).toBeNull();
expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]);
expect(timeline.messages([snapshot])).toEqual([]);
expect(timeline.delta({ ...text, delta: "!" })).toMatchObject({ item: { text: "!" } });
expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]);
expect(timeline.messages([snapshot])).toEqual([]);
});
test("recovers missed fragments from snapshots after reconnect", async () => {
test("recovers missed fragments from snapshots after reconnect", () => {
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(
await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]),
).toMatchObject([{ item: { text: "lo!" } }]);
expect(
await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]),
).toEqual([]);
expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toMatchObject([
{ item: { text: "lo!" } },
]);
expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toEqual([]);
});
test("withholds streamed prose during a structured-output turn", async () => {
@@ -208,62 +203,3 @@ 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,4 +1,3 @@
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";
@@ -16,7 +15,7 @@ function assistant(content: SessionMessageAssistant["content"]): SessionMessageA
};
}
test("a completed edit carries the replaced and replacement text", async () => {
test("a completed edit carries the replaced and replacement text", () => {
// Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`.
const edit = assistant([
{
@@ -46,7 +45,7 @@ test("a completed edit carries the replaced and replacement text", async () => {
},
]);
expect(await collectTimelineSnapshot(new V2Timeline(), [edit])).toMatchObject([
expect(new V2Timeline().messages([edit])).toMatchObject([
{
item: {
type: "tool_call",
@@ -58,7 +57,7 @@ test("a completed edit carries the replaced and replacement text", async () => {
]);
});
test("a tool's image output is shown as an image, not as base64 in the tool output", async () => {
test("a tool's image output is shown as an image, not as base64 in the tool output", () => {
const png =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
const screenshot = assistant([
@@ -81,7 +80,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 = await collectTimelineSnapshot(timeline, [screenshot]);
const events = timeline.messages([screenshot]);
expect(JSON.stringify(events)).not.toContain(png);
expect(events.map((event) => event.type === "timeline" && event.item)).toMatchObject([
@@ -96,7 +95,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(await collectTimelineSnapshot(timeline, [screenshot])).toEqual([]);
expect(timeline.messages([screenshot])).toEqual([]);
});
test("a surrogate pair split across deltas streams back intact", () => {
@@ -55,32 +55,18 @@ export class V2Timeline {
return { type: "timeline", provider: "opencode", item, timestamp: new Date().toISOString() };
}
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> {
messages(messages: SessionMessageInfo[]): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
const state: TimelineState = { structured: false, accepted: false };
for (const message of messages) {
const timestamp = new Date(message.time.created).toISOString();
const push = (item: AgentTimelineItem) =>
publish({ type: "timeline", provider: "opencode", item, timestamp });
events.push({ type: "timeline", provider: "opencode", item, timestamp });
if (message.type === "user") this.userMessage(message, state, push);
else if (message.type === "assistant") await this.assistantMessage(message, state, push);
else if (message.type === "assistant") this.assistantMessage(message, state, push);
else if (message.type === "compaction") this.compactionMessage(message, push);
}
return events;
}
private userMessage(
@@ -103,7 +89,7 @@ export class V2Timeline {
});
}
private async assistantMessage(
private assistantMessage(
message: Extract<SessionMessageInfo, { type: "assistant" }>,
state: TimelineState,
push: (item: AgentTimelineItem) => void,
@@ -113,16 +99,16 @@ export class V2Timeline {
// paths address the same cursor entry.
let textOrdinal = 0;
let reasoningOrdinal = 0;
for (const [partIndex, part] of message.content.entries()) {
message.content.forEach((part, partIndex) => {
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 {
await this.toolPart(message.id, partIndex, part, push);
this.toolPart(message.id, partIndex, part, push);
}
}
});
}
private structuredOutputPart(
@@ -170,7 +156,7 @@ export class V2Timeline {
else push({ type: "reasoning", text: suffix });
}
private async toolPart(
private toolPart(
messageID: string,
partIndex: number,
part: Extract<
@@ -188,16 +174,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.
for (const [index, image] of images.entries()) {
images.forEach((image, index) => {
const imageKey = `${key}:image:${index}`;
if (this.content.has(imageKey)) continue;
if (this.content.has(imageKey)) return;
this.content.set(imageKey, "");
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(
const imageItem = renderProviderImageOutputAsAssistantMarkdown(
{ url: image.uri, mimeType: image.mime },
{ materialize: materializeProviderImage },
);
if (imageItem) push(imageItem);
}
});
}
private compactionMessage(
@@ -3583,63 +3583,3 @@ 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;
}
async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise<string> {
function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string {
try {
const materialized = await materializeProviderImage({
const materialized = materializeProviderImage({
data: image.data,
mimeType: image.mimeType,
});
@@ -379,10 +379,10 @@ async function renderTextOnlyImageHint(image: { data: string; mimeType: string }
}
}
async function convertPromptInput(
function convertPromptInput(
prompt: AgentPromptInput,
options: { model: PiModel | null | undefined },
): Promise<PiPromptPayload> {
): PiPromptPayload {
if (typeof prompt === "string") {
return { text: prompt };
}
@@ -405,7 +405,7 @@ async function convertPromptInput(
mimeType: block.mimeType,
});
} else {
textParts.push(await renderTextOnlyImageHint(block));
textParts.push(renderTextOnlyImageHint(block));
}
continue;
}
@@ -1280,6 +1280,7 @@ 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,9 +1292,6 @@ 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;
@@ -1365,9 +1363,7 @@ export class PiRpcAgentSession implements AgentSession {
if (this.closed || this.activeTurnId !== options.expectedTurnId) {
return { status: "unavailable" };
}
const payload = await convertPromptInput(prompt, { model: this.state.model });
if (this.closed || this.activeTurnId !== options.expectedTurnId)
return { status: "unavailable" };
const payload = convertPromptInput(prompt, { model: this.state.model });
// 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)) {
@@ -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", async () => {
const materialized = await materializeProviderImage({
test("writes image attachments under a private temp directory", () => {
const materialized = materializeProviderImage({
data: "YWJjMTIz",
mimeType: "image/png",
});
@@ -1,11 +1,10 @@
import { existsSync, rmSync, statSync, readFileSync } from "node:fs";
import { existsSync, rmSync } 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,
@@ -13,8 +12,8 @@ import {
const HASH = "a".repeat(64);
async function renderImageMarkdown(imagePath: string): Promise<string> {
const item = await renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
function renderImageMarkdown(imagePath: string): string {
const item = renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
if (!item || item.type !== "assistant_message") {
throw new Error("Expected provider image output to render as assistant markdown.");
}
@@ -41,8 +40,8 @@ describe("isProviderImageMarkdown", () => {
).toBe(true);
});
test("emits Windows file paths as file URIs", async () => {
const markdown = await renderImageMarkdown(
test("emits Windows file paths as file URIs", () => {
const markdown = renderImageMarkdown(
`C:\\Users\\me\\AppData\\Local\\Temp\\paseo-attachments\\${HASH}.png`,
);
@@ -52,24 +51,22 @@ describe("isProviderImageMarkdown", () => {
expect(isProviderImageMarkdown(markdown)).toBe(true);
});
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",
);
test("emits POSIX file paths with spaces as valid file URI markdown", () => {
const markdown = 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", async () => {
const markdown = await renderImageMarkdown("/tmp/screenshot#1?draft.png");
test("encodes URI-significant characters in POSIX file paths", () => {
const markdown = 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", async () => {
const markdown = await renderImageMarkdown("//tmp/screenshot#1.png");
test("preserves double-leading slashes in POSIX file paths", () => {
const markdown = renderImageMarkdown("//tmp/screenshot#1.png");
expect(markdown).toBe("\n\n![Image](file:////tmp/screenshot%231.png)\n\n");
});
@@ -81,8 +78,8 @@ describe("isProviderImageMarkdown", () => {
"\\\\?\\UNC\\server\\share\\shot?draft.png",
"file://server/share/shot%3Fdraft.png",
],
])("encodes %s image paths as file URIs", async (_label, imagePath, expectedSource) => {
expect(await renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`);
])("encodes %s image paths as file URIs", (_label, imagePath, expectedSource) => {
expect(renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`);
});
test("rejects user-authored markdown that is not a materialized attachment", () => {
@@ -95,8 +92,8 @@ describe("isProviderImageMarkdown", () => {
});
describe("materializeProviderImage", () => {
test("recreates the private temp directory if the cached directory is removed", async () => {
const first = await materializeProviderImage({
test("recreates the private temp directory if the cached directory is removed", () => {
const first = materializeProviderImage({
data: "YWJjMTIz",
mimeType: "image/png",
});
@@ -105,7 +102,7 @@ describe("materializeProviderImage", () => {
rmSync(firstDir, { recursive: true, force: true });
const second = await materializeProviderImage({
const second = materializeProviderImage({
data: "ZGVmNDU2",
mimeType: "image/png",
});
@@ -120,8 +117,8 @@ describe("materializeProviderImage", () => {
});
describe("provider image paragraph boundaries", () => {
test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", async (ending) => {
const image = await renderProviderImageOutputAsAssistantMarkdown({
test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", (ending) => {
const image = renderProviderImageOutputAsAssistantMarkdown({
url: "https://example.com/chart.png",
});
if (!image) throw new Error("Expected image output");
@@ -155,10 +152,10 @@ describe("provider image paragraph boundaries", () => {
});
});
test("materialized image output retains paragraph boundaries and attachment recognition", async () => {
const item = await renderProviderImageOutputAsAssistantMarkdown(
test("materialized image output retains paragraph boundaries and attachment recognition", () => {
const item = renderProviderImageOutputAsAssistantMarkdown(
{ data: "YWJjMTIz", mimeType: "image/png" },
{ materialize: async () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) },
{ materialize: () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) },
);
expect(item).toEqual({
type: "assistant_message",
@@ -167,76 +164,3 @@ 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,6 +1,5 @@
import { createHash } from "node:crypto";
import * as fs from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import * as fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -24,26 +23,32 @@ 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();
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. */
function canReuseMaterializedImageAttachmentDir(dir: string): boolean {
try {
const stats = fsSync.lstatSync(dir);
if (!stats.isDirectory()) {
return false;
}
fsSync.chmodSync(dir, PRIVATE_ATTACHMENT_DIR_MODE);
return true;
} catch {
return false;
}
materializedImageAttachmentDir = await fs.mkdtemp(
}
function getMaterializedImageAttachmentDir(): string {
if (
materializedImageAttachmentDir &&
canReuseMaterializedImageAttachmentDir(materializedImageAttachmentDir)
) {
return materializedImageAttachmentDir;
}
materializedImageAttachmentDir = fsSync.mkdtempSync(
path.join(os.tmpdir(), PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX),
);
await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
fsSync.chmodSync(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
return materializedImageAttachmentDir;
}
@@ -66,92 +71,32 @@ function getImageExtension(mimeType: string): string {
}
}
// 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.
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 };
}
// 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;
}): 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;
}
}): 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 };
}
// Recognizes markdown rendered for a materialized provider image: its source is a content-hashed
@@ -167,10 +112,7 @@ export function isProviderImageMarkdown(text: string): boolean {
}
interface RenderProviderImageOutputOptions {
materialize?: (image: {
data: string;
mimeType: string | null;
}) => Promise<MaterializedProviderImage>;
materialize?: (image: { data: string; mimeType: string | null }) => MaterializedProviderImage;
}
function nonEmptyString(value: string | null | undefined): string | null {
@@ -227,10 +169,10 @@ function escapeMarkdownImageSource(value: string): string {
return markdownImageSource(value).replace(/\\/g, "\\\\").replace(/\)/g, "\\)");
}
export async function renderProviderImageOutputAsAssistantMarkdown(
export function renderProviderImageOutputAsAssistantMarkdown(
image: ProviderImageOutput,
options: RenderProviderImageOutputOptions = {},
): Promise<AgentTimelineItem | null> {
): AgentTimelineItem | null {
const source = nonEmptyString(image.path) ?? nonEmptyString(image.url);
if (source && !isDataImageSource(source)) {
const altText = escapeMarkdownImageAlt(nonEmptyString(image.altText) ?? "Image");
@@ -249,7 +191,7 @@ export async function renderProviderImageOutputAsAssistantMarkdown(
let materialized: MaterializedProviderImage | null = null;
try {
materialized = options.materialize
? await options.materialize({
? options.materialize({
data,
mimeType: nonEmptyString(image.mimeType),
})
@@ -1,249 +0,0 @@
// 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,43 +295,12 @@ 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);
@@ -339,8 +308,7 @@ export class TimelineProjection {
const merged = existing ? mergeIdentityEntries(existing, entry) : null;
if (merged && index !== undefined) {
this.rows[index] = { ...merged, seq: merged.seqEnd };
this.extendSpan(index);
return index;
return;
}
const previous = this.rows.at(-1);
const adjacent = previous
@@ -349,159 +317,16 @@ export class TimelineProjection {
if (previous && adjacent.length === 1) {
const mergedText = adjacent[0];
this.rows[this.rows.length - 1] = { ...mergedText, seq: mergedText.seqEnd };
this.extendSpan(this.rows.length - 1);
return this.rows.length - 1;
return;
}
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,
@@ -619,6 +444,21 @@ 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,
@@ -675,6 +515,142 @@ 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,4 +1,3 @@
import { registerPluginTools } from "./plugin-tools.js";
import type { FinishSubscriptions, FinishRegistration } from "../finish-subscriptions/index.js";
import type { AgentMessageDelivery } from "../agent-messages/index.js";
import {
@@ -705,7 +704,6 @@ 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"],
@@ -740,7 +738,6 @@ 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;
}
@@ -3275,14 +3272,6 @@ function createToolCatalog(
},
);
registerPluginTools({
agentManager,
callerAgentId,
scope,
tools,
builtinNames,
policy: options.paseoToolPolicy,
});
return toCatalog();
}
@@ -1,40 +0,0 @@
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,
});
},
});
}
}
+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();
await host.store.patch({ idleRuntimeCleanup: false });
host.store.patch({ idleRuntimeCleanup: false });
expect(loadPersistedConfig(host.home).agents?.idleRuntimeCleanup).toBe(false);
expect((await host.sweep()).collected).toEqual([]);
expect((await other.sweep()).collected).toHaveLength(1);
await host.store.patch({ idleRuntimeCleanup: true });
host.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(await host.store.reload()).toEqual({
expect(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();
await host.store.patch({ idleRuntimeCleanup: previous });
host.store.patch({ idleRuntimeCleanup: previous });
const persistedBefore = loadPersistedConfig(host.home);
const refuse = host.store.onApply(() => {
throw new Error("Another live owner refused");
});
await expect(host.store.patch({ idleRuntimeCleanup: !previous })).rejects.toThrow(
expect(() => host.store.patch({ idleRuntimeCleanup: !previous })).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();
await host.store.patch({ idleRuntimeCleanup: false });
host.store.patch({ idleRuntimeCleanup: false });
expect((await host.sweep()).collected).toHaveLength(1);
});
+7 -18
View File
@@ -161,11 +161,7 @@ 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 {
resolveReloadableConfig,
type ResolvedDaemonSettings,
type CliConfigOverrides,
} from "./config.js";
import { resolveConfigFromPersisted, 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";
@@ -200,7 +196,7 @@ import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import { type PersistedConfig } from "./persisted-config.js";
import { loadPersistedConfig, 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";
@@ -555,7 +551,7 @@ function resolveExpressTrustProxySetting(config: PaseoDaemonConfig): true | stri
return config.trustedProxies ?? ["loopback"];
}
function createInitialRelayConfig(config: ResolvedDaemonSettings): MutableDaemonConfig["relay"] {
function createInitialRelayConfig(config: PaseoDaemonConfig): MutableDaemonConfig["relay"] {
return {
enabled: config.relayEnabled ?? true,
requireProtectedSecurity: config.relayRequireProtectedSecurity ?? false,
@@ -566,7 +562,7 @@ function resolveReloadEnv(config: PaseoDaemonConfig): NodeJS.ProcessEnv {
return config.configReload?.env ?? process.env;
}
function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): MutableDaemonConfig {
function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDaemonConfig {
const providers = config.providerOverrides ?? {};
const initialConfig: MutableDaemonConfig = {
@@ -642,14 +638,14 @@ export async function createPaseoDaemon(
}),
reloadSource: {
resolve: (persisted) => {
const reloaded = resolveReloadableConfig(persisted, {
const reloaded = resolveConfigFromPersisted(config.paseoHome, persisted, {
env: resolveReloadEnv(config),
cli: config.configReload?.cli,
relayEnabledFallback: config.configReload?.relayEnabledFallback,
});
return {
mutable: createInitialMutableDaemonConfig(reloaded),
overrideControlledPaths: reloaded.overrideControlledPaths,
overrideControlledPaths: reloaded.configReload?.overrideControlledPaths ?? [],
};
},
},
@@ -999,7 +995,6 @@ export async function createPaseoDaemon(
const initialAgentManagerState = providerSnapshotManager.getAgentManagerProviderState();
const agentManager = new AgentManager({
pluginLifecycle: pluginRuntime,
pluginAgentTools: pluginRuntime,
clients: initialAgentManagerState.clients,
providerDefinitions: initialAgentManagerState.providerDefinitions,
registry: agentStorage,
@@ -1020,10 +1015,6 @@ export async function createPaseoDaemon(
const unsubscribePluginProviders =
pluginRuntime.subscribeProviderRegistrations(syncPluginProviders);
const unsubscribePluginSessionChanges = pluginRuntime.subscribeSessionOpenChanges(() => {
agentManager.invalidatePluginSessions();
});
const detachAgentStoragePersistence = attachAgentStoragePersistence(
logger,
agentManager,
@@ -1545,7 +1536,7 @@ export async function createPaseoDaemon(
spawnWorkspaceScript,
assertAutomationAllowed: (workspaceId) =>
assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, workspaceId),
getGlobalServicePorts: () => daemonConfigStore.readServicePorts(),
globalServicePorts: loadPersistedConfig(config.paseoHome).worktrees?.servicePorts,
}),
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
@@ -1923,7 +1914,6 @@ 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);
@@ -1943,7 +1933,6 @@ 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,22 +132,3 @@ 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,7 +4,6 @@ import type { CheckoutDiffCompare, CheckoutDiffResult } from "../utils/checkout-
interface ActiveRead {
promise: Promise<CheckoutDiffResult>;
invalidated: boolean;
controller: AbortController;
}
interface CachedDiff {
@@ -18,7 +17,7 @@ interface CheckoutDiffReadInput {
compare: CheckoutDiffCompare;
force?: boolean;
reason?: string;
load: (signal: AbortSignal) => Promise<CheckoutDiffResult>;
load: () => Promise<CheckoutDiffResult>;
}
/** Completed payloads are bounded; active work survives invalidation and eviction. */
@@ -52,7 +51,6 @@ export class CheckoutDiffCache {
if (pending) {
if (force) {
pending.invalidated = true;
pending.controller.abort();
this.values.delete(key);
}
if (!pending.invalidated) return pending.promise;
@@ -68,9 +66,8 @@ 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(read.controller.signal))
.then(load)
.then((value) => {
// Never repopulate the cache with a snapshot invalidated during its build.
if (!read.invalidated)
@@ -89,10 +86,7 @@ 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;
pending.controller.abort();
}
if (pending) pending.invalidated = true;
}
}
}
+1 -30
View File
@@ -4,12 +4,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, test } from "vitest";
import {
loadConfig,
resolveBundledWebUiDistDir,
resolveConfigFromPersisted,
resolveReloadableConfig,
} from "./config.js";
import { loadConfig, resolveBundledWebUiDistDir, resolveConfigFromPersisted } from "./config.js";
import { loadPersistedConfig } from "./persisted-config.js";
const roots: string[] = [];
@@ -19,30 +14,6 @@ describe("server config", () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
test("resolves reloadable settings without resolving the startup password", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-auth-reload-"));
roots.push(paseoHome);
const env = { PASEO_PASSWORD: "synthetic-audit-password" };
const persisted = loadPersistedConfig(paseoHome);
const startup = resolveConfigFromPersisted(paseoHome, persisted, { env });
const started = performance.now();
const timer = new Promise<number>((resolve) =>
setTimeout(() => resolve(performance.now() - started), 0),
);
const reloaded = resolveReloadableConfig(
{
...persisted,
daemon: { ...persisted.daemon, browserTools: { enabled: true } },
},
{ env },
);
const gap = await timer;
expect(reloaded).not.toHaveProperty("auth");
expect(reloaded.browserToolsEnabled).toBe(true);
expect(startup.auth?.password).toBeTypeOf("string");
expect(gap).toBeLessThan(50);
});
test("records when the daemon is managed by Paseo Desktop", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-desktop-managed-"));
roots.push(paseoHome);

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