Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andClaude Opus 5.5 0273b6b614 fix(app): end bare URLs at CJK and fullwidth punctuation (#9799)
A bare URL that md4c does not autolink is linked by the text-link
pattern. That pattern stopped only at ASCII whitespace, quotes and
sentence punctuation, so "https://a.com、https://b.com!" became one
link, and "看这里:https://example.com/foo,还有" linked ",还有" too.
Opening the link went to a URL ending in the encoded punctuation.

CJK, fullwidth and typographic punctuation (、。,()【】「」“”…) now
ends the URL. CJK letters in paths still stay in the link.

Refs #9743

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 20:52:24 +00:00
paseo-bot[bot]andClaude Opus 5.5 8c88b83cd8 fix(server): preview the newest turn's reply in finished notifications (#9793)
A turn the provider starts on its own, such as Claude waking after a
background subagent, has no user message before its reply. The last
assistant message lookup joined every adjacent assistant row, so it
returned the previous turn's reply with the new one appended, and the
220-character notification preview showed only the old reply.

Stop the backward scan at the turn boundary so the lookup returns only
the newest turn's reply, while chunks of one turn are still joined.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 20:02:46 +00:00
tieoandClaude Opus 5.5 b55955541e fix(dictation): surface the daemon's stream error instead of a delivery timeout (#9784)
The daemon drops a dictation stream when its STT session errors and answers
every later chunk with dictation_stream_error. The app only listened for
acks, so finishing waited 30s and reported a generic timeout. The sender now
fails finish with the daemon's message, and the daemon logs the failure that
dropped the stream once instead of nothing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 19:05:47 +00:00
paseo-bot[bot] 2c5f5a1485 test(e2e): keep the planted zombie until its shell has exec'd (#9808)
The daemon-lifecycle cleanup-failure test planted a zombie with
`sleep 0 & echo $!; exec sleep 600`. dash reaps any exited child after
a builtin, so when `sleep 0` exited before `echo` returned, the shell
reaped it and `ps -o stat= -p <pid>` failed. The child now reads fd 3
until the helper closes it, which it does only after the shell has
become `sleep`.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-15 18:38:16 +00:00
685e113358 Fail paseo agent wait when no agent matches the id (#9782)
* fix(cli): fail agent wait when the daemon resolved no agent

The daemon answers wait_for_finish with status "error" both when it could not
resolve the identifier to an agent (final: null) and when the agent it did
resolve finished in error (final: the agent payload). runWaitCommand handed
both to buildWaitResult, which returns a normal result row, so
`paseo agent wait <unknown id>` printed "Agent not found: ..." and exited 0
while every sibling command exits 1 with AGENT_NOT_FOUND (#9736).

Throw AGENT_NOT_FOUND when the state is error and the payload is null, and let
that command error through the inner catch instead of rewrapping it as
WAIT_FAILED. A genuine agent failure still carries its payload, so it keeps
printing its result row. The wire schema is unchanged.

* test(cli): cover the agent wait not-found mapping without module mocks

Move the not-found decision into buildWaitResult, which maps the daemon's
wait reply to the command result, and test that function directly with plain
reply objects instead of mocking the daemon client module. The e2e test in
tests/11-agent-wait.test.ts still covers the command against a real daemon.

Co-authored-by: Eapp1e <163811520+Eapp1e@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): keep daemon errors during agent wait reported as WAIT_FAILED

Map the wait reply to the result after the wait's try/catch, so the
AGENT_NOT_FOUND error leaves without the inner catch passing every coded
error through. Connection and RPC errors from waitForFinish are wrapped as
WAIT_FAILED again, as on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Eapp1e <163811520+Eapp1e@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 17:54:27 +00:00
Huyvux12 28322f1519 fix(server): include local provider config when turn detection is local (#9766) (#9779) 2026-11-15 17:09:46 +00:00
4421f0473a Archive non-Git workspaces from the command center (#9777)
* fix(app): show archive workspace in command center

* test(app): cover archive workspace command center entry

* fix(app): expose archive for non-git workspaces

* fix(app): archive non-Git workspaces from the command center

A plain directory workspace had no Archive workspace entry in the command
center, because the entry came only from the Git action list, which is
empty outside Git. The command center now lists archive for any workspace
that is not Git and runs the workspace's own archive controller, the same
one the Git action uses, so it works whether or not the workspace's
sidebar row is rendered.

Git workspaces are unchanged: archive stays a Git action that shows on
the default list only when it is the promoted primary action.

Co-authored-by: Andreas Heiberg <andreas@heiberg.io>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Andreas Heiberg <andreas@heiberg.io>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 15:59:25 +00:00
paseo-bot[bot] 2a432b1e0a test(app): wait for the slash search itself before erasing it (#9790)
The arrow-choice test waited for /tdd to leave the list as proof that
the search had become "h". The option list is a FlatList that mounts
only the rows near its scroll position: right after the arrows wrap to
/exit at the top, /tdd at the bottom is unmounted while the search is
still "". The wait then passed early, Backspace landed before the
cursor reached the search, the /exit choice stood, and Enter archived
the agent.

The test now waits for a popover that shows /help with neither /tdd
nor /exit, in one query. With the search at "" the list always mounts
the bottom row or the chosen one; only the search "h" leaves both out.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-15 15:51:53 +00:00
Mohamed Boudra 1c175b36b3 Keep a dropped Usage card in its new slot on web (#9780)
On web and desktop, dropping a Usage card made it jump above its new slot
and slide back down for 200 ms.

The web list cleared its drag state on drop and rendered the owner's data
again. Usage saves the order through app settings, so the new order
arrived in a later render. The render in between had the old order with
dnd-kit's transforms cleared, which started a transition back to the old
slot. The new order then moved the row's slot under that running
transition.

The list now holds the dropped key order until the owner's data changes
order, and renders the owner's current rows in that order. The drop and
the new order commit in one render, so dnd-kit skips the transition. A
drag that starts during the hold picks up the rows as displayed. Sidebar
lists already reorder in the drop's render and behave as before. The
native list has followed the same rule since #9748.
2026-11-15 21:51:37 +07:00
Junjie ZHANG aa9984b899 fix(app): show the keyboard focus ring on icon buttons (#9745)
The shared icon button chrome set outlineWidth: 0, which overrides the
:focus-visible ring from public/index.html. Header and toolbar icon
buttons (sidebar toggle, workspace actions, Explorer toggle, new tab,
pane menu, diff toolbar) showed no indication of keyboard focus.

Drop the reset and draw the ring 2px inside the frame: the header title
row clips overflow, so an outset ring loses its top and bottom edges.
Pointer focus stays unringed through the existing *:focus rule.
2026-11-15 13:53:12 +00:00
58 changed files with 987 additions and 1078 deletions
@@ -0,0 +1,105 @@
import { existsSync } from "node:fs";
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { openCommandCenter } from "../support/helpers/command-center";
import { openWorkspaceFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
import { getServerId } from "../support/helpers/server-id";
import { expectWorkspaceAbsentFromSidebar } from "../support/helpers/sidebar";
async function isWorkspaceListed(seeded: SeededWorkspace): Promise<boolean> {
const result = await seeded.client.fetchWorkspaces();
return result.entries.some((entry) => entry.id === seeded.workspaceId);
}
async function expectWorkspaceArchivedOnDaemon(seeded: SeededWorkspace): Promise<void> {
await expect.poll(() => isWorkspaceListed(seeded), { timeout: 30_000 }).toBe(false);
}
test.describe("Command center archive for a non-Git workspace", () => {
test("archives a plain directory workspace from the command center", async ({ page }) => {
const title = "Command Center Directory Archive";
const seeded = await seedWorkspace({ repoPrefix: "cc-archive-dir-", title, git: false });
try {
await gotoAppShell(page);
await openWorkspaceFromCommandCenter(page, seeded, title);
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("archive");
const archive = panel.getByRole("button", { name: /^Archive(?: workspace)?(?:\s|$)/ });
await expect(archive).toBeVisible({ timeout: 30_000 });
await archive.click();
await expectWorkspaceAbsentFromSidebar(page, seeded.workspaceId);
expect(existsSync(seeded.repoPath)).toBe(true);
} finally {
await seeded.cleanup();
}
});
test("archives a plain directory workspace from the command center with the sidebar closed", async ({
page,
}) => {
const title = "Command Center Directory Archive Closed Sidebar";
const seeded = await seedWorkspace({ repoPrefix: "cc-archive-dir-closed-", title, git: false });
try {
await gotoAppShell(page);
await openWorkspaceFromCommandCenter(page, seeded, title);
await page.getByTestId("menu-button").click();
await expect(page.getByTestId("sidebar-search")).toBeHidden({ timeout: 10_000 });
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+k`);
const panel = page.getByTestId("command-center-panel");
await expect(panel).toBeVisible({ timeout: 30_000 });
await panel.getByTestId("command-center-input").fill("archive");
const archive = panel.getByRole("button", { name: /^Archive(?: workspace)?(?:\s|$)/ });
await expect(archive).toBeVisible({ timeout: 30_000 });
await archive.click();
await expectWorkspaceArchivedOnDaemon(seeded);
} finally {
await seeded.cleanup();
}
});
test("archives a plain directory workspace from the command center with its project collapsed", async ({
page,
}) => {
const title = "Command Center Directory Archive Collapsed Project";
const seeded = await seedWorkspace({
repoPrefix: "cc-archive-dir-collapsed-",
title,
git: false,
});
try {
await gotoAppShell(page);
await openWorkspaceFromCommandCenter(page, seeded, title);
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`,
);
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
await page
.getByTestId(`sidebar-project-row-${projectEquivalenceViewKey(seeded.projectKey)}`)
.first()
.click();
await expect(workspaceRow).toHaveCount(0, { timeout: 10_000 });
const panel = await openCommandCenter(page);
await panel.getByTestId("command-center-input").fill("archive");
const archive = panel.getByRole("button", { name: /^Archive(?: workspace)?(?:\s|$)/ });
await expect(archive).toBeVisible({ timeout: 30_000 });
await archive.click();
await expectWorkspaceArchivedOnDaemon(seeded);
} finally {
await seeded.cleanup();
}
});
});
@@ -788,11 +788,20 @@ test.describe("Composer autocomplete", () => {
await input.press("ArrowDown");
await input.press("h");
// On web the search reads the cursor after paint, so the input's value runs ahead of
// the list. Wait on the list itself: /tdd is hidden only while the search is "h", and
// back only once the search is "" again.
await expect(popover.getByText("/tdd", { exact: true })).toHaveCount(0);
// the list. Wait on the list itself. The list mounts only the rows near its scroll
// position, so one missing row proves nothing: right after the arrows wrap to the top,
// /tdd at the bottom is unmounted while the search is still "". With the search at "",
// the list always mounts the bottom row (/tdd) or the chosen one (/exit); only the
// search "h" leaves both out. Check them in one query so no frame falls between them.
await expect(
popover.filter({
has: page.getByText("/help", { exact: true }),
hasNot: page.getByText(/^\/(tdd|exit)$/),
}),
).toBeVisible();
await input.press("Backspace");
await expect(input).toHaveValue("/");
// /tdd is back only once the search is "" again.
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible();
await input.press("Enter");
@@ -0,0 +1,56 @@
import { expect, type Locator, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { seedWorkspace } from "../support/helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
const HEADER_ICON_BUTTONS = ["workspace-header-menu-trigger", "workspace-explorer-toggle"];
function visible(page: Page, testId: string): Locator {
return page.getByTestId(testId).filter({ visible: true }).first();
}
function matchesFocusVisible(target: Locator): Promise<boolean> {
return target.evaluate((el) => el.matches(":focus-visible"));
}
async function expectKeyboardFocusRing(page: Page, testId: string): Promise<void> {
const button = visible(page, testId);
// Chromium matches :focus-visible on a programmatic focus that follows keyboard input.
await page.keyboard.press("Shift");
await button.focus();
await expect(button).toBeFocused();
expect(await matchesFocusVisible(button)).toBe(true);
await expect(button).toHaveCSS("outline-style", "solid");
await expect(button).toHaveCSS("outline-width", "2px");
// Header rows clip overflow; an outset ring would lose its top and bottom edges.
await expect(button).toHaveCSS("outline-offset", "-2px");
}
async function expectNoPointerFocusRing(page: Page, testId: string): Promise<void> {
const button = visible(page, testId);
await button.blur();
// Toggle twice so the layout ends where it started.
await button.click();
await button.click();
await expect(button).toBeFocused();
expect(await matchesFocusVisible(button)).toBe(false);
await expect(button).toHaveCSS("outline-style", "none");
}
test.describe("Icon button focus ring", () => {
test("header icon buttons show the focus ring for keyboard focus only", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
const workspace = await seedWorkspace({ repoPrefix: "icon-button-focus-" });
try {
await gotoWorkspace(page, workspace.workspaceId);
await waitForWorkspaceTabsVisible(page);
for (const testId of HEADER_ICON_BUTTONS) {
await expectKeyboardFocusRing(page, testId);
}
await expectNoPointerFocusRing(page, "workspace-explorer-toggle");
} finally {
await workspace.cleanup();
}
});
});
@@ -37,6 +37,7 @@ import {
pickUsageHost,
usageModalHost,
dragUsageCardAbove,
recordUsageCardPositionsAfterRelease,
expectUsageCardOrder,
expectUsageOptionsFitContent,
setUsageCardOn,
@@ -254,9 +255,15 @@ test.describe("usage modal", () => {
await expect(work.getByText("work", { exact: true })).toBeVisible();
await expectPinnedUsage(page, ["31% Weekly", "20% Weekly"]);
const stopRecording = await recordUsageCardPositionsAfterRelease(page);
await dragUsageCardAbove(page, "beta:home", "alpha:a");
await expectUsageCardOrder(page, ["beta:home", "alpha:a", "beta:work"]);
await expectPinnedUsage(page, ["20% Weekly", "31% Weekly"]);
// The cards are in their new slots from the first frame after the drop.
const framesAfterDrop = await stopRecording();
const settled = framesAfterDrop.at(-1);
expect(settled).toBeDefined();
expect(framesAfterDrop.filter((frame) => frame !== settled)).toEqual([]);
await page.reload();
await expectPinnedUsage(page, ["20% Weekly", "31% Weekly"]);
@@ -96,7 +96,7 @@ export async function listTerminalIds(daemon: IsolatedHostDaemon): Promise<strin
const execFileAsync = promisify(execFile);
async function psField(pid: number, field: "stat" | "lstart"): Promise<string> {
async function psField(pid: number, field: "stat" | "lstart" | "comm"): Promise<string> {
const { stdout } = await execFileAsync("ps", ["-o", `${field}=`, "-p", String(pid)], {
env: { ...process.env, LC_ALL: "C" },
});
@@ -117,17 +117,22 @@ export interface UnclosableTerminalProgram {
export async function plantUnclosableTerminalProgram(
daemon: IsolatedHostDaemon,
): Promise<UnclosableTerminalProgram> {
const parent = spawn("/bin/sh", ["-c", "sleep 0 & echo $!; exec sleep 600"], {
stdio: ["ignore", "pipe", "ignore"],
// The shell reaps any child that has exited whenever it finishes a builtin, so the program
// reads fd 3 until we close it, which happens only once the shell has become `sleep`.
const parent = spawn("/bin/sh", ["-c", "cat <&3 >/dev/null & echo $!; exec sleep 600 3<&-"], {
stdio: ["ignore", "pipe", "ignore", "pipe"],
});
parent.unref();
const release = () => {
parent.stdio[3]?.destroy();
if (parent.exitCode === null && parent.signalCode === null) parent.kill("SIGKILL");
};
try {
const pid = await new Promise<number>((resolve) => {
parent.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim())));
});
await expect.poll(() => psField(parent.pid!, "comm")).toMatch(/(^|\/)sleep$/);
parent.stdio[3]!.destroy();
await expect.poll(async () => (await psField(pid, "stat")).startsWith("Z")).toBe(true);
const gone = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
await new Promise((resolve) => gone.once("exit", resolve));
@@ -187,6 +187,44 @@ export async function dragUsageCardAbove(
await page.mouse.up();
}
/**
* Starts recording where the cards are painted on every frame after the next mouse release.
* Call the returned function to stop and read the frames, each as "id@top" for every card.
*/
export async function recordUsageCardPositionsAfterRelease(
page: Page,
): Promise<() => Promise<string[]>> {
const recording = await usageModal(page).evaluateHandle((modal) => {
const state = { released: false, stopped: false, frames: [] as string[] };
const markReleased = () => {
state.released = true;
};
window.addEventListener("mouseup", markReleased, { capture: true, once: true });
const recordFrame = () => {
if (state.stopped) return;
if (state.released) {
const cards = Array.from(modal.querySelectorAll('[data-testid^="usage-report-"]'));
state.frames.push(
cards
.map((card) => {
const id = card.getAttribute("data-testid")?.slice("usage-report-".length);
return `${id}@${Math.round(card.getBoundingClientRect().top)}`;
})
.join(" "),
);
}
requestAnimationFrame(recordFrame);
};
requestAnimationFrame(recordFrame);
return state;
});
return () =>
recording.evaluate((state) => {
state.stopped = true;
return state.frames;
});
}
/** Each pinned window as it reads: its percent and short label, "31% 5h". */
export async function expectPinnedUsage(page: Page, windows: string[]): Promise<void> {
const pinned = usageItem(page).getByTestId("sidebar-usage-pinned-window");
@@ -25,12 +25,13 @@ function source(gitActions: GitActions): {
dispatched: KeyboardActionDefinition[];
copiedPaths: number;
copiedBranchNames: number;
archivedWorkspaces: number;
toggledLabels: Array<{ name: string; assigned: boolean }>;
} {
const runGitActions: GitAction[] = [];
const dispatched: KeyboardActionDefinition[] = [];
const toggledLabels: Array<{ name: string; assigned: boolean }> = [];
const counters = { copiedPaths: 0, copiedBranchNames: 0 };
const counters = { copiedPaths: 0, copiedBranchNames: 0, archivedWorkspaces: 0 };
return {
value: {
gitActions,
@@ -68,6 +69,7 @@ function source(gitActions: GitActions): {
closePane: "Close pane",
toggleFocusMode: "Toggle focus mode",
toggleExplorerSidebar: "Toggle Explorer sidebar",
archiveWorkspace: "Archive workspace",
rename: "Rename workspace",
copyPath: "Copy workspace path",
copyBranchName: "Copy branch name",
@@ -93,6 +95,9 @@ function source(gitActions: GitActions): {
labelCatalog: null,
dispatch: (action) => dispatched.push(action),
runGitAction: (action) => runGitActions.push(action),
archiveWorkspace: () => {
counters.archivedWorkspaces += 1;
},
copyPath: () => {
counters.copiedPaths += 1;
},
@@ -112,10 +117,48 @@ function source(gitActions: GitActions): {
get copiedBranchNames() {
return counters.copiedBranchNames;
},
get archivedWorkspaces() {
return counters.archivedWorkspaces;
},
};
}
describe("workspace command center contributions", () => {
it("offers archive in a non-Git workspace and runs the workspace's archive", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
const archive = buildWorkspaceCommandCenterContributions(fixture.value).find(
(item) => item.id === "workspace:archive",
);
expect(archive?.visibility).toBe("query");
expect(archive?.presentation).toMatchObject({ title: "Archive workspace" });
archive?.run();
expect(fixture.archivedWorkspaces).toBe(1);
expect(fixture.dispatched).toEqual([]);
});
it("omits archive while the workspace cannot be archived", () => {
const fixture = source({ primary: null, secondary: [], menu: [] });
fixture.value.archiveWorkspace = null;
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(contributions.some((item) => item.id === "workspace:archive")).toBe(false);
});
it("lists archive once in a Git workspace, through its Git action", () => {
const archive = gitAction("archive-workspace", "Archive workspace");
const fixture = source({ primary: null, secondary: [archive], menu: [] });
fixture.value.capabilities.isGit = true;
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
expect(
contributions.filter((item) => item.id.includes("archive")).map((item) => item.id),
).toEqual(["git:archive-workspace"]);
});
it("makes only the policy-selected primary Git action default-visible and runs it", () => {
const primary = gitAction("commit", "Commit");
const fixture = source({
@@ -50,6 +50,7 @@ export interface WorkspaceCommandCenterLabels {
toggleFocusMode: string;
toggleExplorerSidebar: string;
// Workspace management actions
archiveWorkspace: string;
rename: string;
copyPath: string;
copyBranchName: string;
@@ -79,6 +80,7 @@ export interface WorkspaceCommandCenterIcons {
focusMode?: CommandCenterIcon;
explorerSidebar?: CommandCenterIcon;
// Workspace management action icons
archiveWorkspace?: CommandCenterIcon;
copyPath?: CommandCenterIcon;
copyBranchName?: CommandCenterIcon;
pin?: CommandCenterIcon;
@@ -131,6 +133,8 @@ export interface WorkspaceCommandCenterSource {
labelCatalog: readonly WorkspaceCommandCenterLabelChoice[] | null;
dispatch(action: KeyboardActionDefinition): void;
runGitAction(action: GitAction): void;
/** Null while the workspace cannot be archived. A Git workspace archives through `gitActions`. */
archiveWorkspace: (() => void) | null;
copyPath(): void;
copyBranchName(): void;
toggleLabel(name: string, assigned: boolean): void | Promise<void>;
@@ -585,6 +589,23 @@ export function buildWorkspaceCommandCenterContributions(
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
}
// A Git workspace lists archive among its Git actions; any other workspace archives here.
if (!source.capabilities.isGit && source.archiveWorkspace) {
contributions.push(
buildWorkspaceCallback({
source,
id: "workspace:archive",
rank: 100,
title: source.labels.archiveWorkspace,
keywords: ["archive", "workspace"],
icon: source.icons.archiveWorkspace,
shortcutKeys: source.shortcuts.archiveWorkspace,
run: source.archiveWorkspace,
visibility: "query",
}),
);
}
if (source.capabilities.canPin) {
contributions.push(
buildWorkspaceAction({
@@ -203,7 +203,7 @@ export function useWorkspaceCommandCenterActions(): void {
supportsSetupObservation,
);
const { overrides } = useKeyboardShortcutOverrides();
const { gitActions, isGit } = useGitActions({
const { gitActions, isGit, archiveWorkspace } = useGitActions({
serverId: serverId ?? "",
cwd: cwd ?? "",
icons: GIT_ACTION_ICONS,
@@ -270,6 +270,7 @@ export function useWorkspaceCommandCenterActions(): void {
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
toggleExplorerSidebar: t("workspace.tabs.explorerSidebar.toggle"),
// Workspace management labels
archiveWorkspace: t("workspace.git.actions.archive.label"),
rename: t("sidebar.workspace.actions.rename"),
copyPath: t("workspace.header.actions.copyPath"),
copyBranchName: t("workspace.header.actions.copyBranchName"),
@@ -280,6 +281,7 @@ export function useWorkspaceCommandCenterActions(): void {
},
icons: {
...WORKSPACE_COMMAND_CENTER_ICONS,
archiveWorkspace: staticIcon(GIT_ACTION_ICONS.archive),
git: (action) => staticIcon(action.icon),
},
shortcuts: resolveWorkspaceShortcuts(overrides),
@@ -301,12 +303,14 @@ export function useWorkspaceCommandCenterActions(): void {
keyboardActionDispatcher.dispatch(action);
},
runGitAction,
archiveWorkspace,
copyPath,
copyBranchName,
toggleLabel,
}),
[
activeTabIndex,
archiveWorkspace,
activeTabKind,
canPin,
canShowSetup,
@@ -2,31 +2,41 @@ import { describe, expect, it } from "vitest";
import { dragStateInitial, dragStateReducer } from "./drag-reducer";
describe("dragStateReducer", () => {
it("starts tracking the active item with a snapshot of the data", () => {
it("starts tracking the active item with the rows on screen", () => {
const next = dragStateReducer(dragStateInitial<string>(), {
type: "start",
id: "alpha",
data: ["alpha", "beta"],
items: ["alpha", "beta"],
});
expect(next).toEqual({ activeId: "alpha", dragItems: ["alpha", "beta"] });
expect(next).toEqual({ activeId: "alpha", dragItems: ["alpha", "beta"], dropped: null });
});
it("clears the active item and the snapshot", () => {
it("clears the active item and the rows", () => {
const next = dragStateReducer(
{ activeId: "alpha", dragItems: ["alpha", "beta"] },
{ activeId: "alpha", dragItems: ["alpha", "beta"], dropped: null },
{ type: "clear" },
);
expect(next).toEqual({ activeId: null, dragItems: null });
expect(next).toEqual({ activeId: null, dragItems: null, dropped: null });
});
it("holds the dropped order in place of the order it replaces", () => {
const dropped = { order: ["beta", "alpha"], replaces: ["alpha", "beta"] };
const next = dragStateReducer(
{ activeId: "alpha", dragItems: ["alpha", "beta"], dropped: null },
{ type: "drop", dropped },
);
expect(next).toEqual({ activeId: null, dragItems: null, dropped });
});
it("replaces an in-flight drag when a new one starts", () => {
const next = dragStateReducer(
{ activeId: "alpha", dragItems: ["alpha", "beta"] },
{ type: "start", id: "beta", data: ["beta", "gamma"] },
{ activeId: "alpha", dragItems: ["alpha", "beta"], dropped: null },
{ type: "start", id: "beta", items: ["beta", "gamma"] },
);
expect(next).toEqual({ activeId: "beta", dragItems: ["beta", "gamma"] });
expect(next).toEqual({ activeId: "beta", dragItems: ["beta", "gamma"], dropped: null });
});
});
@@ -1,19 +1,33 @@
export interface DragState<T> {
activeId: string | null;
dragItems: T[] | null;
/** A dropped key order, shown in place of the owner's until the owner's order changes. */
export interface DroppedOrder {
order: string[];
/** The owner's key order at the drop. */
replaces: string[];
}
export type DragAction<T> = { type: "start"; id: string; data: T[] } | { type: "clear" };
export interface DragState<T> {
activeId: string | null;
/** The rows on screen while a drag is in flight. */
dragItems: T[] | null;
dropped: DroppedOrder | null;
}
export type DragAction<T> =
| { type: "start"; id: string; items: T[] }
| { type: "drop"; dropped: DroppedOrder }
| { type: "clear" };
export function dragStateInitial<T>(): DragState<T> {
return { activeId: null, dragItems: null };
return { activeId: null, dragItems: null, dropped: null };
}
export function dragStateReducer<T>(state: DragState<T>, action: DragAction<T>): DragState<T> {
switch (action.type) {
case "start":
return { activeId: action.id, dragItems: action.data };
return { activeId: action.id, dragItems: action.items, dropped: null };
case "drop":
return { activeId: null, dragItems: null, dropped: action.dropped };
case "clear":
return { activeId: null, dragItems: null };
return dragStateInitial();
}
}
@@ -1,6 +1,6 @@
import { useCallback, useReducer } from "react";
import { useCallback, useMemo, useReducer } from "react";
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import { dragStateInitial, dragStateReducer } from "./drag-reducer";
import { dragStateInitial, dragStateReducer, type DroppedOrder } from "./drag-reducer";
import { reorderItemsOnDragEnd } from "./reorder-items";
export interface DragReorderHandlers {
@@ -15,6 +15,28 @@ export interface DragReorderState<T> {
handlers: DragReorderHandlers;
}
/**
* The owner's rows in the dropped order, or null once the owner's own order has changed since
* the drop.
*/
function arrangeAsDropped<T>(
data: T[],
keys: string[],
{ order, replaces }: DroppedOrder,
): T[] | null {
const ownerOrderUnchanged =
keys.length === replaces.length && keys.every((key, index) => key === replaces[index]);
if (!ownerOrderUnchanged) return null;
const indexByKey = new Map(keys.map((key, index) => [key, index]));
const arranged: T[] = [];
for (const key of order) {
const index = indexByKey.get(key);
if (index !== undefined) arranged.push(data[index]);
}
return arranged.length === data.length ? arranged : null;
}
export function useDragReorderState<T>({
data,
keyExtractor,
@@ -30,13 +52,22 @@ export function useDragReorderState<T>({
}): DragReorderState<T> {
const [state, dispatch] = useReducer(dragStateReducer<T>, undefined, dragStateInitial<T>);
const keys = useMemo(() => data.map(keyExtractor), [data, keyExtractor]);
const held = useMemo(
() => (state.dropped ? arrangeAsDropped(data, keys, state.dropped) : null),
[data, keys, state.dropped],
);
// The owner's order changed, so the dropped order has nothing left to stand in for.
if (state.dropped && !held) dispatch({ type: "clear" });
const items = state.dragItems ?? held ?? data;
const handleDragStart = useCallback(
(event: DragStartEvent) => {
if (disabled) return;
dispatch({ type: "start", id: String(event.active.id), data });
dispatch({ type: "start", id: String(event.active.id), items });
onDragBegin?.();
},
[data, disabled, onDragBegin],
[disabled, items, onDragBegin],
);
const clearDragState = useCallback(() => {
@@ -46,25 +77,29 @@ export function useDragReorderState<T>({
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
const items = state.dragItems ?? data;
dispatch({ type: "clear" });
if (disabled) return;
const activeId = String(active.id);
const reordered = reorderItemsOnDragEnd({
items,
activeId,
overId: over ? String(over.id) : null,
keyExtractor,
});
if (reordered) onDragEnd?.(reordered, activeId);
const reordered = disabled
? null
: reorderItemsOnDragEnd({
items,
activeId,
overId: over ? String(over.id) : null,
keyExtractor,
});
if (!reordered) {
dispatch({ type: "clear" });
return;
}
// The owner's new order can arrive in a later render. Hold the dropped order until it does.
dispatch({ type: "drop", dropped: { order: reordered.map(keyExtractor), replaces: keys } });
onDragEnd?.(reordered, activeId);
},
[data, disabled, keyExtractor, onDragEnd, state.dragItems],
[disabled, items, keyExtractor, keys, onDragEnd],
);
return {
activeId: state.activeId,
items: state.dragItems ?? data,
items,
handlers: {
onDragStart: handleDragStart,
onDragCancel: clearDragState,
@@ -36,7 +36,10 @@ export interface DraggableListProps<T> {
data: T[];
keyExtractor: (item: T, index: number) => string;
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
/** The new order, and the key of the item the user moved to reach it. */
/**
* The new order, and the key of the item the user moved to reach it. The list keeps showing
* this order until `data` arrives in a different order, so the owner must apply it.
*/
onDragEnd: (data: T[], movedKey: string) => void;
style?: StyleProp<ViewStyle>;
/** Outer container style (useful for nested, non-scrolling lists). */
@@ -1,4 +1,4 @@
import React, { act } from "react";
import React, { act, useCallback, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -11,6 +11,7 @@ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
interface Item {
id: string;
label?: string;
}
function keyExtractor(item: Item): string {
@@ -41,7 +42,7 @@ function renderRowWithHandle(info: DraggableRenderItemInfo<Item>) {
<DraggableListHandle accessibilityLabel={`Move ${info.item.id}`} testID="row-handle">
<span>grip</span>
</DraggableListHandle>
<span data-testid="row-body">{info.item.id}</span>
<span data-testid="row-body">{info.item.label ?? info.item.id}</span>
</div>
);
}
@@ -87,6 +88,87 @@ function pressAndMove(element: HTMLElement, distance: number) {
});
}
const OWNER_DELAY_MS = 50;
function wait(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
/**
* An owner that relabels its rows as soon as one is dropped, and whose new order reaches the list
* in a later render, like one that saves to a store.
*/
function ListWithLateOwner() {
const [items, setItems] = useState<Item[]>([{ id: "one" }, { id: "two" }]);
const relabelThenReorderLater = useCallback((next: Item[]) => {
const relabel = (item: Item): Item => ({ id: item.id, label: `${item.id} dropped` });
setItems((current) => current.map(relabel));
setTimeout(() => setItems(next.map(relabel)), OWNER_DELAY_MS);
}, []);
return (
<DraggableList
data={items}
keyExtractor={keyExtractor}
renderItem={renderRowWithHandle}
onDragEnd={relabelThenReorderLater}
useDragHandle
/>
);
}
function rowOrder(): (string | null)[] {
return Array.from(container?.querySelectorAll('[data-testid="row-body"]') ?? []).map(
(row) => row.textContent,
);
}
/** Drag the element down by `distance` in two mouse moves, letting dnd-kit measure in between. */
async function dragDown(element: HTMLElement, distance: number) {
const { left, top, width, height } = element.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
await act(async () => {
element.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true, button: 0, clientX: x, clientY: y }),
);
});
for (const moved of [distance / 2, distance]) {
await act(async () => {
document.dispatchEvent(
new MouseEvent("mousemove", { bubbles: true, clientX: x, clientY: y + moved }),
);
await wait(20);
});
}
act(() => {
document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
});
}
describe("DraggableList on web", () => {
it("shows the owner's current rows in the dropped order until its new order arrives", async () => {
act(() => {
root?.render(<ListWithLateOwner />);
});
const [firstHandle] = Array.from(
container?.querySelectorAll<HTMLElement>('[data-testid="row-handle"]') ?? [],
);
const [firstRow, secondRow] = Array.from(
container?.querySelectorAll<HTMLElement>('[data-testid="row-body"]') ?? [],
);
const rowDistance =
secondRow.getBoundingClientRect().top - firstRow.getBoundingClientRect().top;
await dragDown(firstHandle, rowDistance);
expect(rowOrder()).toEqual(["two dropped", "one dropped"]);
await act(() => wait(OWNER_DELAY_MS * 2));
expect(rowOrder()).toEqual(["two dropped", "one dropped"]);
});
});
describe("DraggableListHandle on web", () => {
it("starts the row's drag from the handle, and only from the handle", () => {
const onDragBegin = vi.fn();
@@ -562,6 +562,34 @@ describe("bare URL sentence punctuation", () => {
expect(tree.children?.[0]?.children?.[0]?.attributes?.url).toBe("https://example.com/a.");
},
);
it.each([
["https://a.com、https://b.com!", ["https://a.com", "、", "https://b.com", "!"]],
["https://a.com(说明)以及", ["https://a.com", "(说明)以及"]],
["看这里:https://example.com/foo,还有", ["看这里:", "https://example.com/foo", ",还有"]],
["(https://example.com/a)", ["(", "https://example.com/a", ")"]],
["见【https://example.com/a】。", ["见【", "https://example.com/a", "】。"]],
["“https://example.com/a”…", ["“", "https://example.com/a", "”…"]],
[
"见https://zh.wikipedia.org/wiki/人工智能。",
["见", "https://zh.wikipedia.org/wiki/人工智能", "。"],
],
])("ends a bare URL at CJK and typographic punctuation: %s", async (source, expected) => {
const prepared = prepareMarkdownSource(source);
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
const parts = (tree.children?.[0]?.children ?? []).map((node) =>
node.type === "Link" ? String(node.attributes?.url) : String(node.content),
);
expect(parts).toEqual(expected);
expect(toNativeLinkRecognition(prepared.linkRecognition).text).toMatchObject({
isDisabled: false,
isDefault: false,
});
});
});
it("requires workspace context for relative inline paths and escapes literal regex characters", () => {
@@ -37,9 +37,12 @@ function createTextPattern(path: string, forgeReferencePattern?: string): RegExp
// Matches bare URLs that MD4C does not autolink, like single-label or bracketed hosts.
// Quotes and unbalanced closing parentheses end the URL. Trailing sentence punctuation stays outside.
const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
// CJK, fullwidth and typographic punctuation (、。,()【】「」“”…) also ends it; CJK letters do not.
const URL_BOUNDARY =
"\\u2018\\u2019\\u201C\\u201D\\u2026\\u3000-\\u3003\\u3008-\\u3011\\u3014-\\u301F\\uFF01-\\uFF0F\\uFF1A-\\uFF20\\uFF3B-\\uFF40\\uFF5B-\\uFF65";
const URL_CHARACTER = String.raw`[^\s<>()"'\x60${URL_BOUNDARY}]`;
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?${URL_BOUNDARY}]|${URL_GROUP})`;
const TEXT_PATTERN = createTextPattern(PROSE_PATH);
// One entry per distinct workspace forge remote; reuse keeps the engine's regex identity stable.
@@ -126,6 +126,10 @@ export function smallIconButtonChromeFrameSize(compact = false): number {
return ICON_BUTTON_SIZES.small[controlSizingForLayout(compact)];
}
// Icon buttons sit in header and toolbar rows that clip overflow, so the keyboard focus ring
// from public/index.html is drawn inside the frame instead of around it.
const FOCUS_RING_INSET = -2;
const styles = StyleSheet.create((theme) => ({
large: {
width: bySizing(ICON_BUTTON_SIZES.large),
@@ -135,8 +139,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
outlineWidth: 0,
outlineColor: "transparent",
outlineOffset: FOCUS_RING_INSET,
},
small: {
width: ICON_BUTTON_SIZES.small.pointer,
@@ -146,8 +149,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
outlineWidth: 0,
outlineColor: "transparent",
outlineOffset: FOCUS_RING_INSET,
},
smallTouch: {
width: ICON_BUTTON_SIZES.small.touch,
@@ -157,8 +159,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
outlineWidth: 0,
outlineColor: "transparent",
outlineOffset: FOCUS_RING_INSET,
},
round: {
borderRadius: theme.borderRadius.full,
@@ -90,6 +90,16 @@ class FakeDaemonClient {
cancelDictationStream(dictationId: string): void {
this.cancels.push(dictationId);
}
emitStreamError(dictationId: string, error: string): void {
const message = {
type: "dictation_stream_error",
payload: { dictationId, error, retryable: true },
} as SessionOutboundMessage;
for (const listener of this.rawMessageListeners) {
listener(message);
}
}
}
const tick = async () => {
@@ -314,6 +324,29 @@ describe("DictationStreamSender", () => {
await expect(finish).resolves.toEqual({ dictationId: "d1", text: "ok" });
});
it("fails finish with the daemon's stream error instead of waiting for acks", async () => {
const client = new FakeDaemonClient();
client.autoAck = false;
let nextId = 0;
const sender = new DictationStreamSender({
client,
format: "audio/pcm;rate=16000;bits=16",
createDictationId: () => `d${++nextId}`,
});
sender.enqueueSegment("seg0");
await tick();
client.emitStreamError("d1", "Local speech worker request timed out: commit");
sender.enqueueSegment("seg1");
await tick();
const finish = sender.finish(1);
await expect(promiseOutcome(finish, 1000)).resolves.toBe("rejected");
await expect(finish).rejects.toThrow("Local speech worker request timed out");
expect(client.finishes).toEqual([]);
expect(sender.getSegmentCount()).toBe(2);
});
it("does not replay long buffered native dictation in one synchronous burst", async () => {
const client = new FakeDaemonClient();
client.isConnected = false;
@@ -8,6 +8,10 @@ const DICTATION_DRAIN_IDLE_TIMEOUT_MS = 30_000;
const waitForNextFlushTurn = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
type DictationStreamAckMessage = Extract<SessionOutboundMessage, { type: "dictation_stream_ack" }>;
type DictationStreamErrorMessage = Extract<
SessionOutboundMessage,
{ type: "dictation_stream_error" }
>;
interface DrainWaitResult {
kind: "signaled" | "timed-out";
@@ -64,6 +68,7 @@ export class DictationStreamSender {
private startGeneration = 0;
private startPromise: Promise<void> | null = null;
private streamError: Error | null = null;
constructor(params: DictationStreamSenderParams) {
this.format = params.format;
@@ -88,10 +93,11 @@ export class DictationStreamSender {
}
this.clientCleanup = client.subscribeRawMessages((message) => {
if (message.type !== "dictation_stream_ack") {
return;
if (message.type === "dictation_stream_ack") {
this.handleAck(message);
} else if (message.type === "dictation_stream_error") {
this.handleStreamError(message);
}
this.handleAck(message);
});
}
@@ -127,6 +133,7 @@ export class DictationStreamSender {
this.acknowledgedSeq = -1;
this.streamReady = false;
this.startPromise = null;
this.streamError = null;
this.startGeneration += 1;
this.resolveDrainWaiters();
}
@@ -197,6 +204,7 @@ export class DictationStreamSender {
this.sendSeq = 0;
this.acknowledgedSeq = -1;
this.streamReady = false;
this.streamError = null;
const start = (async () => {
await client.startDictationStream(dictationId, this.format);
@@ -285,6 +293,25 @@ export class DictationStreamSender {
this.resolveDrainWaiters();
}
// The daemon drops a stream after an error and answers later chunks with
// errors instead of acks, so waiting for delivery can only time out.
private handleStreamError(message: DictationStreamErrorMessage): void {
if (message.payload.dictationId !== this.dictationId || this.streamError) {
return;
}
this.streamError = new Error(message.payload.error);
this.resolveDrainWaiters();
}
private throwStreamError(): void {
const error = this.streamError;
if (!error) {
return;
}
this.invalidateStream();
throw error;
}
private invalidateStream(): void {
this.resetStreamForReplay();
}
@@ -309,11 +336,13 @@ export class DictationStreamSender {
private async waitForFlushDrain(finalSeq: number): Promise<void> {
while (this.hasPendingDelivery(finalSeq)) {
this.throwStreamError();
const client = this.client;
if (!client?.isConnected || !this.dictationId || !this.streamReady) {
throw new Error("Failed to flush dictation stream");
}
const result = await this.waitForDrainSignal();
this.throwStreamError();
if (result.kind === "timed-out") {
this.invalidateStream();
throw new Error("Timed out waiting for dictation stream delivery");
+15 -1
View File
@@ -209,6 +209,8 @@ interface UseGitActionsResult {
gitActions: GitActions;
branchLabel: string;
isGit: boolean;
/** Archives this workspace, Git or not. Null while it cannot be archived. */
archiveWorkspace: (() => void) | null;
}
interface UseWorkspaceScreenArchiveControllerInput {
@@ -266,6 +268,13 @@ function canArchiveWorkspace(
);
}
function availableArchive(
controller: { canArchive: boolean; isArchiving: boolean },
archive: () => void,
): (() => void) | null {
return controller.canArchive && !controller.isArchiving ? archive : null;
}
function useWorkspaceScreenArchiveController({
serverId,
activeWorkspaceSelection,
@@ -864,7 +873,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
[gitActionsInput, baseRefLabel, hasPullRequest, forge, t],
);
return { gitActions, branchLabel, isGit };
return {
gitActions,
branchLabel,
isGit,
archiveWorkspace: availableArchive(archiveController, handleArchiveWorkspace),
};
}
function translateGitActions(
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { buildWaitResult } from "./wait.js";
const agentId = "11111111-1111-4111-8111-111111111111";
describe("buildWaitResult", () => {
it("reports an identifier the daemon could not resolve as a missing agent", () => {
expect(() =>
buildWaitResult({
state: {
status: "error",
final: null,
error: "Agent not found: does-not-exist",
lastMessage: null,
},
resolvedAgentId: "does-not-exist",
recentActivity: null,
timeoutLabel: null,
}),
).toThrow(
expect.objectContaining({
code: "AGENT_NOT_FOUND",
message: "Agent not found: does-not-exist",
details: "Use `paseo ls` to list available agents",
}),
);
});
it("still reports an agent that finished in error as a result", () => {
const result = buildWaitResult({
state: {
status: "error",
final: { id: agentId, lastError: "The provider returned 500" } as AgentSnapshotPayload,
error: "The provider returned 500",
lastMessage: null,
},
resolvedAgentId: agentId,
recentActivity: null,
timeoutLabel: null,
});
expect(result).toEqual({
agentId,
status: "error",
message: "The provider returned 500",
});
});
});
+21 -6
View File
@@ -86,7 +86,12 @@ type WaitFinishState = Awaited<
ReturnType<Awaited<ReturnType<typeof connectToDaemon>>["waitForFinish"]>
>;
function buildWaitResult(args: {
/**
* Turn the daemon's wait reply into the command result. Throws AGENT_NOT_FOUND when the
* daemon resolved no agent: it answers an unknown identifier with an error and no agent
* payload, while an agent that finished in error always comes with its payload.
*/
export function buildWaitResult(args: {
state: WaitFinishState;
resolvedAgentId: string;
recentActivity: string | null;
@@ -117,6 +122,13 @@ function buildWaitResult(args: {
}
if (state.status === "error") {
if (state.final === null) {
throw {
code: "AGENT_NOT_FOUND",
message: state.error ?? `Agent not found: ${resolvedAgentId}`,
details: "Use `paseo ls` to list available agents",
} satisfies CommandError;
}
return {
agentId: resolvedAgentId,
status: "error",
@@ -155,6 +167,7 @@ export async function runWaitCommand(
const client = await connectToDaemon({ target: options.daemonTarget });
let reply: Omit<Parameters<typeof buildWaitResult>[0], "timeoutLabel">;
try {
try {
const state = await client.waitForFinish(agentIdArg, timeoutMs);
@@ -166,11 +179,7 @@ export async function runWaitCommand(
await client.close();
return {
type: "single",
data: buildWaitResult({ state, resolvedAgentId, recentActivity, timeoutLabel }),
schema: agentWaitSchema,
};
reply = { state, resolvedAgentId, recentActivity };
} catch (waitErr) {
await client.close().catch(() => {});
@@ -198,4 +207,10 @@ export async function runWaitCommand(
};
throw error;
}
return {
type: "single",
data: buildWaitResult({ ...reply, timeoutLabel }),
schema: agentWaitSchema,
};
}
+23
View File
@@ -20,6 +20,7 @@
import assert from "node:assert";
import { getAvailablePort } from "./helpers/network.ts";
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
import { $ } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
@@ -177,6 +178,28 @@ try {
);
console.log("timeout option documents no default limit\n");
}
// Test 13: wait reports AGENT_NOT_FOUND for an unknown ID
{
console.log("Test 13: wait reports AGENT_NOT_FOUND for an unknown ID");
const daemon = await startTestDaemon();
try {
const result = await runPaseoCli(daemon, [
"wait",
"does-not-exist",
"--host",
`127.0.0.1:${daemon.port}`,
"--json",
]);
assert.notStrictEqual(result.exitCode, 0, "wait should fail for an unknown ID");
const { error } = JSON.parse(result.stderr);
assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr);
assert.match(error.details, /paseo ls/);
} finally {
await daemon.stop();
}
console.log("✓ wait reports AGENT_NOT_FOUND for an unknown ID\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true });
-2
View File
@@ -88,8 +88,6 @@ export interface PluginSessionOpenRequest {
readonly background: boolean;
readonly labels: Readonly<Record<string, string>>;
readonly metadata: Readonly<Record<string, JsonValue>>;
/** Instructions appended for this open; not saved on the agent. */
appendSystemPrompt?: string[];
tools: AgentToolDefinition[];
env: Record<string, string>;
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
@@ -673,48 +673,6 @@ async function startAndSteerThroughManager(
return { manager, agentId: agent.id, workdir };
}
test("OpenCode persistence excludes launch-only instructions from storage and clients", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-plugin-persistence-"));
const harness = new V2Harness();
harness.info.location.directory = workdir;
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new OpenCodeV2AgentClient({ logger, runtime: harness.runtime });
const manager = new AgentManager({
clients: { opencode: client },
registry: storage,
logger,
appendSystemPrompt: "Daemon instruction",
pluginLifecycle: {
emit: () => {},
before: async (name, request) =>
name === "agent.open.before"
? { ...request, appendSystemPrompt: ["Plugin instruction"] }
: request,
},
});
const agent = await manager.createAgent({ provider: "opencode", cwd: workdir }, undefined, {
workspaceId: undefined,
});
try {
expect(harness.instructions.get("paseo")).toBe("Daemon instruction\n\nPlugin instruction");
await manager.flush();
const record = await storage.get(agent.id);
expect(record?.persistence?.metadata).not.toHaveProperty("pluginAppendSystemPrompt");
expect(record?.persistence?.metadata).not.toHaveProperty("daemonAppendSystemPrompt");
const live = manager.getAgent(agent.id)!;
expect(toAgentPayload(live).persistence?.metadata).not.toHaveProperty(
"pluginAppendSystemPrompt",
);
expect(toAgentPayload(live).persistence?.metadata).not.toHaveProperty(
"daemonAppendSystemPrompt",
);
} finally {
await manager.closeAgent(agent.id);
await client.shutdown();
rmSync(workdir, { recursive: true, force: true });
}
});
test("stops an OpenCode execution already active when its conversation is reopened", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-opencode-resume-"));
onTestFinished(() => rmSync(workdir, { recursive: true, force: true }));
@@ -13529,6 +13487,51 @@ test.each(["live", "history", "accepted"] as const)(
},
);
test("the last assistant message is the newest turn's reply, not joined to the previous turn's", async () => {
const session = new TestAgentSession({ provider: "codex", cwd: process.cwd() });
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-last-reply-"));
const client = new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})();
const manager = new AgentManager({ clients: { codex: client }, logger });
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const reply = (turnId: string, messageId: string, text: string) =>
session.pushEvent({
type: "timeline",
provider: "codex",
turnId,
item: { type: "assistant_message", messageId, text },
});
try {
session.pushEvent({ type: "turn_started", provider: "codex", turnId: "turn-1" });
reply("turn-1", "message-1", "Earlier status, ");
reply("turn-1", "message-1", "streamed in two chunks.");
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-1" });
await vi.waitFor(async () =>
expect(await manager.getLastAssistantMessage(agent.id)).toBe(
"Earlier status, streamed in two chunks.",
),
);
// A turn the provider starts on its own, such as a background task waking the agent,
// has no user message between its reply and the previous turn's.
session.pushEvent({ type: "turn_started", provider: "codex", turnId: "turn-2" });
reply("turn-2", "message-2", "New status.");
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-2" });
await vi.waitFor(() =>
expect(manager.getTimeline(agent.id).at(-1)).toMatchObject({ messageId: "message-2" }),
);
expect(await manager.getLastAssistantMessage(agent.id)).toBe("New status.");
} finally {
await manager.closeAgent(agent.id);
rmSync(workdir, { recursive: true, force: true });
}
});
test("reads the last durable assistant response without loading a stored-only child", async () => {
const store = new RecordingTimelineStore();
await store.appendCommitted("stored-child", {
@@ -14565,69 +14568,3 @@ test("idle cleanup gives a newly restored runtime a fresh idle window while keep
expect((await h.sweep({ now: h.later(61) })).collected).toHaveLength(1);
expect(h.closes).toBe(closesBeforeRestore + 1);
});
test("plugin instructions are recomputed for each launch without changing saved config", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-plugin-prompt-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new TestAgentClient();
let additions = ["Plugin one", "Plugin two"];
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
appendSystemPrompt: "Daemon",
pluginLifecycle: {
emit: () => {},
before: async (name, request) =>
name === "agent.open.before" ? { ...request, appendSystemPrompt: additions } : request,
},
});
const snapshot = await manager.createAgent(
{ provider: "codex", cwd: workdir, systemPrompt: "Saved" },
undefined,
{ workspaceId: undefined },
);
expect(client.createdConfigs[0]).toMatchObject({
systemPrompt: "Saved",
daemonAppendSystemPrompt: "Daemon",
pluginAppendSystemPrompt: ["Plugin one", "Plugin two"],
});
expect(snapshot.config.systemPrompt).toBe("Saved");
expect((await storage.get(snapshot.id))?.config?.systemPrompt).toBe("Saved");
const handle: AgentPersistenceHandle = {
provider: "codex",
sessionId: "prompt-session",
metadata: { provider: "codex", cwd: workdir, systemPrompt: "Saved" },
};
await manager.closeAgent(snapshot.id);
await manager.resumeAgentFromPersistence(
handle,
{ cwd: workdir, systemPrompt: "Saved" },
snapshot.id,
);
expect(client.resumeOverrides.at(-1)?.pluginAppendSystemPrompt).toEqual([
"Plugin one",
"Plugin two",
]);
additions = ["Changed"];
await manager.closeAgent(snapshot.id);
await manager.resumeAgentFromPersistence(
handle,
{ cwd: workdir, systemPrompt: "Saved" },
snapshot.id,
);
expect(client.resumeOverrides.at(-1)?.pluginAppendSystemPrompt).toEqual(["Changed"]);
additions = ["Refreshed"];
await manager.reloadAgentSession(snapshot.id);
expect(client.resumeOverrides.at(-1)?.pluginAppendSystemPrompt).toEqual(["Refreshed"]);
additions = [];
await manager.closeAgent(snapshot.id);
await manager.resumeAgentFromPersistence(
handle,
{ cwd: workdir, systemPrompt: "Saved" },
snapshot.id,
);
expect(client.resumeOverrides.at(-1)?.systemPrompt).toBe("Saved");
expect(client.resumeOverrides.at(-1)?.daemonAppendSystemPrompt).toBe("Daemon");
expect((await storage.get(snapshot.id))?.config?.systemPrompt).toBe("Saved");
});
@@ -634,12 +634,7 @@ function normalizePersistenceForConfig(
if (!handle) {
return null;
}
const {
mcpServers: _launchMcpServers,
pluginAppendSystemPrompt: _pluginAppendSystemPrompt,
daemonAppendSystemPrompt: _daemonAppendSystemPrompt,
...metadata
} = handle.metadata ?? {};
const { mcpServers: _launchMcpServers, ...metadata } = handle.metadata ?? {};
return {
...handle,
metadata: {
@@ -1732,10 +1727,7 @@ export class AgentManager {
...(record ? { metadata: record.metadata, attention: extractAttention(record) } : {}),
workspaceId,
launchEnv,
persistence:
purpose === "interactive" && session.persistenceUpdatedOnResume
? (session.describePersistence() ?? handle)
: handle,
persistence: handle,
restoring: true,
});
}
@@ -4057,23 +4049,33 @@ export class AgentManager {
private getLastAssistantMessageFromTimeline(
timeline: readonly AgentTimelineItem[],
): string | null {
return this.getLastAssistantMessageSegmentFromTimeline(timeline)?.text ?? null;
return (
this.getLastAssistantMessageSegmentFromTimeline(timeline.map((item) => ({ item })))?.text ??
null
);
}
private getLastAssistantMessageSegmentFromTimeline(
timeline: readonly AgentTimelineItem[],
timeline: readonly Pick<AgentTimelineRow, "item" | "turnId">[],
): { text: string; startsAtBeginning: boolean } | null {
// Collect the last contiguous assistant messages (Claude streams chunks)
// Collect the last contiguous assistant messages of one turn (Claude streams chunks).
// A turn the provider starts on its own has no user message before its reply.
const chunks: string[] = [];
let replyTurnId: string | undefined;
let startsAtBeginning = false;
for (let i = timeline.length - 1; i >= 0; i--) {
const item = timeline[i];
const { item, turnId } = timeline[i];
if (item.type !== "assistant_message") {
if (chunks.length) {
break;
}
continue;
}
if (chunks.length === 0) {
replyTurnId = turnId;
} else if (turnId !== replyTurnId) {
break;
}
chunks.push(item.text);
startsAtBeginning = i === 0;
}
@@ -4089,8 +4091,9 @@ export class AgentManager {
}
private async getLastAssistantMessageFromStores(agentId: string): Promise<string | null> {
const liveTimeline = this.timelineStore.getItems(agentId);
const liveSegment = this.getLastAssistantMessageSegmentFromTimeline(liveTimeline);
const liveSegment = this.getLastAssistantMessageSegmentFromTimeline(
this.timelineStore.getRows(agentId),
);
if (!this.durableTimelineStore) {
return liveSegment?.text ?? null;
}
@@ -6176,7 +6179,6 @@ export class AgentManager {
// A plugin changing during this open must still make the resulting session stale.
const generation = this.sessionOpenGeneration;
this.detachPluginTools(agentId);
let appendSystemPrompt: string[] | undefined;
let mcpServers: AgentSessionConfig["mcpServers"];
let mcpServerOwners: Record<string, string> | undefined;
if (this.pluginLifecycle) {
@@ -6198,14 +6200,12 @@ export class AgentManager {
env = transformed.env;
if (request.purpose === "interactive") {
this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]);
appendSystemPrompt = transformed.appendSystemPrompt;
mcpServers = transformed.mcpServers;
mcpServerOwners = transformed.mcpServerOwners;
}
}
const context: AgentLaunchContext = {
agentId,
...pluginPromptContext(appendSystemPrompt),
mcpServers,
mcpServerOwners,
env: {
@@ -6252,7 +6252,6 @@ export class AgentManager {
throw new Error(`MCP server '${name}' from plugin ${owner} collides with ${existingOwner}`);
}
}
launchConfig = { ...launchConfig, pluginAppendSystemPrompt: launchContext.appendSystemPrompt };
const merged =
Object.keys(launchContext.mcpServers ?? {}).length > 0
? {
@@ -6390,9 +6389,3 @@ export function commandMayHaveChangedExternalState(command: string): boolean {
/\bgit\s+fetch\b/.test(normalized)
);
}
function pluginPromptContext(
appendSystemPrompt: string[] | undefined,
): Pick<AgentLaunchContext, "appendSystemPrompt"> {
return appendSystemPrompt?.length ? { appendSystemPrompt } : {};
}
@@ -337,7 +337,6 @@ describe("toAgentPayload", () => {
nativeHandle: { id: "native" } as unknown,
metadata: {
restored: new Date("2025-03-01T00:00:00.000Z"),
paseoInstructionReceipt: { threadId: "persist-99", digest: "internal-digest" },
empty: {},
mcpServers: {
hub: {
@@ -388,8 +387,6 @@ describe("toAgentPayload", () => {
sessionId: "persist-stored",
metadata: {
conversationId: "conversation-stored",
pluginAppendSystemPrompt: ["plugin instruction"],
daemonAppendSystemPrompt: "daemon instruction",
mcpServers: {
hub: {
type: "http",
@@ -405,8 +402,6 @@ describe("toAgentPayload", () => {
expect(record.persistence?.metadata).toEqual({
conversationId: "conversation-stored",
pluginAppendSystemPrompt: ["plugin instruction"],
daemonAppendSystemPrompt: "daemon instruction",
mcpServers: {
hub: {
type: "http",
@@ -495,9 +495,6 @@ function projectPersistenceHandleForWire(
return projected;
}
delete projected.metadata.mcpServers;
delete projected.metadata.pluginAppendSystemPrompt;
delete projected.metadata.daemonAppendSystemPrompt;
delete projected.metadata.paseoInstructionReceipt;
if (Object.keys(projected.metadata).length === 0) {
delete projected.metadata;
}
@@ -621,8 +621,6 @@ export interface AgentSessionConfig {
* persisted into agent config so daemon setting changes apply cleanly.
*/
daemonAppendSystemPrompt?: string;
/** Plugin instructions for this launch; never persisted. */
pluginAppendSystemPrompt?: string[];
modeId?: string;
model?: string;
thinkingOptionId?: string;
@@ -638,8 +636,6 @@ export interface AgentSessionConfig {
}
export interface AgentLaunchContext {
/** Launch-only instructions contributed by plugins. */
appendSystemPrompt?: string[];
/** Launch-only plugin MCP servers and their owners; never persisted. */
mcpServers?: Record<string, McpServerConfig>;
mcpServerOwners?: Record<string, string>;
@@ -692,8 +688,6 @@ export interface AgentUsageSession {
}
export interface AgentSession {
/** Persist the session's current handle immediately after an interactive resume. */
readonly persistenceUpdatedOnResume?: boolean;
usageSession?(): AgentUsageSession | null;
readonly provider: AgentProvider;
readonly id: string | null;
@@ -1949,11 +1949,7 @@ function mapSessionConfig(
return {
cwd: config.cwd,
env: createProviderEnv({ baseEnv: {}, overlays: [launchContext?.env] }),
systemPrompt: combineSystemPrompts(
config.systemPrompt,
config.daemonAppendSystemPrompt,
...(config.pluginAppendSystemPrompt ?? []),
),
systemPrompt: combineSystemPrompts(config.systemPrompt, config.daemonAppendSystemPrompt),
mcpServers: { ...config.mcpServers },
toolPolicy: config.toolPolicy
? { preapproved: config.toolPolicy.preapproved.map((grant) => ({ ...grant })) }
@@ -1972,9 +1968,8 @@ function mapSessionConfig(
function combineSystemPrompts(
systemPrompt: string | undefined,
daemonPrompt: string | undefined,
...pluginPrompts: string[]
): string | undefined {
const parts = [systemPrompt, daemonPrompt, ...pluginPrompts].filter(
const parts = [systemPrompt, daemonPrompt].filter(
(value): value is string => typeof value === "string" && value.trim().length > 0,
);
return parts.length > 0 ? parts.join("\n\n") : undefined;
@@ -448,9 +448,6 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
get initialTimeline() {
return inner.initialTimeline;
},
get persistenceUpdatedOnResume() {
return inner.persistenceUpdatedOnResume;
},
get controls() {
return inner.controls;
},
@@ -153,56 +153,6 @@ describe("Claude SDK env", () => {
}
});
test("resume uses current plugin instructions and excludes them from persistence", async () => {
const prompts: unknown[] = [];
const queryFactory = ({ options }: ClaudeQueryInput) => {
prompts.push(options.systemPrompt);
return createQueryMock([
{
type: "system",
subtype: "init",
session_id: "persisted-session",
permissionMode: "default",
model: "opus",
},
{ type: "assistant", message: { content: "done" } },
{
type: "result",
subtype: "success",
usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 },
total_cost_usd: 0,
},
]);
};
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const first = await client.createSession({
provider: "claude",
cwd: process.cwd(),
pluginAppendSystemPrompt: ["Plugin instructions"],
});
await first.run("first");
const handle = first.describePersistence()!;
expect(handle.metadata).not.toHaveProperty("pluginAppendSystemPrompt");
await first.close();
const resumed = await client.resumeSession(handle, {
cwd: process.cwd(),
pluginAppendSystemPrompt: undefined,
});
try {
await resumed.run("after disable");
expect(prompts).toEqual([
{ type: "preset", preset: "claude_code", append: "Plugin instructions" },
{ type: "preset", preset: "claude_code", append: "" },
]);
} finally {
await resumed.close();
}
});
test("forwards launch-context env through Claude resume env", async () => {
let capturedEnv: Record<string, string | undefined> | undefined;
const launchContext: AgentLaunchContext = {
@@ -3044,11 +3044,7 @@ class ClaudeAgentSession implements AgentSession {
if (!this.claudeSessionId) {
return null;
}
const {
providerOptions: _providerOptions,
pluginAppendSystemPrompt: _pluginAppendSystemPrompt,
...persistedConfig
} = this.config;
const { providerOptions: _providerOptions, ...persistedConfig } = this.config;
this.persistence = {
provider: "claude",
sessionId: this.claudeSessionId,
@@ -3716,11 +3712,7 @@ class ClaudeAgentSession implements AgentSession {
private buildAppendedSystemPrompt(): string {
return (
composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
...(this.config.pluginAppendSystemPrompt ?? []),
) ?? ""
composeSystemPromptParts(this.config.systemPrompt, this.config.daemonAppendSystemPrompt) ?? ""
);
}
@@ -22,18 +22,6 @@ function isCodexInstalled(): boolean {
}
}
function supportsInstructionSnapshots(): boolean {
try {
const match = execFileSync("codex", ["--version"], { encoding: "utf8" }).match(
/(\d+)\.(\d+)\.(\d+)/,
);
if (!match) return false;
return Number(match[1]) > 0 || Number(match[2]) >= 121;
} catch {
return false;
}
}
function sse(events: unknown[]): string {
return events
.map((event) => {
@@ -145,7 +133,7 @@ async function startMockResponsesServer(sequence: string[]): Promise<{
}> {
const requestBodies: string[] = [];
let index = 0;
const server = createServer({ maxHeaderSize: 1024 * 1024 }, (req, res) => {
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
req.on("end", () => {
@@ -233,85 +221,6 @@ function waitForEvent<TEvent extends AgentStreamEvent>(params: {
}
describe("Codex app-server provider (local e2e)", () => {
test.runIf(supportsInstructionSnapshots())(
"delivers changed and removed instructions to the model across process restarts",
async () => {
const artifactDir = path.resolve(process.cwd(), "../../.artifacts");
mkdirSync(artifactDir, { recursive: true });
const cwd = mkdtempSync(path.join(artifactDir, "codex-snapshot-cwd-"));
const codexHome = mkdtempSync(path.join(artifactDir, "codex-snapshot-home-"));
const server = await startMockResponsesServer([]);
const client = new CodexAppServerAgentClient(createTestLogger());
const base = { provider: "codex" as const, cwd, modeId: "auto", model: "mock-model" };
const launch = { env: { CODEX_HOME: codexHome } };
const run = async (session: Awaited<ReturnType<typeof client.createSession>>) => {
const finished = waitForEvent({
session,
timeoutMs: 20_000,
label: "turn completion",
predicate: (
event,
): event is Extract<
AgentStreamEvent,
{ type: "turn_completed" | "turn_failed" | "turn_canceled" }
> =>
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled",
});
await session.startTurn("continue");
const result = await finished;
if (result.type !== "turn_completed") throw new Error(JSON.stringify(result));
const handle = session.describePersistence();
if (!handle) throw new Error("Expected persisted Codex thread");
await session.close();
return handle;
};
try {
writeMockCodexConfig(codexHome, server.url);
let handle = await run(
await client.createSession(
{ ...base, pluginAppendSystemPrompt: ["old snapshot"] },
launch,
),
);
expect(server.requestBodies.at(-1)).toContain("old snapshot");
expect(server.requestBodies.at(-1)).not.toContain("paseo_instruction_snapshot");
handle = await run(
await client.resumeSession(
handle,
{ ...base, pluginAppendSystemPrompt: ["new snapshot"] },
launch,
),
);
expect(server.requestBodies.at(-1)).toContain("new snapshot");
expect(server.requestBodies.at(-1)?.match(/paseo_instruction_snapshot/g)).toHaveLength(2);
handle = await run(
await client.resumeSession(
handle,
{ ...base, pluginAppendSystemPrompt: ["new snapshot"] },
launch,
),
);
expect(server.requestBodies.at(-1)?.match(/paseo_instruction_snapshot/g)).toHaveLength(2);
await run(
await client.resumeSession(
handle,
{ ...base, pluginAppendSystemPrompt: undefined },
launch,
),
);
expect(server.requestBodies.at(-1)).toContain('\\"instructions\\":\\"\\"');
expect(server.requestBodies.at(-1)?.match(/paseo_instruction_snapshot/g)).toHaveLength(4);
} finally {
await server.close();
rmSync(cwd, { recursive: true, force: true });
rmSync(codexHome, { recursive: true, force: true });
}
},
60_000,
);
test.runIf(isCodexInstalled())(
"concurrent import scans initialize successfully in a fresh shared Codex home",
async () => {
@@ -7,7 +7,6 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { fileURLToPath } from "node:url";
import { CodexInstructionSnapshot } from "./codex/instruction-snapshot.js";
import type {
AgentClient,
@@ -101,7 +100,6 @@ import {
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
import { AgentManager } from "../agent-manager.js";
import { AgentStorage } from "../agent-storage.js";
import { buildProviderRegistry } from "../provider-registry.js";
interface CollaborationModeRecord {
@@ -175,9 +173,6 @@ function createSession(
) as CodexTestSession;
session.connectionState = "connected";
session.currentThreadId = "test-thread";
castInternals<{ instructionSnapshot: { recordNewThread(id: string): void } }>(
session,
).instructionSnapshot.recordNewThread("test-thread");
session.activeForegroundTurnId = "test-turn";
return session;
}
@@ -688,7 +683,7 @@ function codexAgentItem(text: string) {
// An AgentManager over a real Codex provider whose every spawn gets a fresh fake
// app-server serving `threads` from thread/read.
function createReopenableCodexAgent(threads: Record<string, unknown[]>, registry?: AgentStorage) {
function createReopenableCodexAgent(threads: Record<string, unknown[]>) {
const appServers: FakeCodexAppServer[] = [];
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
appServerProcess: createFakeCodexAppServerProcess(() => {
@@ -720,11 +715,7 @@ function createReopenableCodexAgent(threads: Record<string, unknown[]>, registry
},
resumeSession: (...args) => provider.resumeSession(...args),
};
const manager = new AgentManager({
clients: { codex: client },
registry,
logger: createTestLogger(),
});
const manager = new AgentManager({ clients: { codex: client }, logger: createTestLogger() });
const agentId = "00000000-0000-4000-8000-000000000001";
const handle = { provider: "codex", sessionId: "saved-thread" };
const config = createConfig({ cwd: process.cwd() });
@@ -737,9 +728,6 @@ function createReopenableCodexAgent(threads: Record<string, unknown[]>, registry
return {
manager,
agentId,
handle,
config,
appServers,
latestAppServer: () => appServers[appServers.length - 1],
threadMethods: (index: number) =>
appServers[index]
@@ -1418,216 +1406,6 @@ describe("Codex app-server provider", () => {
}
});
test("reopening drops disabled plugin instructions and starts with fresh collaboration settings", async () => {
const modes = {
data: [
{
name: "Code",
mode: "default",
model: null,
reasoning_effort: null,
},
],
};
const firstServer = createFakeCodexAppServer({ "collaborationMode/list": () => modes });
const first = await createProviderWithFakeAppServer(firstServer).createSession(
createConfig({ pluginAppendSystemPrompt: ["Plugin instructions"] }),
);
await first.startTurn("first");
const firstTurn = await firstServer.waitForTurnStart();
expect(
firstServer.requests().find((request) => request.method === "thread/start")?.params,
).toMatchObject({ developerInstructions: "Plugin instructions" });
expect(firstTurn).toMatchObject({
collaborationMode: {
settings: { developer_instructions: null },
},
});
const started = waitForNextEvent(first, "thread_started");
firstServer.startsTurn({ threadId: "thread-1" });
await started;
const handle = first.describePersistence()!;
expect(handle.metadata).not.toHaveProperty("pluginAppendSystemPrompt");
await first.close();
const secondServer = createFakeCodexAppServer({ "collaborationMode/list": () => modes });
const resumed = await createProviderWithFakeAppServer(secondServer).resumeSession(handle, {
pluginAppendSystemPrompt: undefined,
});
try {
const resume = secondServer.requests().find((request) => request.method === "thread/resume");
expect(resume?.params).not.toHaveProperty("developerInstructions");
await resumed.startTurn("after disable");
const secondTurn = await secondServer.waitForTurnStart();
expect(secondTurn).not.toHaveProperty("developerInstructions");
expect(secondTurn).toMatchObject({
collaborationMode: { settings: { developer_instructions: null } },
});
firstServer.assertNoErrors();
secondServer.assertNoErrors();
} finally {
await resumed.close();
}
});
test("reopen injects current instructions once, including an explicit empty snapshot", async () => {
const handlers = {
initialize: () => ({ userAgent: "codex/0.165.0" }),
"thread/inject_items": () => ({}),
};
const firstServer = createFakeCodexAppServer(handlers);
const first = await createProviderWithFakeAppServer(firstServer).createSession(
createConfig({ pluginAppendSystemPrompt: ["Old plugin"] }),
);
await first.startTurn("first");
const started = waitForNextEvent(first, "thread_started");
firstServer.startsTurn({ threadId: "thread-1" });
await started;
expect(
firstServer.requests().filter((request) => request.method === "thread/inject_items"),
).toEqual([]);
const original = first.describePersistence()!;
expect(original.metadata).toHaveProperty("paseoInstructionReceipt");
await first.close();
const changedServer = createFakeCodexAppServer(handlers);
const changed = await createProviderWithFakeAppServer(changedServer).resumeSession(original, {
pluginAppendSystemPrompt: ["New plugin"],
});
const injected = changedServer
.requests()
.filter((request) => request.method === "thread/inject_items");
expect(injected).toHaveLength(1);
expect(JSON.stringify(injected[0]?.params)).toContain("New plugin");
const changedHandle = changed.describePersistence()!;
expect(changedHandle.metadata?.paseoInstructionReceipt).not.toEqual(
original.metadata?.paseoInstructionReceipt,
);
await changed.close();
const sameServer = createFakeCodexAppServer(handlers);
const same = await createProviderWithFakeAppServer(sameServer).resumeSession(changedHandle, {
pluginAppendSystemPrompt: ["New plugin"],
});
expect(
sameServer.requests().filter((request) => request.method === "thread/inject_items"),
).toEqual([]);
await same.close();
const emptyServer = createFakeCodexAppServer(handlers);
const empty = await createProviderWithFakeAppServer(emptyServer).resumeSession(changedHandle, {
pluginAppendSystemPrompt: undefined,
});
const removal = emptyServer
.requests()
.filter((request) => request.method === "thread/inject_items");
expect(removal).toHaveLength(1);
expect(JSON.stringify(removal[0]?.params)).toContain('\\"instructions\\":\\"\\"');
await empty.close();
});
test("requires a compatible Codex version before creating a thread with plugin instructions", async () => {
const appServer = createFakeCodexAppServer({
initialize: () => ({ userAgent: "codex/0.120.0" }),
});
await expect(
createProviderWithFakeAppServer(appServer).createSession(
createConfig({ pluginAppendSystemPrompt: ["Plugin instructions"] }),
),
).rejects.toThrow("Update Codex to 0.121.0 or newer");
});
test("retries an instruction snapshot when injection fails", async () => {
const snapshot = new CodexInstructionSnapshot("current", undefined);
const request = vi
.fn()
.mockRejectedValueOnce(new Error("connection lost"))
.mockResolvedValue({});
await expect(snapshot.apply("thread-1", request, true)).rejects.toThrow("connection lost");
expect(snapshot.describe("thread-1")).toBeUndefined();
await snapshot.apply("thread-1", request, true);
expect(snapshot.describe("thread-1")).toEqual({
threadId: "thread-1",
digest: expect.any(String),
});
await snapshot.apply("thread-1", request, true);
expect(request).toHaveBeenCalledTimes(2);
});
test("keeps built-in Plan instructions while sending plugin instructions to the thread", async () => {
const appServer = createFakeCodexAppServer({
"collaborationMode/list": () => ({
data: [
{ name: "Plan", mode: "plan", model: null, reasoning_effort: "medium" },
{ name: "Default", mode: "default", model: null, reasoning_effort: null },
],
}),
});
const session = await createProviderWithFakeAppServer(appServer).createSession(
createConfig({
pluginAppendSystemPrompt: ["Plugin instructions"],
providerOptions: { plan_mode: true },
}),
);
try {
await session.startTurn("make a plan");
const start = appServer.requests().find((request) => request.method === "thread/start");
expect(start?.params).toMatchObject({ developerInstructions: "Plugin instructions" });
expect(await appServer.waitForTurnStart()).toMatchObject({
collaborationMode: { mode: "plan", settings: { developer_instructions: null } },
});
expect(
appServer.requests().find((request) => request.method === "turn/start")?.params,
).not.toHaveProperty("developerInstructions");
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test.each([
{ instructions: undefined, expected: undefined },
{ instructions: ["Current plugin"], expected: "Current plugin" },
])(
"rebuilds restored instructions with an unavailable collaboration catalog: $expected",
async ({ instructions, expected }) => {
const appServer = createFakeCodexAppServer({
"collaborationMode/list": () => ({ data: [] }),
"thread/resume": () => ({
thread: { id: "saved-thread" },
collaborationMode: {
mode: "default",
settings: {
model: "gpt-5.4",
reasoning_effort: "medium",
developer_instructions: "Old plugin",
},
},
}),
});
const session = await createProviderWithFakeAppServer(appServer).resumeSession(
{ sessionId: "saved-thread" },
{ pluginAppendSystemPrompt: instructions },
);
try {
const resume = appServer.requests().find((request) => request.method === "thread/resume");
if (expected) expect(resume?.params).toMatchObject({ developerInstructions: expected });
else expect(resume?.params).not.toHaveProperty("developerInstructions");
await session.startTurn("after reopen");
const turn = await appServer.waitForTurnStart();
expect(turn).toMatchObject({
collaborationMode: {
mode: "default",
settings: { model: "gpt-5.4", developer_instructions: null },
},
});
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test("keeps the default effort when a session without one is resumed after a restart", async () => {
const beforeRestart = createFakeCodexAppServer();
const firstSession = await createProviderWithFakeAppServer(beforeRestart).createSession(
@@ -2507,12 +2285,7 @@ describe("Codex app-server provider", () => {
]);
// Background terminal observation is independent of conversation history loading.
const historyMethods = (index: number) =>
codex
.threadMethods(index)
.filter(
(method) =>
method !== "thread/backgroundTerminals/list" && method !== "thread/inject_items",
);
codex.threadMethods(index).filter((method) => method !== "thread/backgroundTerminals/list");
expect(historyMethods(0)).toEqual([
"thread/loaded/list",
"thread/resume",
@@ -2526,34 +2299,6 @@ describe("Codex app-server provider", () => {
}
});
test("persists an acknowledged instruction snapshot before a turn on repeated opens", async () => {
const artifactDir = path.resolve(process.cwd(), "../../.artifacts");
mkdirSync(artifactDir, { recursive: true });
const dir = await mkdtemp(path.join(artifactDir, "codex-receipt-"));
const storage = new AgentStorage(dir, createTestLogger());
const codex = createReopenableCodexAgent({}, storage);
try {
let handle = codex.handle;
for (let i = 0; i < 3; i++) {
await codex.manager.resumeAgentFromPersistence(handle, codex.config, codex.agentId);
await storage.flush();
const saved = await storage.get(codex.agentId);
expect(saved?.persistence?.metadata?.paseoInstructionReceipt).toEqual(expect.any(Object));
handle = saved!.persistence!;
await codex.manager.closeAgent(codex.agentId);
}
expect(
codex.appServers
.flatMap((server) => server.requests())
.filter((request) => request.method === "thread/inject_items"),
).toHaveLength(1);
} finally {
if (codex.manager.getAgent(codex.agentId)) await codex.manager.closeAgent(codex.agentId);
await storage.flush();
rmSync(dir, { recursive: true, force: true });
}
});
test("rewinds an agent reopened without reading its Codex history", async () => {
const firstTurn = {
id: "turn-1",
@@ -3073,9 +2818,6 @@ describe("Codex app-server provider", () => {
const session = new CodexAppServerAgentSession(
createConfig({
cwd: "/workspace/project",
systemPrompt: "Agent instructions",
daemonAppendSystemPrompt: "Daemon instructions",
pluginAppendSystemPrompt: ["Plugin instructions"],
mcpServers: {
paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1" },
},
@@ -3107,24 +2849,6 @@ describe("Codex app-server provider", () => {
await session.revertConversation({ messageId: "codex-first" });
const forkRequest = appServer.requests().find((request) => request.method === "thread/fork");
expect(forkRequest?.params).toMatchObject({
developerInstructions: "Agent instructions\n\nDaemon instructions\n\nPlugin instructions",
});
const rewindRequests = appServer
.requests()
.filter((request) =>
["thread/fork", "thread/rollback", "thread/inject_items"].includes(
String(request.method),
),
);
expect(rewindRequests.map((request) => request.method)).toEqual(
historyMode === "legacy"
? ["thread/fork", "thread/rollback", "thread/inject_items"]
: ["thread/fork", "thread/inject_items"],
);
expect(rewindRequests.at(-1)?.params).toMatchObject({ threadId: "forked-thread" });
expect(JSON.stringify(rewindRequests.at(-1)?.params)).toContain("Plugin instructions");
const forkConfigs = appServer
.requests()
.filter((request) => request.method === "thread/fork")
@@ -55,8 +55,7 @@ import path from "node:path";
import { isDeepStrictEqual } from "node:util";
import { z } from "zod";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeAgentSystemPrompt } from "../system-prompt.js";
import { CodexInstructionSnapshot } from "./codex/instruction-snapshot.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js";
import {
fileChangesToPreview,
@@ -3592,12 +3591,9 @@ interface ConsumedRootCompaction {
export class CodexAppServerAgentSession implements AgentSession {
readonly provider = CODEX_PROVIDER;
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
readonly persistenceUpdatedOnResume = true;
private readonly logger: Logger;
private readonly config: AgentSessionConfig;
private readonly instructionSnapshot: CodexInstructionSnapshot;
private supportsInstructionSnapshots = false;
private readonly asyncQuestions: CodexAsyncQuestions;
private readonly codexHome: string;
private currentMode: string;
@@ -3710,13 +3706,6 @@ export class CodexAppServerAgentSession implements AgentSession {
reasoning_effort?: string | null;
developer_instructions?: string | null;
}> = [];
private restoredCollaborationMode: {
name: string;
mode: string;
model: string;
reasoning_effort: string | null;
developer_instructions?: never;
} | null = null;
private resolvedCollaborationMode: {
mode: string;
settings: Record<string, unknown>;
@@ -3762,10 +3751,6 @@ export class CodexAppServerAgentSession implements AgentSession {
this.providerOptions =
validateProviderOptions("codex", CodexProviderOptionsSchema, config.providerOptions) ?? {};
this.config = config;
this.instructionSnapshot = new CodexInstructionSnapshot(
composeAgentSystemPrompt(config),
resumeHandle?.metadata?.paseoInstructionReceipt,
);
this.harnessEnvironment = deps.environment ?? buildCodexAppServerEnv();
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
@@ -3852,17 +3837,9 @@ export class CodexAppServerAgentSession implements AgentSession {
this.registerRequestHandlers();
try {
const userAgent = toObjectRecord(initialized)?.userAgent;
this.threadRollbackAvailable = codexServerHasThreadRollback(userAgent);
this.supportsInstructionSnapshots =
typeof userAgent === "string" && codexVersionAtLeast(userAgent, [0, 121, 0]);
if (
!this.currentThreadId &&
this.config.pluginAppendSystemPrompt?.length &&
!this.supportsInstructionSnapshots
) {
throw new Error("Update Codex to 0.121.0 or newer to apply agent instruction updates.");
}
this.threadRollbackAvailable = codexServerHasThreadRollback(
toObjectRecord(initialized)?.userAgent,
);
this.speedModels =
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
@@ -4060,17 +4037,18 @@ export class CodexAppServerAgentSession implements AgentSession {
settings: Record<string, unknown>;
name: string;
} | null {
const match =
this.findCollaborationMode(this.planModeEnabled ? "plan" : "code") ??
this.restoredCollaborationMode;
const match = this.findCollaborationMode(this.planModeEnabled ? "plan" : "code");
if (!match) return null;
const settings: Record<string, unknown> = {};
if (match.model) settings.model = match.model;
if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort;
// Codex applies thread instructions separately. Null asks it to supply the
// built-in collaboration mode instructions instead of replacing them.
settings.developer_instructions = match.developer_instructions ?? null;
const developerInstructions = composeSystemPromptParts(
match.developer_instructions,
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
if (developerInstructions) settings.developer_instructions = developerInstructions;
if (this.config.model) settings.model = this.config.model;
const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
if (thinkingOptionId) settings.reasoning_effort = thinkingOptionId;
@@ -4330,7 +4308,10 @@ export class CodexAppServerAgentSession implements AgentSession {
// the unnamespaced key emitted by earlier Paseo versions.
params.modelProvider = this.deps.customCodexConfig.model_provider;
}
const developerInstructions = composeAgentSystemPrompt(this.config);
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
if (developerInstructions) {
params.developerInstructions = developerInstructions;
}
@@ -4341,11 +4322,11 @@ export class CodexAppServerAgentSession implements AgentSession {
try {
const loaded = toObjectRecord(await this.client.request("thread/loaded/list", {}));
const ids = Array.isArray(loaded?.data) ? loaded.data : [];
if (!ids.includes(this.currentThreadId)) {
const response = await this.client.request("thread/resume", params);
this.rememberResolvedSandboxPolicy(response);
this.rememberRestoredCollaborationMode(response);
if (ids.includes(this.currentThreadId)) {
return;
}
const response = await this.client.request("thread/resume", params);
this.rememberResolvedSandboxPolicy(response);
} catch (error) {
const threadId = this.currentThreadId;
const message = error instanceof Error ? error.message : String(error);
@@ -4359,34 +4340,12 @@ export class CodexAppServerAgentSession implements AgentSession {
}
const response = await this.client.request("thread/resume", params);
this.rememberResolvedSandboxPolicy(response);
this.rememberRestoredCollaborationMode(response);
this.logger.info({ threadId }, "Unarchived Codex thread to restore active Paseo agent");
} else {
this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread");
throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error });
return;
}
this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread");
throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error });
}
await this.instructionSnapshot.apply(
this.currentThreadId,
this.client.request.bind(this.client),
this.supportsInstructionSnapshots,
);
}
private rememberRestoredCollaborationMode(response: unknown): void {
const mode = toObjectRecord(toObjectRecord(response)?.collaborationMode);
const settings = toObjectRecord(mode?.settings);
if (typeof mode?.mode !== "string" || typeof settings?.model !== "string") return;
// The resumed mode can contain instructions from a previous launch. Keep its
// identity and model, and let Codex restore the built-in mode instructions.
this.restoredCollaborationMode = {
name: mode.mode,
mode: mode.mode,
model: settings.model,
reasoning_effort:
typeof settings.reasoning_effort === "string" ? settings.reasoning_effort : null,
};
this.refreshResolvedCollaborationMode();
}
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
@@ -4502,7 +4461,13 @@ export class CodexAppServerAgentSession implements AgentSession {
if (options?.outputSchema) {
params.outputSchema = normalizeCodexOutputSchema(options.outputSchema);
}
const developerInstructions = composeAgentSystemPrompt(this.config);
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
if (developerInstructions) {
params.developerInstructions = developerInstructions;
}
const codexConfig = this.buildCodexInnerConfig();
if (codexConfig) {
params.config = codexConfig;
@@ -5261,9 +5226,6 @@ export class CodexAppServerAgentSession implements AgentSession {
systemPrompt: this.config.systemPrompt,
mcpServers: this.config.mcpServers,
asyncQuestions: this.asyncQuestions.serialize(),
...(this.instructionSnapshot.describe(threadId)
? { paseoInstructionReceipt: this.instructionSnapshot.describe(threadId) }
: {}),
},
};
}
@@ -5294,15 +5256,9 @@ export class CodexAppServerAgentSession implements AgentSession {
model: this.config.model ?? null,
serviceTier: this.serviceTier,
config: this.buildCodexInnerConfig(),
developerInstructions: composeAgentSystemPrompt(this.config),
userMessageTurns: this.codexUserMessageTurns(),
threadRollbackAvailable: this.threadRollbackAvailable,
setThreadId: async (threadId) => {
await this.instructionSnapshot.apply(
threadId,
this.client!.request.bind(this.client),
this.supportsInstructionSnapshots,
);
this.currentThreadId = threadId;
// Codex writes a forked thread's rollout when it forks it, even before any turn.
this.savedThreadId = threadId;
@@ -5836,7 +5792,6 @@ export class CodexAppServerAgentSession implements AgentSession {
this.cachedRuntimeInfo = null;
}
this.currentThreadId = threadId;
this.instructionSnapshot.recordNewThread(threadId);
}
private buildThreadStartRequest(model: string): {
@@ -5848,7 +5803,10 @@ export class CodexAppServerAgentSession implements AgentSession {
const approvalPolicy = this.hasWorkflowModeOverride ? preset.approvalPolicy : undefined;
const sandbox = this.hasWorkflowModeOverride ? preset.sandbox : undefined;
const innerConfig = this.buildCodexInnerConfig();
const developerInstructions = composeAgentSystemPrompt(this.config);
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
);
const params: Record<string, unknown> = {
model,
cwd: this.config.cwd ?? null,
@@ -1,69 +0,0 @@
import { createHash } from "node:crypto";
import { CodexAppServerRpcError } from "./app-server-transport.js";
/** The receipt records only which snapshot was delivered to a particular native thread. */
interface InstructionReceipt {
threadId: string;
digest: string;
}
function readReceipt(value: unknown): InstructionReceipt | null {
if (!value || typeof value !== "object") return null;
const receipt = value as Record<string, unknown>;
if (typeof receipt.threadId !== "string" || typeof receipt.digest !== "string") return null;
if (!/^[a-f0-9]{64}$/.test(receipt.digest)) return null;
return { threadId: receipt.threadId, digest: receipt.digest };
}
export class CodexInstructionSnapshot {
private receipt: InstructionReceipt | null;
private readonly digest: string;
constructor(
private readonly instructions: string | undefined,
receipt: unknown,
) {
this.receipt = readReceipt(receipt);
this.digest = createHash("sha256")
.update(JSON.stringify({ version: 1, instructions: instructions ?? "" }))
.digest("hex");
}
/** A new thread receives its first snapshot through thread/start. */
recordNewThread(threadId: string): void {
this.receipt = { threadId, digest: this.digest };
}
describe(threadId: string): InstructionReceipt | undefined {
return this.receipt?.threadId === threadId ? { ...this.receipt } : undefined;
}
async apply(
threadId: string,
request: (method: string, params: unknown) => Promise<unknown>,
supported: boolean,
): Promise<void> {
if (this.receipt?.threadId === threadId && this.receipt.digest === this.digest) return;
if (!supported)
throw new Error("Update Codex to 0.121.0 or newer to apply agent instruction updates.");
const snapshot = JSON.stringify({ version: 1, instructions: this.instructions ?? "" });
const text =
"This is the complete current set of Paseo-supplied instructions. It supersedes all earlier Paseo-supplied instructions in the conversation, including earlier snapshots. An empty instruction string means there are no current Paseo-supplied instructions. Provider and collaboration instructions remain in effect.\n\n<paseo_instruction_snapshot>" +
snapshot +
"</paseo_instruction_snapshot>";
try {
await request("thread/inject_items", {
threadId,
items: [{ type: "message", role: "developer", content: [{ type: "input_text", text }] }],
});
} catch (error) {
if (error instanceof CodexAppServerRpcError && error.code === -32601) {
throw new Error("Update Codex to 0.121.0 or newer to apply agent instruction updates.", {
cause: error,
});
}
throw error;
}
this.receipt = { threadId, digest: this.digest };
}
}
@@ -49,7 +49,6 @@ export async function revertCodexConversation(input: {
model?: string | null;
serviceTier?: string | null;
config?: Record<string, unknown> | null;
developerInstructions?: string;
userMessageTurns: CodexUserMessageTurnIndex;
threadRollbackAvailable: boolean;
setThreadId: (threadId: string) => void | Promise<void>;
@@ -77,7 +76,6 @@ export async function revertCodexConversation(input: {
model: input.model ?? null,
serviceTier: input.serviceTier ?? null,
...(input.config ? { config: input.config } : {}),
...(input.developerInstructions ? { developerInstructions: input.developerInstructions } : {}),
// Paseo re-reads the rewound thread through paged history, not the fork reply.
excludeTurns: true,
persistExtendedHistory: true,
@@ -171,7 +171,7 @@ export function createFakeCodexAppServer(
const child = createCodexAppServerChildProcess();
const recordedRollbacks: JsonObject[] = [];
const responseHandlers: Record<string, FakeCodexAppServerHandler> = {
initialize: () => ({ userAgent: "codex/0.150.0" }),
initialize: () => ({}),
"collaborationMode/list": () => ({ data: [] }),
"config/read": () => ({ config: {} }),
getUserSavedConfig: () => ({ config: {} }),
@@ -189,7 +189,6 @@ export function createFakeCodexAppServer(
"thread/loaded/list": () => ({ data: [] }),
"thread/backgroundTerminals/list": () => ({ data: [], nextCursor: null }),
"thread/resume": () => ({}),
"thread/inject_items": () => ({}),
"turn/start": () => ({}),
"thread/fork": (params) => ({
thread: {
@@ -466,7 +466,6 @@ function buildResumeStartInput(input: {
systemPrompt: composeSystemPromptParts(
input.resumeConfig.config.systemPrompt,
input.resumeConfig.config.daemonAppendSystemPrompt,
...(input.resumeConfig.config.pluginAppendSystemPrompt ?? []),
),
};
}
@@ -2371,11 +2370,7 @@ export class OmpAgentClient implements AgentClient {
noSession: config.internal === true,
modeId: launchMode.modeId,
extraArgs: launchMode.extraArgs,
systemPrompt: composeSystemPromptParts(
config.systemPrompt,
config.daemonAppendSystemPrompt,
...(config.pluginAppendSystemPrompt ?? []),
),
systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt),
env: launchContext?.env,
};
const runtimeSession = await this.resolveRuntime(config.providerOptions).startSession(
@@ -30,7 +30,6 @@ import {
type AgentDraftControls,
type AgentOptionControl,
type AgentLaunchContext,
type AgentResumeSessionOptions,
type AgentMode,
type AgentModelDefinition,
type AgentPermissionAction,
@@ -1522,7 +1521,6 @@ export class OpenCodeAgentClient implements AgentClient {
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
_options?: AgentResumeSessionOptions,
): Promise<AgentSession> {
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
const cwd = overrides?.cwd ?? metadata.cwd;
@@ -3694,7 +3692,6 @@ class OpenCodeAgentSession implements AgentSession {
const systemPrompt = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
...(this.config.pluginAppendSystemPrompt ?? []),
);
const model = this.parseModel(this.config.model);
const effectiveMode = resolveOpenCodeRuntimeAgentId(this.currentMode);
@@ -4032,7 +4029,6 @@ class OpenCodeAgentSession implements AgentSession {
const systemPrompt = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
...(this.config.pluginAppendSystemPrompt ?? []),
);
const promptResponse = await this.client.session.promptAsync({
sessionID: this.sessionId,
@@ -4,7 +4,6 @@ import type {
AgentSessionConfig,
AgentLaunchContext,
AgentCreateSessionOptions,
AgentResumeSessionOptions,
AgentPersistenceHandle,
FetchCatalogOptions,
ProviderRefreshContext,
@@ -150,10 +149,9 @@ export class OpenCodeRuntimeClient implements AgentClient {
handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
launch?: AgentLaunchContext,
options?: AgentResumeSessionOptions,
) {
const client = await this.client();
const session = await client.resumeSession(handle, config, launch, options);
const session = await client.resumeSession(handle, config, launch);
return client === this.legacy ? session : withOpenCodeRuntimeNotice(session, 2, handle);
}
async fetchCatalog(options: FetchCatalogOptions, context?: ProviderRefreshContext) {
@@ -28,7 +28,6 @@ export class V2Harness {
readonly prompts: string[] = [];
readonly environments: Array<{ sessionID: string; variables: Record<string, string> }> = [];
readonly mcpAdds: string[] = [];
readonly instructions = new Map<string, string>();
releases = 0;
active = false;
activeReads = 0;
@@ -154,17 +153,7 @@ export class V2Harness {
environment: async (input) => {
this.environments.push(input);
},
instructions: {
entry: {
list: async () => [...this.instructions].map(([key, value]) => ({ key, value })),
put: async ({ key, value }) => {
this.instructions.set(key, String(value));
},
remove: async ({ key }) => {
this.instructions.delete(key);
},
},
},
instructions: { entry: { list: unexpected, put: unexpected, remove: unexpected } },
prompt: async (input, options) => {
const before = this.executions.length;
await this.prompt(input, options);
@@ -17,54 +17,6 @@ function terminalEvents(events: AgentStreamEvent[]) {
}
describe("OpenCode v2 session lifecycle", () => {
test("reopening replaces and removes plugin instructions", async () => {
const harness = new V2Harness();
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const created = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
pluginAppendSystemPrompt: ["first"],
});
expect(harness.instructions.get("paseo")).toBe("first");
const handle = created.describePersistence()!;
await created.close();
const resumed = await client.resumeSession(handle, {
cwd: "/tmp/project",
pluginAppendSystemPrompt: ["changed"],
});
expect(harness.instructions.get("paseo")).toBe("changed");
await resumed.close();
const cleared = await client.resumeSession(handle, {
cwd: "/tmp/project",
pluginAppendSystemPrompt: undefined,
});
expect(harness.instructions.has("paseo")).toBe(false);
await cleared.close();
});
test("opening history does not change an existing session instruction", async () => {
const harness = new V2Harness();
harness.instructions.set("paseo", "Existing plugin instruction");
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.resumeSession(
{ provider: "opencode", sessionId: "session", metadata: { cwd: "/tmp/project" } },
{ cwd: "/tmp/project" },
undefined,
{ purpose: "history" },
);
try {
expect(harness.instructions.get("paseo")).toBe("Existing plugin instruction");
} finally {
await session.close();
}
});
test("refreshes the existing location before reading a forced catalog", async () => {
const harness = new V2Harness();
const refreshed: string[] = [];
@@ -17,7 +17,6 @@ import type {
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentResumeSessionOptions,
AgentDraftControls,
AgentLaunchContext,
AgentPersistenceHandle,
@@ -132,7 +131,6 @@ export class OpenCodeV2AgentClient implements AgentClient {
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launch?: AgentLaunchContext,
options?: AgentResumeSessionOptions,
): Promise<AgentSession> {
const cwd = overrides?.cwd ?? handle.metadata?.cwd;
if (typeof cwd !== "string")
@@ -153,14 +151,7 @@ export class OpenCodeV2AgentClient implements AgentClient {
sessionID: handle.nativeHandle ?? handle.sessionId,
});
await applyResumeOverrides(connection.client, info, overrides);
return await this.attach(
connection,
info,
config,
launch,
true,
options?.purpose === "history",
);
return await this.attach(connection, info, config, launch, true);
} catch (error) {
await connection.release();
throw error;
@@ -172,7 +163,6 @@ export class OpenCodeV2AgentClient implements AgentClient {
config: AgentSessionConfig,
launch: AgentLaunchContext | undefined,
persist: boolean,
historyOnly = false,
) {
const acquire = () =>
this.runtime.acquire(
@@ -219,7 +209,6 @@ export class OpenCodeV2AgentClient implements AgentClient {
bindChild,
acquire,
moved,
historyOnly,
);
try {
await session.initialize(
@@ -75,7 +75,6 @@ export class OpenCodeV2Session implements AgentSession {
bindChild?: (id: string) => void,
private readonly acquire?: () => Promise<V2Connection>,
private readonly moved?: (connection: V2Connection) => void,
private readonly historyOnly = false,
) {
this.background = new OpenCodeBackgroundInventory({
id: info.id,
@@ -194,24 +193,16 @@ export class OpenCodeV2Session implements AgentSession {
});
await this.awaitMcp(server);
}
if (this.historyOnly) return;
const system = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
...(this.config.pluginAppendSystemPrompt ?? []),
);
if (system) {
if (system)
await this.client.session.instructions.entry.put({
sessionID: this.id,
key: "paseo",
value: system,
});
} else {
const entries = await this.client.session.instructions.entry.list({ sessionID: this.id });
if (entries.some((entry) => entry.key === "paseo")) {
await this.client.session.instructions.entry.remove({ sessionID: this.id, key: "paseo" });
}
}
}
private async startStream() {
let ready!: () => void;
@@ -1892,7 +1892,6 @@ describe("PiRpcAgentSession", () => {
createConfig({
systemPrompt: "Agent prompt",
daemonAppendSystemPrompt: "Daemon prompt",
pluginAppendSystemPrompt: ["Plugin one", "Plugin two"],
}),
);
@@ -1911,9 +1910,7 @@ describe("PiRpcAgentSession", () => {
await expect(
applyPaseoExtensionSystemPrompt(actualLaunch.extensionPaths[0]!, "Pi project prompt"),
).resolves.toBe(
"Pi project prompt\n\nAgent prompt\n\nDaemon prompt\n\nPlugin one\n\nPlugin two",
);
).resolves.toBe("Pi project prompt\n\nAgent prompt\n\nDaemon prompt");
await session.close();
});
@@ -1936,7 +1933,6 @@ describe("PiRpcAgentSession", () => {
},
{
daemonAppendSystemPrompt: "Daemon prompt",
pluginAppendSystemPrompt: ["Plugin one", "Plugin two"],
},
);
@@ -1960,9 +1956,7 @@ describe("PiRpcAgentSession", () => {
]);
await expect(
applyPaseoExtensionSystemPrompt(actualLaunch.extensionPaths[0]!, "Pi project prompt"),
).resolves.toBe(
"Pi project prompt\n\nAgent prompt\n\nDaemon prompt\n\nPlugin one\n\nPlugin two",
);
).resolves.toBe("Pi project prompt\n\nAgent prompt\n\nDaemon prompt");
});
test("updates model and thinking through Pi runtime commands", async () => {
@@ -52,7 +52,7 @@ import {
type ResolvedProviderLaunch,
} from "../../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { composeAgentSystemPrompt } from "../../system-prompt.js";
import { composeSystemPromptParts } from "../../system-prompt.js";
import {
buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows,
@@ -2630,7 +2630,7 @@ export class PiRpcAgentClient implements AgentClient {
const mcp = await this.prepareMcpInjection(config.cwd, config.mcpServers, mcpEnv, runtime);
const mcpConfigFile = mcp?.kind === "adapter" ? mcp.configFile : null;
const paseoExtension = createPiPaseoExtensionFile({
systemPrompt: composeAgentSystemPrompt(config),
systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt),
mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined,
});
let runtimeSession: PiRuntimeSession;
@@ -2699,7 +2699,10 @@ export class PiRpcAgentClient implements AgentClient {
);
const mcpConfigFile = mcp?.kind === "adapter" ? mcp.configFile : null;
const paseoExtension = createPiPaseoExtensionFile({
systemPrompt: composeAgentSystemPrompt(resumeConfig.config),
systemPrompt: composeSystemPromptParts(
resumeConfig.config.systemPrompt,
resumeConfig.config.daemonAppendSystemPrompt,
),
mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined,
});
const tempFiles = mcpConfigFile ? [mcpConfigFile, paseoExtension] : [paseoExtension];
@@ -8,15 +8,3 @@ export function composeSystemPromptParts(
return prompt.length > 0 ? prompt : undefined;
}
export function composeAgentSystemPrompt(config: {
systemPrompt?: string;
daemonAppendSystemPrompt?: string;
pluginAppendSystemPrompt?: string[];
}): string | undefined {
return composeSystemPromptParts(
config.systemPrompt,
config.daemonAppendSystemPrompt,
...(config.pluginAppendSystemPrompt ?? []),
);
}
@@ -345,7 +345,7 @@ export class DictationStreamManager {
}): Promise<void> {
const state = this.streams.get(params.dictationId);
if (!state) {
this.failDictationStream(params.dictationId, "Dictation stream not started", true);
this.emitUnknownStreamError(params.dictationId);
return;
}
@@ -528,7 +528,17 @@ export class DictationStreamManager {
return path;
}
// Chunks keep arriving for a stream the daemon already dropped, one error
// per chunk; the failure that dropped it is logged where it happened.
private emitUnknownStreamError(dictationId: string): void {
this.emit({
type: "dictation_stream_error",
payload: { dictationId, error: "Dictation stream not started", retryable: true },
});
}
private failDictationStream(dictationId: string, error: string, retryable: boolean): void {
this.logger.warn({ dictationId, error, retryable }, "Dictation stream failed");
this.emit({
type: "dictation_stream_error",
payload: { dictationId, error, retryable },
@@ -543,6 +553,10 @@ export class DictationStreamManager {
const state = this.streams.get(dictationId);
const debugRecordingPath = await this.maybePersistDictationStreamAudio(dictationId);
if (!state || this.streams.get(dictationId) !== state) return;
this.logger.warn(
{ dictationId, error, retryable, ackSeq: state.ackSeq },
"Dictation stream failed",
);
this.emit({
type: "dictation_stream_error",
payload: {
@@ -527,25 +527,13 @@ test("agent creation hooks change the provider and environment before the sessio
});
server.addHook("agent.open.before", async ({ request }) => {
console.log(JSON.stringify({ hook: "agent.open.before", request }));
return { ...request, appendSystemPrompt: [...(request.appendSystemPrompt ?? []), "Plugin instructions"], env: { ...request.env, HOOK_OPEN: "opened" } };
return { ...request, env: { ...request.env, HOOK_OPEN: "opened" } };
});
server.addHook("agent.create.after", async (event) => {
console.log(JSON.stringify({ hook: "agent.create.after", event }));
});`,
reportOpen: true,
});
const extraPlugin = path.join(directory, "extra-plugin");
await mkdir(extraPlugin);
await writeFile(
path.join(extraPlugin, "paseo-plugin.json"),
JSON.stringify({ id: "a-prompt", requirements: { paseo: ">=0.8.0" } }),
);
const writePrompt = (prompt: string) =>
writeFile(
path.join(extraPlugin, "index.server.ts"),
`export default function contribute(server) { server.addHook("agent.open.before", async ({ request }) => ({ ...request, appendSystemPrompt: [...(request.appendSystemPrompt ?? []), ${JSON.stringify(prompt)}] })); return () => {}; }`,
);
await writePrompt("First plugin");
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
@@ -553,17 +541,15 @@ test("agent creation hooks change the provider and environment before the sessio
await client.fetchAgents({ subscribe: {} });
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(pluginDirectory);
await client.installDirectoryPlugin(extraPlugin);
const agent = await client.createAgent({
provider: "claude",
cwd: directory,
title: "Hook test",
systemPrompt: "Saved instructions",
});
expect(agent.provider).toBe(PROVIDER_ID);
expect(await daemon.daemon.agentStorage.get(agent.id)).toMatchObject({
provider: PROVIDER_ID,
config: { model: "shutdown-model", systemPrompt: "Saved instructions" },
config: { model: "shutdown-model" },
});
await expect
.poll(async () => {
@@ -583,32 +569,13 @@ test("agent creation hooks change the provider and environment before the sessio
},
{
hook: "provider.open",
config: {
env: { HOOK_CREATE: "created", HOOK_OPEN: "opened" },
systemPrompt: "Saved instructions\n\nFirst plugin\n\nPlugin instructions",
},
config: { env: { HOOK_CREATE: "created", HOOK_OPEN: "opened" } },
},
{
hook: "agent.create.after",
event: { agent: { id: agent.id, provider: PROVIDER_ID } },
},
]);
await writePrompt("Changed plugin");
await client.reloadPlugin("a-prompt");
await daemon.daemon.agentManager.reloadAgentSession(agent.id);
await client.disablePlugin("a-prompt");
await daemon.daemon.agentManager.reloadAgentSession(agent.id);
const opens = (await client.getPluginLogs("shutdown-provider-plugin"))
.filter((entry) => entry.message.startsWith('{"hook":"provider.open"'))
.map((entry) => JSON.parse(entry.message).config.systemPrompt);
expect(opens).toEqual([
"Saved instructions\n\nFirst plugin\n\nPlugin instructions",
"Saved instructions\n\nChanged plugin\n\nPlugin instructions",
"Saved instructions\n\nPlugin instructions",
]);
expect((await daemon.daemon.agentStorage.get(agent.id))?.config?.systemPrompt).toBe(
"Saved instructions",
);
await client.archiveAgent(agent.id);
} finally {
await client.close();
@@ -228,9 +228,7 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }, { background:
},
paseo,
),
).rejects.toThrow(
"agent.open.before hooks can only change env, tools, mcpServers, and appendSystemPrompt",
);
).rejects.toThrow("agent.open.before hooks can only change env, tools, and mcpServers");
},
);
@@ -359,42 +357,3 @@ test("checkout-branch hooks reject a branch-off baseRef", async () => {
validateBeforeResult("worktree.create.before", request, { ...request, baseRef: "main" }),
).rejects.toThrow(/baseRef/);
});
test("session-open instructions compose, survive old hooks, and are ignored for history", async () => {
const hooks = new PluginHookHandlers(() => {});
hooks.addHook("agent.open.before", async ({ request }) => ({
...request,
appendSystemPrompt: [...(request.appendSystemPrompt ?? []), "first"],
}));
hooks.addHook("agent.open.before", async ({ request }) => {
const { appendSystemPrompt: _ignored, ...oldRequest } = request;
return oldRequest;
});
hooks.addHook("agent.open.before", async ({ request }) => ({
...request,
appendSystemPrompt: [...(request.appendSystemPrompt ?? []), "second"],
}));
const input = {
agentId: "agent",
workspaceId: null,
provider: "codex",
cwd: "/project",
reason: "resume",
purpose: "interactive",
env: {},
title: null,
labels: {},
metadata: {},
tools: [],
mcpServers: {},
};
const interactive = await hooks.invoke("interactive", "agent.open.before", input, paseo);
expect(interactive).toMatchObject({ appendSystemPrompt: ["first", "second"] });
const history = await hooks.invoke(
"history",
"agent.open.before",
{ ...input, purpose: "history" },
paseo,
);
expect(history).not.toHaveProperty("appendSystemPrompt");
});
@@ -108,7 +108,6 @@ const beforeSchemas = {
labels: z.record(z.string(), z.string()),
// Optional so a plugin that returns its own object keeps working; omitted means unchanged.
metadata: z.record(z.string(), z.custom<JsonValue>()).optional(),
appendSystemPrompt: z.array(z.string()).optional(),
tools: z.array(agentToolMetadataSchema),
env: z.record(z.string(), z.string()),
mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(),
@@ -249,18 +248,9 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
const previous = beforeSchemas["agent.open.before"].parse(input);
const next = beforeSchemas["agent.open.before"].parse(result);
if (changesSessionOpenTarget(previous, next)) {
throw new Error(
"agent.open.before hooks can only change env, tools, mcpServers, and appendSystemPrompt",
);
throw new Error("agent.open.before hooks can only change env, tools, and mcpServers");
}
return {
...result,
background: previous.background,
metadata: previous.metadata,
...(next.appendSystemPrompt === undefined && previous.appendSystemPrompt !== undefined
? { appendSystemPrompt: previous.appendSystemPrompt }
: {}),
};
return { ...result, background: previous.background, metadata: previous.metadata };
}
if (name === "agent.create.before") {
const previous = beforeSchemas["agent.create.before"].parse(input);
@@ -386,7 +376,6 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
if (request.purpose === "history") {
request.tools = [];
request.mcpServers = {};
delete request.appendSystemPrompt;
} else this.agentTools?.attach(request.agentId, tools);
return request;
}
@@ -87,7 +87,12 @@ function shouldIncludeLocalProviderConfig(params: {
params.providers.dictationStt.provider === "local") ||
(params.providers.voiceStt.enabled !== false &&
params.providers.voiceStt.provider === "local") ||
(params.providers.voiceTts.enabled !== false && params.providers.voiceTts.provider === "local");
(params.providers.voiceTts.enabled !== false &&
params.providers.voiceTts.provider === "local") ||
// Turn detection is always local, so an enabled local turn detector
// needs the local provider config even when no speech site uses it.
(params.providers.voiceTurnDetection.enabled !== false &&
params.providers.voiceTurnDetection.provider === "local");
return (
localRequestedByFeature ||
@@ -168,6 +168,122 @@ describe("resolveSpeechConfig", () => {
},
);
test("includes local provider config for local turn detection with OpenAI voice STT/TTS", () => {
const persisted = PersistedConfigSchema.parse({
features: {
dictation: { enabled: false },
voiceMode: {
enabled: true,
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
tts: { provider: "openai", apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" },
},
},
});
const result = resolveSpeechConfig({
paseoHome: "/tmp/paseo-home",
env: {} as NodeJS.ProcessEnv,
persisted,
});
expect(result.speech.providers.voiceTurnDetection).toEqual({
provider: "local",
explicit: false,
enabled: true,
});
expect(result.speech.providers.voiceStt).toEqual({
provider: "openai",
explicit: true,
enabled: true,
});
expect(result.speech.providers.voiceTts).toEqual({
provider: "openai",
explicit: true,
enabled: true,
});
expect(result.speech.providers.dictationStt).toEqual({
provider: "local",
explicit: false,
enabled: false,
});
// Turn detection is always local, so the local provider config must be
// present even when no speech site uses the local provider and no
// models directory is configured; otherwise voice mode reports
// turn_detection_unavailable.
expect(result.speech.local).toEqual({
modelsDir: path.join("/tmp/paseo-home", "models", "local-speech"),
models: {
dictationStt: "parakeet-tdt-0.6b-v2-int8",
voiceStt: "parakeet-tdt-0.6b-v2-int8",
voiceTts: "kokoro-en-v0_19",
voiceTtsSpeakerId: 0,
},
});
});
test("includes local provider config for local turn detection with dictation on OpenAI", () => {
const persisted = PersistedConfigSchema.parse({
features: {
dictation: {
enabled: true,
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
},
voiceMode: {
enabled: true,
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
tts: { provider: "openai", apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" },
},
},
});
const result = resolveSpeechConfig({
paseoHome: "/tmp/paseo-home",
env: {} as NodeJS.ProcessEnv,
persisted,
});
expect(result.speech.providers.voiceTurnDetection).toEqual({
provider: "local",
explicit: false,
enabled: true,
});
expect(result.speech.local).toEqual({
modelsDir: path.join("/tmp/paseo-home", "models", "local-speech"),
models: {
dictationStt: "parakeet-tdt-0.6b-v2-int8",
voiceStt: "parakeet-tdt-0.6b-v2-int8",
voiceTts: "kokoro-en-v0_19",
voiceTtsSpeakerId: 0,
},
});
});
test("omits local provider config when voice mode is off and no speech site is local", () => {
const persisted = PersistedConfigSchema.parse({
features: {
dictation: { enabled: false },
voiceMode: {
enabled: false,
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
tts: { provider: "openai", apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" },
},
},
});
const result = resolveSpeechConfig({
paseoHome: "/tmp/paseo-home",
env: {} as NodeJS.ProcessEnv,
persisted,
});
expect(result.speech.providers.voiceTurnDetection).toEqual({
provider: "local",
explicit: false,
enabled: false,
});
expect(result.speech.local).toBeUndefined();
});
test("respects disabled dictation and voice mode feature flags", () => {
const persisted = PersistedConfigSchema.parse({
features: {
@@ -27,10 +27,6 @@ export default function contribute(server: PluginServerContext) {
return {
...request,
appendSystemPrompt: [
...(request.appendSystemPrompt ?? []),
"Follow the team engineering guide.",
],
mcpServers: {
...request.mcpServers,
company: {
+18 -31
View File
@@ -31,7 +31,7 @@ Names follow `subject.operation.position`: the subject, the operation, then `bef
All handlers must be async. Turn completion includes success, failure, and cancellation; inspect `outcome`. Permission completion includes every resolution; inspect `resolution`.
Creating an agent happens once, and `agent.create.before` changes its saved configuration. Opening starts the provider process: the first run, resume after a daemon restart, reading history, importing an outside session, or reload. `agent.open.before` contributes launch-only environment, instructions, tools, and MCP servers each time. Creating opens the agent; opening an existing agent does not create it; closing ends the running instance. Opening a tab in the app is a separate action.
Creating an agent happens once, and `agent.create.before` changes its saved configuration. Opening starts the provider process: the first run, resume after a daemon restart, reading history, importing an outside session, or reload. `agent.open.before` contributes launch-only environment, tools, and MCP servers each time. Creating opens the agent; opening an existing agent does not create it; closing ends the running instance. Opening a tab in the app is a separate action.
`worktree.create.after` runs before the workspace is registered. It precedes `workspace.create.after`, and both precede the first agent's `agent.create.before` / `agent.open.before`.
@@ -49,22 +49,22 @@ Paseo only sees the returned promise, so detached work gets no error reporting a
## Hooks
| Hook | Waits | Payload | Handler may change |
| ------------------------- | ----- | ----------------------------------------- | -------------------------------------------------------------- |
| `workspace.create.before` | yes | `{ request }` | Entire create request |
| `workspace.create.after` | no | `{ workspace }` | |
| `workspace.archive.after` | no | `{ workspace }` | |
| `worktree.create.before` | yes | `{ request }` | Creation options and destination |
| `worktree.create.after` | yes | `{ worktree }` | Files in the worktree; throw to abort creation |
| `agent.create.before` | yes | `{ request: { config, env?, metadata } }` | Saved config except `cwd`; `env`; `metadata` |
| `agent.create.after` | no | `{ agent }` | |
| `agent.open.before` | yes | `{ request }` | Launch-only `env`, `appendSystemPrompt`, `tools`, `mcpServers` |
| `agent.close.after` | no | `{ agent }` | |
| `agent.archive.after` | no | `{ agent, archivedAt }` | |
| `agent.turn.before` | no | `{ agent, turnId }` | |
| `agent.turn.after` | no | `{ agent, turnId, outcome }` | |
| `agent.permission.before` | no | `{ agent, request }` | |
| `agent.permission.after` | no | `{ agent, requestId, resolution }` | |
| Hook | Waits | Payload | Handler may change |
| ------------------------- | ----- | ----------------------------------------- | ---------------------------------------------- |
| `workspace.create.before` | yes | `{ request }` | Entire create request |
| `workspace.create.after` | no | `{ workspace }` | |
| `workspace.archive.after` | no | `{ workspace }` | |
| `worktree.create.before` | yes | `{ request }` | Creation options and destination |
| `worktree.create.after` | yes | `{ worktree }` | Files in the worktree; throw to abort creation |
| `agent.create.before` | yes | `{ request: { config, env?, metadata } }` | Saved config except `cwd`; `env`; `metadata` |
| `agent.create.after` | no | `{ agent }` | |
| `agent.open.before` | yes | `{ request }` | Launch-only `env`, `tools`, `mcpServers` |
| `agent.close.after` | no | `{ agent }` | |
| `agent.archive.after` | no | `{ agent, archivedAt }` | |
| `agent.turn.before` | no | `{ agent, turnId }` | |
| `agent.turn.after` | no | `{ agent, turnId, outcome }` | |
| `agent.permission.before` | no | `{ agent, request }` | |
| `agent.permission.after` | no | `{ agent, requestId, resolution }` | |
## Shared payloads
@@ -283,7 +283,7 @@ server.addHook("agent.create.after", async ({ agent }) => {
## agent.open.before
Receives `{ request }`; only `env`, `appendSystemPrompt`, `tools`, and `mcpServers` may change, for this launch. All other fields are read-only. A throw, invalid result, or timeout fails the open.
Receives `{ request }`; only `env`, `tools`, and `mcpServers` may change, for this launch. All other fields are read-only. A throw, invalid result, or timeout fails the open.
Do not await a write to the agent being opened: `setMetadata`, `setLabels`, or an option, model, thinking or mode change. The write waits for the open to finish, the open waits for your hook, so the hook times out and the open fails. Start the write without awaiting it; it runs right after the open:
@@ -329,19 +329,6 @@ server.addHook("agent.open.before", async ({ request }, { paseo }) => {
| `purpose` | `interactive`, `history` |
| `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. |
### Append instructions on every session opening
```ts
server.addHook("agent.open.before", async ({ request }) => ({
...request,
appendSystemPrompt: [...(request.appendSystemPrompt ?? []), "Follow the team engineering guide."],
}));
```
Instructions follow the saved agent prompt and daemon instructions, in plugin order. They are not saved in the agent configuration and are ignored for history opens. Reload the plugin after changing its data to apply new instructions on the next reopen. Running turns continue; removing an instruction does not remove conversation history. Internal agents also receive these instructions. The ACP adapter does not send system instructions to its providers. Plugin providers receive the composed prompt, but must apply it themselves.
Codex instruction updates require Codex 0.121.0 or newer with a recognizable version reported by the app server. Source builds reporting `0.0.0` must use a versioned build. Paseo adds a current instruction snapshot to existing threads; earlier snapshots remain in the conversation. A crash during an update or rewinding a thread can repeat a snapshot.
### Inject environment variables on every session opening
```ts
+18 -31
View File
@@ -29,7 +29,7 @@ export default function contribute(server: PluginServerContext) {
所有处理函数都必须为异步函数。轮次完成包括成功、失败和取消,请查看 `outcome`。权限完成包括所有处理结果,请查看 `resolution`。
创建 Agent 会发生一次,`agent.create.before` 修改已保存的配置。打开 Agent 指启动 Provider 进程,包括首次运行、daemon 重启后恢复、读取历史、导入外部会话和重新加载。`agent.open.before` 为每次启动提供临时的环境变量、指令、工具和 MCP 服务器。创建会打开 Agent;打开已有 Agent 不会创建它;关闭会结束运行中的实例。在应用中打开标签页不属于此操作。
创建 Agent 会发生一次,`agent.create.before` 修改已保存的配置。打开 Agent 指启动 Provider 进程,包括首次运行、daemon 重启后恢复、读取历史、导入外部会话和重新加载。`agent.open.before` 为每次启动提供临时的环境变量、工具和 MCP 服务器。创建会打开 Agent;打开已有 Agent 不会创建它;关闭会结束运行中的实例。在应用中打开标签页不属于此操作。
`worktree.create.after` 在工作区注册前运行,随后是 `workspace.create.after`,两者都早于第一个 Agent 的 `agent.create.before` / `agent.open.before`。
@@ -47,22 +47,22 @@ Paseo 只观察返回的 Promise,因此独立任务没有错误报告,也没
## 钩子一览 {#hooks}
| 钩子 | 等待 | 载荷 | 可修改 |
| ------------------------- | ---- | ----------------------------------------- | --------------------------------------------------------------- |
| `workspace.create.before` | 是 | `{ request }` | 整个创建请求 |
| `workspace.create.after` | 否 | `{ workspace }` | |
| `workspace.archive.after` | 否 | `{ workspace }` | |
| `worktree.create.before` | 是 | `{ request }` | 创建选项和目标路径 |
| `worktree.create.after` | 是 | `{ worktree }` | worktree 中的文件;抛出错误中止创建 |
| `agent.create.before` | 是 | `{ request: { config, env?, metadata } }` | 除 `cwd` 外的已保存配置;`env`;`metadata` |
| `agent.create.after` | 否 | `{ agent }` | |
| `agent.open.before` | 是 | `{ request }` | 仅用于启动的 `env`、`appendSystemPrompt`、`tools`、`mcpServers` |
| `agent.close.after` | 否 | `{ agent }` | |
| `agent.archive.after` | 否 | `{ agent, archivedAt }` | |
| `agent.turn.before` | 否 | `{ agent, turnId }` | |
| `agent.turn.after` | 否 | `{ agent, turnId, outcome }` | |
| `agent.permission.before` | 否 | `{ agent, request }` | |
| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | |
| 钩子 | 等待 | 载荷 | 可修改 |
| ------------------------- | ---- | ----------------------------------------- | ------------------------------------------ |
| `workspace.create.before` | 是 | `{ request }` | 整个创建请求 |
| `workspace.create.after` | 否 | `{ workspace }` | |
| `workspace.archive.after` | 否 | `{ workspace }` | |
| `worktree.create.before` | 是 | `{ request }` | 创建选项和目标路径 |
| `worktree.create.after` | 是 | `{ worktree }` | worktree 中的文件;抛出错误中止创建 |
| `agent.create.before` | 是 | `{ request: { config, env?, metadata } }` | 除 `cwd` 外的已保存配置;`env`;`metadata` |
| `agent.create.after` | 否 | `{ agent }` | |
| `agent.open.before` | 是 | `{ request }` | 仅用于启动的 `env`、`tools`、`mcpServers` |
| `agent.close.after` | 否 | `{ agent }` | |
| `agent.archive.after` | 否 | `{ agent, archivedAt }` | |
| `agent.turn.before` | 否 | `{ agent, turnId }` | |
| `agent.turn.after` | 否 | `{ agent, turnId, outcome }` | |
| `agent.permission.before` | 否 | `{ agent, request }` | |
| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | |
## 共享载荷 {#shared-payloads}
@@ -279,7 +279,7 @@ server.addHook("agent.create.after", async ({ agent }) => {
## agent.open.before {#agentopenbefore}
收到 `{ request }`,只能修改 `env`、`appendSystemPrompt`、`tools` 和 `mcpServers`,修改只用于此次启动。其他字段只读。抛出错误、无效返回或超时会让打开操作失败。
收到 `{ request }`,只能修改 `env`、`tools` 和 `mcpServers`,修改只用于此次启动。其他字段只读。抛出错误、无效返回或超时会让打开操作失败。
不要 await 对正在打开的 Agent 的写入,例如 `setMetadata`。该写入要等打开完成,而打开在等你的钩子,结果钩子超时,打开失败。启动写入但不要 await,它会在打开完成后立即执行:
@@ -325,19 +325,6 @@ server.addHook("agent.open.before", async ({ request }, { paseo }) => {
| `purpose` | `interactive`、`history` |
| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 |
### 在每次打开会话时追加指令 {#append-instructions-on-every-session-opening}
```ts
server.addHook("agent.open.before", async ({ request }) => ({
...request,
appendSystemPrompt: [...(request.appendSystemPrompt ?? []), "Follow the team engineering guide."],
}));
```
插件指令按插件顺序追加在已保存的 Agent 提示词和 daemon 指令之后。指令不会保存到 Agent 配置中,读取历史时也会忽略这些指令。插件数据更改后,重新加载插件以在下次重新打开会话时应用新指令。正在运行的轮次会继续。移除指令不会删除对话历史。内部 Agent 也会收到这些指令。ACP 适配器不会向 Provider 发送系统指令。插件 Provider 会收到组合后的提示词,但需要自行应用。
更新 Codex 指令需要 Codex 0.121.0 或更新版本,且 app server 报告的版本必须可识别。版本显示为 `0.0.0` 的源码构建需要换用带版本号的构建。Paseo 会向现有会话添加当前指令快照;之前的快照仍保留在对话中。更新期间发生崩溃或回退会话时,快照可能重复。
### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening}
```ts