mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abf9a6ff77 | ||
|
|
ae153721c8 |
@@ -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"));
|
||||
|
||||
@@ -2495,7 +2495,6 @@ function ComposerContentImpl({
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={activeSendBehavior}
|
||||
sendShortcut={appSettings.sendShortcut}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: "بسأل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "確認する",
|
||||
|
||||
@@ -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: "물어보기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "Спрашивать",
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -15,4 +15,3 @@ export {
|
||||
isSyntaxThemeId,
|
||||
resolveSyntaxColors,
|
||||
} from "./themes.js";
|
||||
export { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "./budgeted-highlighter.js";
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -2639,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)
|
||||
@@ -2651,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,
|
||||
@@ -2971,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();
|
||||
@@ -3012,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 });
|
||||
@@ -3056,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();
|
||||
@@ -3066,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(),
|
||||
@@ -3249,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;
|
||||
@@ -3415,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)) {
|
||||
@@ -3459,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
|
||||
@@ -4126,15 +4095,13 @@ 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;
|
||||
this.routeSdkMessageFromPump(message);
|
||||
await this.routeSdkMessageFromPump(message);
|
||||
return false;
|
||||
} finally {
|
||||
this.processingSdkMessages--;
|
||||
@@ -4254,11 +4221,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return isClaudeTurnInit(message) && !this.activeForegroundTurnId;
|
||||
}
|
||||
|
||||
private ownsQuery(query: Query): boolean {
|
||||
return !this.closed && this.query === query;
|
||||
}
|
||||
|
||||
private routeSdkMessageFromPump(message: SDKMessage): void {
|
||||
private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> {
|
||||
if (this.consumeSessionStateChange(message)) {
|
||||
return;
|
||||
}
|
||||
@@ -4292,7 +4255,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
"provider.claude.parsed_event",
|
||||
);
|
||||
|
||||
const events = this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
|
||||
const events = await this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
|
||||
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
@@ -4327,11 +4290,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.dispatchEvents(events);
|
||||
}
|
||||
|
||||
private buildPumpedMessageEvents(
|
||||
private async buildPumpedMessageEvents(
|
||||
message: SDKMessage,
|
||||
messageIdHint: string | null,
|
||||
turnId: string | null,
|
||||
): AgentStreamEvent[] {
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const messageEvents = this.translateMessageToEvents(message, {
|
||||
suppressAssistantText: true,
|
||||
suppressReasoning: true,
|
||||
@@ -5309,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(
|
||||
@@ -5345,12 +5299,12 @@ 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) {
|
||||
for (const entry of entries) {
|
||||
this.ingestPersistedHistoryEntry(entry, timeline, replay);
|
||||
}
|
||||
|
||||
@@ -5360,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
|
||||
@@ -5473,10 +5421,10 @@ 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),
|
||||
@@ -6243,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) {
|
||||
@@ -6781,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.
|
||||
@@ -6881,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,
|
||||
|
||||
+262
-306
@@ -45,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}',
|
||||
@@ -68,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,
|
||||
@@ -79,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",
|
||||
@@ -97,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({
|
||||
@@ -134,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,
|
||||
@@ -279,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",
|
||||
@@ -364,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",
|
||||
@@ -386,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",
|
||||
});
|
||||
@@ -442,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",
|
||||
@@ -502,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",
|
||||
@@ -519,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",
|
||||
@@ -534,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",
|
||||
@@ -546,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",
|
||||
@@ -559,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);
|
||||
}
|
||||
@@ -601,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({
|
||||
@@ -621,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);
|
||||
|
||||
@@ -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[],
|
||||
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;
|
||||
@@ -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<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> {
|
||||
}): { 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,
|
||||
|
||||
+13
-13
@@ -35,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({
|
||||
@@ -62,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,
|
||||
@@ -91,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([
|
||||
{
|
||||
@@ -114,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,
|
||||
@@ -128,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(),
|
||||
});
|
||||
@@ -145,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 } },
|
||||
],
|
||||
@@ -166,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(),
|
||||
});
|
||||
@@ -179,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: [],
|
||||
}),
|
||||
|
||||
+13
-18
@@ -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>
|
||||
>;
|
||||
entriesByRunId?: ReadonlyMap<string, Iterable<ClaudeReplayEntry>>;
|
||||
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
|
||||
}): Promise<SubagentObservation[]> {
|
||||
const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries);
|
||||
}): 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,17 +115,17 @@ export async function observeReplayWorkflows(input: {
|
||||
return observations;
|
||||
}
|
||||
|
||||
async function observeWorkflowTimeline(input: {
|
||||
function observeWorkflowTimeline(input: {
|
||||
id: string;
|
||||
result: unknown;
|
||||
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
|
||||
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 = input.convertEntry?.(entry) ?? [];
|
||||
for (const item of items) {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -568,43 +568,31 @@ interface ResolveConfigFromPersistedOptions {
|
||||
relayEnabledFallback?: boolean;
|
||||
}
|
||||
|
||||
// Startup values, including authentication, never enter runtime settings resolution.
|
||||
export type ResolvedDaemonSettings = Pick<
|
||||
PaseoDaemonConfig,
|
||||
| "corsAllowedOrigins"
|
||||
| "hostnames"
|
||||
| "trustedProxies"
|
||||
| "mcpEnabled"
|
||||
| "mcpInjectIntoAgents"
|
||||
| "browserToolsEnabled"
|
||||
| "git"
|
||||
| "autoArchiveAfterMerge"
|
||||
| "enableTerminalAgentHooks"
|
||||
| "appendSystemPrompt"
|
||||
| "terminalProfiles"
|
||||
| "agentProfiles"
|
||||
| "skillSelection"
|
||||
| "pluginsEnabled"
|
||||
| "plugins"
|
||||
| "relayEnabled"
|
||||
| "relayRequireProtectedSecurity"
|
||||
| "appBaseUrl"
|
||||
| "idleRuntimeCleanup"
|
||||
| "providerCatalogRefreshTimeoutMs"
|
||||
| "metadataGeneration"
|
||||
| "providerOverrides"
|
||||
>;
|
||||
|
||||
export function resolveConfigFromPersisted(
|
||||
paseoHome: string,
|
||||
persisted: PersistedConfig,
|
||||
options?: ResolveConfigFromPersistedOptions,
|
||||
): PaseoDaemonConfig {
|
||||
const env = configurationEnvironment(options?.env ?? process.env);
|
||||
const cli = options?.cli;
|
||||
const resolvedOptions = options ?? {};
|
||||
const env = configurationEnvironment(resolvedOptions.env ?? process.env);
|
||||
const cli = resolvedOptions.cli;
|
||||
const relayEnabledFallback =
|
||||
options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined;
|
||||
const { overrideControlledPaths, ...settings } = resolveReloadableConfig(persisted, options);
|
||||
resolvedOptions.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined;
|
||||
|
||||
const listen = resolveListenAddress(env, cli, persisted);
|
||||
const {
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
browserToolsEnabled,
|
||||
autoArchiveAfterMerge,
|
||||
appendSystemPrompt,
|
||||
terminalProfiles,
|
||||
agentProfiles,
|
||||
hostnames,
|
||||
trustedProxies,
|
||||
appBaseUrl,
|
||||
} = resolveStaticLoadConfigSettings(env, cli, persisted);
|
||||
|
||||
const relay = resolveRelayConfig({
|
||||
env,
|
||||
persisted,
|
||||
@@ -612,14 +600,42 @@ export function resolveConfigFromPersisted(
|
||||
cliRelayUseTls: cli?.relayUseTls,
|
||||
enabledFallback: relayEnabledFallback,
|
||||
});
|
||||
const { openai, speech } = resolveSpeechConfig({ paseoHome, env, persisted });
|
||||
const serviceProxy = resolveServiceProxyConfig(env, persisted);
|
||||
const webUi = resolveWebUiConfig(paseoHome, env, cli, persisted);
|
||||
|
||||
const { openai, speech } = resolveSpeechConfig({
|
||||
paseoHome,
|
||||
env,
|
||||
persisted,
|
||||
});
|
||||
|
||||
const voiceLlm = resolveVoiceLlmConfig(env, persisted);
|
||||
const providerOverrides = extractProviderOverrides(
|
||||
persisted.agents?.providers as Record<string, unknown> | undefined,
|
||||
);
|
||||
|
||||
const overrideControlledPaths = resolveOverrideControlledPaths(env, cli, persisted);
|
||||
|
||||
return {
|
||||
...settings,
|
||||
listen: resolveListenAddress(env, cli, persisted),
|
||||
listen,
|
||||
paseoHome,
|
||||
desktopManaged: env.PASEO_DESKTOP_MANAGED === "1",
|
||||
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
|
||||
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
|
||||
hostnames,
|
||||
trustedProxies,
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
browserToolsEnabled,
|
||||
git: resolveGitProcessConfig(env, persisted),
|
||||
autoArchiveAfterMerge,
|
||||
enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false,
|
||||
appendSystemPrompt,
|
||||
terminalProfiles,
|
||||
agentProfiles,
|
||||
skillSelection: persisted.agents?.skills?.selection,
|
||||
pluginsEnabled: persisted.pluginsEnabled ?? false,
|
||||
plugins: persisted.plugins,
|
||||
pluginRegistries: persisted.pluginRegistries,
|
||||
pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY,
|
||||
mcpDebug: env.MCP_DEBUG === "1",
|
||||
@@ -627,20 +643,27 @@ export function resolveConfigFromPersisted(
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
staticDir: "public",
|
||||
agentClients: {},
|
||||
relayEnabled: relay.enabled,
|
||||
relayEnabledMutable: relay.enabledMutable,
|
||||
relayRequireProtectedSecurity: relay.requireProtectedSecurity,
|
||||
relayEndpoint: relay.endpoint,
|
||||
relayPublicEndpoint: relay.publicEndpoint,
|
||||
relayUseTls: relay.useTls,
|
||||
relayPublicUseTls: relay.publicUseTls,
|
||||
serviceProxy: resolveServiceProxyConfig(env, persisted),
|
||||
webUi: resolveWebUiConfig(paseoHome, env, cli, persisted),
|
||||
serviceProxy,
|
||||
webUi,
|
||||
appBaseUrl,
|
||||
auth: resolveAuthConfig(env, persisted),
|
||||
openai,
|
||||
speech,
|
||||
voiceLlmProvider: voiceLlm.provider,
|
||||
voiceLlmProviderExplicit: voiceLlm.providerExplicit,
|
||||
voiceLlmModel: voiceLlm.model,
|
||||
agentProviderSettings: extractAgentProviderSettings(settings.providerOverrides),
|
||||
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
|
||||
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
|
||||
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
|
||||
metadataGeneration: persisted.agents?.metadataGeneration,
|
||||
providerOverrides,
|
||||
log: resolveLogConfigFromEnv(env, persisted),
|
||||
configReload: {
|
||||
env: { ...env },
|
||||
@@ -652,40 +675,6 @@ export function resolveConfigFromPersisted(
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveReloadableConfig(
|
||||
persisted: PersistedConfig,
|
||||
options?: ResolveConfigFromPersistedOptions,
|
||||
): ResolvedDaemonSettings & { overrideControlledPaths: string[] } {
|
||||
const env = configurationEnvironment(options?.env ?? process.env);
|
||||
const cli = options?.cli;
|
||||
const relay = resolveRelayConfig({
|
||||
env,
|
||||
persisted,
|
||||
cliRelayEnabled: cli?.relayEnabled,
|
||||
cliRelayUseTls: cli?.relayUseTls,
|
||||
enabledFallback:
|
||||
options?.relayEnabledFallback ?? persisted.daemon?.relay?.enabled === undefined,
|
||||
});
|
||||
return {
|
||||
...resolveStaticLoadConfigSettings(env, cli, persisted),
|
||||
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
|
||||
git: resolveGitProcessConfig(env, persisted),
|
||||
enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false,
|
||||
skillSelection: persisted.agents?.skills?.selection,
|
||||
pluginsEnabled: persisted.pluginsEnabled ?? false,
|
||||
plugins: persisted.plugins,
|
||||
relayEnabled: relay.enabled,
|
||||
relayRequireProtectedSecurity: relay.requireProtectedSecurity,
|
||||
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
|
||||
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
|
||||
metadataGeneration: persisted.agents?.metadataGeneration,
|
||||
providerOverrides: extractProviderOverrides(
|
||||
persisted.agents?.providers as Record<string, unknown> | undefined,
|
||||
),
|
||||
overrideControlledPaths: resolveOverrideControlledPaths(env, cli, persisted),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadConfig(
|
||||
paseoHome: string,
|
||||
options?: Omit<ResolveConfigFromPersistedOptions, "relayEnabledFallback">,
|
||||
|
||||
@@ -101,7 +101,7 @@ describe("DaemonConfigStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("patch persists relay state and emits its field change", async () => {
|
||||
test("patch persists relay state and emits its field change", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
@@ -117,13 +117,13 @@ describe("DaemonConfigStore", () => {
|
||||
const changes: unknown[] = [];
|
||||
store.onFieldChange("relay.enabled", (value) => changes.push(value));
|
||||
|
||||
await store.patch({ relay: { enabled: true } });
|
||||
store.patch({ relay: { enabled: true } });
|
||||
|
||||
expect(changes).toEqual([true]);
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("patch persists the relay protected security requirement independently of relay enablement", async () => {
|
||||
test("patch persists the relay protected security requirement independently of relay enablement", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(
|
||||
@@ -145,14 +145,14 @@ describe("DaemonConfigStore", () => {
|
||||
const changes: unknown[] = [];
|
||||
store.onFieldChange("relay.requireProtectedSecurity", (value) => changes.push(value));
|
||||
|
||||
await store.patch({ relay: { requireProtectedSecurity: true } });
|
||||
store.patch({ relay: { requireProtectedSecurity: true } });
|
||||
|
||||
expect(changes).toEqual([true]);
|
||||
expect(store.get().relay).toEqual({ enabled: true, requireProtectedSecurity: true });
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.requireProtectedSecurity).toBe(true);
|
||||
});
|
||||
|
||||
test("patch round-trips agent profiles through the strictly-parsed persisted config", async () => {
|
||||
test("patch round-trips agent profiles through the strictly-parsed persisted config", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
@@ -166,7 +166,7 @@ describe("DaemonConfigStore", () => {
|
||||
appendSystemPrompt: "",
|
||||
});
|
||||
|
||||
await store.patch({
|
||||
store.patch({
|
||||
agentProfiles: [
|
||||
{
|
||||
id: "profile_ui",
|
||||
@@ -198,7 +198,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(store.get().agentProfiles).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("patch replaces the whole agent profile list rather than merging entries", async () => {
|
||||
test("patch replaces the whole agent profile list rather than merging entries", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
@@ -216,13 +216,13 @@ describe("DaemonConfigStore", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] });
|
||||
store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] });
|
||||
|
||||
expect(store.get().agentProfiles).toEqual([{ id: "a", name: "Keep", provider: "claude" }]);
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("rolls back config when a field transition fails", async () => {
|
||||
test("rolls back config when a field transition fails", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
@@ -241,14 +241,14 @@ describe("DaemonConfigStore", () => {
|
||||
}
|
||||
});
|
||||
|
||||
await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow(
|
||||
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
|
||||
"Relay transport failed to start",
|
||||
);
|
||||
expect(store.get().relay?.enabled).toBe(false);
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("rolls back live owners when a later transactional owner fails", async () => {
|
||||
test("rolls back live owners when a later transactional owner fails", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
@@ -272,7 +272,7 @@ describe("DaemonConfigStore", () => {
|
||||
throw new Error("Provider refresh failed");
|
||||
});
|
||||
|
||||
await expect(store.patch({ browserTools: { enabled: true } })).rejects.toThrow(
|
||||
expect(() => store.patch({ browserTools: { enabled: true } })).toThrow(
|
||||
"Provider refresh failed",
|
||||
);
|
||||
expect(browserToolsEnabled).toBe(false);
|
||||
@@ -280,7 +280,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.browserTools?.enabled).toBeUndefined();
|
||||
});
|
||||
|
||||
test("rejects relay patches when a launch override owns the setting", async () => {
|
||||
test("rejects relay patches when a launch override owns the setting", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(
|
||||
@@ -299,12 +299,12 @@ describe("DaemonConfigStore", () => {
|
||||
{ relayEnabledMutable: false },
|
||||
);
|
||||
|
||||
await expect(store.patch({ relay: { enabled: true } })).rejects.toThrow(
|
||||
expect(() => store.patch({ relay: { enabled: true } })).toThrow(
|
||||
"Relay is controlled by a daemon launch override",
|
||||
);
|
||||
});
|
||||
|
||||
test("unrelated patches do not persist a one-launch relay override", async () => {
|
||||
test("unrelated patches do not persist a one-launch relay override", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
@@ -331,12 +331,12 @@ describe("DaemonConfigStore", () => {
|
||||
{ relayEnabledMutable: false },
|
||||
);
|
||||
|
||||
await store.patch({ browserTools: { enabled: true } });
|
||||
store.patch({ browserTools: { enabled: true } });
|
||||
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("unrelated patches persist only requested file intent", async () => {
|
||||
test("unrelated patches persist only requested file intent", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const before = loadPersistedConfig(paseoHome);
|
||||
@@ -362,7 +362,7 @@ describe("DaemonConfigStore", () => {
|
||||
{ relayEnabledMutable: false },
|
||||
);
|
||||
|
||||
await store.patch({
|
||||
store.patch({
|
||||
appendSystemPrompt: "Only this field",
|
||||
// Reload-only runtime state is accepted as unknown wire data for forward
|
||||
// compatibility but is not part of the patch capability.
|
||||
@@ -376,7 +376,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists provider enabled flags into config.json", async () => {
|
||||
test("patch persists provider enabled flags into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -417,7 +417,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
await store.patch({
|
||||
store.patch({
|
||||
providers: {
|
||||
gemini: { enabled: false },
|
||||
},
|
||||
@@ -432,7 +432,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists provider Paseo-tool policy without changing availability", async () => {
|
||||
test("patch persists provider Paseo-tool policy without changing availability", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
writeFileSync(
|
||||
@@ -449,14 +449,14 @@ describe("DaemonConfigStore", () => {
|
||||
appendSystemPrompt: "",
|
||||
});
|
||||
|
||||
await store.patch({
|
||||
store.patch({
|
||||
providers: {
|
||||
claude: {
|
||||
paseoTools: { enabled: true, disabledTools: ["list_agents"] },
|
||||
},
|
||||
},
|
||||
});
|
||||
await store.patch({
|
||||
store.patch({
|
||||
providers: {
|
||||
claude: {
|
||||
paseoTools: { disabledTools: ["create_agent"] },
|
||||
@@ -474,7 +474,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch removes provider entries from config.json", async () => {
|
||||
test("patch removes provider entries from config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -519,7 +519,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
const next = await store.patch({ removeProviders: ["gemini"] });
|
||||
const next = store.patch({ removeProviders: ["gemini"] });
|
||||
|
||||
expect(next.providers.gemini).toBeUndefined();
|
||||
expect(next.providers.claude).toEqual({ enabled: false });
|
||||
@@ -528,7 +528,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(persisted.agents?.providers?.claude).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
test("patch removes the providers object when the last provider is deleted", async () => {
|
||||
test("patch removes the providers object when the last provider is deleted", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -567,13 +567,13 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
await store.patch({ removeProviders: ["gemini"] });
|
||||
store.patch({ removeProviders: ["gemini"] });
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.agents?.providers).toBeUndefined();
|
||||
});
|
||||
|
||||
test("patch removes deleted providers from metadata generation", async () => {
|
||||
test("patch removes deleted providers from metadata generation", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -629,7 +629,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
const next = await store.patch({ removeProviders: ["gemini"] });
|
||||
const next = store.patch({ removeProviders: ["gemini"] });
|
||||
|
||||
expect(next.metadataGeneration.providers).toEqual([{ provider: "claude", model: "haiku" }]);
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
@@ -638,7 +638,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists provider removal when in-memory config is already clean", async () => {
|
||||
test("patch persists provider removal when in-memory config is already clean", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -680,7 +680,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
const next = await store.patch({ removeProviders: ["gemini"] });
|
||||
const next = store.patch({ removeProviders: ["gemini"] });
|
||||
|
||||
expect(next.providers.gemini).toBeUndefined();
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
@@ -688,7 +688,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] });
|
||||
});
|
||||
|
||||
test("patch persists append system prompt into config.json", async () => {
|
||||
test("patch persists append system prompt into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -706,7 +706,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
await store.patch({
|
||||
store.patch({
|
||||
appendSystemPrompt: "Prefer terse replies.",
|
||||
});
|
||||
|
||||
@@ -714,7 +714,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
|
||||
});
|
||||
|
||||
test("patch persists browser tools opt-in into config.json", async () => {
|
||||
test("patch persists browser tools opt-in into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -731,13 +731,13 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
await store.patch({ browserTools: { enabled: true } });
|
||||
store.patch({ browserTools: { enabled: true } });
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.daemon?.browserTools).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
test("patch persists provider additional models into config.json", async () => {
|
||||
test("patch persists provider additional models into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -755,7 +755,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
await store.patch({
|
||||
store.patch({
|
||||
providers: {
|
||||
claude: {
|
||||
additionalModels: [
|
||||
@@ -779,7 +779,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists daemon append system prompt into config.json", async () => {
|
||||
test("patch persists daemon append system prompt into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -797,7 +797,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
await store.patch({
|
||||
store.patch({
|
||||
appendSystemPrompt: "Prefer terse replies.",
|
||||
});
|
||||
|
||||
@@ -805,7 +805,7 @@ describe("DaemonConfigStore", () => {
|
||||
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
|
||||
});
|
||||
|
||||
test("patch persists enable terminal agent hooks into config.json", async () => {
|
||||
test("patch persists enable terminal agent hooks into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -822,13 +822,13 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
await store.patch({ enableTerminalAgentHooks: true });
|
||||
store.patch({ enableTerminalAgentHooks: true });
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.daemon?.enableTerminalAgentHooks).toBe(true);
|
||||
});
|
||||
|
||||
test("patch persists metadata generation providers into config.json", async () => {
|
||||
test("patch persists metadata generation providers into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -846,7 +846,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
await store.patch({
|
||||
store.patch({
|
||||
metadataGeneration: {
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
@@ -864,7 +864,7 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists clearing metadata generation providers into config.json", async () => {
|
||||
test("patch persists clearing metadata generation providers into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -899,13 +899,13 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
await store.patch({ metadataGeneration: { providers: [] } });
|
||||
store.patch({ metadataGeneration: { providers: [] } });
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] });
|
||||
});
|
||||
|
||||
test("patch persists the host idle runtime cleanup opt-out for the next daemon start", async () => {
|
||||
test("patch persists the host idle runtime cleanup opt-out for the next daemon start", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
writeFileSync(
|
||||
@@ -930,7 +930,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect((await store.patch({ idleRuntimeCleanup: false })).idleRuntimeCleanup).toBe(false);
|
||||
expect(store.patch({ idleRuntimeCleanup: false }).idleRuntimeCleanup).toBe(false);
|
||||
|
||||
expect(loadPersistedConfig(paseoHome).agents).toEqual({
|
||||
metadataGeneration: { providers: [{ provider: "claude" }] },
|
||||
@@ -938,11 +938,11 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false);
|
||||
|
||||
await store.patch({ idleRuntimeCleanup: true });
|
||||
store.patch({ idleRuntimeCleanup: true });
|
||||
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true);
|
||||
});
|
||||
|
||||
test("patch persists custom ACP provider overrides into config.json", async () => {
|
||||
test("patch persists custom ACP provider overrides into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -960,7 +960,7 @@ describe("DaemonConfigStore", () => {
|
||||
undefined,
|
||||
);
|
||||
|
||||
await store.patch({
|
||||
store.patch({
|
||||
providers: {
|
||||
"paseo-e2e-acp": {
|
||||
extends: "acp",
|
||||
@@ -1028,49 +1028,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
writeFileSync(path.join(paseoHome, "config.json"), `${JSON.stringify(config, null, 2)}\n`);
|
||||
}
|
||||
|
||||
test("applies overlapping plugin mutations to the latest committed sources", async () => {
|
||||
const { store } = createReloadableStore({
|
||||
initialPersisted: {
|
||||
plugins: {
|
||||
a: { source: "directory", path: "/a", enabled: false },
|
||||
b: { source: "directory", path: "/b", enabled: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const enable = store.patch((current) => ({
|
||||
plugins: {
|
||||
...current.plugins,
|
||||
a: { ...current.plugins.a!, enabled: true },
|
||||
},
|
||||
}));
|
||||
const disable = store.patch((current) => ({
|
||||
plugins: {
|
||||
...current.plugins,
|
||||
b: { ...current.plugins.b!, enabled: false },
|
||||
},
|
||||
}));
|
||||
await Promise.all([enable, disable]);
|
||||
expect(store.get().plugins).toEqual({
|
||||
a: { source: "directory", path: "/a", enabled: true },
|
||||
b: { source: "directory", path: "/b", enabled: false },
|
||||
});
|
||||
});
|
||||
|
||||
test("serializes overlapping patches before committing their combined state", async () => {
|
||||
const { paseoHome, store } = createReloadableStore();
|
||||
const first = store.patch({ appendSystemPrompt: "first" });
|
||||
const second = store.patch({ browserTools: { enabled: true } });
|
||||
expect(store.get().appendSystemPrompt).toBe("");
|
||||
await Promise.all([first, second]);
|
||||
expect(store.get().appendSystemPrompt).toBe("first");
|
||||
expect(store.get().browserTools.enabled).toBe(true);
|
||||
expect(loadPersistedConfig(paseoHome).daemon).toMatchObject({
|
||||
appendSystemPrompt: "first",
|
||||
browserTools: { enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("applies mutable edits and reports startup-only edits", async () => {
|
||||
test("applies mutable edits and reports startup-only edits", () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore();
|
||||
writeConfig(paseoHome, {
|
||||
...persisted,
|
||||
@@ -1082,7 +1040,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(await store.reload()).toEqual({
|
||||
expect(store.reload()).toEqual({
|
||||
appliedPaths: [
|
||||
"daemon.browserTools.enabled",
|
||||
"daemon.git.maxProcessConcurrency",
|
||||
@@ -1095,13 +1053,13 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().git).toEqual({ maxProcessesPerSecond: 12, maxProcessConcurrency: 3 });
|
||||
});
|
||||
|
||||
test("reloads idle runtime cleanup in both directions without requiring a restart", async () => {
|
||||
test("reloads idle runtime cleanup in both directions without requiring a restart", () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore();
|
||||
const changes: unknown[] = [];
|
||||
store.onFieldChange("idleRuntimeCleanup", (value) => changes.push(value));
|
||||
for (const enabled of [false, true]) {
|
||||
writeConfig(paseoHome, { ...persisted, agents: { idleRuntimeCleanup: enabled } });
|
||||
expect(await store.reload()).toEqual({
|
||||
expect(store.reload()).toEqual({
|
||||
appliedPaths: ["agents.idleRuntimeCleanup"],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
@@ -1109,8 +1067,8 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().idleRuntimeCleanup).toBe(enabled);
|
||||
}
|
||||
expect(changes).toEqual([false, true]);
|
||||
await store.patch({ idleRuntimeCleanup: false });
|
||||
expect(await store.reload()).toEqual({
|
||||
store.patch({ idleRuntimeCleanup: false });
|
||||
expect(store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
@@ -1118,7 +1076,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().idleRuntimeCleanup).toBe(false);
|
||||
});
|
||||
|
||||
test("applies the global plugin switch in both directions", async () => {
|
||||
test("applies the global plugin switch in both directions", () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore({
|
||||
initialPersisted: { version: 1, pluginsEnabled: false },
|
||||
});
|
||||
@@ -1126,7 +1084,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
store.onFieldChange("pluginsEnabled", (value) => changes.push(value));
|
||||
|
||||
writeConfig(paseoHome, { ...persisted, pluginsEnabled: true });
|
||||
expect(await store.reload()).toEqual({
|
||||
expect(store.reload()).toEqual({
|
||||
appliedPaths: ["pluginsEnabled"],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
@@ -1134,7 +1092,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().pluginsEnabled).toBe(true);
|
||||
|
||||
writeConfig(paseoHome, { ...persisted, pluginsEnabled: false });
|
||||
expect(await store.reload()).toEqual({
|
||||
expect(store.reload()).toEqual({
|
||||
appliedPaths: ["pluginsEnabled"],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
@@ -1143,7 +1101,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(changes).toEqual([true, false]);
|
||||
});
|
||||
|
||||
test("classifies every leaf when a parent subtree is added", async () => {
|
||||
test("classifies every leaf when a parent subtree is added", () => {
|
||||
const { paseoHome, store } = createReloadableStore({
|
||||
initialPersisted: { version: 1 },
|
||||
});
|
||||
@@ -1158,14 +1116,14 @@ describe("DaemonConfigStore reload", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(await store.reload()).toEqual({
|
||||
expect(store.reload()).toEqual({
|
||||
appliedPaths: ["daemon.relay.enabled"],
|
||||
restartRequiredPaths: ["daemon.relay.endpoint", "daemon.relay.useTls"],
|
||||
overrideControlledPaths: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("classifies every leaf when the daemon subtree is removed", async () => {
|
||||
test("classifies every leaf when the daemon subtree is removed", () => {
|
||||
const { paseoHome, store } = createReloadableStore({
|
||||
initialPersisted: {
|
||||
version: 1,
|
||||
@@ -1186,7 +1144,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
});
|
||||
writeConfig(paseoHome, { version: 1 });
|
||||
|
||||
expect(await store.reload()).toEqual({
|
||||
expect(store.reload()).toEqual({
|
||||
appliedPaths: ["daemon.browserTools.enabled"],
|
||||
restartRequiredPaths: [
|
||||
"daemon.listen",
|
||||
@@ -1200,7 +1158,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().relay?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps overridden leaves separate from restart-required siblings", async () => {
|
||||
test("keeps overridden leaves separate from restart-required siblings", () => {
|
||||
const { paseoHome, store } = createReloadableStore({
|
||||
initialPersisted: { version: 1 },
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
@@ -1212,25 +1170,25 @@ describe("DaemonConfigStore reload", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(await store.reload()).toEqual({
|
||||
expect(store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: ["daemon.relay.endpoint"],
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid JSON and invalid schema apply nothing", async () => {
|
||||
test("invalid JSON and invalid schema apply nothing", () => {
|
||||
const { paseoHome, store } = createReloadableStore();
|
||||
writeFileSync(path.join(paseoHome, "config.json"), "{ nope\n");
|
||||
await expect(store.reload()).rejects.toThrow("Invalid JSON");
|
||||
expect(() => store.reload()).toThrow("Invalid JSON");
|
||||
expect(store.get().browserTools.enabled).toBe(false);
|
||||
|
||||
writeConfig(paseoHome, { daemon: { browserTools: { enabled: "yes" } } });
|
||||
await expect(store.reload()).rejects.toThrow("Invalid config");
|
||||
expect(() => store.reload()).toThrow("Invalid config");
|
||||
expect(store.get().browserTools.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("removing providers and optional profiles clears live state", async () => {
|
||||
test("removing providers and optional profiles clears live state", () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore();
|
||||
writeConfig(paseoHome, {
|
||||
...persisted,
|
||||
@@ -1245,10 +1203,10 @@ describe("DaemonConfigStore reload", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await store.reload();
|
||||
store.reload();
|
||||
|
||||
writeConfig(paseoHome, persisted);
|
||||
const result = await store.reload();
|
||||
const result = store.reload();
|
||||
|
||||
expect(result.appliedPaths).toEqual([
|
||||
"agents.providers",
|
||||
@@ -1260,7 +1218,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().agentProfiles).toBeUndefined();
|
||||
});
|
||||
|
||||
test("reports a launch-controlled edit without changing live state", async () => {
|
||||
test("reports a launch-controlled edit without changing live state", () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore({
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
});
|
||||
@@ -1270,7 +1228,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
daemon: { ...persisted.daemon, relay: { enabled: !initialRelay } },
|
||||
});
|
||||
|
||||
expect(await store.reload()).toEqual({
|
||||
expect(store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
@@ -1278,7 +1236,7 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().relay?.enabled).toBe(initialRelay);
|
||||
});
|
||||
|
||||
test("an unrelated patch does not mark a manual override-owned edit as applied", async () => {
|
||||
test("an unrelated patch does not mark a manual override-owned edit as applied", () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore({
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
});
|
||||
@@ -1286,16 +1244,16 @@ describe("DaemonConfigStore reload", () => {
|
||||
...persisted,
|
||||
daemon: { ...persisted.daemon, relay: { enabled: true } },
|
||||
});
|
||||
await store.patch({ appendSystemPrompt: "patched elsewhere" });
|
||||
store.patch({ appendSystemPrompt: "patched elsewhere" });
|
||||
|
||||
expect(await store.reload()).toEqual({
|
||||
expect(store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
});
|
||||
});
|
||||
|
||||
test("reports startup-only launch overrides instead of restart warnings", async () => {
|
||||
test("reports startup-only launch overrides instead of restart warnings", () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore({
|
||||
overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"],
|
||||
});
|
||||
@@ -1311,16 +1269,16 @@ describe("DaemonConfigStore reload", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(await store.reload()).toEqual({
|
||||
expect(store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: ["daemon.listen", "daemon.relay.endpoint"],
|
||||
});
|
||||
});
|
||||
|
||||
test("a no-op reload returns empty path lists", async () => {
|
||||
test("a no-op reload returns empty path lists", () => {
|
||||
const { store } = createReloadableStore();
|
||||
expect(await store.reload()).toEqual({
|
||||
expect(store.reload()).toEqual({
|
||||
appliedPaths: [],
|
||||
restartRequiredPaths: [],
|
||||
overrideControlledPaths: [],
|
||||
@@ -1359,25 +1317,25 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
return { paseoHome, store, applied, persisted };
|
||||
}
|
||||
|
||||
test("reads, patches, and clears local STT language without losing it on other saves", async () => {
|
||||
test("reads, patches, and clears local STT language without losing it on other saves", () => {
|
||||
const { paseoHome, store } = createSpeechStore({
|
||||
features: { dictation: { stt: { language: "fr" } } },
|
||||
});
|
||||
expect(store.get().features?.dictation.stt).toEqual({ provider: "local", language: "fr" });
|
||||
await store.patch({ features: { voiceMode: { enabled: true } } });
|
||||
store.patch({ features: { voiceMode: { enabled: true } } });
|
||||
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({
|
||||
provider: "local",
|
||||
language: "fr",
|
||||
});
|
||||
const patched = await store.patch({
|
||||
const patched = store.patch({
|
||||
features: { dictation: { stt: { provider: "local", language: "de" } } },
|
||||
});
|
||||
expect(patched.features?.dictation.stt).toEqual({ provider: "local", language: "de" });
|
||||
await store.patch({ features: { dictation: { stt: { language: null } } } });
|
||||
store.patch({ features: { dictation: { stt: { language: null } } } });
|
||||
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({ provider: "local" });
|
||||
});
|
||||
|
||||
test("reports language env overrides for both local STT sites", async () => {
|
||||
test("reports language env overrides for both local STT sites", () => {
|
||||
const { store } = createSpeechStore(
|
||||
{},
|
||||
{ PASEO_DICTATION_LANGUAGE: "fr", PASEO_VOICE_LANGUAGE: "de" },
|
||||
@@ -1388,22 +1346,22 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps voice mode off until it is turned on", async () => {
|
||||
test("keeps voice mode off until it is turned on", () => {
|
||||
const { paseoHome, store } = createSpeechStore();
|
||||
|
||||
expect(store.get().features?.dictation.enabled).toBe(true);
|
||||
expect(store.get().features?.voiceMode.enabled).toBe(false);
|
||||
|
||||
const result = await store.patch({ features: { voiceMode: { enabled: true } } });
|
||||
const result = store.patch({ features: { voiceMode: { enabled: true } } });
|
||||
|
||||
expect(result.features?.voiceMode.enabled).toBe(true);
|
||||
expect(loadPersistedConfig(paseoHome).features?.voiceMode?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("stores the OpenAI key without ever returning it", async () => {
|
||||
test("stores the OpenAI key without ever returning it", () => {
|
||||
const { paseoHome, store, applied } = createSpeechStore();
|
||||
|
||||
const result = await store.patch({
|
||||
const result = store.patch({
|
||||
features: { dictation: { stt: { provider: "openai", apiKey: " sk-test " } } },
|
||||
});
|
||||
|
||||
@@ -1415,26 +1373,26 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("applies a replaced key even though the visible settings do not change", async () => {
|
||||
test("applies a replaced key even though the visible settings do not change", () => {
|
||||
const { store, applied } = createSpeechStore({
|
||||
version: 1,
|
||||
features: { dictation: { stt: { provider: "openai", apiKey: "sk-old" } } },
|
||||
});
|
||||
|
||||
await store.patch({ features: { dictation: { stt: { apiKey: "sk-new" } } } });
|
||||
store.patch({ features: { dictation: { stt: { apiKey: "sk-new" } } } });
|
||||
|
||||
expect(applied.map((next) => next.features?.dictation?.stt)).toEqual([
|
||||
{ provider: "openai", apiKey: "sk-new" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("switching provider clears the previous provider's model", async () => {
|
||||
test("switching provider clears the previous provider's model", () => {
|
||||
const { paseoHome, store } = createSpeechStore({
|
||||
version: 1,
|
||||
features: { dictation: { stt: { provider: "local", model: "parakeet-tdt-0.6b-v3-int8" } } },
|
||||
});
|
||||
|
||||
const result = await store.patch({ features: { dictation: { stt: { provider: "openai" } } } });
|
||||
const result = store.patch({ features: { dictation: { stt: { provider: "openai" } } } });
|
||||
|
||||
expect(result.features?.dictation.stt).toEqual({ provider: "openai", hasApiKey: false });
|
||||
expect(loadPersistedConfig(paseoHome).features?.dictation?.stt).toEqual({
|
||||
@@ -1442,62 +1400,62 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a local model id outside the catalog without persisting it", async () => {
|
||||
test("rejects a local model id outside the catalog without persisting it", () => {
|
||||
const { paseoHome, store, applied } = createSpeechStore();
|
||||
|
||||
await expect(
|
||||
expect(() =>
|
||||
store.patch({ features: { voiceMode: { tts: { provider: "local", model: "nope" } } } }),
|
||||
).rejects.toThrow("Unknown local voice model: nope");
|
||||
).toThrow("Unknown local voice model: nope");
|
||||
expect(loadPersistedConfig(paseoHome).features?.voiceMode).toBeUndefined();
|
||||
expect(applied).toEqual([]);
|
||||
});
|
||||
|
||||
test("reload applies hand-edited speech settings without a restart", async () => {
|
||||
test("reload applies hand-edited speech settings without a restart", () => {
|
||||
const { paseoHome, store, applied, persisted } = createSpeechStore();
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({ ...persisted, features: { voiceMode: { enabled: false } } }, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const result = await store.reload();
|
||||
const result = store.reload();
|
||||
|
||||
expect(result.appliedPaths).toContain("features.voiceMode");
|
||||
expect(result.restartRequiredPaths).toEqual([]);
|
||||
expect(store.get().features?.voiceMode.enabled).toBe(false);
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
test("merges same-provider fields and replaces the whole variant when provider changes", async () => {
|
||||
test("merges same-provider fields and replaces the whole variant when provider changes", () => {
|
||||
const { paseoHome, store } = createSpeechStore();
|
||||
await store.patch({
|
||||
store.patch({
|
||||
features: {
|
||||
voiceMode: {
|
||||
stt: { provider: "openai", apiKey: "secret", baseUrl: "https://site/v1", model: "first" },
|
||||
},
|
||||
},
|
||||
});
|
||||
await store.patch({ features: { voiceMode: { stt: { model: "second", baseUrl: null } } } });
|
||||
store.patch({ features: { voiceMode: { stt: { model: "second", baseUrl: null } } } });
|
||||
expect(loadPersistedConfig(paseoHome).features?.voiceMode?.stt).toEqual({
|
||||
provider: "openai",
|
||||
apiKey: "secret",
|
||||
model: "second",
|
||||
});
|
||||
await store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "third" } } } });
|
||||
store.patch({ features: { voiceMode: { stt: { provider: "openai", model: "third" } } } });
|
||||
expect(store.get().features?.voiceMode.stt).toEqual({
|
||||
provider: "openai",
|
||||
model: "third",
|
||||
hasApiKey: true,
|
||||
});
|
||||
await store.patch({ features: { voiceMode: { stt: { apiKey: "new-secret" } } } });
|
||||
await store.patch({ features: { voiceMode: { stt: { provider: "local" } } } });
|
||||
await store.patch({ features: { voiceMode: { stt: { provider: "openai" } } } });
|
||||
store.patch({ features: { voiceMode: { stt: { apiKey: "new-secret" } } } });
|
||||
store.patch({ features: { voiceMode: { stt: { provider: "local" } } } });
|
||||
store.patch({ features: { voiceMode: { stt: { provider: "openai" } } } });
|
||||
expect(store.get().features?.voiceMode.stt).toEqual({ provider: "openai", hasApiKey: false });
|
||||
});
|
||||
|
||||
test("keeps a saved OpenAI key when a full variant patch omits the write-only key", async () => {
|
||||
test("keeps a saved OpenAI key when a full variant patch omits the write-only key", () => {
|
||||
const { paseoHome, store } = createSpeechStore({
|
||||
features: { voiceMode: { stt: { provider: "openai", apiKey: "saved-secret" } } },
|
||||
});
|
||||
const result = await store.patch({
|
||||
const result = store.patch({
|
||||
features: { voiceMode: { stt: { provider: "openai", model: "whisper-1" } } },
|
||||
});
|
||||
expect(result.features?.voiceMode.stt).toEqual({
|
||||
@@ -1513,7 +1471,7 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
expect(JSON.stringify(result)).not.toContain("saved-secret");
|
||||
});
|
||||
|
||||
test("loads a legacy host, redacts its key and writes the normalized config after any save", async () => {
|
||||
test("loads a legacy host, redacts its key and writes the normalized config after any save", () => {
|
||||
const { paseoHome, store } = createSpeechStore({
|
||||
providers: { openai: { apiKey: "legacy-secret", stt: { baseUrl: "https://old/v1" } } },
|
||||
features: { voiceMode: { enabled: true, stt: { provider: "openai" } } },
|
||||
@@ -1524,13 +1482,13 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
hasApiKey: true,
|
||||
});
|
||||
expect(JSON.stringify(store.get())).not.toContain("legacy-secret");
|
||||
await store.patch({ autoArchiveAfterMerge: true });
|
||||
store.patch({ autoArchiveAfterMerge: true });
|
||||
const disk = JSON.parse(readFileSync(path.join(paseoHome, "config.json"), "utf8"));
|
||||
expect(disk.providers).not.toHaveProperty("openai");
|
||||
expect(disk.features.voiceMode.stt.apiKey).toBe("legacy-secret");
|
||||
});
|
||||
|
||||
test("reports env overrides and removes fallback provenance after saving a key", async () => {
|
||||
test("reports env overrides and removes fallback provenance after saving a key", () => {
|
||||
const { store } = createSpeechStore(
|
||||
{ features: { voiceMode: { stt: { provider: "openai", model: "configured" } } } },
|
||||
{
|
||||
@@ -1556,7 +1514,7 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
"features.voiceMode.stt.model": "STT_MODEL",
|
||||
});
|
||||
expect(other.get().features?.voiceMode.stt.hasApiKey).toBe(false);
|
||||
await other.patch({
|
||||
other.patch({
|
||||
features: { voiceMode: { stt: { apiKey: "saved-secret", baseUrl: "https://saved/v1" } } },
|
||||
});
|
||||
expect(other.get().envOverrides).toEqual({ "features.voiceMode.stt.model": "STT_MODEL" });
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
loadPersistedConfig,
|
||||
savePersistedConfigAsync,
|
||||
loadPersistedConfigAsync,
|
||||
savePersistedConfig,
|
||||
type PersistedConfig,
|
||||
} from "./persisted-config.js";
|
||||
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
|
||||
@@ -336,7 +335,6 @@ export function applyMutableProviderConfigToOverrides(
|
||||
}
|
||||
|
||||
export class DaemonConfigStore {
|
||||
private mutations: Promise<unknown> = Promise.resolve();
|
||||
private current: MutableDaemonConfig;
|
||||
private readonly paseoHome: string;
|
||||
private readonly logger: LoggerLike | undefined;
|
||||
@@ -378,29 +376,16 @@ export class DaemonConfigStore {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
public patch(
|
||||
partial:
|
||||
| MutableDaemonConfigPatch
|
||||
| ((current: MutableDaemonConfig) => MutableDaemonConfigPatch),
|
||||
): Promise<MutableDaemonConfig> {
|
||||
return this.enqueue(() =>
|
||||
this.applySupportedPatch(
|
||||
pickSupportedPatchFields(
|
||||
MutableDaemonConfigPatchSchema.parse(
|
||||
typeof partial === "function" ? partial(this.current) : partial,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
public patch(partial: MutableDaemonConfigPatch): MutableDaemonConfig {
|
||||
const parsedPatch = pickSupportedPatchFields(MutableDaemonConfigPatchSchema.parse(partial));
|
||||
return this.applySupportedPatch(parsedPatch);
|
||||
}
|
||||
|
||||
public setAgentSkillSelection(selection: AgentSkillSelection): Promise<MutableDaemonConfig> {
|
||||
return this.enqueue(() => this.applySupportedPatch({ skills: { selection } }));
|
||||
public setAgentSkillSelection(selection: AgentSkillSelection): MutableDaemonConfig {
|
||||
return this.applySupportedPatch({ skills: { selection } });
|
||||
}
|
||||
|
||||
private async applySupportedPatch(
|
||||
parsedPatch: SupportedMutableConfigPatch,
|
||||
): Promise<MutableDaemonConfig> {
|
||||
private applySupportedPatch(parsedPatch: SupportedMutableConfigPatch): MutableDaemonConfig {
|
||||
if (parsedPatch.relay?.enabled !== undefined && !this.relayEnabledMutable) {
|
||||
throw new Error(
|
||||
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
|
||||
@@ -429,7 +414,7 @@ export class DaemonConfigStore {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
const { previous: persistedBeforePatch, knownNext } = await this.persistConfig(
|
||||
const { previous: persistedBeforePatch, knownNext } = this.persistConfig(
|
||||
patch,
|
||||
removedProviders,
|
||||
);
|
||||
@@ -438,7 +423,7 @@ export class DaemonConfigStore {
|
||||
try {
|
||||
this.applyReplacement(next, { removedProviders });
|
||||
} catch (error) {
|
||||
await savePersistedConfigAsync(this.paseoHome, persistedBeforePatch, this.logger);
|
||||
savePersistedConfig(this.paseoHome, persistedBeforePatch, this.logger);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -469,16 +454,12 @@ export class DaemonConfigStore {
|
||||
);
|
||||
}
|
||||
|
||||
public reload(): Promise<DaemonConfigReloadResult> {
|
||||
return this.enqueue(() => this.reloadPersisted());
|
||||
}
|
||||
|
||||
private async reloadPersisted(): Promise<DaemonConfigReloadResult> {
|
||||
public reload(): DaemonConfigReloadResult {
|
||||
if (!this.reloadSource) {
|
||||
throw new Error("Daemon config reload is unavailable for this daemon instance");
|
||||
}
|
||||
|
||||
const persisted = await loadPersistedConfigAsync(this.paseoHome, this.logger);
|
||||
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
|
||||
const resolved = this.reloadSource.resolve(persisted);
|
||||
// Plugin source changes require the plugin lifecycle operation or a daemon
|
||||
// restart. The global switch is independently reloadable.
|
||||
@@ -640,24 +621,11 @@ export class DaemonConfigStore {
|
||||
};
|
||||
}
|
||||
|
||||
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.mutations.then(operation);
|
||||
this.mutations = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
public readServicePorts(): Promise<NonNullable<PersistedConfig["worktrees"]>["servicePorts"]> {
|
||||
return this.enqueue(
|
||||
async () =>
|
||||
(await loadPersistedConfigAsync(this.paseoHome, this.logger)).worktrees?.servicePorts,
|
||||
);
|
||||
}
|
||||
|
||||
private async persistConfig(
|
||||
private persistConfig(
|
||||
patch: Omit<SupportedMutableConfigPatch, "removeProviders">,
|
||||
removeProviders: readonly string[],
|
||||
): Promise<{ previous: PersistedConfig; knownNext: PersistedConfig }> {
|
||||
const persisted = await loadPersistedConfigAsync(this.paseoHome, this.logger);
|
||||
): { previous: PersistedConfig; knownNext: PersistedConfig } {
|
||||
const persisted = loadPersistedConfig(this.paseoHome, this.logger);
|
||||
const merge = (source: PersistedConfig) =>
|
||||
mergeMutablePatchIntoPersistedConfig({
|
||||
persisted: source,
|
||||
@@ -667,7 +635,7 @@ export class DaemonConfigStore {
|
||||
});
|
||||
const nextPersisted = merge(persisted);
|
||||
const knownNext = merge(this.lastKnownPersisted);
|
||||
await savePersistedConfigAsync(this.paseoHome, nextPersisted, this.logger);
|
||||
savePersistedConfig(this.paseoHome, nextPersisted, this.logger);
|
||||
return { previous: persisted, knownNext };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
@@ -224,76 +223,3 @@ test("All changes subscriptions refresh on file edits and commits", async () =>
|
||||
await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("large source diffs stay renderable for reads, subscriptions, and commit files", async () => {
|
||||
const ctx = await createDaemonTestContext();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-budget-"));
|
||||
const runGit = promisify(execFile);
|
||||
try {
|
||||
git(cwd, "init", "-b", "main");
|
||||
git(cwd, "config", "user.name", "Test");
|
||||
git(cwd, "config", "user.email", "test@example.com");
|
||||
git(cwd, "config", "core.autocrlf", "false");
|
||||
const original = "const value = Math.max(42, 7);\n".repeat(Math.ceil((5 * 1024 * 1024) / 30));
|
||||
writeFileSync(join(cwd, "generated.ts"), original);
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
|
||||
await writeFile(join(cwd, "generated.ts"), original.replace("42", "43"));
|
||||
const pending = ctx.client.getCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
const rpcStarted = performance.now();
|
||||
await ctx.client.fetchAgents({});
|
||||
const rpcMs = performance.now() - rpcStarted;
|
||||
const initial = await pending;
|
||||
expect(initial.error).toBeNull();
|
||||
const check = (file: (typeof initial.files)[number] | null | undefined, value: string) => {
|
||||
expect(file).toMatchObject({ path: "generated.ts", additions: 1, deletions: 1 });
|
||||
const lines = file!.hunks.flatMap((hunk) => hunk.lines);
|
||||
expect(lines.filter((line) => line.type === "add").map((line) => line.content)).toEqual([
|
||||
`const value = Math.max(${value}, 7);`,
|
||||
]);
|
||||
expect(lines.find((line) => line.type === "add")?.tokens).toContainEqual({
|
||||
text: value,
|
||||
style: "number",
|
||||
});
|
||||
};
|
||||
check(initial.files[0], "43");
|
||||
const subscription = ctx.client.observeCheckoutDiff(cwd, { mode: "uncommitted" });
|
||||
try {
|
||||
let latest = await subscription.ready;
|
||||
check(latest.files[0], "43");
|
||||
subscription.subscribe({
|
||||
snapshot: (snapshot) => {
|
||||
latest = snapshot;
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type === "checkout_diff_update") latest = message.payload;
|
||||
},
|
||||
});
|
||||
await writeFile(join(cwd, "generated.ts"), original.replace("42", "44"));
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
latest.files[0]?.hunks.flatMap((hunk) => hunk.lines).find((line) => line.type === "add")
|
||||
?.content,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe("const value = Math.max(44, 7);");
|
||||
check(latest.files[0], "44");
|
||||
} finally {
|
||||
await subscription.release();
|
||||
}
|
||||
await runGit("git", ["add", "."], { cwd });
|
||||
await runGit("git", ["-c", "commit.gpgsign=false", "commit", "-m", "edit"], { cwd });
|
||||
const { stdout } = await runGit("git", ["rev-parse", "HEAD"], { cwd });
|
||||
const commit = await ctx.client.getCommitFileDiff(cwd, stdout.trim(), "generated.ts");
|
||||
check(commit.file, "44");
|
||||
console.info({
|
||||
sourceBytes: Buffer.byteLength(original),
|
||||
cheapRpcMs: rpcMs,
|
||||
isolatedDaemonPort: ctx.daemon.port,
|
||||
});
|
||||
} finally {
|
||||
await ctx.cleanup();
|
||||
await rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -87,53 +87,3 @@ test("logs --follow preserves the same assistant text as one-shot logs", async (
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("omitted after limit pages forward while explicit zero preserves complete CLI history", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
|
||||
const initial = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
|
||||
const cursor = { epoch: initial.epoch, seq: initial.window.maxSeq };
|
||||
for (let index = 0; index < 451; index++) {
|
||||
await daemon.daemon.agentManager.appendTimelineItem(agent.id, {
|
||||
type: "user_message",
|
||||
text: String(index),
|
||||
});
|
||||
}
|
||||
const first = await client.fetchAgentTimeline(agent.id, { direction: "after", cursor });
|
||||
expect(first.entries).toHaveLength(200);
|
||||
expect(first.hasNewer).toBe(true);
|
||||
expect(first.endCursor).toEqual({ epoch: cursor.epoch, seq: cursor.seq + 200 });
|
||||
const second = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor: first.endCursor!,
|
||||
});
|
||||
const third = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor: second.endCursor!,
|
||||
});
|
||||
expect(second.entries).toHaveLength(200);
|
||||
expect(third.entries).toHaveLength(51);
|
||||
expect(third.hasNewer).toBe(false);
|
||||
expect(
|
||||
[...first.entries, ...second.entries, ...third.entries].map((entry) => entry.item),
|
||||
).toEqual(
|
||||
Array.from({ length: 451 }, (_, index) => ({ type: "user_message", text: String(index) })),
|
||||
);
|
||||
const allAfter = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor,
|
||||
limit: 0,
|
||||
});
|
||||
const allTail = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
|
||||
expect(allAfter.entries).toHaveLength(451);
|
||||
expect(allAfter.hasNewer).toBe(false);
|
||||
expect(allTail.entries).toHaveLength(initial.entries.length + 451);
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { realpath } from "node:fs";
|
||||
import { readlink } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import pLimit from "p-limit";
|
||||
import { dirname, resolve as resolvePath, sep } from "node:path";
|
||||
import { looksLikeDefiniteWindowsPath, normalizePathPreservingCase } from "../utils/path.js";
|
||||
|
||||
const nativeRealpath = promisify(realpath.native);
|
||||
const jsRealpath = promisify(realpath);
|
||||
const identityIo = pLimit(2);
|
||||
|
||||
/** A subscription owns its lexical namespace, including the OS's real-root alias. */
|
||||
export class GitObservationPaths {
|
||||
private constructor(
|
||||
private readonly root: string,
|
||||
private readonly aliases: string[],
|
||||
private readonly windows: boolean,
|
||||
) {}
|
||||
|
||||
static async create(root: string): Promise<GitObservationPaths> {
|
||||
const windows = looksLikeDefiniteWindowsPath(root);
|
||||
const normalizePath = (value: string) =>
|
||||
windows
|
||||
? normalizePathPreservingCase(value, true).replaceAll("\\", "/").toLowerCase()
|
||||
: normalizePathPreservingCase(value, false);
|
||||
const aliases = new Set([normalizePath(root)]);
|
||||
// Sequential: foreign paths must not fan out into the shared libuv pool.
|
||||
for (const resolve of [nativeRealpath, jsRealpath]) {
|
||||
try {
|
||||
aliases.add(normalizePath(await identityIo(() => resolve(root))));
|
||||
} catch {
|
||||
/* Missing roots retain lexical identity. */
|
||||
}
|
||||
}
|
||||
// A link may name /var/... while realpath returns /private/var/... on macOS.
|
||||
// Retain literal targets too, including chains, without following links in callbacks.
|
||||
let current = root;
|
||||
const visited = new Set<string>();
|
||||
for (let remaining = 40; remaining > 0; remaining--) {
|
||||
const alias = normalizePath(current);
|
||||
if (visited.has(alias)) break;
|
||||
visited.add(alias);
|
||||
aliases.add(alias);
|
||||
try {
|
||||
const target = await identityIo(() => readlink(current));
|
||||
current = resolvePath(dirname(current), target);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new GitObservationPaths(root, [...aliases], windows);
|
||||
}
|
||||
|
||||
relative(candidate: string): string | null {
|
||||
const normalized = this.normalizeEventPath(candidate);
|
||||
const comparable = this.windows ? normalized.toLowerCase() : normalized;
|
||||
for (const alias of this.aliases) {
|
||||
if (comparable === alias) return "";
|
||||
const prefix = alias.endsWith("/") ? alias : `${alias}/`;
|
||||
if (comparable.startsWith(prefix)) {
|
||||
// Slice the original spelling, preserving case in ref names on Windows.
|
||||
return normalized.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compile excluded subtrees in every established root namespace. The event callback
|
||||
// only compares strings; neither an ignored root nor an event needs another realpath.
|
||||
createEventFilter(excludedRoots: Iterable<string>): (candidate: string) => boolean {
|
||||
const excluded: string[] = [];
|
||||
for (const root of excludedRoots) {
|
||||
const relative = this.relative(root);
|
||||
if (relative === null) continue;
|
||||
for (const alias of this.aliases) {
|
||||
const subtree = relative ? `${alias}/${relative}` : alias;
|
||||
excluded.push(this.windows ? subtree.toLowerCase() : subtree);
|
||||
}
|
||||
}
|
||||
const prefixes = excluded.map((root) => `${root}/`);
|
||||
return (candidate) => {
|
||||
const normalized = this.normalizeEventPath(candidate);
|
||||
const comparable = this.windows ? normalized.toLowerCase() : normalized;
|
||||
if (this.aliases.includes(comparable)) return false;
|
||||
for (let i = 0; i < excluded.length; i++) {
|
||||
if (comparable === excluded[i] || comparable.startsWith(prefixes[i])) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeEventPath(candidate: string): string {
|
||||
if (this.windows) return normalizePathPreservingCase(candidate, true).replaceAll("\\", "/");
|
||||
if (candidate.includes("/.") || candidate.includes("//") || candidate.endsWith("/"))
|
||||
return normalizePathPreservingCase(candidate, false);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
matches = (candidate: string): boolean => this.relative(candidate) === "";
|
||||
|
||||
toWatchPath(candidate: string): string {
|
||||
const relative = this.relative(candidate);
|
||||
if (relative === null) return candidate;
|
||||
return relative ? `${this.root}${sep}${relative}` : this.root;
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ describe("daemon agent skill selection", () => {
|
||||
it("does not expose selection through the generic config patch path", async () => {
|
||||
const { config, root, store } = await createStore();
|
||||
|
||||
await config.patch({
|
||||
config.patch({
|
||||
skills: { selection: { mode: "custom", skills: ["paseo"] } },
|
||||
} as MutableDaemonConfigPatch);
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export function createSkillSelectionStore(
|
||||
},
|
||||
async set(selection) {
|
||||
const parsed = coerceSkillSelection(selection);
|
||||
await configStore.setAgentSkillSelection(parsed);
|
||||
configStore.setAgentSkillSelection(parsed);
|
||||
return parsed;
|
||||
},
|
||||
async isSet() {
|
||||
|
||||
@@ -548,7 +548,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
);
|
||||
|
||||
expect(created.worktree.branchName).toBe("dazzling-yak");
|
||||
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "pending",
|
||||
@@ -575,7 +575,7 @@ test("renames an eligible unnamed branch-off worktree once on first agent contex
|
||||
branchName: "renamed-from-agent-context",
|
||||
});
|
||||
expect(branchAfterFirst).toBe("renamed-from-agent-context");
|
||||
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
@@ -762,7 +762,7 @@ test("leaves the branch alone when generated branch text is invalid", async () =
|
||||
.toString()
|
||||
.trim(),
|
||||
).toBe("dazzling-yak");
|
||||
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 2,
|
||||
firstAgentBranchAutoName: {
|
||||
status: "attempted",
|
||||
@@ -794,7 +794,7 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
|
||||
// baseRefName must be the repo's default branch, not the checked-out branch
|
||||
// itself — otherwise the diff panel compares the branch against itself and
|
||||
// always shows no changes.
|
||||
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 1,
|
||||
baseRefName: "main",
|
||||
});
|
||||
@@ -831,7 +831,7 @@ test("does not mark GitHub PR checkout worktrees as eligible for first-agent ren
|
||||
createDeps(),
|
||||
);
|
||||
|
||||
expect(await readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
|
||||
version: 1,
|
||||
baseRefName: "main",
|
||||
});
|
||||
|
||||
@@ -77,7 +77,7 @@ async function createPaseoWorktreeWithPriority(
|
||||
const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService);
|
||||
const createdWorktree = await createWorktreeCore(input, deps);
|
||||
try {
|
||||
await maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({
|
||||
relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd,
|
||||
targetWorktreePath: createdWorktree.worktree.worktreePath,
|
||||
@@ -182,9 +182,9 @@ export async function attemptFirstAgentBranchAutoName(options: {
|
||||
return { attempted: false, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
let metadata: Awaited<ReturnType<typeof readPaseoWorktreeMetadata>>;
|
||||
let metadata: ReturnType<typeof readPaseoWorktreeMetadata>;
|
||||
try {
|
||||
metadata = await readPaseoWorktreeMetadata(options.cwd);
|
||||
metadata = readPaseoWorktreeMetadata(options.cwd);
|
||||
} catch {
|
||||
return { attempted: false, renamed: false, branchName: null };
|
||||
}
|
||||
@@ -199,11 +199,11 @@ export async function attemptFirstAgentBranchAutoName(options: {
|
||||
const getCurrentBranchImpl = options.getCurrentBranch ?? getCurrentBranch;
|
||||
const placeholderBranchName = metadata.firstAgentBranchAutoName.placeholderBranchName;
|
||||
if ((await getCurrentBranchImpl(options.cwd)) !== placeholderBranchName) {
|
||||
await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
return { attempted: true, renamed: false, branchName: null };
|
||||
}
|
||||
|
||||
await markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted(options.cwd);
|
||||
|
||||
const branchName = await options.generateBranchNameFromContext({
|
||||
cwd: options.cwd,
|
||||
@@ -264,15 +264,15 @@ async function findAvailableBranchName(options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function maybeMarkFirstAgentBranchAutoNameEligible(options: {
|
||||
function maybeMarkFirstAgentBranchAutoNameEligible(options: {
|
||||
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
|
||||
}): Promise<void> {
|
||||
}): void {
|
||||
const { createdWorktree } = options;
|
||||
if (!createdWorktree.created || createdWorktree.intent.kind !== "branch-off") {
|
||||
return;
|
||||
}
|
||||
|
||||
await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, {
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, {
|
||||
placeholderBranchName: createdWorktree.worktree.branchName,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,11 +16,9 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
loadPersistedConfig,
|
||||
loadPersistedConfigAsync,
|
||||
PersistedConfigSchema,
|
||||
readPersistedConfig,
|
||||
savePersistedConfig,
|
||||
savePersistedConfigAsync,
|
||||
} from "./persisted-config.js";
|
||||
import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js";
|
||||
|
||||
@@ -829,14 +827,11 @@ describe("readPersistedConfig with an unreadable config.json", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === "win32").each([
|
||||
{ name: "startup", load: loadPersistedConfig, save: savePersistedConfig },
|
||||
{ name: "runtime", load: loadPersistedConfigAsync, save: savePersistedConfigAsync },
|
||||
])("persisted config file permissions ($name)", ({ load, save }) => {
|
||||
test("initializes config.json with private permissions", async () => {
|
||||
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
|
||||
test("initializes config.json with private permissions", () => {
|
||||
const home = createTempHome();
|
||||
try {
|
||||
await load(home);
|
||||
loadPersistedConfig(home);
|
||||
|
||||
expect(modeOf(path.join(home, "config.json"))).toBe(PRIVATE_FILE_MODE);
|
||||
} finally {
|
||||
@@ -844,14 +839,14 @@ describe.skipIf(process.platform === "win32").each([
|
||||
}
|
||||
});
|
||||
|
||||
test("repairs permissive config.json permissions when loading", async () => {
|
||||
test("repairs permissive config.json permissions when loading", () => {
|
||||
const home = createTempHome();
|
||||
const configPath = path.join(home, "config.json");
|
||||
try {
|
||||
writeFileSync(configPath, "{}\n", { mode: PERMISSIVE_FILE_MODE });
|
||||
chmodSync(configPath, PERMISSIVE_FILE_MODE);
|
||||
|
||||
await load(home);
|
||||
loadPersistedConfig(home);
|
||||
|
||||
expect(modeOf(configPath)).toBe(PRIVATE_FILE_MODE);
|
||||
} finally {
|
||||
@@ -859,24 +854,11 @@ describe.skipIf(process.platform === "win32").each([
|
||||
}
|
||||
});
|
||||
|
||||
test("repairs unreadable owner permissions before reading config.json", async () => {
|
||||
const home = createTempHome();
|
||||
const configPath = path.join(home, "config.json");
|
||||
try {
|
||||
writeFileSync(configPath, "{}\n");
|
||||
chmodSync(configPath, 0);
|
||||
expect(await load(home)).toEqual({});
|
||||
expect(modeOf(configPath)).toBe(PRIVATE_FILE_MODE);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("saves config.json with private permissions", async () => {
|
||||
test("saves config.json with private permissions", () => {
|
||||
const parent = createTempHome();
|
||||
const home = path.join(parent, "home");
|
||||
try {
|
||||
await save(home, {
|
||||
savePersistedConfig(home, {
|
||||
features: { dictation: { stt: { provider: "openai", apiKey: "secret" } } },
|
||||
});
|
||||
|
||||
@@ -888,37 +870,37 @@ describe.skipIf(process.platform === "win32").each([
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === "win32").each([
|
||||
{ name: "startup", save: savePersistedConfig },
|
||||
{ name: "runtime", save: savePersistedConfigAsync },
|
||||
])("config.json symlinked from another directory ($name)", ({ save }) => {
|
||||
test("saving writes through the symlink and leaves it in place", async () => {
|
||||
const parent = createTempHome();
|
||||
const home = path.join(parent, "home");
|
||||
const dotfiles = path.join(parent, "dotfiles");
|
||||
const linkedConfigPath = path.join(dotfiles, "config.json");
|
||||
const configPath = path.join(home, "config.json");
|
||||
try {
|
||||
mkdirSync(home);
|
||||
mkdirSync(dotfiles, { mode: 0o755 });
|
||||
chmodSync(dotfiles, 0o755);
|
||||
writeFileSync(linkedConfigPath, '{ "version": 1 }\n');
|
||||
symlinkSync(linkedConfigPath, configPath);
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"config.json symlinked from another directory",
|
||||
() => {
|
||||
test("saving writes through the symlink and leaves it in place", () => {
|
||||
const parent = createTempHome();
|
||||
const home = path.join(parent, "home");
|
||||
const dotfiles = path.join(parent, "dotfiles");
|
||||
const linkedConfigPath = path.join(dotfiles, "config.json");
|
||||
const configPath = path.join(home, "config.json");
|
||||
try {
|
||||
mkdirSync(home);
|
||||
mkdirSync(dotfiles, { mode: 0o755 });
|
||||
chmodSync(dotfiles, 0o755);
|
||||
writeFileSync(linkedConfigPath, '{ "version": 1 }\n');
|
||||
symlinkSync(linkedConfigPath, configPath);
|
||||
|
||||
await save(home, { version: 1, daemon: { relay: { enabled: false } } });
|
||||
savePersistedConfig(home, { version: 1, daemon: { relay: { enabled: false } } });
|
||||
|
||||
expect(lstatSync(configPath).isSymbolicLink()).toBe(true);
|
||||
expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({
|
||||
version: 1,
|
||||
daemon: { relay: { enabled: false } },
|
||||
});
|
||||
expect(modeOf(dotfiles)).toBe(0o755);
|
||||
expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE);
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
expect(lstatSync(configPath).isSymbolicLink()).toBe(true);
|
||||
expect(JSON.parse(readFileSync(linkedConfigPath, "utf8"))).toEqual({
|
||||
version: 1,
|
||||
daemon: { relay: { enabled: false } },
|
||||
});
|
||||
expect(modeOf(dotfiles)).toBe(0o755);
|
||||
expect(modeOf(linkedConfigPath)).toBe(PRIVATE_FILE_MODE);
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("speech provider config normalization", () => {
|
||||
test.each([undefined, "local"])(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { PluginRegistriesSchema } from "@getpaseo/protocol/plugin-registry";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
@@ -10,12 +9,7 @@ import {
|
||||
ProviderOverridesSchema,
|
||||
} from "./agent/provider-launch-config.js";
|
||||
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
|
||||
import {
|
||||
ensurePrivateFile,
|
||||
ensurePrivateFileAsync,
|
||||
writePrivateFileAtomic,
|
||||
writePrivateFileAtomicSync,
|
||||
} from "./private-files.js";
|
||||
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
|
||||
import { AgentProfileSchema, AgentSkillSelectionSchema } from "@getpaseo/protocol/agent-profile";
|
||||
import { PluginIdSchema, PluginSourceSchema } from "@getpaseo/protocol/plugin-config";
|
||||
import { TerminalProfileSchema } from "@getpaseo/protocol/terminal-profile";
|
||||
@@ -529,40 +523,6 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Runtime config IO. Startup and offline CLI consumers retain their synchronous entry points. */
|
||||
export async function loadPersistedConfigAsync(
|
||||
paseoHome: string,
|
||||
logger?: LoggerLike,
|
||||
): Promise<PersistedConfig> {
|
||||
const log = getLogger(logger);
|
||||
const configPath = getConfigPath(paseoHome);
|
||||
let raw: string;
|
||||
await ensurePrivateFileAsync(configPath);
|
||||
try {
|
||||
raw = await readFile(configPath, "utf-8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw new Error(
|
||||
`[Config] Failed to read ${configPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
try {
|
||||
raw = JSON.stringify(DEFAULT_PERSISTED_CONFIG, null, 2) + "\n";
|
||||
await writePrivateFileAtomic(configPath, raw);
|
||||
log?.info(`Initialized config file at ${configPath}`);
|
||||
} catch (initializationError) {
|
||||
throw new Error(
|
||||
`[Config] Failed to initialize ${configPath}: ${initializationError instanceof Error ? initializationError.message : String(initializationError)}`,
|
||||
{ cause: initializationError },
|
||||
);
|
||||
}
|
||||
}
|
||||
const config = parseConfigFile(configPath, raw, log);
|
||||
log?.info(`Loaded from ${configPath}`);
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Observe the file without initializing a home, identity, or default configuration. */
|
||||
export function readPersistedConfig(
|
||||
paseoHome: string,
|
||||
@@ -682,20 +642,6 @@ export function savePersistedConfig(
|
||||
const log = getLogger(logger);
|
||||
const configPath = getConfigPath(paseoHome);
|
||||
|
||||
const data = serializePersistedConfig(config);
|
||||
|
||||
try {
|
||||
writePrivateFileAtomicSync(configPath, data);
|
||||
log?.info(`Saved to ${configPath}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`[Config] Failed to write ${configPath}: ${message}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function serializePersistedConfig(config: PersistedConfig): string {
|
||||
const result = PersistedConfigSchema.safeParse(config);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
@@ -704,23 +650,13 @@ function serializePersistedConfig(config: PersistedConfig): string {
|
||||
throw new Error(`[Config] Invalid config to save:\n${issues}`);
|
||||
}
|
||||
|
||||
return JSON.stringify(result.data, null, 2) + "\n";
|
||||
}
|
||||
|
||||
export async function savePersistedConfigAsync(
|
||||
paseoHome: string,
|
||||
config: PersistedConfig,
|
||||
logger?: LoggerLike,
|
||||
): Promise<void> {
|
||||
const configPath = getConfigPath(paseoHome);
|
||||
const data = serializePersistedConfig(config);
|
||||
try {
|
||||
await writePrivateFileAtomic(configPath, data);
|
||||
getLogger(logger)?.info(`Saved to ${configPath}`);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`[Config] Failed to write ${configPath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
writePrivateFileAtomicSync(configPath, JSON.stringify(result.data, null, 2) + "\n");
|
||||
log?.info(`Saved to ${configPath}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`[Config] Failed to write ${configPath}: ${message}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,752 +0,0 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import {
|
||||
createTestAgentClient,
|
||||
type TestAgentClientOptions,
|
||||
} from "../test-utils/fake-agent-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { BuiltinPluginLoader } from "./builtin/index.js";
|
||||
import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
AgentSessionConfig,
|
||||
AgentPromptInput,
|
||||
} from "../agent/agent-sdk-types.js";
|
||||
|
||||
function recordingProvider(native: boolean, callbacks: TestAgentClientOptions = {}) {
|
||||
const launches: { config: AgentSessionConfig; context?: AgentLaunchContext }[] = [];
|
||||
const turns: { prompt: AgentPromptInput; launchCount: number }[] = [];
|
||||
const steers: AgentPromptInput[] = [];
|
||||
const events = { interrupts: 0 };
|
||||
const inner = createTestAgentClient("codex", {
|
||||
...callbacks,
|
||||
supportsMcpServers: true,
|
||||
onStartTurn: (prompt, config) => {
|
||||
turns.push({ prompt, launchCount: launches.length });
|
||||
callbacks.onStartTurn?.(prompt, config);
|
||||
},
|
||||
steerActiveTurn: async (prompt) => {
|
||||
steers.push(prompt);
|
||||
return { status: "accepted" };
|
||||
},
|
||||
onInterrupt: () => {
|
||||
events.interrupts++;
|
||||
callbacks.onInterrupt?.();
|
||||
},
|
||||
});
|
||||
const provider: AgentClient = {
|
||||
provider: inner.provider,
|
||||
capabilities: { ...inner.capabilities, supportsNativePaseoTools: native },
|
||||
createSession: async (config, context, options) => {
|
||||
launches.push({ config, context });
|
||||
return inner.createSession(config, context, options);
|
||||
},
|
||||
resumeSession: async (handle, config, context, options) => {
|
||||
launches.push({ config: config as AgentSessionConfig, context });
|
||||
return inner.resumeSession(handle, config, context, options);
|
||||
},
|
||||
isAvailable: () => inner.isAvailable(),
|
||||
fetchCatalog: (options) => inner.fetchCatalog(options),
|
||||
};
|
||||
return { provider, launches, turns, steers, events };
|
||||
}
|
||||
|
||||
const pluginSource = `
|
||||
import { z } from "zod";
|
||||
import { defineAgentTool } from "@getpaseo/plugin/server";
|
||||
let calls = 0;
|
||||
const tool = defineAgentTool({
|
||||
name: "plugin_echo", description: "Echo for the chosen agent",
|
||||
input: z.object({ text: z.string().min(1).refine(value => value !== "forbidden", "Forbidden by plugin").describe("Message") }),
|
||||
async execute(input, { agent, paseo, signal }) {
|
||||
calls++;
|
||||
if (input.text === "throw") throw new Error("handler failed");
|
||||
return { text: JSON.stringify({ agent, input, calls, sdk: typeof paseo.agents.ref, aborted: signal.aborted }) };
|
||||
},
|
||||
});
|
||||
export default function contribute(server) {
|
||||
server.before("agent.session_open", ({ request }) => {
|
||||
if (request.labels.x !== "y") return;
|
||||
return { ...request, env: { ...request.env, OPEN_INFO: JSON.stringify({ title: request.title, labels: request.labels, reason: request.reason }) }, tools: [...request.tools, tool] };
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
async function setup(
|
||||
native = false,
|
||||
source = pluginSource,
|
||||
policy?: { enabled?: boolean; disabledTools?: string[] },
|
||||
providerOptions?: TestAgentClientOptions,
|
||||
) {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-tools-"));
|
||||
const recorder = recordingProvider(native, providerOptions);
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
daemonVersion: "0.13.2",
|
||||
agentClients: { codex: recorder.provider },
|
||||
mcpEnabled: true,
|
||||
providerOverrides: { codex: { paseoTools: policy } },
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.13.2",
|
||||
});
|
||||
const mcps: Client[] = [];
|
||||
async function install(id: string, code: string) {
|
||||
const pluginDirectory = path.join(directory, id);
|
||||
await mkdir(pluginDirectory);
|
||||
await writeFile(
|
||||
path.join(pluginDirectory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id, requirements: { paseo: ">=0.13.2" } }),
|
||||
);
|
||||
await writeFile(path.join(pluginDirectory, "index.server.ts"), code);
|
||||
await client.installDirectoryPlugin(pluginDirectory);
|
||||
}
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
try {
|
||||
await install("tools-test", source);
|
||||
} catch (error) {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
...recorder,
|
||||
daemon,
|
||||
client,
|
||||
directory,
|
||||
install,
|
||||
async create(labels: Record<string, string> = {}) {
|
||||
return daemon.daemon.agentManager.createAgent(
|
||||
{ provider: "codex", cwd: directory },
|
||||
undefined,
|
||||
{ labels, initialTitle: "Chosen title" },
|
||||
);
|
||||
},
|
||||
async mcp(agentId?: string) {
|
||||
const server = recorder.launches.at(-1)!.config.mcpServers?.paseo;
|
||||
let url: string;
|
||||
if (agentId) url = `http://127.0.0.1:${daemon.port}/mcp/agents?callerAgentId=${agentId}`;
|
||||
else {
|
||||
if (server?.type !== "http") throw new Error("Expected HTTP server");
|
||||
url = server.url;
|
||||
}
|
||||
const mcp = new Client({ name: "plugin-tools-test", version: "1" });
|
||||
mcps.push(mcp);
|
||||
await mcp.connect(new StreamableHTTPClientTransport(new URL(url)));
|
||||
return mcp;
|
||||
},
|
||||
async close() {
|
||||
await Promise.all(mcps.map((mcp) => mcp.close()));
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("session hooks attach tools only to matching agents over MCP, with validated input and caller context", async () => {
|
||||
const h = await setup();
|
||||
try {
|
||||
const a = await h.create({ x: "y" });
|
||||
expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO)).toEqual({
|
||||
title: "Chosen title",
|
||||
labels: { x: "y" },
|
||||
reason: "create",
|
||||
});
|
||||
const mcpA = await h.mcp();
|
||||
const listed = (await mcpA.listTools()).tools.find((tool) => tool.name === "plugin_echo");
|
||||
expect(listed).toMatchObject({
|
||||
name: "plugin_echo",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { text: { type: "string", minLength: 1, description: "Message" } },
|
||||
required: ["text"],
|
||||
},
|
||||
});
|
||||
const b = await h.create();
|
||||
const mcpB = await h.mcp();
|
||||
expect((await mcpB.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo");
|
||||
const invalid = await mcpA.callTool({ name: "plugin_echo", arguments: { text: "" } });
|
||||
expect(invalid.isError).toBe(true);
|
||||
expect(JSON.stringify(invalid)).toContain("text");
|
||||
expect(
|
||||
await mcpA.callTool({ name: "plugin_echo", arguments: { text: "forbidden" } }),
|
||||
).toMatchObject({
|
||||
isError: true,
|
||||
content: [{ type: "text", text: expect.stringContaining("Forbidden by plugin") }],
|
||||
});
|
||||
const result = await mcpA.callTool({ name: "plugin_echo", arguments: { text: "hello" } });
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(JSON.parse((result.content as { text: string }[])[0].text)).toMatchObject({
|
||||
agent: { id: a.id, cwd: h.directory, title: "Chosen title" },
|
||||
input: { text: "hello" },
|
||||
calls: 1,
|
||||
sdk: "function",
|
||||
aborted: false,
|
||||
});
|
||||
expect(a.id).not.toBe(b.id);
|
||||
expect(
|
||||
await mcpA.callTool({ name: "plugin_echo", arguments: { text: "throw" } }),
|
||||
).toMatchObject({ isError: true, content: [{ type: "text", text: "handler failed" }] });
|
||||
expect(
|
||||
(await mcpA.callTool({ name: "plugin_echo", arguments: { text: "still alive" } })).isError,
|
||||
).not.toBe(true);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("native providers get the same attached schema and execution", async () => {
|
||||
const h = await setup(true);
|
||||
try {
|
||||
const a = await h.create({ x: "y" });
|
||||
const catalog = h.launches.at(-1)!.context!.paseoTools!;
|
||||
expect(catalog.getTool("plugin_echo")?.description).toBe("Echo for the chosen agent");
|
||||
const mcp = await h.mcp(a.id);
|
||||
const advertised = (await mcp.listTools()).tools.find((tool) => tool.name === "plugin_echo")!;
|
||||
expect(serializePaseoToolInputParameters(catalog.getTool("plugin_echo")!)).toEqual(
|
||||
advertised.inputSchema,
|
||||
);
|
||||
const result = await catalog.executeTool("plugin_echo", { text: "native" });
|
||||
expect(JSON.parse(result.content[0].text!)).toMatchObject({
|
||||
agent: { id: a.id },
|
||||
input: { text: "native" },
|
||||
});
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("resume uses the stored title and labels, and close removes the attachment", async () => {
|
||||
const h = await setup(true);
|
||||
try {
|
||||
const a = await h.create({ x: "y" });
|
||||
const catalog = h.launches.at(-1)!.context!.paseoTools!;
|
||||
const handle = a.persistence!;
|
||||
await h.daemon.daemon.agentManager.setTitle(a.id, "Stored title");
|
||||
await h.daemon.daemon.agentManager.closeAgent(a.id);
|
||||
expect(h.daemon.daemon.agentManager.getPluginTools(a.id)).toEqual([]);
|
||||
await expect(catalog.executeTool("plugin_echo", { text: "closed" })).rejects.toThrow(
|
||||
"Agent is not live",
|
||||
);
|
||||
await h.daemon.daemon.agentManager.resumeAgentFromPersistence(handle, a.config, a.id);
|
||||
expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO)).toEqual({
|
||||
title: "Stored title",
|
||||
labels: { x: "y" },
|
||||
reason: "resume",
|
||||
});
|
||||
expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")?.name).toBe(
|
||||
"plugin_echo",
|
||||
);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test.each([
|
||||
{
|
||||
name: "Paseo",
|
||||
source: pluginSource.replace('name: "plugin_echo"', 'name: "list_agents"'),
|
||||
other: false,
|
||||
message: /plugin tools-test.*Paseo tool list_agents/,
|
||||
},
|
||||
{
|
||||
name: "another plugin",
|
||||
source: pluginSource,
|
||||
other: true,
|
||||
message: /plugin tools-test.*plugin other-tools/,
|
||||
},
|
||||
{
|
||||
name: "the same plugin",
|
||||
source: pluginSource.replace("tools: [...request.tools, tool]", "tools: [tool, tool]"),
|
||||
other: false,
|
||||
message: /Duplicate agent tool: plugin_echo/,
|
||||
},
|
||||
])(
|
||||
"collisions with $name fail only targeted session opens",
|
||||
async ({ source, other, message }) => {
|
||||
const h = await setup(false, source);
|
||||
try {
|
||||
if (other) await h.install("other-tools", pluginSource);
|
||||
await expect(h.create({ x: "y" })).rejects.toThrow(message);
|
||||
expect(h.launches).toHaveLength(0);
|
||||
await h.create();
|
||||
expect(h.launches).toHaveLength(1);
|
||||
expect((await (await h.mcp()).listTools()).tools.map((tool) => tool.name)).not.toContain(
|
||||
"plugin_echo",
|
||||
);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test("each plugin owns its tool array and callbacks can remove earlier attachments", async () => {
|
||||
const source = pluginSource.replace(
|
||||
" return () => {};",
|
||||
`
|
||||
server.before("agent.session_open", ({ request }) => {
|
||||
if (request.labels.x === "y" && request.tools[0]?.name !== "plugin_echo") throw new Error("Lost plugin-local definitions");
|
||||
return { ...request, tools: [] };
|
||||
});
|
||||
return () => {};`,
|
||||
);
|
||||
const h = await setup(false, source);
|
||||
try {
|
||||
await h.install(
|
||||
"other-tools",
|
||||
pluginSource
|
||||
.replace('name: "plugin_echo"', 'name: "other_echo"')
|
||||
.replace(
|
||||
' if (request.labels.x !== "y") return;',
|
||||
' if (request.tools.length !== 0) throw new Error("Saw another plugin tools");\n if (request.labels.x !== "y") return;',
|
||||
),
|
||||
);
|
||||
await h.create({ x: "y" });
|
||||
const names = (await (await h.mcp()).listTools()).tools.map((tool) => tool.name);
|
||||
expect(names).toContain("other_echo");
|
||||
expect(names).not.toContain("plugin_echo");
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("disabled plugins remove tools from existing and new MCP catalogs", async () => {
|
||||
const h = await setup();
|
||||
try {
|
||||
await h.create({ x: "y" });
|
||||
const mcp = await h.mcp();
|
||||
expect((await mcp.listTools()).tools.map((tool) => tool.name)).toContain("plugin_echo");
|
||||
await h.client.patchDaemonConfig({ pluginsEnabled: false });
|
||||
expect((await mcp.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo");
|
||||
await h.create({ x: "y" });
|
||||
expect((await (await h.mcp()).listTools()).tools.map((tool) => tool.name)).not.toContain(
|
||||
"plugin_echo",
|
||||
);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test.each([false, true])(
|
||||
"provider tools disabled gates plugin tools (native=%s)",
|
||||
async (native) => {
|
||||
const h = await setup(native, pluginSource, { enabled: false });
|
||||
try {
|
||||
await h.create({ x: "y" });
|
||||
expect(h.launches.at(-1)!.context!.paseoTools).toBeUndefined();
|
||||
expect(h.launches.at(-1)!.config.mcpServers?.paseo).toBeUndefined();
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test("disabledTools filters plugin tool names", async () => {
|
||||
const h = await setup(true, pluginSource, { disabledTools: ["plugin_echo"] });
|
||||
try {
|
||||
await h.create({ x: "y" });
|
||||
expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")).toBeUndefined();
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
const waitingSource = pluginSource
|
||||
.replace("import { z }", 'import { writeFile } from "node:fs/promises";\nimport { z }')
|
||||
.replace(
|
||||
" calls++;",
|
||||
`
|
||||
calls++;
|
||||
if (input.text === "wait") {
|
||||
await writeFile(agent.cwd + "/started", agent.id);
|
||||
await new Promise(resolve => signal.addEventListener("abort", resolve, { once: true }));
|
||||
await writeFile(agent.cwd + "/aborted", agent.id);
|
||||
return { text: "discard this result" };
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
test("caller cancellation aborts the plugin handler and discards its result", async () => {
|
||||
const h = await setup(true, waitingSource);
|
||||
try {
|
||||
const a = await h.create({ x: "y" });
|
||||
const catalog = h.launches.at(-1)!.context!.paseoTools!;
|
||||
const controller = new AbortController();
|
||||
const call = catalog.executeTool(
|
||||
"plugin_echo",
|
||||
{ text: "wait" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
const rejected = expect(call).rejects.toThrow("Canceled by caller");
|
||||
await expect.poll(() => readFile(path.join(h.directory, "started"), "utf8")).toBe(a.id);
|
||||
controller.abort(new Error("Canceled by caller"));
|
||||
await rejected;
|
||||
await expect.poll(() => readFile(path.join(h.directory, "aborted"), "utf8")).toBe(a.id);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("a plugin stopping mid-call returns a tool error", async () => {
|
||||
const h = await setup(true, waitingSource);
|
||||
try {
|
||||
const a = await h.create({ x: "y" });
|
||||
const call = h.launches
|
||||
.at(-1)!
|
||||
.context!.paseoTools!.executeTool("plugin_echo", { text: "wait" });
|
||||
await expect.poll(() => readFile(path.join(h.directory, "started"), "utf8")).toBe(a.id);
|
||||
await h.client.patchDaemonConfig({ pluginsEnabled: false });
|
||||
expect(await call).toMatchObject({
|
||||
isError: true,
|
||||
content: [{ type: "text", text: expect.stringContaining("Plugin stopped") }],
|
||||
});
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("history sessions ignore attached tools", async () => {
|
||||
const h = await setup(true);
|
||||
try {
|
||||
const a = await h.create({ x: "y" });
|
||||
const handle = a.persistence!;
|
||||
await h.client.archiveAgent(a.id);
|
||||
await h.daemon.daemon.agentManager.resumeAgentFromPersistence(handle, a.config, a.id);
|
||||
expect(h.daemon.daemon.agentManager.getPluginTools(a.id)).toEqual([]);
|
||||
expect(h.launches.at(-1)!.context!.paseoTools?.getTool("plugin_echo")).toBeUndefined();
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("disabled plugins attach no tools to a new native session", async () => {
|
||||
const h = await setup(true);
|
||||
try {
|
||||
await h.client.patchDaemonConfig({ pluginsEnabled: false });
|
||||
await h.create({ x: "y" });
|
||||
expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")).toBeUndefined();
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("structured tool results get model-visible text when text is omitted", async () => {
|
||||
const h = await setup(
|
||||
true,
|
||||
pluginSource.replace(
|
||||
" calls++;",
|
||||
' return { structuredContent: { status: "created" } };',
|
||||
),
|
||||
);
|
||||
try {
|
||||
await h.create({ x: "y" });
|
||||
expect(
|
||||
await h.launches
|
||||
.at(-1)!
|
||||
.context!.paseoTools!.executeTool("plugin_echo", { text: "structured" }),
|
||||
).toEqual({
|
||||
content: [{ type: "text", text: JSON.stringify({ status: "created" }, null, 2) }],
|
||||
structuredContent: { status: "created" },
|
||||
isError: undefined,
|
||||
});
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("reloading a plugin retires its old attachments before an agent reopens", async () => {
|
||||
const h = await setup(true);
|
||||
try {
|
||||
const a = await h.create({ x: "y" });
|
||||
const mcp = await h.mcp(a.id);
|
||||
const oldCatalog = h.launches.at(-1)!.context!.paseoTools!;
|
||||
expect((await mcp.listTools()).tools.map((tool) => tool.name)).toContain("plugin_echo");
|
||||
await h.client.reloadPlugin("tools-test");
|
||||
expect((await mcp.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo");
|
||||
expect(h.daemon.daemon.agentManager.getPluginTools(a.id)).toEqual([]);
|
||||
expect(await oldCatalog.executeTool("plugin_echo", { text: "retired" })).toMatchObject({
|
||||
isError: true,
|
||||
content: [{ type: "text", text: expect.stringContaining("not available") }],
|
||||
});
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("built-in plugins attach and execute tools while configured plugins are disabled", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-builtin-tools-"));
|
||||
const directory = path.join(root, "builtin-tools");
|
||||
await mkdir(directory);
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "builtin-tools", requirements: { paseo: ">=0.13.2" } }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.server.ts"), pluginSource);
|
||||
const recorder = recordingProvider(true);
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
daemonVersion: "0.13.2",
|
||||
agentClients: { codex: recorder.provider },
|
||||
pluginsEnabled: false,
|
||||
builtinPlugins: new BuiltinPluginLoader(root, ["builtin-tools"]),
|
||||
});
|
||||
try {
|
||||
const a = await daemon.daemon.agentManager.createAgent(
|
||||
{ provider: "codex", cwd: root },
|
||||
undefined,
|
||||
{ labels: { x: "y" } },
|
||||
);
|
||||
const catalog = recorder.launches.at(-1)!.context!.paseoTools!;
|
||||
expect(catalog.getTool("plugin_echo")?.name).toBe("plugin_echo");
|
||||
const result = await catalog.executeTool("plugin_echo", { text: "builtin" });
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(JSON.parse(result.content[0].text!)).toMatchObject({
|
||||
agent: { id: a.id },
|
||||
input: { text: "builtin" },
|
||||
});
|
||||
} finally {
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("a session hook missing tools reports a schema issue", async () => {
|
||||
const h = await setup(
|
||||
false,
|
||||
pluginSource.replace("tools: [...request.tools, tool]", "tools: undefined"),
|
||||
);
|
||||
try {
|
||||
await expect(h.create({ x: "y" })).rejects.toThrow('"tools"');
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
async function sendAndFinish(h: Awaited<ReturnType<typeof setup>>, agentId: string, text: string) {
|
||||
const previous = h.turns.length;
|
||||
await h.client.sendMessage(agentId, text, { activeTurnBehavior: "steer" });
|
||||
await expect.poll(() => h.turns.length).toBe(previous + 1);
|
||||
await expect.poll(() => h.daemon.daemon.agentManager.hasInFlightRun(agentId)).toBe(false);
|
||||
}
|
||||
|
||||
test("an idle agent reopens once before its next prompt after a session-hook plugin reload", async () => {
|
||||
const h = await setup();
|
||||
try {
|
||||
const b = await h.create();
|
||||
const mcp = await h.mcp();
|
||||
expect((await mcp.listTools()).tools.map((tool) => tool.name)).not.toContain("plugin_echo");
|
||||
await writeFile(
|
||||
path.join(h.directory, "tools-test", "index.server.ts"),
|
||||
pluginSource.replace(' if (request.labels.x !== "y") return;', ""),
|
||||
);
|
||||
await h.client.reloadPlugin("tools-test");
|
||||
expect(h.launches).toHaveLength(1);
|
||||
await sendAndFinish(h, b.id, "first prompt");
|
||||
expect(h.launches).toHaveLength(2);
|
||||
expect(h.turns.map((turn) => turn.launchCount)).toEqual([2]);
|
||||
expect((await mcp.listTools()).tools.map((tool) => tool.name)).toContain("plugin_echo");
|
||||
await sendAndFinish(h, b.id, "second prompt");
|
||||
expect(h.launches).toHaveLength(2);
|
||||
expect(h.turns.map((turn) => turn.launchCount)).toEqual([2, 2]);
|
||||
expect(h.events.interrupts).toBe(0);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("a busy agent is steered after plugin reload and reopens only at its next idle prompt", async () => {
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const h = await setup(true, pluginSource, undefined, { beforeTurnComplete: async () => blocked });
|
||||
try {
|
||||
const a = await h.create({ x: "y" });
|
||||
await h.client.sendMessage(a.id, "busy prompt", { activeTurnBehavior: "steer" });
|
||||
await expect.poll(() => h.daemon.daemon.agentManager.hasInFlightRun(a.id)).toBe(true);
|
||||
await expect
|
||||
.poll(() => h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId)
|
||||
.not.toBeNull();
|
||||
const turnId = h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId;
|
||||
await h.client.reloadPlugin("tools-test");
|
||||
await h.client.sendMessage(a.id, "steering prompt", { activeTurnBehavior: "steer" });
|
||||
expect(h.steers).toHaveLength(1);
|
||||
expect(h.launches).toHaveLength(1);
|
||||
expect(h.events.interrupts).toBe(0);
|
||||
expect(h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId).toBe(turnId);
|
||||
release();
|
||||
await expect.poll(() => h.daemon.daemon.agentManager.hasInFlightRun(a.id)).toBe(false);
|
||||
await sendAndFinish(h, a.id, "idle prompt");
|
||||
expect(h.launches).toHaveLength(2);
|
||||
expect(h.events.interrupts).toBe(0);
|
||||
expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")?.name).toBe(
|
||||
"plugin_echo",
|
||||
);
|
||||
await sendAndFinish(h, a.id, "stable prompt");
|
||||
expect(h.launches).toHaveLength(2);
|
||||
} finally {
|
||||
release();
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
const reloadRpcSource = pluginSource
|
||||
.replace("import { z }", 'import { defineRpc } from "@getpaseo/plugin";\nimport { z }')
|
||||
.replace(
|
||||
" return () => {};",
|
||||
`
|
||||
server.handle(defineRpc({ name: "reload_agent", input: z.object({ id: z.string() }), output: z.object({ id: z.string() }) }), async ({ id }, { paseo }) => {
|
||||
const agent = paseo.agents.ref(id);
|
||||
await agent.refresh();
|
||||
await agent.reload();
|
||||
return { id: agent.current().id };
|
||||
});
|
||||
return () => {};
|
||||
`,
|
||||
);
|
||||
|
||||
test("a plugin can reload an agent through the SDK and rerun session hooks with reason refresh", async () => {
|
||||
const h = await setup(true, reloadRpcSource);
|
||||
try {
|
||||
const a = await h.create({ x: "y" });
|
||||
expect(h.launches).toHaveLength(1);
|
||||
expect(await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id })).toEqual({
|
||||
id: a.id,
|
||||
});
|
||||
expect(h.launches).toHaveLength(2);
|
||||
expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh");
|
||||
expect(h.launches.at(-1)!.context!.paseoTools!.getTool("plugin_echo")?.name).toBe(
|
||||
"plugin_echo",
|
||||
);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("starting and stopping a session-hook plugin each causes one idle reopen", async () => {
|
||||
const h = await setup(true);
|
||||
try {
|
||||
const a = await h.create({ x: "y" });
|
||||
await h.install(
|
||||
"added-tools",
|
||||
pluginSource.replace('name: "plugin_echo"', 'name: "added_echo"'),
|
||||
);
|
||||
await sendAndFinish(h, a.id, "after start");
|
||||
expect(h.launches).toHaveLength(2);
|
||||
expect(h.launches.at(-1)!.context!.paseoTools!.getTool("added_echo")?.name).toBe("added_echo");
|
||||
await h.client.disablePlugin("added-tools");
|
||||
await sendAndFinish(h, a.id, "after stop");
|
||||
expect(h.launches).toHaveLength(3);
|
||||
expect(h.launches.at(-1)!.context!.paseoTools!.getTool("added_echo")).toBeUndefined();
|
||||
await sendAndFinish(h, a.id, "stable after stop");
|
||||
expect(h.launches).toHaveLength(3);
|
||||
expect(h.events.interrupts).toBe(0);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("plugins without a session-open hook do not make agent sessions stale", async () => {
|
||||
const h = await setup(true);
|
||||
try {
|
||||
const a = await h.create({ x: "y" });
|
||||
await h.install(
|
||||
"no-session-hook",
|
||||
"export default function contribute(server) { server.on('agent.created', () => {}); return () => {}; }",
|
||||
);
|
||||
await sendAndFinish(h, a.id, "after unrelated start");
|
||||
expect(h.launches).toHaveLength(1);
|
||||
await h.client.reloadPlugin("no-session-hook");
|
||||
await sendAndFinish(h, a.id, "after unrelated reload");
|
||||
expect(h.launches).toHaveLength(1);
|
||||
await h.client.disablePlugin("no-session-hook");
|
||||
await sendAndFinish(h, a.id, "after unrelated stop");
|
||||
expect(h.launches).toHaveLength(1);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("explicit SDK reload interrupts a running turn before reopening its session", async () => {
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const h = await setup(true, reloadRpcSource, undefined, {
|
||||
beforeTurnComplete: async () => blocked,
|
||||
onInterrupt: () => release(),
|
||||
});
|
||||
try {
|
||||
const a = await h.create({ x: "y" });
|
||||
await h.client.sendMessage(a.id, "running before explicit reload", {
|
||||
activeTurnBehavior: "steer",
|
||||
});
|
||||
await expect
|
||||
.poll(() => h.daemon.daemon.agentManager.getAgent(a.id)!.activeTurnId)
|
||||
.not.toBeNull();
|
||||
expect(await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id })).toEqual({
|
||||
id: a.id,
|
||||
});
|
||||
expect(h.events.interrupts).toBe(1);
|
||||
expect(h.launches).toHaveLength(2);
|
||||
expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh");
|
||||
} finally {
|
||||
release();
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("the agent-tools example appends notes only for labeled agents", async () => {
|
||||
const h = await setup();
|
||||
try {
|
||||
await h.client.installDirectoryPlugin(
|
||||
fileURLToPath(new URL("../../../../../plugin-examples/agent-tools", import.meta.url)),
|
||||
);
|
||||
const a = await h.create({ "notes/enabled": "true" });
|
||||
const mcpA = await h.mcp(a.id);
|
||||
expect((await mcpA.listTools()).tools.map((tool) => tool.name)).toContain(
|
||||
"append_workspace_note",
|
||||
);
|
||||
expect(
|
||||
await mcpA.callTool({
|
||||
name: "append_workspace_note",
|
||||
arguments: { note: "Run the targeted tests before committing." },
|
||||
}),
|
||||
).toMatchObject({
|
||||
content: [
|
||||
{ type: "text", text: `Saved note to ${path.join(h.directory, "AGENT_NOTES.md")}` },
|
||||
],
|
||||
structuredContent: { path: path.join(h.directory, "AGENT_NOTES.md") },
|
||||
});
|
||||
expect(await readFile(path.join(h.directory, "AGENT_NOTES.md"), "utf8")).toBe(
|
||||
"Run the targeted tests before committing.\n",
|
||||
);
|
||||
await mcpA.callTool({
|
||||
name: "append_workspace_note",
|
||||
arguments: { note: "Keep existing notes." },
|
||||
});
|
||||
expect(await readFile(path.join(h.directory, "AGENT_NOTES.md"), "utf8")).toBe(
|
||||
"Run the targeted tests before committing.\nKeep existing notes.\n",
|
||||
);
|
||||
await h.create();
|
||||
const mcpB = await h.mcp();
|
||||
expect((await mcpB.listTools()).tools.map((tool) => tool.name)).not.toContain(
|
||||
"append_workspace_note",
|
||||
);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
@@ -1,46 +0,0 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { defineAgentTool } from "@getpaseo/plugin/server";
|
||||
import { serializeAgentTool } from "./agent-tools.js";
|
||||
import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js";
|
||||
|
||||
test("agent tool schemas survive the process boundary and catalog round trip", () => {
|
||||
const input = z
|
||||
.object({
|
||||
title: z.string().min(1).describe("Issue title"),
|
||||
state: z.enum(["open", "closed"]),
|
||||
tags: z.array(z.string()).optional(),
|
||||
nested: z.object({ enabled: z.boolean() }),
|
||||
})
|
||||
.describe("Create an issue");
|
||||
const tool = defineAgentTool({
|
||||
name: "create_issue",
|
||||
description: "Create",
|
||||
input,
|
||||
execute: () => ({ text: "done" }),
|
||||
});
|
||||
const metadata = JSON.parse(JSON.stringify(serializeAgentTool(tool)));
|
||||
const restored = z.fromJSONSchema(metadata.inputSchema);
|
||||
expect(
|
||||
serializePaseoToolInputParameters({
|
||||
...metadata,
|
||||
inputSchema: restored,
|
||||
handler: async () => ({ content: [] }),
|
||||
}),
|
||||
).toEqual({
|
||||
...metadata.inputSchema,
|
||||
additionalProperties: {},
|
||||
properties: {
|
||||
...metadata.inputSchema.properties,
|
||||
nested: { ...metadata.inputSchema.properties.nested, additionalProperties: {} },
|
||||
},
|
||||
});
|
||||
expect(restored.parse({ title: "Bug", state: "open", nested: { enabled: true } })).toEqual({
|
||||
title: "Bug",
|
||||
state: "open",
|
||||
nested: { enabled: true },
|
||||
});
|
||||
expect(
|
||||
restored.safeParse({ title: "", state: "unknown", nested: { enabled: true } }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
AgentToolDefinition,
|
||||
AgentToolResult,
|
||||
PluginHookContext,
|
||||
PluginSessionOpenRequest,
|
||||
PluginHookAgent,
|
||||
} from "@getpaseo/plugin/server";
|
||||
import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
|
||||
|
||||
export const agentToolMetadataSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.regex(/^[a-z][a-z0-9_]*$/)
|
||||
.max(64),
|
||||
title: z.string().optional(),
|
||||
description: z.string(),
|
||||
inputSchema: z.record(z.string(), z.unknown()),
|
||||
pluginId: z.string().optional(),
|
||||
pluginInstanceId: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type AgentToolMetadata = z.infer<typeof agentToolMetadataSchema>;
|
||||
export type AttachedAgentTool = AgentToolMetadata & { pluginId: string; pluginInstanceId: string };
|
||||
export type SessionOpenWireRequest = Omit<PluginSessionOpenRequest, "tools"> & {
|
||||
tools: AgentToolMetadata[];
|
||||
};
|
||||
|
||||
export interface PluginAgentToolsRuntime {
|
||||
isToolPluginAvailable(pluginId: string, pluginInstanceId: string): boolean;
|
||||
detachTools(agentId: string): void;
|
||||
callTool(
|
||||
pluginId: string,
|
||||
pluginInstanceId: string,
|
||||
agent: PluginHookAgent,
|
||||
name: string,
|
||||
input: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AgentToolResult>;
|
||||
}
|
||||
|
||||
export function serializeAgentTool(tool: AgentToolDefinition): AgentToolMetadata {
|
||||
return agentToolMetadataSchema.parse({
|
||||
name: tool.name,
|
||||
title: tool.title,
|
||||
description: tool.description,
|
||||
inputSchema: toJsonSchemaCompat(tool.input, { strictUnions: true, pipeStrategy: "input" }),
|
||||
});
|
||||
}
|
||||
|
||||
// This store lives only in the plugin process. Definitions and parsed values never cross IPC.
|
||||
export class PluginAgentTools {
|
||||
private readonly agents = new Map<string, AgentToolDefinition[]>();
|
||||
private readonly calls = new Map<string, { agentId: string; controller: AbortController }>();
|
||||
|
||||
attach(agentId: string, tools: AgentToolDefinition[]): AgentToolMetadata[] {
|
||||
const names = new Set<string>();
|
||||
const metadata = tools.map((tool) => {
|
||||
const serialized = serializeAgentTool(tool);
|
||||
if (names.has(tool.name)) throw new Error(`Duplicate agent tool: ${tool.name}`);
|
||||
names.add(tool.name);
|
||||
return serialized;
|
||||
});
|
||||
this.agents.set(agentId, tools);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
detach(agentId: string): void {
|
||||
this.agents.delete(agentId);
|
||||
for (const call of this.calls.values()) {
|
||||
if (call.agentId === agentId) call.controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
cancel(requestId: string): void {
|
||||
this.calls.get(requestId)?.controller.abort();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
for (const call of this.calls.values()) call.controller.abort();
|
||||
this.agents.clear();
|
||||
}
|
||||
|
||||
async call(
|
||||
requestId: string,
|
||||
agent: PluginHookAgent,
|
||||
name: string,
|
||||
input: unknown,
|
||||
paseo: PluginHookContext["paseo"],
|
||||
): Promise<AgentToolResult> {
|
||||
const controller = new AbortController();
|
||||
this.calls.set(requestId, { agentId: agent.id, controller });
|
||||
try {
|
||||
const tool = this.agents.get(agent.id)?.find((definition) => definition.name === name);
|
||||
if (!tool) throw new Error(`Agent tool not attached: ${name}`);
|
||||
const parsed = await tool.input.parseAsync(input);
|
||||
controller.signal.throwIfAborted();
|
||||
const result = await tool.execute(parsed, { agent, paseo, signal: controller.signal });
|
||||
controller.signal.throwIfAborted();
|
||||
return result;
|
||||
} catch (error) {
|
||||
return { isError: true, text: error instanceof Error ? error.message : String(error) };
|
||||
} finally {
|
||||
this.calls.delete(requestId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { readPluginManifest } from "./manifest.js";
|
||||
import { assertPluginCompatibility } from "@getpaseo/protocol/plugin-requirements";
|
||||
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { isBuiltin } from "node:module";
|
||||
import path from "node:path";
|
||||
import { createPluginImportReader, type PluginImportKind } from "./compiler-imports.js";
|
||||
import type { Metafile, OnResolveResult, Plugin } from "esbuild";
|
||||
import {
|
||||
isPluginClientOnlySdkSpecifier,
|
||||
isPluginServerOnlySdkSpecifier,
|
||||
PLUGIN_SDK_SPECIFIERS,
|
||||
} from "./plugin-sdk-specifiers.js";
|
||||
|
||||
import { loadEsbuild, SERVER_HOST_MODULES } from "./compiler-esbuild.js";
|
||||
|
||||
type PluginBuildTarget = "client" | "server";
|
||||
|
||||
type PluginModuleLocation = PluginBuildTarget | "shared" | "invalid";
|
||||
|
||||
function directoryTarget(filePath: string, pluginDirectory: string): PluginModuleLocation | null {
|
||||
const relative = path.relative(pluginDirectory, filePath);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) return "invalid";
|
||||
const segments = relative.split(path.sep);
|
||||
if (segments.includes("node_modules")) return null;
|
||||
if (segments[0] === "client") return "client";
|
||||
if (segments[0] === "server") return "server";
|
||||
if (segments[0] === "shared") return "shared";
|
||||
if (/^index\.client\.tsx?$/.test(relative)) return "client";
|
||||
if (/^index\.server\.tsx?$/.test(relative)) return "server";
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
function containsPath(directory: string, filePath: string): boolean {
|
||||
const relative = path.relative(directory, filePath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function dependencyName(specifier: string): string {
|
||||
const segments = specifier.split("/");
|
||||
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
|
||||
}
|
||||
|
||||
function findDependencyRoot(
|
||||
resolvedPath: string,
|
||||
specifier: string,
|
||||
pluginDirectory: string,
|
||||
): string | null {
|
||||
const expectedName = dependencyName(specifier);
|
||||
const ambientName = `@types/${expectedName.replace(/^@/, "").replace("/", "__")}`;
|
||||
let directory = path.dirname(resolvedPath);
|
||||
for (;;) {
|
||||
const manifestPath = path.join(directory, "package.json");
|
||||
if (existsSync(manifestPath)) {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { name?: unknown };
|
||||
if (manifest.name === expectedName || manifest.name === ambientName) {
|
||||
if (containsPath(pluginDirectory, directory) || containsPath(directory, pluginDirectory)) {
|
||||
return null;
|
||||
}
|
||||
return directory;
|
||||
}
|
||||
}
|
||||
const parent = path.dirname(directory);
|
||||
if (parent === directory) return null;
|
||||
directory = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function moduleBoundaryError(
|
||||
moduleLocation: PluginModuleLocation | null,
|
||||
target: PluginBuildTarget | "shared",
|
||||
filePath: string,
|
||||
): OnResolveResult | null {
|
||||
if (moduleLocation === "invalid") {
|
||||
return {
|
||||
errors: [{ text: `Plugin modules belong in client/, server/, or shared/: ${filePath}` }],
|
||||
};
|
||||
}
|
||||
if (moduleLocation === null || moduleLocation === "shared" || moduleLocation === target) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
text: `${moduleLocation}-only module cannot be imported into the plugin ${target} bundle: ${filePath}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function lexicalBoundaryError(
|
||||
specifier: string,
|
||||
resolveDirectory: string,
|
||||
pluginDirectory: string,
|
||||
target: PluginBuildTarget | "shared",
|
||||
): OnResolveResult | null {
|
||||
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) return null;
|
||||
const lexicalPath = path.resolve(resolveDirectory, specifier);
|
||||
if (containsPath(pluginDirectory, lexicalPath)) {
|
||||
return moduleBoundaryError(directoryTarget(lexicalPath, pluginDirectory), target, lexicalPath);
|
||||
}
|
||||
// Normalize only a containing root alias. Resolving the whole import would erase
|
||||
// an authored server/ or client/ location when the final file is a symlink.
|
||||
for (let ancestor = path.dirname(lexicalPath); ; ancestor = path.dirname(ancestor)) {
|
||||
if (existsSync(ancestor) && realpathSync.native(ancestor) === pluginDirectory) {
|
||||
const ownedPath = path.join(pluginDirectory, path.relative(ancestor, lexicalPath));
|
||||
return moduleBoundaryError(directoryTarget(ownedPath, pluginDirectory), target, lexicalPath);
|
||||
}
|
||||
if (path.dirname(ancestor) === ancestor) return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: string): Plugin {
|
||||
const boundaryResolution = {};
|
||||
const linkedDependencyRoots = new Set<string>();
|
||||
return {
|
||||
name: `paseo-plugin-${target}-runtime-boundary`,
|
||||
setup(buildContext) {
|
||||
const checked = new Set<string>();
|
||||
const imports = createPluginImportReader(pluginDirectory);
|
||||
function resolvedBoundaryError(
|
||||
resolvedFile: string,
|
||||
specifier: string,
|
||||
importer: string,
|
||||
owner: PluginBuildTarget | "shared",
|
||||
) {
|
||||
const file = realpathSync.native(resolvedFile);
|
||||
const location = directoryTarget(file, pluginDirectory);
|
||||
if (location === "invalid") {
|
||||
if (
|
||||
[...linkedDependencyRoots].some(
|
||||
(root) => containsPath(root, importer) && containsPath(root, file),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
if (!specifier.startsWith(".") && !path.isAbsolute(specifier)) {
|
||||
const root = findDependencyRoot(file, specifier, pluginDirectory);
|
||||
if (root) {
|
||||
linkedDependencyRoots.add(root);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return moduleBoundaryError(location, owner, file);
|
||||
}
|
||||
async function resolveImportFiles(
|
||||
file: string,
|
||||
specifier: string,
|
||||
kind: PluginImportKind,
|
||||
typeOnly: boolean,
|
||||
): Promise<Set<string>> {
|
||||
const declaration = imports.resolve(specifier, file, kind);
|
||||
const dependencyFiles = new Set<string>();
|
||||
if (declaration && (typeOnly || /\.d\.[cm]?ts$/.test(declaration)))
|
||||
dependencyFiles.add(declaration);
|
||||
if (typeOnly && !declaration) {
|
||||
throw new Error(`Could not resolve type dependency "${specifier}" imported by ${file}`);
|
||||
}
|
||||
if (!typeOnly && kind !== "type-reference") {
|
||||
const resolution = await buildContext.resolve(specifier, {
|
||||
importer: file,
|
||||
resolveDir: path.dirname(file),
|
||||
kind,
|
||||
});
|
||||
// esbuild owns missing-runtime errors: erased imports and guarded optional
|
||||
// requires are legal. Inspect every dependency that actually resolves.
|
||||
if (!resolution.errors.length && !resolution.external && resolution.namespace === "file")
|
||||
dependencyFiles.add(realpathSync.native(resolution.path));
|
||||
}
|
||||
return dependencyFiles;
|
||||
}
|
||||
async function checkSourceImports(
|
||||
sourcePath: string,
|
||||
inheritedOwner: PluginBuildTarget | "shared",
|
||||
): Promise<OnResolveResult | null> {
|
||||
const file = realpathSync.native(sourcePath);
|
||||
const owner =
|
||||
directoryTarget(file, pluginDirectory) === "shared" ? "shared" : inheritedOwner;
|
||||
const key = `${owner}:${file}`;
|
||||
if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null;
|
||||
checked.add(key);
|
||||
for (const { specifier, kind, typeOnly } of imports.read(file)) {
|
||||
const packageSpecifier =
|
||||
kind === "type-reference"
|
||||
? `@types/${specifier.replace(/^@/, "").replace("/", "__")}`
|
||||
: specifier;
|
||||
const error =
|
||||
runtimeSpecifierError(specifier, owner, file) ??
|
||||
runtimeSpecifierError(packageSpecifier, owner, file) ??
|
||||
lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner);
|
||||
if (error) return error;
|
||||
// Protocol types are shared host contracts, also exposed by the SDK's peer
|
||||
// dependency during development. Erased imports need no plugin-local install;
|
||||
// runtime imports still go through normal dependency resolution below.
|
||||
if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue;
|
||||
// Host modules have separately enforced SDK boundaries and need no local installation.
|
||||
if (
|
||||
(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) ||
|
||||
/^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) ||
|
||||
isBuiltin(specifier) ||
|
||||
packageSpecifier === "@types/node"
|
||||
)
|
||||
continue;
|
||||
const dependencyFiles = await resolveImportFiles(file, specifier, kind, typeOnly);
|
||||
for (const dependencyFile of dependencyFiles) {
|
||||
const boundaryError = resolvedBoundaryError(dependencyFile, specifier, file, owner);
|
||||
if (boundaryError) return boundaryError;
|
||||
const dependencyError = await checkSourceImports(dependencyFile, owner);
|
||||
if (dependencyError) return dependencyError;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
buildContext.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, (args) =>
|
||||
checkSourceImports(args.path, target),
|
||||
);
|
||||
buildContext.onResolve({ filter: /.*/ }, async (args) => {
|
||||
if (args.kind === "entry-point") return null;
|
||||
if (args.pluginData === boundaryResolution) return null;
|
||||
const importer =
|
||||
args.namespace === "file" ? realpathSync.native(args.importer) : args.importer;
|
||||
const resolveDir = args.namespace === "file" ? path.dirname(importer) : args.resolveDir;
|
||||
const owner = directoryTarget(importer, pluginDirectory) === "shared" ? "shared" : target;
|
||||
const specifierError = runtimeSpecifierError(args.path, owner, importer);
|
||||
if (specifierError) return specifierError;
|
||||
const lexicalError = lexicalBoundaryError(args.path, resolveDir, pluginDirectory, owner);
|
||||
if (lexicalError) return lexicalError;
|
||||
const resolution = await buildContext.resolve(args.path, {
|
||||
importer,
|
||||
namespace: args.namespace,
|
||||
resolveDir,
|
||||
kind: args.kind,
|
||||
pluginData: boundaryResolution,
|
||||
with: args.with,
|
||||
});
|
||||
if (
|
||||
resolution.errors.length > 0 ||
|
||||
resolution.external ||
|
||||
resolution.namespace !== "file"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return resolvedBoundaryError(resolution.path, args.path, importer, owner);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function wrapCommonJsBundle(code: string): string {
|
||||
return `(function(require) {\nconst module = { exports: {} };\nconst exports = module.exports;\n${code}\nreturn module.exports;\n})`;
|
||||
}
|
||||
|
||||
function makeHermesInteropEager(code: string): string {
|
||||
// Hermes evaluates esbuild's lazy CommonJS interop getters from a string with
|
||||
// the final loop binding, so every named import can resolve to the last export.
|
||||
// Plugin bundles execute once and do not need live bindings from host modules.
|
||||
return code.replaceAll("get: () => from[key]", "value: from[key]");
|
||||
}
|
||||
|
||||
function runtimeSpecifierError(
|
||||
specifier: string,
|
||||
target: PluginBuildTarget | "shared",
|
||||
importer: string,
|
||||
): OnResolveResult | null {
|
||||
let kind: string | null = null;
|
||||
if (specifier === "@getpaseo/plugin/client/host") kind = "host-private";
|
||||
else if (
|
||||
(specifier === "@getpaseo/plugin" ||
|
||||
specifier.startsWith("@getpaseo/plugin/") ||
|
||||
specifier === "@paseo/plugin" ||
|
||||
specifier.startsWith("@paseo/plugin/")) &&
|
||||
!(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier)
|
||||
)
|
||||
kind = "Unknown SDK";
|
||||
else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node"))
|
||||
kind = "Node";
|
||||
else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only";
|
||||
else if (
|
||||
target !== "client" &&
|
||||
(isPluginClientOnlySdkSpecifier(specifier) ||
|
||||
/^((?:@types\/)?react(?:-dom|-native)?|use-sync-external-store|@tanstack\/react-query)(\/|$)/.test(
|
||||
specifier,
|
||||
))
|
||||
)
|
||||
kind = "client-only";
|
||||
return kind
|
||||
? {
|
||||
errors: [
|
||||
{
|
||||
text: `${kind} module cannot be imported into the plugin ${target} bundle: ${specifier} imported by ${importer}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function checkSharedDependencies(inputs: Metafile["inputs"], pluginDirectory: string): void {
|
||||
function inputLocation(file: string): PluginModuleLocation | null {
|
||||
const absolutePath = path.resolve(file);
|
||||
// Metafile keys must stay unchanged for graph traversal. Only filesystem
|
||||
// inputs have canonical paths; data URLs and external specifiers do not.
|
||||
return directoryTarget(
|
||||
existsSync(absolutePath) ? realpathSync.native(absolutePath) : absolutePath,
|
||||
pluginDirectory,
|
||||
);
|
||||
}
|
||||
const pending = Object.keys(inputs).filter((file) => inputLocation(file) === "shared");
|
||||
const visited = new Set<string>();
|
||||
while (pending.length) {
|
||||
const file = pending.pop()!;
|
||||
if (visited.has(file)) continue;
|
||||
visited.add(file);
|
||||
for (const dependency of inputs[file]?.imports ?? []) {
|
||||
const location = dependency.external ? null : inputLocation(dependency.path);
|
||||
const error =
|
||||
runtimeSpecifierError(dependency.original ?? dependency.path, "shared", file) ??
|
||||
(dependency.external
|
||||
? null
|
||||
: moduleBoundaryError(
|
||||
location === "invalid" ? null : location,
|
||||
"shared",
|
||||
dependency.path,
|
||||
));
|
||||
if (error?.errors?.length) throw new Error(error.errors[0].text);
|
||||
if (!dependency.external) pending.push(dependency.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
|
||||
const { build } = loadEsbuild();
|
||||
// Use native canonical paths throughout: TypeScript expands Windows short names
|
||||
// when resolving type references, while the JS realpath implementation retains them.
|
||||
const pluginDirectory = realpathSync.native(path.dirname(entryPath));
|
||||
const result = await build({
|
||||
entryPoints: [realpathSync.native(entryPath)],
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
jsx: "automatic",
|
||||
// `import icon from "./icon.svg"` yields the markup for an inline `{ svg }` icon.
|
||||
loader: { ".svg": "text" },
|
||||
platform: target === "server" ? "node" : "neutral",
|
||||
target: target === "server" ? "node20" : "es2020",
|
||||
// The neutral platform reads no package.json entry fields, so packages without
|
||||
// `exports` would not resolve. Read the same fields as esbuild's browser platform
|
||||
// minus `browser`, since the bundle also runs in React Native.
|
||||
mainFields: target === "client" ? ["module", "main"] : undefined,
|
||||
// Metro lowers async syntax before Hermes sees app code. Plugin client bundles bypass Metro,
|
||||
// so apply the same compatibility transform before the app evaluates them from source.
|
||||
supported: target === "client" ? { "async-await": false } : undefined,
|
||||
external:
|
||||
target === "client"
|
||||
? [
|
||||
...PLUGIN_SDK_SPECIFIERS,
|
||||
"@tanstack/react-query",
|
||||
"react",
|
||||
"react/jsx-runtime",
|
||||
"react-native",
|
||||
"zod",
|
||||
]
|
||||
: SERVER_HOST_MODULES,
|
||||
plugins: [createRuntimeBoundaryPlugin(target, pluginDirectory)],
|
||||
metafile: true,
|
||||
logLevel: "silent",
|
||||
treeShaking: true,
|
||||
write: false,
|
||||
});
|
||||
checkSharedDependencies(result.metafile.inputs, pluginDirectory);
|
||||
const output = result.outputFiles[0]?.text;
|
||||
if (!output) throw new Error(`Plugin ${target} compilation produced no output`);
|
||||
return wrapCommonJsBundle(makeHermesInteropEager(output));
|
||||
}
|
||||
|
||||
export async function compilePlugin(entryPaths: {
|
||||
client: string | null;
|
||||
server: string | null;
|
||||
}): Promise<{
|
||||
clientBundle: string | null;
|
||||
serverBundle: string | null;
|
||||
}> {
|
||||
const [clientBundle, serverBundle] = await Promise.all([
|
||||
entryPaths.client ? compileTarget(entryPaths.client, "client") : null,
|
||||
entryPaths.server ? compileTarget(entryPaths.server, "server") : null,
|
||||
]);
|
||||
return { clientBundle, serverBundle };
|
||||
}
|
||||
|
||||
const CLIENT_ENTRY_FILENAMES = ["index.client.ts", "index.client.tsx"] as const;
|
||||
const SERVER_ENTRY_FILENAMES = ["index.server.ts", "index.server.tsx"] as const;
|
||||
async function findEntry(directory: string, filenames: readonly string[]): Promise<string | null> {
|
||||
for (const filename of filenames) {
|
||||
const filePath = path.join(directory, filename);
|
||||
const info = await stat(filePath).catch(() => null);
|
||||
if (info?.isFile()) return filePath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveEntryPaths(directory: string): Promise<{
|
||||
client: string | null;
|
||||
server: string | null;
|
||||
}> {
|
||||
const [client, server] = await Promise.all([
|
||||
findEntry(directory, CLIENT_ENTRY_FILENAMES),
|
||||
findEntry(directory, SERVER_ENTRY_FILENAMES),
|
||||
]);
|
||||
if (client || server) return { client, server };
|
||||
const legacyEntry = await findEntry(directory, ["index.ts", "index.tsx"]);
|
||||
if (legacyEntry) {
|
||||
throw new Error(
|
||||
"This plugin was made for an older version of Paseo and cannot run on Paseo v0.8. Ask its author to update it. Plugin authors can follow the migration guide: https://paseo.sh/docs/plugins/migration",
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Plugin entry points are missing: expected ${CLIENT_ENTRY_FILENAMES.join(" or ")} and/or ${SERVER_ENTRY_FILENAMES.join(" or ")} in ${directory}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function compileDirectoryPlugin(
|
||||
directory: string,
|
||||
version: string,
|
||||
builtinId?: string,
|
||||
) {
|
||||
const manifest = await readPluginManifest(directory);
|
||||
if (builtinId !== undefined && manifest.id !== builtinId) {
|
||||
throw new Error(`Built-in plugin ${builtinId} has manifest ID ${manifest.id}`);
|
||||
}
|
||||
assertPluginCompatibility({ ...manifest, version, runtime: "daemon" });
|
||||
const bundles = await compilePlugin(await resolveEntryPaths(directory));
|
||||
return { ...bundles, manifest };
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { PLUGIN_SDK_SPECIFIERS } from "./plugin-sdk-specifiers.js";
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
export const SERVER_HOST_MODULES = [...PLUGIN_SDK_SPECIFIERS, "zod"];
|
||||
const ESBUILD_BINARY_PATH = "ESBUILD_BINARY_PATH";
|
||||
|
||||
// esbuild resolves its own platform binary via require.resolve() the first time its
|
||||
// module is evaluated. Inside the packaged desktop app that resolves to a path under
|
||||
// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked.
|
||||
// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path
|
||||
// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads.
|
||||
export function unpackedEsbuildBinaryFromPackageDir(
|
||||
esbuildPackageDir: string,
|
||||
platform: NodeJS.Platform,
|
||||
arch: string,
|
||||
): string | null {
|
||||
const asarSegment = `${path.sep}app.asar${path.sep}`;
|
||||
const asarIndex = esbuildPackageDir.indexOf(asarSegment);
|
||||
if (asarIndex === -1) return null;
|
||||
return path.join(
|
||||
esbuildPackageDir.slice(0, asarIndex),
|
||||
"app.asar.unpacked",
|
||||
"node_modules",
|
||||
`@esbuild/${platform}-${arch}`,
|
||||
...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveExistingAsarUnpackedEsbuildBinary(
|
||||
esbuildPackageDir: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch,
|
||||
exists: (file: string) => boolean = existsSync,
|
||||
): string | null {
|
||||
const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch);
|
||||
return binaryPath && exists(binaryPath) ? binaryPath : null;
|
||||
}
|
||||
|
||||
function resolveAsarUnpackedEsbuildBinary(): string | null {
|
||||
let esbuildDir: string;
|
||||
try {
|
||||
esbuildDir = path.dirname(nodeRequire.resolve("esbuild/package.json"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir);
|
||||
}
|
||||
|
||||
export function loadEsbuild(): typeof import("esbuild") {
|
||||
const previousBinaryPath = process.env[ESBUILD_BINARY_PATH];
|
||||
const unpackedBinary = resolveAsarUnpackedEsbuildBinary();
|
||||
if (unpackedBinary) process.env[ESBUILD_BINARY_PATH] = unpackedBinary;
|
||||
|
||||
try {
|
||||
// esbuild reads this variable while its CommonJS module is evaluated. Keep
|
||||
// the compatibility bridge local so it cannot become an agent's environment.
|
||||
return nodeRequire("esbuild") as typeof import("esbuild");
|
||||
} finally {
|
||||
if (previousBinaryPath === undefined) delete process.env[ESBUILD_BINARY_PATH];
|
||||
else process.env[ESBUILD_BINARY_PATH] = previousBinaryPath;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { parentPort } from "node:worker_threads";
|
||||
import type { ZodIssue } from "zod";
|
||||
import { compilePlugin, compileDirectoryPlugin } from "./compiler-build.js";
|
||||
import { runPluginBuild } from "./preparation-build.js";
|
||||
import type { CompilerTask } from "./compiler.js";
|
||||
|
||||
export type CompilerReply =
|
||||
| { kind: "log"; data: Record<string, unknown>; message: string }
|
||||
| { kind: "result"; value: unknown }
|
||||
| {
|
||||
kind: "error";
|
||||
error: Error;
|
||||
properties: Record<string, unknown>;
|
||||
issues?: ZodIssue[];
|
||||
errors?: unknown[];
|
||||
warnings?: unknown[];
|
||||
};
|
||||
|
||||
const port = parentPort!;
|
||||
// The compiler owner serializes requests; the port queues the first request
|
||||
// until this entrypoint has loaded, including in source/tsx development.
|
||||
port.on("message", (task: CompilerTask) => {
|
||||
void run(task);
|
||||
});
|
||||
|
||||
async function run(task: CompilerTask): Promise<void> {
|
||||
try {
|
||||
let value: unknown;
|
||||
switch (task.kind) {
|
||||
case "entries":
|
||||
value = await compilePlugin(task.entries);
|
||||
break;
|
||||
case "directory":
|
||||
value = await compileDirectoryPlugin(task.directory, task.version, task.builtinId);
|
||||
break;
|
||||
case "prepare":
|
||||
await runPluginBuild(
|
||||
task.directory,
|
||||
task.commands,
|
||||
{
|
||||
info: (data, message) =>
|
||||
port.postMessage({ kind: "log", data, message } satisfies CompilerReply),
|
||||
},
|
||||
task.environment,
|
||||
);
|
||||
break;
|
||||
}
|
||||
port.postMessage({ kind: "result", value } satisfies CompilerReply);
|
||||
} catch (cause) {
|
||||
const error = cause instanceof Error ? cause : new Error(String(cause));
|
||||
port.postMessage({
|
||||
kind: "error",
|
||||
error,
|
||||
// Error cloning drops own metadata such as filesystem code/path. Zod's
|
||||
// readonly issues are reconstructed separately by the compiler owner.
|
||||
properties: Object.fromEntries(Object.entries(error).filter(([key]) => key !== "issues")),
|
||||
...("issues" in error ? { issues: error.issues as ZodIssue[] } : {}),
|
||||
...("errors" in error ? { errors: error.errors as unknown[] } : {}),
|
||||
...("warnings" in error ? { warnings: error.warnings as unknown[] } : {}),
|
||||
} satisfies CompilerReply);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user