mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 01:14:52 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab3cf9d69a | ||
|
|
736f7bf042 | ||
|
|
07a2e4accd | ||
|
|
1f64e68987 | ||
|
|
e06adf7a3e | ||
|
|
d2cc18b3a1 | ||
|
|
5062d5ee90 | ||
|
|
71dc9f44c9 | ||
|
|
7495c20f78 | ||
|
|
b92a34ab3d | ||
|
|
8018231a00 | ||
|
|
555818f5f7 | ||
|
|
27caf913f6 | ||
|
|
ae6910cadb | ||
|
|
a9a140f6ea | ||
|
|
555191f95d | ||
|
|
b86cbd38e5 |
@@ -63,6 +63,24 @@ function htmlPreview(page: Page) {
|
||||
};
|
||||
}
|
||||
|
||||
async function followHtmlPreviewLink(page: Page, name: string): Promise<void> {
|
||||
await htmlPreview(page).document.getByRole("link", { name, exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectPageHandlesFragment(
|
||||
page: Page,
|
||||
handler: "Document" | "Window",
|
||||
): Promise<void> {
|
||||
const preview = htmlPreview(page);
|
||||
await followHtmlPreviewLink(page, `${handler} handled jump`);
|
||||
await expect(preview.document.getByRole("status")).toHaveText(`${handler} handled click`);
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).not.toBeInViewport();
|
||||
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
|
||||
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
|
||||
}
|
||||
|
||||
async function selectFileView(page: Page, view: "Preview" | "Source"): Promise<void> {
|
||||
const option = page.getByTestId("file-panel-bar").getByRole("button", {
|
||||
name: view,
|
||||
@@ -826,6 +844,192 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect(preview.host).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps HTML fragment links in the preview and scrolls to the target", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragments-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "fragments.html"),
|
||||
`<!doctype html><html><body>
|
||||
<a href="#section">Jump to section</a>
|
||||
<div style="height: 1200px"></div>
|
||||
<h2 id="section">Target section</h2>
|
||||
</body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "fragments.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expect(preview.document.getByRole("link", { name: "Jump to section" })).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("fragment-before-click.png") });
|
||||
await preview.document.getByRole("link", { name: "Jump to section" }).click();
|
||||
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("#section");
|
||||
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
await page.screenshot({ path: test.info().outputPath("fragment-after-click.png") });
|
||||
});
|
||||
|
||||
test("lets an HTML page prevent fragment navigation", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragment-handler-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "handled.html"),
|
||||
`<!doctype html><html><body>
|
||||
<a href="#section" onclick="event.preventDefault(); document.querySelector('output').textContent = 'Page handled click'">Handled jump</a>
|
||||
<output>Waiting for click</output>
|
||||
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||
</body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "handled.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await preview.document.getByRole("link", { name: "Handled jump" }).click();
|
||||
await expect(preview.document.locator("output")).toHaveText("Page handled click");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).not.toBeInViewport();
|
||||
expect(await preview.document.locator("body").evaluate(() => location.hash)).toBe("");
|
||||
expect(await preview.document.locator("body").evaluate(() => window.scrollY)).toBe(0);
|
||||
});
|
||||
|
||||
test("respects delegated HTML fragment handlers and normal URL parsing", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragment-defaults-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "defaults.html"),
|
||||
`<!doctype html><html><body>
|
||||
<nav style="position: fixed; top: 0; background: white">
|
||||
<a href="#section" id="document-handler">Document handled jump</a>
|
||||
<a href="#section" id="window-handler">Window handled jump</a>
|
||||
<a href=" \t#section\n">Whitespace jump</a>
|
||||
<a href="#100%">Percent jump</a>
|
||||
<output>Waiting for click</output>
|
||||
</nav>
|
||||
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||
<div style="height: 1200px"></div><h2 id="100%">Percent target</h2>
|
||||
<div style="height: 1200px"></div>
|
||||
<script>
|
||||
document.addEventListener('click', function(event) {
|
||||
if (event.target.id === 'document-handler') {
|
||||
event.preventDefault(); document.querySelector('output').textContent = 'Document handled click';
|
||||
}
|
||||
});
|
||||
window.addEventListener('click', function(event) {
|
||||
if (event.target.id === 'window-handler') {
|
||||
event.preventDefault(); document.querySelector('output').textContent = 'Window handled click';
|
||||
}
|
||||
});
|
||||
</script></body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "defaults.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
await expectPageHandlesFragment(page, "Document");
|
||||
await expectPageHandlesFragment(page, "Window");
|
||||
|
||||
await followHtmlPreviewLink(page, "Whitespace jump");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
await followHtmlPreviewLink(page, "Percent jump");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Percent target" }),
|
||||
).toBeInViewport();
|
||||
});
|
||||
|
||||
test("handles HTML fragment targets, empty and missing fragments, and modified clicks", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "html-fragment-targets-" });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "targets.html"),
|
||||
`<!doctype html><html><head><style>:target { color: rgb(255, 0, 0); }</style></head><body>
|
||||
<nav style="position: fixed; top: 0; background: white">
|
||||
<a href="#section"><span>Nested jump</span></a>
|
||||
<a href="#legacy">Named jump</a>
|
||||
<a href="#encoded%20section">Encoded jump</a>
|
||||
<a href="#missing">Missing jump</a>
|
||||
<a href="#">Empty jump</a>
|
||||
<output>Waiting for hashchange</output><output id="modified">Waiting for modified click</output>
|
||||
</nav>
|
||||
<div style="height: 1200px"></div><h2 id="section">Target section</h2>
|
||||
<div style="height: 1200px"></div><a name="legacy">Named target</a>
|
||||
<div style="height: 1200px"></div><h2 id="encoded section">Encoded target</h2>
|
||||
<div style="height: 1200px"></div>
|
||||
<script>
|
||||
window.addEventListener('hashchange', function () { document.querySelector('output').textContent = location.hash || '(empty)'; });
|
||||
function modifiedClick(event) {
|
||||
if (event.button === 1 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
|
||||
document.getElementById('modified').textContent = String(event.defaultPrevented);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
window.addEventListener('click', modifiedClick);
|
||||
window.addEventListener('auxclick', modifiedClick);
|
||||
window.addEventListener('contextmenu', modifiedClick);
|
||||
</script></body></html>`,
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "targets.html");
|
||||
|
||||
const preview = htmlPreview(page);
|
||||
const body = preview.document.locator("body");
|
||||
const nested = preview.document.getByText("Nested jump", { exact: true });
|
||||
await nested.click();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("#section");
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
await expect(preview.document.locator("#section")).toHaveCSS("color", "rgb(255, 0, 0)");
|
||||
|
||||
await body.evaluate(() => window.scrollTo(0, 0));
|
||||
await nested.click();
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Target section" }),
|
||||
).toBeInViewport();
|
||||
|
||||
await preview.document.getByRole("link", { name: "Named jump" }).click();
|
||||
await expect(preview.document.getByText("Named target", { exact: true })).toBeInViewport();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("#legacy");
|
||||
|
||||
await preview.document.getByRole("link", { name: "Encoded jump" }).click();
|
||||
await expect(
|
||||
preview.document.getByRole("heading", { name: "Encoded target" }),
|
||||
).toBeInViewport();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("#encoded%20section");
|
||||
const scrollY = await body.evaluate(() => window.scrollY);
|
||||
await preview.document.getByRole("link", { name: "Missing jump" }).click();
|
||||
expect(await body.evaluate(() => location.hash)).toBe("#missing");
|
||||
expect(await body.evaluate(() => window.scrollY)).toBe(scrollY);
|
||||
|
||||
await preview.document.getByRole("link", { name: "Empty jump" }).click();
|
||||
await expect(preview.document.locator("output").first()).toHaveText("(empty)");
|
||||
expect(await body.evaluate(() => window.scrollY)).toBe(0);
|
||||
|
||||
for (const modifier of ["Control", "Meta", "Shift", "Alt"] as const) {
|
||||
await nested.click({ modifiers: [modifier] });
|
||||
await expect(preview.document.locator("#modified")).toHaveText("false");
|
||||
}
|
||||
await nested.click({ button: "middle" });
|
||||
await expect(preview.document.locator("#modified")).toHaveText("false");
|
||||
expect(await body.evaluate(() => location.hash)).toBe("");
|
||||
expect(await body.evaluate(() => window.scrollY)).toBe(0);
|
||||
});
|
||||
|
||||
test("runs inline scripts without allowing fetch egress", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-html-csp-" });
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import {
|
||||
collapseFolder,
|
||||
expandFolder,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { delayDirectoryListingResponse } from "../support/helpers/directory-listing-gate";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
@@ -49,3 +52,66 @@ test.describe("File explorer collapse", () => {
|
||||
await expectExplorerEntryVisible(page, "guide.md");
|
||||
});
|
||||
});
|
||||
|
||||
test("Files Refresh reveals new files when a cached collapsed folder is reopened", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await expectExplorerEntryVisible(page, "guide.md");
|
||||
await collapseFolder(page, "docs");
|
||||
await expectExplorerEntryHidden(page, "guide.md");
|
||||
await createFileOutsidePaseo("docs/NEW.md");
|
||||
await refreshFiles(page);
|
||||
await expandFolder(page, "docs");
|
||||
await expectExplorerEntryVisible(page, "guide.md");
|
||||
await expectExplorerEntryVisible(page, "NEW.md");
|
||||
|
||||
await createFileOutsidePaseo("docs/NEXT.md");
|
||||
await refreshFiles(page);
|
||||
await expectExplorerEntryVisible(page, "NEXT.md");
|
||||
});
|
||||
|
||||
async function refreshFiles(page: import("@playwright/test").Page): Promise<void> {
|
||||
const refresh = page.getByRole("button", { name: "Refresh files", exact: true });
|
||||
await refresh.click();
|
||||
await expect(refresh).toBeEnabled();
|
||||
}
|
||||
|
||||
async function createFileOutsidePaseo(filePath: string): Promise<void> {
|
||||
await writeFile(path.join(workspace.repoPath, filePath), "# New output\n");
|
||||
}
|
||||
|
||||
test("Files Refresh ignores an old reply for a folder collapsed while loading", async ({
|
||||
page,
|
||||
}) => {
|
||||
const reply = await delayDirectoryListingResponse(page, "docs");
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await reply.waitUntilHeld();
|
||||
await collapseFolder(page, "docs");
|
||||
await createFileOutsidePaseo("docs/DELAYED.md");
|
||||
await refreshFiles(page);
|
||||
await reply.release();
|
||||
await expandFolder(page, "docs");
|
||||
await expectExplorerEntryVisible(page, "DELAYED.md");
|
||||
});
|
||||
|
||||
test("Files Refresh preserves expanded folders while their saved view is restoring", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "docs");
|
||||
await expectExplorerEntryVisible(page, "guide.md");
|
||||
const reply = await delayDirectoryListingResponse(page, "docs");
|
||||
await page.reload();
|
||||
await openFileExplorer(page);
|
||||
await reply.waitUntilHeld();
|
||||
await refreshFiles(page);
|
||||
await expectExplorerEntryVisible(page, "guide.md");
|
||||
await reply.release();
|
||||
await expectExplorerEntryVisible(page, "guide.md");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Open an existing compact chat, with the keyboard closed, then interrupt its host connection.
|
||||
// Hold that interruption externally (for example, stop a disposable SSH TCP forward).
|
||||
// Leave the daemon running. This test observes the public reconnecting state.
|
||||
// node placement.mjs <session> <platform> <coordinate-scale> <composer-label> <artifacts>
|
||||
// coordinate-scale is screenshot pixels per layout point (Android density; iOS snapshots use 1).
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const [, , session, platform, coordinateScale, composerLabel, artifacts] = process.argv;
|
||||
assert(
|
||||
session && ["android", "ios"].includes(platform) && composerLabel && artifacts,
|
||||
"Pass all five arguments",
|
||||
);
|
||||
const scale = Number(coordinateScale);
|
||||
assert(scale > 0, "Pass a positive coordinate scale");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", ["--session", session, "--platform", platform, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 90_000,
|
||||
});
|
||||
}
|
||||
function capture(name) {
|
||||
const snapshot = ad("snapshot", "--raw", "--json");
|
||||
writeFileSync(path.join(artifacts, `${name}.json`), snapshot);
|
||||
ad("screenshot", path.join(artifacts, `${name}.png`));
|
||||
const nodes = JSON.parse(snapshot).data.nodes;
|
||||
const header = nodes.find((node) => node.identifier === "floating-header");
|
||||
const toast = nodes.find((node) => node.identifier === "agent-reconnecting-toast");
|
||||
assert(header?.rect && toast?.rect, "Measured header and reconnecting toast must be present");
|
||||
assert(toast.rect.height > 0 && toast.rect.width > 0, "Toast must have visible bounds");
|
||||
const gap = (toast.rect.y - header.rect.y - header.rect.height) / scale;
|
||||
assert(Math.abs(gap - 12) <= 1, `${name}: actual header-to-toast gap ${gap}, expected12 points`);
|
||||
return toast.rect.y / scale;
|
||||
}
|
||||
|
||||
ad("wait", 'id="agent-reconnecting-toast"', "20000");
|
||||
const closedTop = capture("keyboard-closed");
|
||||
ad("press", `label=${JSON.stringify(composerLabel)}`);
|
||||
const opened = ad("snapshot", "--raw", "--json");
|
||||
const nodes = JSON.parse(opened).data.nodes;
|
||||
assert(
|
||||
nodes.some(
|
||||
(node) => /keyboard/i.test(node.type ?? "") || /inputmethod/i.test(node.bundleId ?? ""),
|
||||
),
|
||||
"Use a real visible software keyboard; open Android sessions with --no-test-ime",
|
||||
);
|
||||
const openTop = capture("keyboard-open");
|
||||
assert(Math.abs(openTop - closedTop) <= 1, "Keyboard must not translate the toast");
|
||||
console.log(
|
||||
"PASS: rendered reconnecting toast stays12 points below measured header, keyboard closed/open",
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Page, WebSocketRoute } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
function listingResponse(message: string | Buffer) {
|
||||
try {
|
||||
const envelope = JSON.parse(message.toString());
|
||||
return envelope.message?.type === "file_explorer_response" ? envelope.message.payload : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Delay one real directory response after the daemon has read its contents. */
|
||||
export async function delayDirectoryListingResponse(page: Page, directoryPath: string) {
|
||||
let held: { socket: WebSocketRoute; message: string | Buffer; requestId: string } | null = null;
|
||||
let resolveHeld!: () => void;
|
||||
const heldResponse = new Promise<void>((resolve) => {
|
||||
resolveHeld = resolve;
|
||||
});
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (socket) => {
|
||||
const server = socket.connectToServer();
|
||||
socket.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
const response = listingResponse(message);
|
||||
if (!held && response?.mode === "list" && response.path === directoryPath) {
|
||||
held = { socket, message, requestId: response.requestId };
|
||||
resolveHeld();
|
||||
return;
|
||||
}
|
||||
socket.send(message);
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitUntilHeld: () => heldResponse,
|
||||
release: async () => {
|
||||
if (!held) throw new Error("Directory response must be held before release");
|
||||
held.socket.send(held.message);
|
||||
// Let the delivered reply update the UI before the user reopens the folder.
|
||||
await page.evaluate(
|
||||
() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -64,10 +64,15 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
import { usePanelStore, type ExpandedPathsUpdate, type SortOption } from "@/stores/panel-store";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import {
|
||||
DIRECTORY_LISTING_INVALIDATED,
|
||||
type DirectoryListingResult,
|
||||
} from "@/file-explorer/directory-listings";
|
||||
import { isHiddenExplorerPath } from "@/file-explorer/visibility";
|
||||
import {
|
||||
flattenExplorerTree,
|
||||
reconcileRestoredExpandedPaths,
|
||||
refreshExplorerDirectories,
|
||||
restoreExpandedDirectories,
|
||||
setExpandedDirectoryPath,
|
||||
showHiddenFilesAndRestoreExpandedDirectories,
|
||||
@@ -435,6 +440,7 @@ export function FileExplorerPane({
|
||||
|
||||
const {
|
||||
requestDirectoryListing,
|
||||
invalidateDirectoryListings,
|
||||
createEntry,
|
||||
renameEntry,
|
||||
duplicateEntry,
|
||||
@@ -886,9 +892,11 @@ export function FileExplorerPane({
|
||||
refreshExplorerDirectories({
|
||||
hasWorkspaceScope,
|
||||
expandedPaths,
|
||||
showHiddenFiles: usePanelStore.getState().explorerShowHiddenFiles,
|
||||
requestDirectoryListing,
|
||||
invalidateDirectoryListings,
|
||||
}),
|
||||
[expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
||||
[invalidateDirectoryListings, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
||||
);
|
||||
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
|
||||
queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey],
|
||||
@@ -1424,7 +1432,7 @@ function toggleDirectory({
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<ExplorerDirectory | null>;
|
||||
) => Promise<DirectoryListingResult>;
|
||||
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
|
||||
}): void {
|
||||
if (!workspaceStateKey) {
|
||||
@@ -1561,7 +1569,7 @@ async function initializeExplorer({
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<ExplorerDirectory | null>;
|
||||
) => Promise<DirectoryListingResult>;
|
||||
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
|
||||
}): Promise<void> {
|
||||
if (!hasWorkspaceScope || hasInitializedRef.current) {
|
||||
@@ -1572,6 +1580,7 @@ async function initializeExplorer({
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
if (rootDirectory === DIRECTORY_LISTING_INVALIDATED) return;
|
||||
if (!rootDirectory) {
|
||||
hasInitializedRef.current = false;
|
||||
return;
|
||||
@@ -1593,7 +1602,10 @@ async function initializeExplorer({
|
||||
const hiddenPersistedPaths = showHiddenFiles
|
||||
? []
|
||||
: Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath);
|
||||
const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths];
|
||||
const restoredPathsWithHidden =
|
||||
restoredPaths === DIRECTORY_LISTING_INVALIDATED
|
||||
? DIRECTORY_LISTING_INVALIDATED
|
||||
: [...restoredPaths, ...hiddenPersistedPaths];
|
||||
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
|
||||
reconcileRestoredExpandedPaths({
|
||||
persistedExpandedPaths,
|
||||
@@ -1603,39 +1615,6 @@ async function initializeExplorer({
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshExplorerDirectories({
|
||||
hasWorkspaceScope,
|
||||
expandedPaths,
|
||||
requestDirectoryListing,
|
||||
}: {
|
||||
hasWorkspaceScope: boolean;
|
||||
expandedPaths: Set<string>;
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<ExplorerDirectory | null>;
|
||||
}): Promise<null> {
|
||||
if (!hasWorkspaceScope) {
|
||||
return null;
|
||||
}
|
||||
const showHiddenFiles = usePanelStore.getState().explorerShowHiddenFiles;
|
||||
const directoryPaths = Array.from(expandedPaths).filter(
|
||||
(path) => showHiddenFiles || !isHiddenExplorerPath(path),
|
||||
);
|
||||
if (!directoryPaths.includes(".")) {
|
||||
directoryPaths.unshift(".");
|
||||
}
|
||||
await Promise.all(
|
||||
directoryPaths.map((path) =>
|
||||
requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function getErrorRecoveryPath(state: AgentFileExplorerState | undefined): string {
|
||||
if (!state) {
|
||||
return ".";
|
||||
|
||||
@@ -1,8 +1,26 @@
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { createContext, useContext, useCallback, useState, useId, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Portal, PortalHost } from "@gorhom/portal";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { EdgeFade } from "@/components/ui/edge-fade";
|
||||
|
||||
const OverlayClearanceContext = createContext(0);
|
||||
const OverlayHostContext = createContext<string | null>(null);
|
||||
|
||||
/** Keep status overlays above the fade without lifting the scrolling content. */
|
||||
export function FloatingHeaderOverlay({ children }: { children: ReactNode }) {
|
||||
const hostName = useContext(OverlayHostContext);
|
||||
const active = useRetainedPanelActive();
|
||||
if (!hostName) return children;
|
||||
return active ? <Portal hostName={hostName}>{children}</Portal> : null;
|
||||
}
|
||||
|
||||
/** Clearance for stationary overlays inside this layout, measured to the header bottom. */
|
||||
export function useFloatingHeaderOverlayClearance(): number {
|
||||
return useContext(OverlayClearanceContext);
|
||||
}
|
||||
|
||||
/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */
|
||||
const FADE_OVERHANG = 48;
|
||||
/** Share of the fade that stays opaque, so the header's title and icons keep contrast. */
|
||||
@@ -20,15 +38,24 @@ interface FloatingHeaderLayoutProps {
|
||||
* same tree position in both modes, so toggling `floating` never remounts them.
|
||||
*/
|
||||
export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) {
|
||||
const overlayHostName = useId();
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setHeaderHeight(Math.round(event.nativeEvent.layout.height));
|
||||
}, []);
|
||||
|
||||
const overlayClearance = floating ? headerHeight : 0;
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{floating ? null : header}
|
||||
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
|
||||
<View style={styles.content}>
|
||||
<OverlayClearanceContext.Provider value={overlayClearance}>
|
||||
<OverlayHostContext.Provider value={floating ? overlayHostName : null}>
|
||||
{children(floating ? headerHeight : 0)}
|
||||
</OverlayHostContext.Provider>
|
||||
</OverlayClearanceContext.Provider>
|
||||
</View>
|
||||
{floating ? (
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<EdgeFade
|
||||
@@ -37,11 +64,19 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea
|
||||
backdrop="surfaceWorkspace"
|
||||
style={styles.fade}
|
||||
/>
|
||||
<View pointerEvents="box-none" onLayout={handleHeaderLayout}>
|
||||
<View
|
||||
testID="floating-header"
|
||||
collapsable={false}
|
||||
pointerEvents="box-none"
|
||||
onLayout={handleHeaderLayout}
|
||||
>
|
||||
{header}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.statusOverlay} pointerEvents="box-none">
|
||||
<PortalHost name={overlayHostName} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +90,13 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
statusOverlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
FloatingHeaderOverlay,
|
||||
useFloatingHeaderOverlayClearance,
|
||||
} from "@/components/floating-header";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
@@ -190,9 +194,11 @@ export function ToastViewport({
|
||||
return clearTimer;
|
||||
}, [clearTimer, durationMs, scheduleDismiss, toastId]);
|
||||
|
||||
const panelClearance = useFloatingHeaderOverlayClearance();
|
||||
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
||||
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0;
|
||||
const topOffset =
|
||||
placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : panelClearance;
|
||||
|
||||
const content = (
|
||||
<View
|
||||
@@ -220,7 +226,7 @@ export function ToastViewport({
|
||||
return createPortal(content, getOverlayRoot());
|
||||
}
|
||||
|
||||
return content;
|
||||
return placement === "panel" ? <FloatingHeaderOverlay>{content}</FloatingHeaderOverlay> : content;
|
||||
}
|
||||
|
||||
function ToastCard({
|
||||
|
||||
@@ -179,8 +179,8 @@ export function ComposerDock({
|
||||
<View style={fullscreen ? fullscreenSurface : dockStyles.composer}>{composer}</View>
|
||||
</ComposerViewportContent>
|
||||
</ComposerViewportContent>
|
||||
{overlay}
|
||||
</KeyboardTranslateView>
|
||||
{overlay}
|
||||
</ComposerViewport>
|
||||
);
|
||||
}
|
||||
@@ -214,8 +214,8 @@ export function ComposerDock({
|
||||
{composer}
|
||||
</View>
|
||||
</ComposerViewportContent>
|
||||
{overlay}
|
||||
</KeyboardTranslateView>
|
||||
{overlay}
|
||||
</ComposerViewport>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import ts from "typescript";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
it("keeps dock overlays outside keyboard translation in every native layout", () => {
|
||||
const source = ts.createSourceFile(
|
||||
"dock.tsx",
|
||||
readFileSync(new URL("./index.native.tsx", import.meta.url), "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
let surfaces = 0;
|
||||
function visit(node: ts.Node) {
|
||||
if (
|
||||
ts.isJsxElement(node) &&
|
||||
node.openingElement.tagName.getText(source) === "KeyboardTranslateView"
|
||||
) {
|
||||
surfaces++;
|
||||
function assertStationary(child: ts.Node) {
|
||||
if (ts.isJsxExpression(child))
|
||||
expect(child.expression?.getText(source)).not.toBe("overlay");
|
||||
ts.forEachChild(child, assertStationary);
|
||||
}
|
||||
ts.forEachChild(node, assertStationary);
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(source);
|
||||
expect(surfaces).toBe(2);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentFileExplorerState, ExplorerDirectory } from "@/stores/session-store";
|
||||
import {
|
||||
refreshExplorerDirectories,
|
||||
restoreExpandedDirectories,
|
||||
showHiddenFilesAndRestoreExpandedDirectories,
|
||||
reconcileRestoredExpandedPaths,
|
||||
} from "./tree";
|
||||
import {
|
||||
DIRECTORY_LISTING_INVALIDATED,
|
||||
invalidateExplorerDirectoryListings,
|
||||
loadExplorerDirectory,
|
||||
} from "./directory-listings";
|
||||
|
||||
function directory(path: string, names: string[]): ExplorerDirectory {
|
||||
return {
|
||||
path,
|
||||
entries: names.map((name) => ({
|
||||
path: `${path}/${name}`,
|
||||
name,
|
||||
kind: "file",
|
||||
size: 0,
|
||||
modifiedAt: "2026-01-01T00:00:00.000Z",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function explorer() {
|
||||
let state: AgentFileExplorerState = {
|
||||
listingGeneration: 0,
|
||||
directories: new Map([[".", directory(".", ["docs"])]]),
|
||||
isLoading: false,
|
||||
lastError: null,
|
||||
pendingRequest: null,
|
||||
currentPath: ".",
|
||||
history: ["."],
|
||||
lastVisitedPath: ".",
|
||||
selectedEntryPath: null,
|
||||
};
|
||||
const updateState = (update: (state: AgentFileExplorerState) => AgentFileExplorerState) => {
|
||||
state = update(state);
|
||||
return state;
|
||||
};
|
||||
return {
|
||||
state: () => state,
|
||||
load: (path: string, result: Promise<ExplorerDirectory>) =>
|
||||
loadExplorerDirectory({
|
||||
path,
|
||||
updateState,
|
||||
listDirectory: () => result,
|
||||
fallbackError: "Directory listing failed",
|
||||
options: { recordHistory: false, setCurrentPath: false },
|
||||
}),
|
||||
refresh: (retained: readonly string[]) =>
|
||||
invalidateExplorerDirectoryListings(updateState, retained),
|
||||
};
|
||||
}
|
||||
|
||||
describe("directory listings across Refresh", () => {
|
||||
it("ignores an earlier reply for a collapsed folder while the fresh root is loading", async () => {
|
||||
const model = explorer();
|
||||
const old = deferred<ExplorerDirectory>();
|
||||
const oldLoad = model.load("docs", old.promise);
|
||||
model.refresh(["."]);
|
||||
const fresh = deferred<ExplorerDirectory>();
|
||||
const freshLoad = model.load(".", fresh.promise);
|
||||
old.resolve(directory("docs", ["OLD.md"]));
|
||||
|
||||
await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED);
|
||||
expect(model.state().directories.has("docs")).toBe(false);
|
||||
expect(model.state().isLoading).toBe(true);
|
||||
expect(model.state().pendingRequest?.path).toBe(".");
|
||||
|
||||
const root = directory(".", ["docs", "NEW.md"]);
|
||||
fresh.resolve(root);
|
||||
await expect(freshLoad).resolves.toEqual(root);
|
||||
expect(model.state().directories.get(".")).toEqual(root);
|
||||
expect(model.state().isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let a pre-refresh reply replace a newer listing of the same folder", async () => {
|
||||
const model = explorer();
|
||||
const old = deferred<ExplorerDirectory>();
|
||||
const oldLoad = model.load("docs", old.promise);
|
||||
model.refresh([".", "docs"]);
|
||||
const fresh = directory("docs", ["NEW.md"]);
|
||||
await model.load("docs", Promise.resolve(fresh));
|
||||
old.resolve(directory("docs", ["OLD.md"]));
|
||||
|
||||
await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED);
|
||||
expect(model.state().directories.get("docs")).toEqual(fresh);
|
||||
});
|
||||
|
||||
it("ignores errors from requests invalidated by Refresh", async () => {
|
||||
const model = explorer();
|
||||
const old = deferred<ExplorerDirectory>();
|
||||
const oldLoad = model.load("docs", old.promise);
|
||||
model.refresh(["."]);
|
||||
const fresh = deferred<ExplorerDirectory>();
|
||||
const freshLoad = model.load(".", fresh.promise);
|
||||
old.reject(new Error("Old request failed"));
|
||||
|
||||
await expect(oldLoad).resolves.toBe(DIRECTORY_LISTING_INVALIDATED);
|
||||
expect(model.state().lastError).toBeNull();
|
||||
expect(model.state().isLoading).toBe(true);
|
||||
fresh.resolve(directory(".", ["docs"]));
|
||||
await freshLoad;
|
||||
});
|
||||
|
||||
it("keeps expanded listings visible and discards collapsed listings on Refresh", async () => {
|
||||
const model = explorer();
|
||||
const docs = directory("docs", ["guide.md"]);
|
||||
await model.load("docs", Promise.resolve(docs));
|
||||
await model.load("assets", Promise.resolve(directory("assets", ["logo.png"])));
|
||||
model.refresh([".", "docs"]);
|
||||
|
||||
expect(model.state().directories.get("docs")).toEqual(docs);
|
||||
expect(model.state().directories.has("assets")).toBe(false);
|
||||
expect(model.state().currentPath).toBe(".");
|
||||
expect(model.state().history).toEqual(["."]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves saved expansion when Refresh invalidates restoration (hidden: %s)",
|
||||
async (hidden) => {
|
||||
const model = explorer();
|
||||
const folder = hidden ? ".hidden" : "docs";
|
||||
const root: ExplorerDirectory = {
|
||||
path: ".",
|
||||
entries: [
|
||||
{
|
||||
name: folder,
|
||||
path: folder,
|
||||
kind: "directory",
|
||||
size: 0,
|
||||
modifiedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
const saved = new Set([".", folder]);
|
||||
const pending = deferred<ExplorerDirectory>();
|
||||
const input = {
|
||||
rootDirectory: root,
|
||||
persistedExpandedPaths: saved,
|
||||
requestDirectoryListing: (path: string) => model.load(path, pending.promise),
|
||||
};
|
||||
const restoration = hidden
|
||||
? showHiddenFilesAndRestoreExpandedDirectories({ ...input, showHiddenFiles: () => {} })
|
||||
: restoreExpandedDirectories({ ...input, showHiddenFiles: false });
|
||||
const fresh = directory(folder, ["NEW.md"]);
|
||||
await refreshExplorerDirectories({
|
||||
hasWorkspaceScope: true,
|
||||
expandedPaths: saved,
|
||||
showHiddenFiles: hidden,
|
||||
invalidateDirectoryListings: model.refresh,
|
||||
requestDirectoryListing: (path) =>
|
||||
model.load(path, Promise.resolve(path === "." ? root : fresh)),
|
||||
});
|
||||
pending.resolve(directory(folder, ["OLD.md"]));
|
||||
const restored = await restoration;
|
||||
const expanded = reconcileRestoredExpandedPaths({
|
||||
persistedExpandedPaths: saved,
|
||||
currentExpandedPaths: saved,
|
||||
restoredExpandedPaths: restored,
|
||||
});
|
||||
expect(model.state().directories.get(folder)).toEqual(fresh);
|
||||
expect(expanded).toEqual([".", folder]);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { AgentFileExplorerState, ExplorerDirectory } from "@/stores/session-store";
|
||||
|
||||
export const DIRECTORY_LISTING_INVALIDATED = Symbol("directory-listing-invalidated");
|
||||
export type DirectoryListingResult =
|
||||
| ExplorerDirectory
|
||||
| null
|
||||
| typeof DIRECTORY_LISTING_INVALIDATED;
|
||||
|
||||
type UpdateExplorerState = (
|
||||
update: (state: AgentFileExplorerState) => AgentFileExplorerState,
|
||||
) => AgentFileExplorerState | null;
|
||||
|
||||
function pushHistory(history: string[], path: string): string[] {
|
||||
const normalizedHistory = history.length === 0 ? ["."] : history;
|
||||
return normalizedHistory.at(-1) === path ? normalizedHistory : [...normalizedHistory, path];
|
||||
}
|
||||
|
||||
export async function loadExplorerDirectory({
|
||||
path,
|
||||
options,
|
||||
updateState,
|
||||
listDirectory,
|
||||
fallbackError,
|
||||
}: {
|
||||
path: string;
|
||||
options?: { recordHistory?: boolean; setCurrentPath?: boolean };
|
||||
updateState: UpdateExplorerState;
|
||||
listDirectory: () => Promise<ExplorerDirectory>;
|
||||
fallbackError: string;
|
||||
}): Promise<DirectoryListingResult> {
|
||||
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
|
||||
const shouldRecordHistory = options?.recordHistory ?? shouldSetCurrentPath;
|
||||
const initial = updateState((state) => ({
|
||||
...state,
|
||||
isLoading: true,
|
||||
lastError: null,
|
||||
pendingRequest: { path, mode: "list" },
|
||||
...(shouldSetCurrentPath
|
||||
? {
|
||||
currentPath: path,
|
||||
history: shouldRecordHistory ? pushHistory(state.history, path) : state.history,
|
||||
lastVisitedPath: path,
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
if (!initial) return null;
|
||||
const generation = initial.listingGeneration;
|
||||
try {
|
||||
const directory = await listDirectory();
|
||||
let accepted = false;
|
||||
updateState((state) => {
|
||||
if (state.listingGeneration !== generation) return state;
|
||||
accepted = true;
|
||||
const directories = new Map(state.directories);
|
||||
directories.set(directory.path, directory);
|
||||
return { ...state, directories, isLoading: false, lastError: null, pendingRequest: null };
|
||||
});
|
||||
return accepted ? directory : DIRECTORY_LISTING_INVALIDATED;
|
||||
} catch (error) {
|
||||
let invalidated = false;
|
||||
updateState((state) => {
|
||||
if (state.listingGeneration !== generation) {
|
||||
invalidated = true;
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: error instanceof Error ? error.message : fallbackError,
|
||||
pendingRequest: null,
|
||||
};
|
||||
});
|
||||
return invalidated ? DIRECTORY_LISTING_INVALIDATED : null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh invalidates earlier replies as well as listings of collapsed folders. */
|
||||
export function invalidateExplorerDirectoryListings(
|
||||
updateState: UpdateExplorerState,
|
||||
retainedPaths: readonly string[],
|
||||
): void {
|
||||
const retained = new Set(retainedPaths);
|
||||
updateState((state) => ({
|
||||
...state,
|
||||
listingGeneration: state.listingGeneration + 1,
|
||||
directories: new Map([...state.directories].filter(([path]) => retained.has(path))),
|
||||
isLoading: false,
|
||||
pendingRequest: null,
|
||||
lastError: null,
|
||||
}));
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExplorerEntry } from "@/stores/session-store";
|
||||
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
|
||||
import {
|
||||
MAX_AUTO_EXPANDED_DIRECTORY_DEPTH,
|
||||
flattenExplorerTree,
|
||||
reconcileRestoredExpandedPaths,
|
||||
refreshExplorerDirectories,
|
||||
restoreExpandedDirectories,
|
||||
setExpandedDirectoryPath,
|
||||
showHiddenFilesAndRestoreExpandedDirectories,
|
||||
@@ -195,3 +196,76 @@ describe("file explorer tree", () => {
|
||||
await expect(restoration).resolves.toEqual([".", ".hidden"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshExplorerDirectories", () => {
|
||||
function makeListing(path: string, names: string[]): ExplorerDirectory {
|
||||
return {
|
||||
path,
|
||||
entries: names.map((name) => ({
|
||||
name,
|
||||
path: path === "." ? name : `${path}/${name}`,
|
||||
kind: "file" as const,
|
||||
size: 0,
|
||||
modifiedAt: "2026-01-01T00:00:00.000Z",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createExplorer(disk: Record<string, string[]>, cached: Record<string, string[]>) {
|
||||
const directories = new Map(
|
||||
Object.entries(cached).map(([path, names]) => [path, makeListing(path, names)]),
|
||||
);
|
||||
return {
|
||||
directories,
|
||||
requestDirectoryListing: async (path: string) => {
|
||||
const listing = makeListing(path, disk[path] ?? []);
|
||||
directories.set(path, listing);
|
||||
return listing;
|
||||
},
|
||||
invalidateDirectoryListings: (retainedPaths: readonly string[]) => {
|
||||
const retained = new Set(retainedPaths);
|
||||
for (const path of directories.keys()) if (!retained.has(path)) directories.delete(path);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("does not keep a stale listing for a collapsed folder", async () => {
|
||||
// docs/ was expanded and collapsed earlier, then NEW.md was created in it.
|
||||
const explorer = createExplorer(
|
||||
{ ".": ["docs"], docs: ["README.md", "NEW.md"] },
|
||||
{ ".": ["docs"], docs: ["README.md"] },
|
||||
);
|
||||
|
||||
await refreshExplorerDirectories({
|
||||
hasWorkspaceScope: true,
|
||||
expandedPaths: new Set(["."]),
|
||||
showHiddenFiles: false,
|
||||
requestDirectoryListing: explorer.requestDirectoryListing,
|
||||
invalidateDirectoryListings: explorer.invalidateDirectoryListings,
|
||||
});
|
||||
|
||||
// Expanding docs/ only fetches when it has no cached listing.
|
||||
expect(explorer.directories.has("docs")).toBe(false);
|
||||
expect(explorer.directories.get(".")?.entries.map((entry) => entry.name)).toEqual(["docs"]);
|
||||
});
|
||||
|
||||
it("refreshes expanded folders and keeps their listings", async () => {
|
||||
const explorer = createExplorer(
|
||||
{ ".": ["src"], src: ["a.ts", "b.ts"] },
|
||||
{ ".": ["src"], src: ["a.ts"] },
|
||||
);
|
||||
|
||||
await refreshExplorerDirectories({
|
||||
hasWorkspaceScope: true,
|
||||
expandedPaths: new Set([".", "src"]),
|
||||
showHiddenFiles: false,
|
||||
requestDirectoryListing: explorer.requestDirectoryListing,
|
||||
invalidateDirectoryListings: explorer.invalidateDirectoryListings,
|
||||
});
|
||||
|
||||
expect(explorer.directories.get("src")?.entries.map((entry) => entry.name)).toEqual([
|
||||
"a.ts",
|
||||
"b.ts",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
|
||||
import type { SortOption } from "@/stores/panel-store/state";
|
||||
import { DIRECTORY_LISTING_INVALIDATED, type DirectoryListingResult } from "./directory-listings";
|
||||
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "./visibility";
|
||||
|
||||
export const MAX_AUTO_EXPANDED_DIRECTORY_DEPTH = 5;
|
||||
@@ -16,11 +17,13 @@ interface FlattenExplorerTreeInput {
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
type RestoredExpandedPaths = string[] | typeof DIRECTORY_LISTING_INVALIDATED;
|
||||
|
||||
interface RestoreExpandedDirectoriesInput {
|
||||
rootDirectory: ExplorerDirectory;
|
||||
persistedExpandedPaths: ReadonlySet<string>;
|
||||
showHiddenFiles: boolean;
|
||||
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
|
||||
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>;
|
||||
}
|
||||
|
||||
interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
|
||||
@@ -33,7 +36,7 @@ interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
|
||||
interface ReconcileRestoredExpandedPathsInput {
|
||||
persistedExpandedPaths: ReadonlySet<string>;
|
||||
currentExpandedPaths: ReadonlySet<string>;
|
||||
restoredExpandedPaths: string[];
|
||||
restoredExpandedPaths: RestoredExpandedPaths;
|
||||
}
|
||||
|
||||
interface SetExpandedDirectoryPathInput {
|
||||
@@ -85,7 +88,7 @@ export async function restoreExpandedDirectories({
|
||||
persistedExpandedPaths,
|
||||
showHiddenFiles,
|
||||
requestDirectoryListing,
|
||||
}: RestoreExpandedDirectoriesInput): Promise<string[]> {
|
||||
}: RestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
|
||||
const restoredPaths = ["."];
|
||||
const restoredPathSet = new Set(restoredPaths);
|
||||
let parentDirectories = [rootDirectory];
|
||||
@@ -110,9 +113,11 @@ export async function restoreExpandedDirectories({
|
||||
const requestedDirectories = await Promise.all(
|
||||
pathsToRequest.map((path) => requestDirectoryListing(path)),
|
||||
);
|
||||
if (requestedDirectories.includes(DIRECTORY_LISTING_INVALIDATED))
|
||||
return DIRECTORY_LISTING_INVALIDATED;
|
||||
parentDirectories = [];
|
||||
for (const directory of requestedDirectories) {
|
||||
if (!directory) {
|
||||
if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) {
|
||||
continue;
|
||||
}
|
||||
restoredPaths.push(directory.path);
|
||||
@@ -128,7 +133,7 @@ export function showHiddenFilesAndRestoreExpandedDirectories({
|
||||
persistedExpandedPaths,
|
||||
showHiddenFiles,
|
||||
requestDirectoryListing,
|
||||
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<string[]> {
|
||||
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
|
||||
showHiddenFiles();
|
||||
return restoreExpandedDirectories({
|
||||
rootDirectory,
|
||||
@@ -143,6 +148,8 @@ export function reconcileRestoredExpandedPaths({
|
||||
currentExpandedPaths,
|
||||
restoredExpandedPaths,
|
||||
}: ReconcileRestoredExpandedPathsInput): string[] {
|
||||
if (restoredExpandedPaths === DIRECTORY_LISTING_INVALIDATED)
|
||||
return Array.from(currentExpandedPaths);
|
||||
const reconciledPaths = new Set(restoredExpandedPaths);
|
||||
|
||||
for (const path of persistedExpandedPaths) {
|
||||
@@ -205,7 +212,7 @@ function sortExplorerEntries(entries: ExplorerEntry[], sortOption: SortOption):
|
||||
/** Load and expand the path to a directory selected outside the tree. */
|
||||
export async function revealExplorerDirectory(input: {
|
||||
directoryPath: string;
|
||||
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
|
||||
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>;
|
||||
expandPaths: (paths: string[]) => void;
|
||||
showHiddenFiles: () => void;
|
||||
}): Promise<void> {
|
||||
@@ -218,7 +225,51 @@ export async function revealExplorerDirectory(input: {
|
||||
}
|
||||
}
|
||||
for (const path of paths) {
|
||||
if (!(await input.requestDirectoryListing(path))) return;
|
||||
const directory = await input.requestDirectoryListing(path);
|
||||
if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) return;
|
||||
}
|
||||
input.expandPaths(paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Files Refresh: re-lists the expanded directories (and the root), and drops the cached
|
||||
* listing of every other directory. Expanding a directory only fetches when it has no
|
||||
* cached listing, so keeping a collapsed directory's old listing would show it as it was
|
||||
* before the refresh.
|
||||
*/
|
||||
export async function refreshExplorerDirectories({
|
||||
hasWorkspaceScope,
|
||||
expandedPaths,
|
||||
showHiddenFiles,
|
||||
requestDirectoryListing,
|
||||
invalidateDirectoryListings,
|
||||
}: {
|
||||
hasWorkspaceScope: boolean;
|
||||
expandedPaths: Set<string>;
|
||||
showHiddenFiles: boolean;
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<DirectoryListingResult>;
|
||||
invalidateDirectoryListings: (retainedPaths: readonly string[]) => void;
|
||||
}): Promise<null> {
|
||||
if (!hasWorkspaceScope) {
|
||||
return null;
|
||||
}
|
||||
const directoryPaths = Array.from(expandedPaths).filter(
|
||||
(path) => showHiddenFiles || !isHiddenExplorerPath(path),
|
||||
);
|
||||
if (!directoryPaths.includes(".")) {
|
||||
directoryPaths.unshift(".");
|
||||
}
|
||||
invalidateDirectoryListings(directoryPaths);
|
||||
await Promise.all(
|
||||
directoryPaths.map((path) =>
|
||||
requestDirectoryListing(path, {
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -25,12 +25,17 @@ const iframeStyle = {
|
||||
|
||||
export function FileHtmlPreview({ html, testID }: { html: string; testID?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const document = useMemo(() => withPreviewCsp(html), [html]);
|
||||
// A document URL lets the browser resolve fragment links within the preview.
|
||||
// srcDoc would inherit the app URL and navigate the frame back into Paseo.
|
||||
const source = useMemo(
|
||||
() => `data:text/html;charset=utf-8,${encodeURIComponent(withPreviewCsp(html))}`,
|
||||
[html],
|
||||
);
|
||||
return (
|
||||
<iframe
|
||||
data-testid={testID}
|
||||
title={t("panels.file.filePanels.builtin.html")}
|
||||
srcDoc={document}
|
||||
src={source}
|
||||
sandbox={SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
style={iframeStyle}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useSessionStore,
|
||||
type AgentFileExplorerState,
|
||||
type ExplorerDirectory,
|
||||
} from "@/stores/session-store";
|
||||
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { revealExplorerDirectory } from "@/file-explorer/tree";
|
||||
import {
|
||||
loadExplorerDirectory,
|
||||
type DirectoryListingResult,
|
||||
invalidateExplorerDirectoryListings,
|
||||
} from "@/file-explorer/directory-listings";
|
||||
import { parentExplorerPath } from "@/utils/explorer-paths";
|
||||
|
||||
function createExplorerState(): AgentFileExplorerState {
|
||||
return {
|
||||
listingGeneration: 0,
|
||||
directories: new Map(),
|
||||
isLoading: false,
|
||||
lastError: null,
|
||||
@@ -22,15 +24,6 @@ function createExplorerState(): AgentFileExplorerState {
|
||||
};
|
||||
}
|
||||
|
||||
function pushHistory(history: string[], path: string): string[] {
|
||||
const normalizedHistory = history.length === 0 ? ["."] : history;
|
||||
const last = normalizedHistory[normalizedHistory.length - 1];
|
||||
if (last === path) {
|
||||
return normalizedHistory;
|
||||
}
|
||||
return [...normalizedHistory, path];
|
||||
}
|
||||
|
||||
export interface FileExplorerWorkspaceScope {
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot?: string | null;
|
||||
@@ -75,16 +68,21 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
);
|
||||
|
||||
const updateExplorerState = useCallback(
|
||||
(updater: (prev: AgentFileExplorerState) => AgentFileExplorerState) => {
|
||||
(
|
||||
updater: (prev: AgentFileExplorerState) => AgentFileExplorerState,
|
||||
): AgentFileExplorerState | null => {
|
||||
if (!workspaceStateKey) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
let updated: AgentFileExplorerState | null = null;
|
||||
setFileExplorer(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(workspaceStateKey) ?? createExplorerState();
|
||||
next.set(workspaceStateKey, updater(current));
|
||||
updated = updater(current);
|
||||
next.set(workspaceStateKey, updated);
|
||||
return next;
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
[serverId, setFileExplorer, workspaceStateKey],
|
||||
);
|
||||
@@ -93,80 +91,23 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
async (
|
||||
path: string,
|
||||
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
): Promise<ExplorerDirectory | null> => {
|
||||
): Promise<DirectoryListingResult> => {
|
||||
if (!workspaceStateKey) {
|
||||
return null;
|
||||
}
|
||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||
const shouldSetCurrentPath = options?.setCurrentPath ?? true;
|
||||
const shouldRecordHistory = options?.recordHistory ?? shouldSetCurrentPath;
|
||||
|
||||
updateExplorerState((state) => ({
|
||||
...state,
|
||||
isLoading: true,
|
||||
lastError: null,
|
||||
pendingRequest: { path: normalizedPath, mode: "list" },
|
||||
...(shouldSetCurrentPath
|
||||
? {
|
||||
currentPath: normalizedPath,
|
||||
history: shouldRecordHistory
|
||||
? pushHistory(state.history, normalizedPath)
|
||||
: state.history,
|
||||
lastVisitedPath: normalizedPath,
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
|
||||
if (!normalizedWorkspaceRoot) {
|
||||
updateExplorerState((state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: t("workspace.fileExplorer.states.unavailable"),
|
||||
pendingRequest: null,
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
updateExplorerState((state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: t("workspace.terminal.hostDisconnected"),
|
||||
pendingRequest: null,
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const directory = await client.listDirectory(normalizedWorkspaceRoot, normalizedPath);
|
||||
updateExplorerState((state) => {
|
||||
const nextState: AgentFileExplorerState = {
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError: null,
|
||||
pendingRequest: null,
|
||||
directories: state.directories,
|
||||
};
|
||||
|
||||
const directories = new Map(state.directories);
|
||||
directories.set(directory.path, directory);
|
||||
nextState.directories = directories;
|
||||
|
||||
return nextState;
|
||||
});
|
||||
return directory;
|
||||
} catch (error) {
|
||||
updateExplorerState((state) => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
lastError:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("workspace.fileExplorer.errors.failedToListDirectory"),
|
||||
pendingRequest: null,
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
return loadExplorerDirectory({
|
||||
path: normalizedPath,
|
||||
options,
|
||||
updateState: updateExplorerState,
|
||||
fallbackError: t("workspace.fileExplorer.errors.failedToListDirectory"),
|
||||
listDirectory: async () => {
|
||||
if (!normalizedWorkspaceRoot)
|
||||
throw new Error(t("workspace.fileExplorer.states.unavailable"));
|
||||
if (!client) throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
return client.listDirectory(normalizedWorkspaceRoot, normalizedPath);
|
||||
},
|
||||
});
|
||||
},
|
||||
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
|
||||
);
|
||||
@@ -308,10 +249,17 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
[updateExplorerState],
|
||||
);
|
||||
|
||||
const invalidateDirectoryListings = useCallback(
|
||||
(retainedPaths: readonly string[]) =>
|
||||
invalidateExplorerDirectoryListings(updateExplorerState, retainedPaths),
|
||||
[updateExplorerState],
|
||||
);
|
||||
|
||||
return {
|
||||
workspaceStateKey,
|
||||
revealDirectory,
|
||||
requestDirectoryListing,
|
||||
invalidateDirectoryListings,
|
||||
requestFileDownloadToken,
|
||||
requestFileBytes,
|
||||
createEntry,
|
||||
|
||||
@@ -260,6 +260,7 @@ interface ExplorerRequestState {
|
||||
}
|
||||
|
||||
export interface AgentFileExplorerState {
|
||||
listingGeneration: number;
|
||||
directories: Map<string, ExplorerDirectory>;
|
||||
isLoading: boolean;
|
||||
lastError: string | null;
|
||||
|
||||
@@ -13,6 +13,37 @@ describe("cron expression parser", () => {
|
||||
expect(cron.dayOfWeek.matches(0)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a step after a standalone number", () => {
|
||||
expect(validateCronExpression("0/15 * * * *")).toBe(
|
||||
"Invalid cron minute step: use * or a range before /",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["5/1 * * * *", "minute"],
|
||||
["0,5/15 * * * *", "minute"],
|
||||
["* 1/2 * * *", "hour"],
|
||||
["* * 1/2 * *", "day-of-month"],
|
||||
["* * * 1/2 *", "month"],
|
||||
["* * * * 1/2", "day-of-week"],
|
||||
])("rejects ignored numeric steps in %s", (expression, field) => {
|
||||
expect(validateCronExpression(expression)).toBe(
|
||||
`Invalid cron ${field} step: use * or a range before /`,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ expression: "*/15 * * * *", expected: [0, 15, 30, 45] },
|
||||
{ expression: "0-59/15 * * * *", expected: [0, 15, 30, 45] },
|
||||
{ expression: "5-59/15 * * * *", expected: [5, 20, 35, 50] },
|
||||
])("preserves supported steps in $expression", ({ expression, expected }) => {
|
||||
const cron = parseCronExpression(expression);
|
||||
const minutes = Array.from({ length: 60 }, (_, minute) => minute);
|
||||
const matches = minutes.filter((minute) => cron.minute.matches(minute));
|
||||
|
||||
expect(matches).toEqual(expected);
|
||||
});
|
||||
|
||||
it("reports invalid expressions with server-facing copy", () => {
|
||||
expect(validateCronExpression("* * *")).toBe("Cron expressions must have 5 fields");
|
||||
expect(validateCronExpression("*/5/2 * * * *")).toBe("Invalid cron minute step");
|
||||
|
||||
@@ -90,6 +90,9 @@ function parseField(source: string, bounds: CronFieldBounds): CronFieldMatcher {
|
||||
if (!/^\d+$/.test(base)) {
|
||||
throw new Error(`Invalid cron ${bounds.name} value`);
|
||||
}
|
||||
// COMPAT(cronNumericSteps): added in v0.12.0, remove after 2027-04-17 once
|
||||
// persisted numeric steps are migrated. Validation rejects new numeric steps;
|
||||
// existing schedules retain their historical singleton match.
|
||||
const value = Number.parseInt(base, 10);
|
||||
if (!Number.isInteger(value) || value < bounds.min || value > bounds.max) {
|
||||
throw new Error(`Invalid cron ${bounds.name} value`);
|
||||
@@ -122,6 +125,12 @@ export function parseCronExpression(expression: string): ParsedCronExpression {
|
||||
export function validateCronExpression(expression: string): string | null {
|
||||
try {
|
||||
parseCronExpression(expression);
|
||||
const fields = expression.trim().split(/\s+/);
|
||||
for (const [index, field] of fields.entries()) {
|
||||
if (field.split(",").some((part) => /^\d+\//.test(part))) {
|
||||
return `Invalid cron ${CRON_FIELD_BOUNDS[index].name} step: use * or a range before /`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : "Invalid cron expression";
|
||||
|
||||
@@ -7317,6 +7317,7 @@ test("ignores stale autonomous terminals without lowering the active turn lifecy
|
||||
const running = manager.getAgent(snapshot.id);
|
||||
expect(running?.lifecycle).toBe("running");
|
||||
expect(running ? toAgentPayload(running).activeTurn?.turnId : null).toBe("turn-b");
|
||||
expect(running?.lastError).toBeUndefined();
|
||||
});
|
||||
|
||||
const active = manager.getAgent(snapshot.id)!;
|
||||
@@ -7359,7 +7360,7 @@ test("ignores stale autonomous terminals without lowering the active turn lifecy
|
||||
const stillRunning = manager.getAgent(snapshot.id);
|
||||
expect(stillRunning?.lifecycle).toBe("running");
|
||||
expect(stillRunning ? toAgentPayload(stillRunning).activeTurn?.turnId : null).toBe("turn-b");
|
||||
expect(stillRunning?.lastError).toBe("turn-b marker");
|
||||
expect(stillRunning?.lastError).toBeUndefined();
|
||||
expect(stillRunning?.lastUsage).toEqual({ inputTokens: 7 });
|
||||
expect(stillRunning?.pendingPermissions.has("turn-b-permission")).toBe(true);
|
||||
expect(manager.getTimeline(snapshot.id)).toEqual(timelineBeforeStaleTerminals);
|
||||
@@ -8683,6 +8684,118 @@ test("clearAgentAttention on errored agent stays cleared until a new error trans
|
||||
expect(persistedAfterSecondFailure?.attentionReason).toBe("error");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["autonomous", "turn_completed", "idle", "finished", undefined],
|
||||
["autonomous", "turn_failed", "error", "error", "Retry failed."],
|
||||
["foreground", "turn_completed", "idle", "finished", undefined],
|
||||
["foreground", "turn_failed", "error", "error", "Retry failed."],
|
||||
] as const)(
|
||||
"%s recovery clears error attention before %s",
|
||||
async (mode, terminal, status, expectedReason, lastError) => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-recovery-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
class RecoverySession extends TestAgentSession {
|
||||
override async startTurn(): Promise<{ turnId: string }> {
|
||||
return { turnId: "recovered-turn" };
|
||||
}
|
||||
}
|
||||
|
||||
class RecoveryClient extends TestAgentClient {
|
||||
readonly session = new RecoverySession({ provider: "codex", cwd: workdir });
|
||||
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
|
||||
const client = new RecoveryClient();
|
||||
const attentionReasons: Array<"finished" | "error" | "permission"> = [];
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
onAgentAttention: ({ reason }) => attentionReasons.push(reason),
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
const running = waitForAgentLifecycle(manager, agent.id, "running");
|
||||
client.session.pushEvent({ type: "turn_started", provider: "codex", turnId: "failed-turn" });
|
||||
await running;
|
||||
const failed = waitForAgentLifecycle(manager, agent.id, "error");
|
||||
client.session.pushEvent({
|
||||
type: "turn_failed",
|
||||
provider: "codex",
|
||||
turnId: "failed-turn",
|
||||
error: "Selected model is at capacity.",
|
||||
});
|
||||
await failed;
|
||||
await vi.waitFor(() => expect(manager.hasInFlightRun(agent.id)).toBe(false));
|
||||
expect(toAgentPayload(manager.getAgent(agent.id)!)).toMatchObject({
|
||||
status: "error",
|
||||
attentionReason: "error",
|
||||
lastError: "Selected model is at capacity.",
|
||||
});
|
||||
|
||||
const recovered = waitForAgentLifecycle(manager, agent.id, "running");
|
||||
const stream =
|
||||
mode === "foreground"
|
||||
? drainAsyncGenerator(manager.streamAgent(agent.id, "Continue after capacity recovers."))
|
||||
: null;
|
||||
if (mode === "autonomous") {
|
||||
client.session.pushEvent({
|
||||
type: "turn_started",
|
||||
provider: "codex",
|
||||
turnId: "recovered-turn",
|
||||
});
|
||||
}
|
||||
await recovered;
|
||||
await manager.flush();
|
||||
expect(toAgentPayload(manager.getAgent(agent.id)!)).toMatchObject({
|
||||
status: "running",
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
});
|
||||
expect(manager.getAgent(agent.id)?.lastError).toBeUndefined();
|
||||
expect(await storage.get(agent.id)).toMatchObject({
|
||||
lastStatus: "running",
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
});
|
||||
expect((await storage.get(agent.id))?.lastError).toBeUndefined();
|
||||
|
||||
const settled = waitForAgentLifecycle(manager, agent.id, status);
|
||||
client.session.pushEvent({
|
||||
type: terminal,
|
||||
provider: "codex",
|
||||
turnId: "recovered-turn",
|
||||
...(terminal === "turn_failed" ? { error: "Retry failed." } : {}),
|
||||
});
|
||||
await settled;
|
||||
await stream;
|
||||
await manager.flush();
|
||||
expect(toAgentPayload(manager.getAgent(agent.id)!)).toMatchObject({
|
||||
status,
|
||||
requiresAttention: true,
|
||||
attentionReason: expectedReason,
|
||||
});
|
||||
expect(manager.getAgent(agent.id)?.lastError).toBe(lastError);
|
||||
expect(await storage.get(agent.id)).toMatchObject({
|
||||
lastStatus: status,
|
||||
requiresAttention: true,
|
||||
attentionReason: expectedReason,
|
||||
});
|
||||
expect((await storage.get(agent.id))?.lastError).toBe(lastError);
|
||||
expect(attentionReasons).toEqual(["error", expectedReason]);
|
||||
},
|
||||
);
|
||||
|
||||
test("streamAgent clears pending run when startTurn fails before a turn id exists", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-start-turn-failure-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -2840,6 +2840,7 @@ export class AgentManager {
|
||||
this.runs.settleForegroundStart(pendingRun, { status: "started", turnId });
|
||||
agent.activeForegroundTurnId = turnId;
|
||||
this.openActiveTurn(agent, turnId, turnStartedAt);
|
||||
agent.attention = { requiresAttention: false };
|
||||
agent.lifecycle = "running";
|
||||
this.touchUpdatedAt(agent);
|
||||
// AgentManager owns the accepted-turn boundary. Publish liveness before the canonical
|
||||
@@ -4952,6 +4953,8 @@ export class AgentManager {
|
||||
if (eventTurnId) {
|
||||
this.openActiveTurn(agent, eventTurnId, new Date());
|
||||
}
|
||||
agent.lastError = undefined;
|
||||
agent.attention = { requiresAttention: false };
|
||||
agent.lifecycle = "running";
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
@@ -399,14 +399,16 @@ function remoteObservation(client: DaemonClient): HostObservation {
|
||||
// A new turn may start between the snapshot and the current-response read.
|
||||
// Bound that wait and refresh state instead of attributing the old event to it.
|
||||
const current = await client.waitForFinish(agentId, 100);
|
||||
if (current.status === "timeout")
|
||||
snapshot = (await client.fetchAgent(agentId))?.agent ?? null;
|
||||
else {
|
||||
if (!current.final && current.error && current.error !== `Agent not found: ${agentId}`)
|
||||
throw new Error(current.error);
|
||||
snapshot = current.final;
|
||||
lastMessage = current.lastMessage;
|
||||
if (current.status === "timeout") {
|
||||
// Queued delivery can leave a closed/error snapshot visible before the new turn.
|
||||
// A timed-out wait has not established an outcome for that queued work.
|
||||
if (event?.type === "permission_resolved") handler(snapshot, event);
|
||||
return;
|
||||
}
|
||||
if (!current.final && current.error && current.error !== `Agent not found: ${agentId}`)
|
||||
throw new Error(current.error);
|
||||
snapshot = current.final;
|
||||
lastMessage = current.lastMessage;
|
||||
}
|
||||
handler(snapshot, event, lastMessage);
|
||||
};
|
||||
|
||||
@@ -404,6 +404,7 @@ describe("Suite A: Core Fixes", () => {
|
||||
expect(parseAgentMessage(callerPrompts[0]).source).toEqual({
|
||||
kind: "agent-notification",
|
||||
agentId: childId,
|
||||
serverId: daemon.daemon.getServerId(),
|
||||
title: "Notification child",
|
||||
event: "finished",
|
||||
});
|
||||
@@ -517,6 +518,7 @@ describe("Suite A: Core Fixes", () => {
|
||||
expect(notification.source).toEqual({
|
||||
kind: "agent-notification",
|
||||
agentId: childId,
|
||||
serverId: daemon.daemon.getServerId(),
|
||||
title: "Watched child",
|
||||
event: "errored",
|
||||
});
|
||||
@@ -579,7 +581,11 @@ describe("Suite A: Core Fixes", () => {
|
||||
type: "tool_call",
|
||||
agentMessage: {
|
||||
event: outcome,
|
||||
sender: { id: childId, title: "Follow-up child" },
|
||||
sender: {
|
||||
id: childId,
|
||||
serverId: daemonHandle.daemon.getServerId(),
|
||||
title: "Follow-up child",
|
||||
},
|
||||
text: expect.stringContaining(`(Follow-up child) ${outcome}.`),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -488,58 +488,6 @@ async function pluginMessages(session: Awaited<ReturnType<AgentClient["createSes
|
||||
);
|
||||
}
|
||||
|
||||
test("plugin rewind discards child outcomes from the withdrawn branch", async () => {
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
async handleInput(input, emit) {
|
||||
if (input.type !== "session.open" || input.history !== "replay") return false;
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: CAPABILITIES,
|
||||
restoration: "core",
|
||||
persistence: input.persistence,
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
emit({
|
||||
type: "session.opened",
|
||||
sessionId: "rewound-child",
|
||||
parentSessionId: input.sessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
emit({ type: "session.closed", sessionId: "rewound-child" });
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
const open = harness.inputs.find((input) => input.type === "session.open")!;
|
||||
harness.emit({
|
||||
type: "session.opened",
|
||||
sessionId: "rewound-child",
|
||||
parentSessionId: open.sessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: tmpdir(),
|
||||
});
|
||||
harness.emit({
|
||||
type: "session.turn",
|
||||
sessionId: "rewound-child",
|
||||
turnId: "withdrawn-turn",
|
||||
state: "failed",
|
||||
});
|
||||
await session.revertConversation!({ messageId: "user-B" });
|
||||
const history = await Array.fromAsync(session.streamHistory());
|
||||
expect(history.findLast((event) => event.type === "provider_subagent")).toMatchObject({
|
||||
event: { type: "upsert", id: "rewound-child", status: "completed" },
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("combined plugin rewind replays the retained native branch", async () => {
|
||||
await withPluginRewindSession({}, async (session) => {
|
||||
await session.revertBoth!({ messageId: "user-B" });
|
||||
@@ -1286,11 +1234,6 @@ describe("PluginAgentClientRegistry", () => {
|
||||
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
|
||||
{ id: child.id, title: child.title, status: outcome },
|
||||
]);
|
||||
harness.emit({ type: "session.closed", sessionId: "reusable-child" });
|
||||
await nextTurn();
|
||||
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
|
||||
{ id: child.id, title: child.title, status: outcome },
|
||||
]);
|
||||
} finally {
|
||||
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
|
||||
await plugins.shutdown();
|
||||
@@ -1325,7 +1268,6 @@ describe("PluginAgentClientRegistry", () => {
|
||||
turnId: "second-turn",
|
||||
state,
|
||||
});
|
||||
emit({ type: "session.closed", sessionId: "reusable-child" });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1352,100 +1294,6 @@ describe("PluginAgentClientRegistry", () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
test("keeps a child's canceled or failed turn when the child closes normally", async () => {
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: "/workspace",
|
||||
});
|
||||
try {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
const open = harness.inputs.find((input) => input.type === "session.open")!;
|
||||
const outcomes = [
|
||||
["child-canceled", "canceled"],
|
||||
["child-failed", "failed"],
|
||||
["child-completed", "completed"],
|
||||
] as const;
|
||||
for (const [sessionId, state] of outcomes) {
|
||||
harness.emit({
|
||||
type: "session.opened",
|
||||
sessionId,
|
||||
parentSessionId: open.sessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: "/workspace",
|
||||
});
|
||||
harness.emit({ type: "session.turn", sessionId, turnId: "turn", state: "started" });
|
||||
harness.emit({ type: "session.turn", sessionId, turnId: "turn", state });
|
||||
harness.emit({ type: "session.closed", sessionId });
|
||||
}
|
||||
|
||||
const lastStatus = (id: string) =>
|
||||
events
|
||||
.flatMap((event) =>
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "upsert" &&
|
||||
event.event.id === id
|
||||
? [event.event.status]
|
||||
: [],
|
||||
)
|
||||
.at(-1);
|
||||
expect(lastStatus("child-canceled")).toBe("canceled");
|
||||
expect(lastStatus("child-failed")).toBe("failed");
|
||||
expect(lastStatus("child-completed")).toBe("completed");
|
||||
} finally {
|
||||
await session.close();
|
||||
registry.replace([]);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ turns: [], error: false, expected: "completed" },
|
||||
{ turns: ["canceled", "started"], error: false, expected: "completed" },
|
||||
{ turns: ["failed", "started", "completed"], error: false, expected: "completed" },
|
||||
{ turns: ["canceled"], error: true, expected: "failed" },
|
||||
] as const)(
|
||||
"closes a plugin child using its latest turn: $turns, error=$error",
|
||||
async ({ turns, error, expected }) => {
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
try {
|
||||
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: tmpdir(),
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
const open = harness.inputs.find((input) => input.type === "session.open")!;
|
||||
harness.emit({
|
||||
type: "session.opened",
|
||||
sessionId: "close-control",
|
||||
parentSessionId: open.sessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: tmpdir(),
|
||||
});
|
||||
for (const state of turns) {
|
||||
harness.emit({ type: "session.turn", sessionId: "close-control", turnId: "turn", state });
|
||||
}
|
||||
harness.emit({
|
||||
type: "session.closed",
|
||||
sessionId: "close-control",
|
||||
...(error ? { error: { message: "Provider failed to close" } } : {}),
|
||||
});
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: "provider_subagent",
|
||||
event: { type: "upsert", id: "close-control", status: expected },
|
||||
});
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("gates persistence operations on negotiated provider capabilities", async () => {
|
||||
const harness = createProviderHarness({ capabilities: ["session.persistence"] });
|
||||
|
||||
@@ -1200,8 +1200,6 @@ class PluginAgentSession implements AgentSession {
|
||||
{ session: ProviderRuntimeSession; unsubscribe: () => void }
|
||||
>();
|
||||
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
|
||||
// Outcome of each child's latest finished turn, so a plain close does not report it as completed.
|
||||
private readonly childTurnOutcomes = new Map<string, "completed" | "canceled" | "failed">();
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private currentTurnId: string | null = null;
|
||||
private closed = false;
|
||||
@@ -1674,11 +1672,6 @@ class PluginAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
if (event.type === "session.turn") {
|
||||
if (event.state === "started") {
|
||||
this.childTurnOutcomes.delete(childId);
|
||||
} else {
|
||||
this.childTurnOutcomes.set(childId, event.state);
|
||||
}
|
||||
this.publish({
|
||||
type: "provider_subagent",
|
||||
provider: this.provider,
|
||||
@@ -1697,10 +1690,7 @@ class PluginAgentSession implements AgentSession {
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: childId,
|
||||
status:
|
||||
event.type === "session.runtime_failed" || event.error
|
||||
? "failed"
|
||||
: (this.childTurnOutcomes.get(childId) ?? "completed"),
|
||||
status: event.type === "session.runtime_failed" || event.error ? "failed" : "completed",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1718,7 +1708,6 @@ class PluginAgentSession implements AgentSession {
|
||||
this.history.clear();
|
||||
this.timelineSnapshots.clear();
|
||||
this.childSnapshots.clear();
|
||||
this.childTurnOutcomes.clear();
|
||||
this.revertTokens.clear();
|
||||
this.pendingPermissions.clear();
|
||||
this.permissionResponses.clear();
|
||||
|
||||
@@ -643,6 +643,7 @@ interface PendingPermission {
|
||||
resolve: (response: RequestPermissionResponse) => void;
|
||||
reject: (error: Error) => void;
|
||||
turnId: string | null;
|
||||
toolCallId: string;
|
||||
}
|
||||
|
||||
interface PendingUserMessage {
|
||||
@@ -2644,6 +2645,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
resolve,
|
||||
reject,
|
||||
turnId: this.activeForegroundTurnId,
|
||||
toolCallId: params.toolCall.toolCallId,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3207,6 +3209,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
snapshot = this.toolSnapshotTransformer(snapshot);
|
||||
}
|
||||
this.toolCalls.set(toolCallId, snapshot);
|
||||
if (update.status === "completed" || update.status === "failed") {
|
||||
this.cancelPendingPermissions((pending) => pending.toolCallId === toolCallId);
|
||||
}
|
||||
return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))];
|
||||
}
|
||||
|
||||
@@ -3414,11 +3419,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
|
||||
): void {
|
||||
this.deliverTranslatedEvents(this.flushPendingUserMessage());
|
||||
// A turn that ends without completing leaves no way to answer its open
|
||||
// permission requests, so tell the agent they were cancelled.
|
||||
if (event.type !== "turn_completed") {
|
||||
this.cancelPendingPermissions();
|
||||
}
|
||||
// Failed and canceled turns clear every request in the manager. Answer the
|
||||
// same set here so an outside-turn request cannot keep the provider blocked.
|
||||
this.cancelPendingPermissions(
|
||||
(pending) => event.type !== "turn_completed" || pending.turnId === event.turnId,
|
||||
);
|
||||
this.activeForegroundTurnId = null;
|
||||
this.fallbackAssistantMessageId = null;
|
||||
if (this.submittedUserMessageTurnId === event.turnId) {
|
||||
@@ -3427,11 +3432,21 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.pushEvent(event);
|
||||
}
|
||||
|
||||
private cancelPendingPermissions(): void {
|
||||
for (const pending of this.pendingPermissions.values()) {
|
||||
private cancelPendingPermissions(
|
||||
matches: (pending: PendingPermission) => boolean = () => true,
|
||||
): void {
|
||||
for (const [requestId, pending] of this.pendingPermissions) {
|
||||
if (!matches(pending)) continue;
|
||||
this.pendingPermissions.delete(requestId);
|
||||
pending.resolve({ outcome: { outcome: "cancelled" } });
|
||||
this.pushEvent({
|
||||
type: "permission_resolved",
|
||||
provider: this.provider,
|
||||
requestId,
|
||||
resolution: { behavior: "deny", message: "Permission request canceled" },
|
||||
turnId: pending.turnId ?? undefined,
|
||||
});
|
||||
}
|
||||
this.pendingPermissions.clear();
|
||||
}
|
||||
|
||||
private emitBootstrapThreadEvent(): void {
|
||||
|
||||
@@ -227,6 +227,57 @@ function deferred<T>() {
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("Codex file-change approvals", () => {
|
||||
test("file-change approval includes the matching patch when notifications are still queued", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const { session } = await startPublicSteeringSession(appServer);
|
||||
try {
|
||||
for (const [id, filePath, diff] of [
|
||||
["file-alpha", "/workspace/project/alpha.txt", "@@ -1 +1 @@\n-blue\n+green\n"],
|
||||
["file-beta", "/workspace/project/beta.txt", "@@ -1 +1 @@\n-small\n+large\n"],
|
||||
]) {
|
||||
appServer.startsFileChange({
|
||||
threadId: "thread-1",
|
||||
turnId: "native-A",
|
||||
callId: id,
|
||||
path: filePath,
|
||||
diff,
|
||||
});
|
||||
}
|
||||
const permission = waitForNextPermission(session);
|
||||
appServer.requestFileChangeApproval({
|
||||
itemId: "file-alpha",
|
||||
threadId: "thread-1",
|
||||
turnId: "native-A",
|
||||
reason: "Apply the patch",
|
||||
});
|
||||
|
||||
expect((await permission).request).toEqual({
|
||||
id: "permission-file-alpha",
|
||||
provider: "codex",
|
||||
name: "CodexFileChange",
|
||||
kind: "tool",
|
||||
title: "Apply file changes: alpha.txt",
|
||||
description: "Apply the patch",
|
||||
detail: {
|
||||
type: "edit",
|
||||
filePath: "alpha.txt",
|
||||
unifiedDiff: "@@ -1 +1 @@\n-blue\n+green\n",
|
||||
},
|
||||
metadata: { itemId: "file-alpha", threadId: "thread-1", turnId: "native-A" },
|
||||
});
|
||||
|
||||
await session.respondToPermission("permission-file-alpha", { behavior: "allow" });
|
||||
await expect(appServer.waitForApprovalDecision("file-alpha")).resolves.toEqual({
|
||||
decision: "accept",
|
||||
});
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex active-turn steering admission", () => {
|
||||
test("steers an accepted turn whose native id arrives after startTurn returns", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
|
||||
@@ -3483,6 +3483,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private pendingReasoning = new Map<string, string[]>();
|
||||
private pendingCommandOutputDeltas = new Map<string, string[]>();
|
||||
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
||||
private pendingToolCallDetails = new Map<string, ToolCallTimelineItem["detail"]>();
|
||||
private pendingAssistantMessageBoundary = false;
|
||||
private terminalCommandByProcessId = new Map<string, string>();
|
||||
// The root thread's still-running command process ids, keyed by the native turn that
|
||||
@@ -5251,6 +5252,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.clearPendingPermissions();
|
||||
this.pendingToolCallDetails.clear();
|
||||
this.pendingSubAgentNotificationsByThreadId.clear();
|
||||
this.subscribers.clear();
|
||||
this.startupEvents = null;
|
||||
@@ -6416,6 +6418,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.pendingReasoning.clear();
|
||||
this.pendingCommandOutputDeltas.clear();
|
||||
this.pendingFileChangeOutputDeltas.clear();
|
||||
this.pendingToolCallDetails.clear();
|
||||
this.pendingAssistantMessageBoundary = false;
|
||||
this.warnedIncompleteEditToolCallIds.clear();
|
||||
this.pendingRootCompactionItemIds.clear();
|
||||
@@ -6789,6 +6792,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
||||
return;
|
||||
}
|
||||
if (parsed.item.id) {
|
||||
this.pendingToolCallDetails.delete(parsed.item.id);
|
||||
}
|
||||
this.receiveAsyncQuestion(parsed.threadId, parsed.item);
|
||||
if (this.isUserMessageItem(parsed.item)) {
|
||||
await this.handleUserMessageItem(parsed);
|
||||
@@ -6836,10 +6842,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (timelineItem.type === "assistant_message") {
|
||||
this.pendingAssistantMessageBoundary = true;
|
||||
}
|
||||
if (itemId) {
|
||||
this.emittedItemCompletedIds.add(itemId);
|
||||
this.emittedItemStartedIds.delete(itemId);
|
||||
}
|
||||
this.emittedItemCompletedIds.add(itemId);
|
||||
this.emittedItemStartedIds.delete(itemId);
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
return;
|
||||
}
|
||||
@@ -6875,7 +6879,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private consumeStreamedTextCompletion(
|
||||
timelineItem: AgentTimelineItem,
|
||||
itemId: string | null | undefined,
|
||||
): boolean {
|
||||
): itemId is string {
|
||||
if (!itemId) {
|
||||
return false;
|
||||
}
|
||||
@@ -6984,6 +6988,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (!timelineItem || timelineItem.type !== "tool_call") {
|
||||
return;
|
||||
}
|
||||
this.pendingToolCallDetails.set(timelineItem.callId, timelineItem.detail);
|
||||
const registeredChildThreadIds = this.registerSubAgentToolCall({
|
||||
timelineItem,
|
||||
rawItem: parsed.item,
|
||||
@@ -7253,7 +7258,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
private handleFileChangeApprovalRequest(params: unknown): Promise<unknown> {
|
||||
private async handleFileChangeApprovalRequest(params: unknown): Promise<unknown> {
|
||||
await this.pendingNotifications;
|
||||
const parsed = z
|
||||
.object({
|
||||
itemId: z.string(),
|
||||
@@ -7263,20 +7269,25 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
})
|
||||
.parse(params);
|
||||
const requestId = `permission-${parsed.itemId}`;
|
||||
const detail: ToolCallTimelineItem["detail"] = this.pendingToolCallDetails.get(
|
||||
parsed.itemId,
|
||||
) ?? {
|
||||
type: "unknown",
|
||||
input: { reason: parsed.reason ?? null },
|
||||
output: null,
|
||||
};
|
||||
const title =
|
||||
detail.type === "edit" && detail.filePath
|
||||
? `Apply file changes: ${detail.filePath}`
|
||||
: "Apply file changes";
|
||||
const request: AgentPermissionRequest = {
|
||||
id: requestId,
|
||||
provider: CODEX_PROVIDER,
|
||||
name: "CodexFileChange",
|
||||
kind: "tool",
|
||||
title: "Apply file changes",
|
||||
title,
|
||||
description: parsed.reason ?? undefined,
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: {
|
||||
reason: parsed.reason ?? null,
|
||||
},
|
||||
output: null,
|
||||
},
|
||||
detail,
|
||||
metadata: {
|
||||
itemId: parsed.itemId,
|
||||
threadId: parsed.threadId,
|
||||
|
||||
@@ -43,6 +43,13 @@ interface FakeTerminalInput {
|
||||
processId: string;
|
||||
text: string;
|
||||
}
|
||||
interface FakeFileChange {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
callId: string;
|
||||
path: string;
|
||||
diff: string;
|
||||
}
|
||||
interface FakeLegacyPatch {
|
||||
threadId: string;
|
||||
callId: string;
|
||||
@@ -88,6 +95,7 @@ export interface FakeCodexAppServer {
|
||||
completesSubAgentActivity(params: FakeSubAgentActivity): void;
|
||||
completesCompaction(params: { threadId: string; itemId: string }): void;
|
||||
runsLegacyCommand(params: FakeLegacyCommand): void;
|
||||
startsFileChange(params: FakeFileChange): void;
|
||||
appliesLegacyPatch(params: FakeLegacyPatch): void;
|
||||
completesCommand(params: FakeLegacyCommand): void;
|
||||
completesSilentCommand(params: FakeSilentCommand): void;
|
||||
@@ -467,6 +475,20 @@ export function createFakeCodexAppServer(
|
||||
success: true,
|
||||
});
|
||||
},
|
||||
startsFileChange(params) {
|
||||
writeNotification("item/started", {
|
||||
threadId: params.threadId,
|
||||
turnId: params.turnId,
|
||||
item: {
|
||||
type: "fileChange",
|
||||
id: params.callId,
|
||||
status: "inProgress",
|
||||
changes: [
|
||||
{ path: params.path, kind: { type: "update", move_path: null }, diff: params.diff },
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
appliesLegacyPatch(params) {
|
||||
const changes = [
|
||||
{
|
||||
|
||||
+156
-22
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import type { AgentSession, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
|
||||
@@ -12,6 +13,7 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
|
||||
test.each([
|
||||
{ ending: "fail", terminalEvent: "turn_failed" },
|
||||
{ ending: "cancel", terminalEvent: "turn_canceled" },
|
||||
{ ending: "end_turn", terminalEvent: "turn_completed" },
|
||||
] as const)(
|
||||
"answers a permission request left open by a $terminalEvent turn with cancelled",
|
||||
async ({ ending, terminalEvent }) => {
|
||||
@@ -25,6 +27,16 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
|
||||
await waitForEvent(events, terminalEvent);
|
||||
expect(hasEvent(events, "permission_requested")).toBe(true);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
const requested = permissionRequests(events)[0]!;
|
||||
expect(resolvedEvents(events)).toEqual([
|
||||
{
|
||||
type: "permission_resolved",
|
||||
provider: "acp",
|
||||
requestId: requested.request.id,
|
||||
resolution: { behavior: "deny", message: "Permission request canceled" },
|
||||
turnId: requested.turnId,
|
||||
},
|
||||
]);
|
||||
|
||||
const replyStart = events.length;
|
||||
await session.startTurn("turn two");
|
||||
@@ -39,6 +51,84 @@ describe("GenericACPAgentClient permission requests when a turn ends early", ()
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["completed", "failed"] as const)(
|
||||
"cancels only the matching permission when its tool becomes %s",
|
||||
async (status) => {
|
||||
await withFakeACPAgent(status, async (command, cwd) => {
|
||||
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
|
||||
const session = await client.createSession({ provider: "acp", cwd });
|
||||
try {
|
||||
const events = collectEvents(session);
|
||||
await session.startTurn("turn one");
|
||||
await waitForToolUpdate(events);
|
||||
expect(pendingToolIds(session)).toEqual(["tool-2"]);
|
||||
expect(resolvedEvents(events)).toEqual([
|
||||
expect.objectContaining({ requestId: permissionRequests(events)[0]!.request.id }),
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("keeps a permission raised outside a turn answerable after another turn completes", async () => {
|
||||
await withFakeACPAgent("outside_turn", async (command, cwd) => {
|
||||
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
|
||||
const session = await client.createSession({ provider: "acp", cwd });
|
||||
try {
|
||||
const outside = session.getPendingPermissions();
|
||||
expect(pendingToolIds(session)).toEqual(["tool-outside"]);
|
||||
const events = collectEvents(session);
|
||||
await session.startTurn("turn one");
|
||||
await waitForEvent(events, "turn_completed");
|
||||
expect(session.getPendingPermissions()).toEqual(outside);
|
||||
await session.respondToPermission(outside[0]!.id, { behavior: "allow" });
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["fail", "cancel"] as const)(
|
||||
"settles an outside-turn permission that the manager clears when a turn ends with %s",
|
||||
async (ending) => {
|
||||
await withFakeACPAgent(`outside_${ending}`, async (command, cwd) => {
|
||||
const logger = createTestLogger();
|
||||
const client = new GenericACPAgentClient({
|
||||
logger,
|
||||
command,
|
||||
waitForInitialCommands: false,
|
||||
});
|
||||
const manager = new AgentManager({ clients: { acp: client }, logger });
|
||||
const agent = await manager.createAgent({ provider: "acp", cwd }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
try {
|
||||
await waitForManagedPermission(manager, agent.id);
|
||||
if (ending === "fail") {
|
||||
await expect(manager.runAgent(agent.id, "turn one")).rejects.toThrow(
|
||||
"turn failed with a permission open",
|
||||
);
|
||||
} else {
|
||||
await expect(manager.runAgent(agent.id, "turn one")).resolves.toMatchObject({
|
||||
canceled: true,
|
||||
});
|
||||
}
|
||||
expect(manager.getPendingPermissions(agent.id)).toEqual([]);
|
||||
const result = await manager.runAgent(agent.id, "turn two");
|
||||
expect(result.finalText).toBe(
|
||||
'perm-outside answered: {"outcome":{"outcome":"cancelled"}}',
|
||||
);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function collectEvents(session: AgentSession): AgentStreamEvent[] {
|
||||
@@ -47,6 +137,10 @@ function collectEvents(session: AgentSession): AgentStreamEvent[] {
|
||||
return events;
|
||||
}
|
||||
|
||||
async function waitForManagedPermission(manager: AgentManager, agentId: string): Promise<void> {
|
||||
await expect.poll(() => manager.getPendingPermissions(agentId).length).toBe(1);
|
||||
}
|
||||
|
||||
function hasEvent(
|
||||
events: AgentStreamEvent[],
|
||||
type: AgentStreamEvent["type"],
|
||||
@@ -77,8 +171,32 @@ function assistantText(events: AgentStreamEvent[]): string {
|
||||
.join("");
|
||||
}
|
||||
|
||||
function permissionRequests(events: AgentStreamEvent[]) {
|
||||
return events.filter((event) => event.type === "permission_requested");
|
||||
}
|
||||
|
||||
function resolvedEvents(events: AgentStreamEvent[]) {
|
||||
return events.filter((event) => event.type === "permission_resolved");
|
||||
}
|
||||
|
||||
function pendingToolIds(session: AgentSession) {
|
||||
return session.getPendingPermissions().map((request) => request.metadata?.toolCallId);
|
||||
}
|
||||
|
||||
async function waitForToolUpdate(events: AgentStreamEvent[]): Promise<void> {
|
||||
await expect.poll(() => assistantText(events)).toBe("tool terminal");
|
||||
}
|
||||
|
||||
async function withFakeACPAgent(
|
||||
ending: "fail" | "cancel",
|
||||
ending:
|
||||
| "fail"
|
||||
| "cancel"
|
||||
| "end_turn"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "outside_turn"
|
||||
| "outside_fail"
|
||||
| "outside_cancel",
|
||||
run: (command: [string, ...string[]], cwd: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-permission-turn-failure-"));
|
||||
@@ -91,8 +209,7 @@ async function withFakeACPAgent(
|
||||
}
|
||||
}
|
||||
|
||||
// Turn one asks for permission, then fails or is cancelled while the request is
|
||||
// still open.
|
||||
// Turn one ends while its permission request is still open.
|
||||
// Later turns report whether that request was ever answered.
|
||||
const fakeACPAgentScript = `
|
||||
const readline = require("node:readline");
|
||||
@@ -101,15 +218,34 @@ const ending = process.argv[2];
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
let promptCount = 0;
|
||||
let permissionAnswer = null;
|
||||
let outsidePermissionAnswer = null;
|
||||
|
||||
function write(message) {
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
|
||||
}
|
||||
|
||||
function ask(sessionId, requestId, toolCallId) {
|
||||
const toolCall = { toolCallId, title: "echo hello", kind: "execute", status: "pending" };
|
||||
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call", ...toolCall } } });
|
||||
write({
|
||||
id: requestId,
|
||||
method: "session/request_permission",
|
||||
params: {
|
||||
sessionId,
|
||||
toolCall,
|
||||
options: [
|
||||
{ optionId: "allow", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "reject", name: "Reject", kind: "reject_once" },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
rl.on("line", (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === undefined && message.id === "perm-1") {
|
||||
permissionAnswer = message.result ?? { error: message.error };
|
||||
if (message.method === undefined) {
|
||||
if (message.id === "perm-1") permissionAnswer = message.result ?? { error: message.error };
|
||||
if (message.id === "perm-outside") outsidePermissionAnswer = message.result ?? { error: message.error };
|
||||
return;
|
||||
}
|
||||
if (message.method === "initialize") {
|
||||
@@ -117,6 +253,7 @@ rl.on("line", (line) => {
|
||||
return;
|
||||
}
|
||||
if (message.method === "session/new") {
|
||||
if (ending.startsWith("outside_")) ask("session-1", "perm-outside", "tool-outside");
|
||||
write({ id: message.id, result: { sessionId: "session-1" } });
|
||||
return;
|
||||
}
|
||||
@@ -124,35 +261,32 @@ rl.on("line", (line) => {
|
||||
const sessionId = message.params.sessionId;
|
||||
promptCount += 1;
|
||||
if (promptCount === 1) {
|
||||
const toolCall = { toolCallId: "tool-1", title: "echo hello", kind: "execute", status: "pending" };
|
||||
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call", ...toolCall } } });
|
||||
write({
|
||||
id: "perm-1",
|
||||
method: "session/request_permission",
|
||||
params: {
|
||||
sessionId,
|
||||
toolCall,
|
||||
options: [
|
||||
{ optionId: "allow", name: "Allow once", kind: "allow_once" },
|
||||
{ optionId: "reject", name: "Reject", kind: "reject_once" },
|
||||
],
|
||||
},
|
||||
});
|
||||
ask(sessionId, "perm-1", "tool-1");
|
||||
if (ending === "completed" || ending === "failed") {
|
||||
ask(sessionId, "perm-2", "tool-2");
|
||||
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "tool_call_update", toolCallId: "tool-1", status: ending } } });
|
||||
write({ method: "session/update", params: { sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "tool terminal" } } } });
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (ending === "cancel") {
|
||||
if (ending === "cancel" || ending === "outside_cancel") {
|
||||
write({ id: message.id, result: { stopReason: "cancelled" } });
|
||||
} else if (ending === "end_turn" || ending === "outside_turn") {
|
||||
write({ id: message.id, result: { stopReason: "end_turn" } });
|
||||
} else {
|
||||
write({ id: message.id, error: { code: -32603, message: "turn failed with a permission open" } });
|
||||
}
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
const state = permissionAnswer === null ? "unanswered" : "answered: " + JSON.stringify(permissionAnswer);
|
||||
const answer = ending.startsWith("outside_") ? outsidePermissionAnswer : permissionAnswer;
|
||||
const requestId = ending.startsWith("outside_") ? "perm-outside" : "perm-1";
|
||||
const state = answer === null ? "unanswered" : "answered: " + JSON.stringify(answer);
|
||||
write({
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId,
|
||||
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "perm-1 " + state } },
|
||||
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: requestId + " " + state } },
|
||||
},
|
||||
});
|
||||
write({ id: message.id, result: { stopReason: "end_turn" } });
|
||||
|
||||
@@ -1600,6 +1600,111 @@ describe("PiRpcAgentSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("closes a history-only Pi runtime after replaying its timeline", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((runtimeSession) => {
|
||||
runtimeSession.messages = [
|
||||
{ role: "user", content: "Old question" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Old answer" }] },
|
||||
];
|
||||
});
|
||||
|
||||
const client: import("../../agent-sdk-types.js").AgentClient = createClient(pi);
|
||||
const session = await client.resumeSession(
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session",
|
||||
metadata: { cwd: tmpdir() },
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
{ purpose: "history" },
|
||||
);
|
||||
|
||||
onTestFinished(() => session.close());
|
||||
const history: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) history.push(event);
|
||||
|
||||
expect(history).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: expect.objectContaining({ type: "assistant_message", text: "Old answer" }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(pi.latestSession().closeCalls).toBe(1);
|
||||
|
||||
await session.close();
|
||||
expect(pi.latestSession().closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("closes a history-only Pi runtime when replay is stopped", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((runtimeSession) => {
|
||||
runtimeSession.messages = [
|
||||
{ role: "user", content: "Old question" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "Old answer" }] },
|
||||
];
|
||||
});
|
||||
const session = await createClient(pi).resumeSession(
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session",
|
||||
metadata: { cwd: tmpdir() },
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
{ purpose: "history" },
|
||||
);
|
||||
onTestFinished(() => session.close());
|
||||
const history = session.streamHistory();
|
||||
expect((await history.next()).done).toBe(false);
|
||||
await history.return();
|
||||
expect(pi.latestSession().closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("closes a history-only Pi runtime when replay fails", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((runtimeSession) => {
|
||||
runtimeSession.getMessagesError = new Error("History unavailable");
|
||||
});
|
||||
const session = await createClient(pi).resumeSession(
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session",
|
||||
metadata: { cwd: tmpdir() },
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
{ purpose: "history" },
|
||||
);
|
||||
onTestFinished(() => session.close());
|
||||
const history = session.streamHistory();
|
||||
await expect(history.next()).rejects.toThrow("History unavailable");
|
||||
expect(pi.latestSession().closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("keeps an interactive Pi runtime open after replaying history", async () => {
|
||||
const pi = new FakePi();
|
||||
const session = await createClient(pi).resumeSession({
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session",
|
||||
metadata: { cwd: tmpdir() },
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
for await (const _event of session.streamHistory()) {
|
||||
// Hydrate the session before its next interactive prompt.
|
||||
}
|
||||
expect(pi.latestSession().closeCalls).toBe(0);
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ provider: "pi" });
|
||||
});
|
||||
|
||||
test("resumes a session whose model was removed on the model Pi falls back to", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.removeModel("9router/deepseek-v4-flash");
|
||||
@@ -3740,14 +3845,17 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
},
|
||||
);
|
||||
|
||||
test("a history resume of an agent whose folder is gone removes its session copy on close and leaves the original unchanged", async () => {
|
||||
test("a history resume of an agent whose folder is gone removes its session copy after replay and leaves the original unchanged", async () => {
|
||||
const archived = writeArchivedSession(createDeletedFolder());
|
||||
const pi = new FakePi();
|
||||
|
||||
const session = await resumeArchived(createClient(pi), archived, "history");
|
||||
const sessionCopy = pi.recordedLaunches[0]!.session!;
|
||||
expect(existsSync(sessionCopy)).toBe(true);
|
||||
await session.close();
|
||||
for await (const _event of session.streamHistory()) {
|
||||
// Completion releases both the runtime and its temporary session copy.
|
||||
}
|
||||
expect(pi.latestSession().closeCalls).toBe(1);
|
||||
|
||||
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
|
||||
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
|
||||
|
||||
@@ -253,6 +253,7 @@ interface PiRpcAgentSessionOptions {
|
||||
currentModeId?: string | null;
|
||||
persistedSessionFile?: string;
|
||||
cleanup?: () => void;
|
||||
closeAfterHistory?: boolean;
|
||||
extensionTimeoutMs?: number;
|
||||
logger: Logger;
|
||||
usagePollScheduler?: PiUsagePollScheduler;
|
||||
@@ -1255,6 +1256,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.currentModeId = options.currentModeId ?? null;
|
||||
this.persistedSessionFile = options.persistedSessionFile;
|
||||
this.cleanup = options.cleanup;
|
||||
this.closeAfterHistory = options.closeAfterHistory ?? false;
|
||||
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
|
||||
this.logger = options.logger;
|
||||
this.extensionHost = createPiExtensionHost(this.logger);
|
||||
@@ -1286,6 +1288,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
private readonly config: AgentSessionConfig;
|
||||
private readonly persistedSessionFile?: string;
|
||||
private readonly cleanup?: () => void;
|
||||
private readonly closeAfterHistory: boolean;
|
||||
private readonly extensionTimeoutMs: number;
|
||||
|
||||
get id(): string | null {
|
||||
@@ -1454,16 +1457,20 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
await this.requestEntryCapture("history");
|
||||
yield* streamPiHistory(
|
||||
this.provider,
|
||||
await this.runtimeSession.getMessages(),
|
||||
this.contextUserEntries,
|
||||
{},
|
||||
// At most eight 2 MiB child files per replay; later cards retain their summaries.
|
||||
createPiExtensionHost(this.logger, undefined, 16 * 1024 * 1024),
|
||||
this.closeController.signal,
|
||||
);
|
||||
try {
|
||||
await this.requestEntryCapture("history");
|
||||
yield* streamPiHistory(
|
||||
this.provider,
|
||||
await this.runtimeSession.getMessages(),
|
||||
this.contextUserEntries,
|
||||
{},
|
||||
// At most eight 2 MiB child files per replay; later cards retain their summaries.
|
||||
createPiExtensionHost(this.logger, undefined, 16 * 1024 * 1024),
|
||||
this.closeController.signal,
|
||||
);
|
||||
} finally {
|
||||
if (this.closeAfterHistory) await this.close();
|
||||
}
|
||||
}
|
||||
|
||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||
@@ -2738,6 +2745,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
capabilities: capabilitiesForSession(mcp !== null),
|
||||
persistedSessionFile: sessionCopy ? sessionFile : undefined,
|
||||
cleanup: cleanupTempFiles,
|
||||
closeAfterHistory: options?.purpose === "history",
|
||||
extensionTimeoutMs: providerOptions.extensionTimeoutMs,
|
||||
logger: this.logger,
|
||||
usagePollScheduler: this.usagePollScheduler,
|
||||
|
||||
@@ -153,6 +153,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
// The user entries on the current branch that getMessages() replays.
|
||||
contextUserEntries: FakePiUserEntry[] = [];
|
||||
abortRequested = false;
|
||||
closeCalls = 0;
|
||||
readonly canceledExtensionUiRequests: string[] = [];
|
||||
readonly extensionUiResponses: Array<{
|
||||
id: string;
|
||||
@@ -161,6 +162,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
setModelResult: PiModel | null = null;
|
||||
models: PiModel[] = [];
|
||||
messages: PiAgentMessage[] = [];
|
||||
getMessagesError: Error | null = null;
|
||||
stats: PiSessionStats = {
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
@@ -303,6 +305,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
}
|
||||
|
||||
async getMessages(): Promise<PiAgentMessage[]> {
|
||||
if (this.getMessagesError) throw this.getMessagesError;
|
||||
return this.messages;
|
||||
}
|
||||
|
||||
@@ -422,7 +425,9 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
this.respondToExtensionUiRequest(id, { cancelled: true });
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
async close(): Promise<void> {
|
||||
this.closeCalls += 1;
|
||||
}
|
||||
|
||||
emit(event: PiRuntimeEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
|
||||
@@ -2357,3 +2357,74 @@ test("a pre-write host budget rejection leaves sibling routes connected", async
|
||||
true,
|
||||
]);
|
||||
}, 30000);
|
||||
|
||||
test.each([false, true])(
|
||||
"remote prompt to a closed agent (archived=%s) reports its new turn instead of stale closure",
|
||||
{ timeout: 30000 },
|
||||
async (archived) => {
|
||||
const parentPrompts: string[] = [];
|
||||
const started = Promise.withResolvers<void>();
|
||||
const finish = Promise.withResolvers<void>();
|
||||
const a = await daemon({
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn: (prompt) => parentPrompts.push(String(prompt)),
|
||||
}),
|
||||
});
|
||||
const b = await daemon({
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn: () => started.resolve(),
|
||||
beforeTurnComplete: () => finish.promise,
|
||||
}),
|
||||
});
|
||||
cleanup.push(async () => finish.resolve());
|
||||
const origin = await api(a);
|
||||
const destination = await api(b);
|
||||
await origin.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||
const remoteId = (await online(origin.paseo)).hosts[0]!.serverId!;
|
||||
const workspace = await origin.paseo.workspaces.create({
|
||||
source: { kind: "directory", path: a.staticDir },
|
||||
});
|
||||
const remoteWorkspace = await destination.paseo.workspaces.create({
|
||||
source: { kind: "directory", path: b.staticDir },
|
||||
});
|
||||
const parent = await workspace.agents.create({
|
||||
config: { provider: "claude/claude-test", modeId: "full-access" },
|
||||
title: "Closed target caller",
|
||||
});
|
||||
const child = await remoteWorkspace.agents.create({
|
||||
config: { provider: "claude/claude-test", modeId: "full-access" },
|
||||
title: "Closed target",
|
||||
});
|
||||
if (archived) await child.archive();
|
||||
else await b.daemon.agentManager.closeAgent(child.id);
|
||||
expect((await destination.client.fetchAgent(child.id))?.agent.status).toBe("closed");
|
||||
const mcp = new Client({ name: "closed-target-caller", version: "1" });
|
||||
await mcp.connect(
|
||||
new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${a.port}/mcp/agents?callerAgentId=${parent.id}`),
|
||||
),
|
||||
);
|
||||
cleanup.push(() => mcp.close());
|
||||
const result = await mcp.callTool({
|
||||
name: "send_agent_prompt",
|
||||
arguments: { serverId: remoteId, agentId: child.id, prompt: "Finish the new task" },
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
expect(result.structuredContent).toMatchObject({ deliveryStatus: "queued", status: "closed" });
|
||||
await started.promise;
|
||||
expect(parentPrompts).toEqual([]);
|
||||
finish.resolve();
|
||||
await expect.poll(() => parentPrompts, { timeout: 10000 }).toHaveLength(1);
|
||||
expect(parentPrompts[0]).toContain('event="finished"');
|
||||
expect(parentPrompts[0]).not.toContain('event="closed"');
|
||||
expect((await destination.client.fetchAgent(child.id))?.agent).toMatchObject({
|
||||
status: "idle",
|
||||
archivedAt: null,
|
||||
});
|
||||
await expect
|
||||
.poll(async () =>
|
||||
JSON.parse(await readFile(join(a.paseoHome, "finish-subscriptions.json"), "utf8")),
|
||||
)
|
||||
.toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -36,6 +36,23 @@ function containsPath(directory: string, filePath: string): boolean {
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
// @types/node can declare builtins newer than the host Node (node:stream/iter on Node 24).
|
||||
function isNodeSpecifier(specifier: string): boolean {
|
||||
return isBuiltin(specifier) || specifier.startsWith("node:") || specifier === "@types/node";
|
||||
}
|
||||
|
||||
function declaredPackage(input: {
|
||||
specifier: string;
|
||||
kind: PluginImportKind;
|
||||
typeOnly: boolean;
|
||||
}): string {
|
||||
const { specifier, kind, typeOnly } = input;
|
||||
if (kind === "type-reference") return `@types/${specifier.replace(/^@/, "").replace("/", "__")}`;
|
||||
// Declarations load Node's ambient types with `import "node"`.
|
||||
if (typeOnly && specifier === "node") return "@types/node";
|
||||
return specifier;
|
||||
}
|
||||
|
||||
function dependencyName(specifier: string): string {
|
||||
const segments = specifier.split("/");
|
||||
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
|
||||
@@ -180,10 +197,7 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory:
|
||||
if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null;
|
||||
checked.add(key);
|
||||
for (const { specifier, kind, typeOnly } of imports.read(file)) {
|
||||
const packageSpecifier =
|
||||
kind === "type-reference"
|
||||
? `@types/${specifier.replace(/^@/, "").replace("/", "__")}`
|
||||
: specifier;
|
||||
const packageSpecifier = declaredPackage({ specifier, kind, typeOnly });
|
||||
const error =
|
||||
runtimeSpecifierError(specifier, owner, file) ??
|
||||
runtimeSpecifierError(packageSpecifier, owner, file) ??
|
||||
@@ -197,7 +211,7 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory:
|
||||
if (
|
||||
(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) ||
|
||||
/^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) ||
|
||||
isBuiltin(specifier) ||
|
||||
isNodeSpecifier(specifier) ||
|
||||
packageSpecifier === "@types/node"
|
||||
)
|
||||
continue;
|
||||
@@ -288,8 +302,7 @@ function runtimeSpecifierError(
|
||||
!(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier)
|
||||
)
|
||||
kind = "Unknown SDK";
|
||||
else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node"))
|
||||
kind = "Node";
|
||||
else if (target !== "server" && isNodeSpecifier(specifier)) kind = "Node";
|
||||
else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only";
|
||||
else if (
|
||||
target !== "client" &&
|
||||
|
||||
@@ -389,6 +389,46 @@ export type Value = string;`,
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ name: "import 'node' with Node types installed", source: 'import "node";', nodeTypes: true },
|
||||
{ name: "import 'node' without Node types", source: 'import "node";', nodeTypes: false },
|
||||
{
|
||||
name: "a node: builtin unknown to the host",
|
||||
source: 'import type { Future } from "node:paseo-future-builtin";',
|
||||
nodeTypes: false,
|
||||
},
|
||||
])("treats Node declarations as host types in server dependencies: $name", async (fixture) => {
|
||||
const entries = await createSplitPlugin();
|
||||
const dependency = path.join(entries.directory, "node_modules/image-codec");
|
||||
await mkdir(dependency, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dependency, "package.json"),
|
||||
JSON.stringify({ name: "image-codec", main: "index.js", types: "index.d.ts" }),
|
||||
);
|
||||
await writeFile(path.join(dependency, "index.js"), 'module.exports = "decoded";');
|
||||
await writeFile(
|
||||
path.join(dependency, "index.d.ts"),
|
||||
`${fixture.source}\ndeclare const value: string;\nexport = value;`,
|
||||
);
|
||||
if (fixture.nodeTypes) {
|
||||
const nodeTypes = path.join(entries.directory, "node_modules/@types/node");
|
||||
await mkdir(nodeTypes, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(nodeTypes, "package.json"),
|
||||
JSON.stringify({ name: "@types/node", types: "index.d.ts" }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(nodeTypes, "index.d.ts"),
|
||||
'declare module "node:paseo-future-builtin" { export * from "node:stream"; }\nimport type { Future } from "node:paseo-future-builtin";',
|
||||
);
|
||||
}
|
||||
await writeFile(entries.server, 'import value from "image-codec"; export default value;');
|
||||
|
||||
await expect(compilePlugin({ client: null, server: entries.server })).resolves.toMatchObject({
|
||||
serverBundle: expect.stringContaining("decoded"),
|
||||
});
|
||||
});
|
||||
|
||||
it("checks the physical owner of type-only symlinks", async () => {
|
||||
const entries = await createSplitPlugin();
|
||||
await writeFile(
|
||||
|
||||
@@ -15,7 +15,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import pino from "pino";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { DaemonConfigStore } from "../daemon-config-store.js";
|
||||
import { PluginService } from "./index.js";
|
||||
import { BuiltinPluginLoader } from "./builtin/index.js";
|
||||
@@ -638,91 +638,6 @@ describe("PluginService", () => {
|
||||
await service.stopAllPlugins();
|
||||
});
|
||||
|
||||
it("resolves a bare registry id even when a matching directory exists in the daemon cwd", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
const owner = path.join(process.cwd(), `registry-shadow-${randomUUID()}`);
|
||||
await mkdir(owner);
|
||||
roots.push(home, owner);
|
||||
const source = `${path.basename(owner)}/example`;
|
||||
const repository = await createPlugin("registry-example", "export default () => () => {};\n");
|
||||
await mkdir(path.join(owner, "example"));
|
||||
await cp(repository, path.join(owner, "example"), { recursive: true });
|
||||
await runGitCommand(["init", "-b", "main"], { cwd: repository });
|
||||
await runGitCommand(["add", "-A"], { cwd: repository });
|
||||
await runGitCommand(
|
||||
[
|
||||
"-c",
|
||||
"user.name=Paseo Tests",
|
||||
"-c",
|
||||
"user.email=paseo@example.test",
|
||||
"commit",
|
||||
"-m",
|
||||
"fixture",
|
||||
],
|
||||
{ cwd: repository },
|
||||
);
|
||||
const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository });
|
||||
const requests: Array<string | undefined> = [];
|
||||
const server = createServer((request, response) => {
|
||||
requests.push(request.url);
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
id: source,
|
||||
name: "Example",
|
||||
description: "Test",
|
||||
categories: [],
|
||||
author: { github: path.basename(owner) },
|
||||
repository: { url: "https://github.com/fixture/example" },
|
||||
artifact: {
|
||||
kind: "git",
|
||||
remote: pathToFileURL(repository).href,
|
||||
commit: stdout.trim(),
|
||||
pluginPath: ".",
|
||||
},
|
||||
media: [],
|
||||
submittedAt: "2026-10-03",
|
||||
reviewedAt: "2026-10-03",
|
||||
updatedAt: "2026-10-03",
|
||||
publishedAt: "2026-10-03",
|
||||
readme: "# Test",
|
||||
}),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing registry address");
|
||||
const url = `http://127.0.0.1:${address.port}`;
|
||||
const store = createStore(home);
|
||||
await store.patch({ pluginsEnabled: false });
|
||||
const service = bindTestSessionHost(
|
||||
new PluginService(pino({ level: "silent" }), store, "0.4.0", {
|
||||
managedSources: new ManagedPluginSources(home, { defaultUrl: url }),
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await service.start();
|
||||
await expect(service.installSource({ source })).resolves.toMatchObject({
|
||||
id: "registry-example",
|
||||
status: "disabled",
|
||||
installation: {
|
||||
identity: { kind: "git", registry: { url, id: source } },
|
||||
currentRevision: stdout.trim(),
|
||||
},
|
||||
});
|
||||
expect(requests).toEqual([`/plugins/${source}.json`]);
|
||||
await expect(
|
||||
service.installSource({ source: `./${source}`, id: "local-example" }),
|
||||
).resolves.toMatchObject({
|
||||
installation: { identity: { kind: "directory", path: path.join(owner, "example") } },
|
||||
});
|
||||
expect(requests).toEqual([`/plugins/${source}.json`]);
|
||||
} finally {
|
||||
await service.stopAllPlugins();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("names a missing explicit directory instead of trying a managed source", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
roots.push(home);
|
||||
@@ -1526,6 +1441,105 @@ export default function contribute(plugin: unknown) {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginService registry source resolution", () => {
|
||||
let home: string;
|
||||
let owner: string;
|
||||
let source: string;
|
||||
let repository: string;
|
||||
let revision: string;
|
||||
|
||||
// Prepare the real Git fixture before the install deadline starts. On Windows,
|
||||
// these four Git launches can consume much of the test’s 30-second budget.
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||
owner = path.join(process.cwd(), `registry-shadow-${randomUUID()}`);
|
||||
await mkdir(owner);
|
||||
roots.push(home, owner);
|
||||
source = `${path.basename(owner)}/example`;
|
||||
repository = await createPlugin("registry-example", "export default () => () => {};\n");
|
||||
await mkdir(path.join(owner, "example"));
|
||||
await cp(repository, path.join(owner, "example"), { recursive: true });
|
||||
await runGitCommand(["init", "-b", "main"], { cwd: repository });
|
||||
await runGitCommand(["add", "-A"], { cwd: repository });
|
||||
await runGitCommand(
|
||||
[
|
||||
"-c",
|
||||
"user.name=Paseo Tests",
|
||||
"-c",
|
||||
"user.email=paseo@example.test",
|
||||
"commit",
|
||||
"-m",
|
||||
"fixture",
|
||||
],
|
||||
{ cwd: repository },
|
||||
);
|
||||
const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository });
|
||||
revision = stdout.trim();
|
||||
});
|
||||
|
||||
it("resolves a bare registry id even when a matching directory exists in the daemon cwd", async () => {
|
||||
const requests: Array<string | undefined> = [];
|
||||
const server = createServer((request, response) => {
|
||||
requests.push(request.url);
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
id: source,
|
||||
name: "Example",
|
||||
description: "Test",
|
||||
categories: [],
|
||||
author: { github: path.basename(owner) },
|
||||
repository: { url: "https://github.com/fixture/example" },
|
||||
artifact: {
|
||||
kind: "git",
|
||||
remote: pathToFileURL(repository).href,
|
||||
commit: revision,
|
||||
pluginPath: ".",
|
||||
},
|
||||
media: [],
|
||||
submittedAt: "2026-10-03",
|
||||
reviewedAt: "2026-10-03",
|
||||
updatedAt: "2026-10-03",
|
||||
publishedAt: "2026-10-03",
|
||||
readme: "# Test",
|
||||
}),
|
||||
);
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing registry address");
|
||||
const url = `http://127.0.0.1:${address.port}`;
|
||||
const store = createStore(home);
|
||||
await store.patch({ pluginsEnabled: false });
|
||||
const service = bindTestSessionHost(
|
||||
new PluginService(pino({ level: "silent" }), store, "0.4.0", {
|
||||
managedSources: new ManagedPluginSources(home, { defaultUrl: url }),
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await service.start();
|
||||
await expect(service.installSource({ source })).resolves.toMatchObject({
|
||||
id: "registry-example",
|
||||
status: "disabled",
|
||||
installation: {
|
||||
identity: { kind: "git", registry: { url, id: source } },
|
||||
currentRevision: revision,
|
||||
},
|
||||
});
|
||||
expect(requests).toEqual([`/plugins/${source}.json`]);
|
||||
await expect(
|
||||
service.installSource({ source: `./${source}`, id: "local-example" }),
|
||||
).resolves.toMatchObject({
|
||||
installation: { identity: { kind: "directory", path: path.join(owner, "example") } },
|
||||
});
|
||||
expect(requests).toEqual([`/plugins/${source}.json`]);
|
||||
} finally {
|
||||
await service.stopAllPlugins();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("npm plugin installation", () => {
|
||||
let registry: Awaited<ReturnType<typeof startNpmRegistry>>;
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { parseCronExpression } from "@getpaseo/protocol/schedule/cron-expression";
|
||||
import {
|
||||
parseCronExpression,
|
||||
validateCronExpression,
|
||||
} from "@getpaseo/protocol/schedule/cron-expression";
|
||||
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
interface CronDateParts {
|
||||
@@ -78,7 +81,10 @@ function createCronDatePartsReader(timeZone: string | undefined): (date: Date) =
|
||||
|
||||
export function validateScheduleCadence(cadence: ScheduleCadence): void {
|
||||
if (cadence.type === "cron") {
|
||||
parseCronExpression(cadence.expression);
|
||||
const error = validateCronExpression(cadence.expression);
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
if (cadence.timezone !== undefined) {
|
||||
assertValidTimeZone(cadence.timezone);
|
||||
}
|
||||
|
||||
@@ -2114,6 +2114,81 @@ describe("ScheduleService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects numeric cron steps when creating or updating a schedule", async () => {
|
||||
const service = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
logger: createTestLogger(),
|
||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
now: () => now,
|
||||
});
|
||||
const input = {
|
||||
prompt: "Periodic check",
|
||||
cadence: { type: "cron" as const, expression: "*/15 * * * *" },
|
||||
target: {
|
||||
type: "new-agent" as const,
|
||||
config: { provider: "claude" as const, cwd: tempDir },
|
||||
},
|
||||
runOnCreate: false,
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.create({ ...input, cadence: { type: "cron", expression: "0/15 * * * *" } }),
|
||||
).rejects.toThrow("Invalid cron minute step: use * or a range before /");
|
||||
expect(await service.list()).toEqual([]);
|
||||
|
||||
const created = await service.create(input);
|
||||
await expect(
|
||||
service.update({ id: created.id, cadence: { type: "cron", expression: "0/15 * * * *" } }),
|
||||
).rejects.toThrow("Invalid cron minute step: use * or a range before /");
|
||||
expect((await service.inspect(created.id)).cadence).toEqual(input.cadence);
|
||||
});
|
||||
|
||||
test("recovers and runs numeric cron steps persisted by an older daemon", async () => {
|
||||
const service = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
logger: createTestLogger(),
|
||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
now: () => now,
|
||||
runner: async () => ({ agentId: null, output: "ok" }),
|
||||
});
|
||||
const created = await service.create({
|
||||
prompt: "Periodic check",
|
||||
cadence: { type: "cron", expression: "0 * * * *" },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: { provider: "claude", cwd: tempDir },
|
||||
},
|
||||
runOnCreate: false,
|
||||
});
|
||||
const store = new ScheduleStore(join(tempDir, "schedules"), createTestLogger());
|
||||
await store.update(created.id, (schedule) => ({
|
||||
...schedule,
|
||||
cadence: { type: "cron", expression: "0/15 * * * *" },
|
||||
}));
|
||||
|
||||
now = new Date("2026-01-01T03:10:00.000Z");
|
||||
try {
|
||||
await service.start();
|
||||
await service.stop();
|
||||
expect((await service.inspect(created.id)).nextRunAt).toBe("2026-01-01T04:00:00.000Z");
|
||||
|
||||
now = new Date("2026-01-01T04:00:00.000Z");
|
||||
await service.tick();
|
||||
const inspected = await service.inspect(created.id);
|
||||
expect(inspected.cadence).toEqual({ type: "cron", expression: "0/15 * * * *" });
|
||||
expect(inspected.status).toBe("active");
|
||||
expect(inspected.nextRunAt).toBe("2026-01-01T05:00:00.000Z");
|
||||
expect(inspected.runs).toHaveLength(1);
|
||||
expect(inspected.runs[0]).toMatchObject({ status: "succeeded", output: "ok" });
|
||||
} finally {
|
||||
await service.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("advances stale nextRunAt on daemon restart", async () => {
|
||||
const service1 = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
|
||||
@@ -6,7 +6,11 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import type { CheckoutSnapshotFacts, CheckoutStatusGit } from "../utils/checkout-git.js";
|
||||
import {
|
||||
getCheckoutStatus as readCheckoutStatus,
|
||||
type CheckoutSnapshotFacts,
|
||||
type CheckoutStatusGit,
|
||||
} from "../utils/checkout-git.js";
|
||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import { createFileObserver } from "./file-observer/index.js";
|
||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||
@@ -60,6 +64,49 @@ afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"disposal waits for an admitted Git refresh after observation ends (unsubscribed: %s)",
|
||||
async (unsubscribeBeforeDisposal) => {
|
||||
const artifacts = path.resolve(".artifacts");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
const root = realpathSync(mkdtempSync(path.join(artifacts, "git-refresh-shutdown-")));
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" });
|
||||
const entered = Promise.withResolvers<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
const service = new WorkspaceGitServiceImpl({
|
||||
logger: pino({ enabled: false }),
|
||||
paseoHome: path.join(root, "home"),
|
||||
deps: {
|
||||
getCheckoutStatus: async (...args) => {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
return readCheckoutStatus(...args);
|
||||
},
|
||||
},
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: root }, () => {});
|
||||
try {
|
||||
await entered.promise;
|
||||
if (unsubscribeBeforeDisposal) subscription.unsubscribe();
|
||||
let disposed = false;
|
||||
const closing = service.dispose().then(() => {
|
||||
disposed = true;
|
||||
return undefined;
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(disposed).toBe(false);
|
||||
release.resolve();
|
||||
await closing;
|
||||
expect(disposed).toBe(true);
|
||||
} finally {
|
||||
release.resolve();
|
||||
subscription.unsubscribe();
|
||||
await service.dispose();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("a fresh checkout read makes a newly initialized repository available to cached readers", async () => {
|
||||
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-checkout-init-")));
|
||||
const repo = path.join(root, "repo");
|
||||
|
||||
@@ -614,6 +614,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
private readonly disposeController = new AbortController();
|
||||
private disposed = false;
|
||||
private disposePromise: Promise<void> | null = null;
|
||||
private readonly pendingRefreshes = new Set<Promise<WorkspaceGitRuntimeSnapshot>>();
|
||||
private readonly snapshotUpdatedListeners = new Set<WorkspaceGitSnapshotUpdatedListener>();
|
||||
private readonly workspaceTargets = new Map<string, WorkspaceGitTarget>();
|
||||
private readonly repoTargets = new Map<string, RepoGitTarget>();
|
||||
@@ -1092,7 +1093,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.workingTreeWatchResolutions.clear();
|
||||
this.workingTreeWatchAliases.clear();
|
||||
this.snapshotUpdatedListeners.clear();
|
||||
this.disposePromise = this.fileObserver.close();
|
||||
// Unsubscribing can remove a target while its Git command still owns the cwd.
|
||||
// Stop admission first, then drain those refreshes before releasing the service.
|
||||
this.disposePromise = Promise.all([
|
||||
this.fileObserver.close(),
|
||||
Promise.allSettled(this.pendingRefreshes),
|
||||
]).then(() => undefined);
|
||||
return this.disposePromise;
|
||||
}
|
||||
|
||||
@@ -3212,11 +3218,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
|
||||
const promise = this.runWorkspaceRefreshLoop(target, request).finally(() => {
|
||||
this.pendingRefreshes.delete(promise);
|
||||
const state = target.refreshState;
|
||||
if (state.status === "in-flight" && state.promise === promise) {
|
||||
target.refreshState = { status: "idle" };
|
||||
}
|
||||
});
|
||||
this.pendingRefreshes.add(promise);
|
||||
target.refreshState = {
|
||||
status: "in-flight",
|
||||
promise,
|
||||
|
||||
@@ -558,34 +558,31 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
});
|
||||
|
||||
test("runs setup from an exact workspace subdirectory", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const tempDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "worktree-setup-test-")));
|
||||
cleanupPaths.push(tempDir);
|
||||
const repoDir = path.join(tempDir, "source");
|
||||
const sourceWorkspaceCwd = path.join(repoDir, "packages", "app");
|
||||
const worktree: WorktreeConfig = {
|
||||
branchName: "feature-subdirectory-setup",
|
||||
worktreePath: path.join(tempDir, "worktree"),
|
||||
};
|
||||
const workspaceCwd = path.join(worktree.worktreePath, "packages", "app");
|
||||
// Setup consumes an existing workspace. Git creation is covered by the workflow tests.
|
||||
mkdirSync(sourceWorkspaceCwd, { recursive: true });
|
||||
mkdirSync(workspaceCwd, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", worktree.branchName], {
|
||||
cwd: worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
});
|
||||
writeFileSync(
|
||||
path.join(sourceWorkspaceCwd, "paseo.json"),
|
||||
path.join(workspaceCwd, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: ["pwd > setup-cwd.txt"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add app setup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const createdWorktree = await createLegacyWorktreeForTest({
|
||||
branchName: "feature-subdirectory-setup",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "feature-subdirectory-setup",
|
||||
|
||||
paseoHome,
|
||||
});
|
||||
const workspaceCwd = path.join(createdWorktree.worktreePath, "packages", "app");
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
{
|
||||
@@ -600,16 +597,16 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
requestCwd: sourceWorkspaceCwd,
|
||||
repoRoot: repoDir,
|
||||
workspaceId: "ws-subdirectory-setup",
|
||||
worktree: createdWorktree,
|
||||
worktree,
|
||||
shouldBootstrap: true,
|
||||
slug: "feature-subdirectory-setup",
|
||||
worktreePath: createdWorktree.worktreePath,
|
||||
worktreePath: worktree.worktreePath,
|
||||
workspaceCwd,
|
||||
},
|
||||
);
|
||||
|
||||
expect(existsSync(path.join(workspaceCwd, "setup-cwd.txt"))).toBe(true);
|
||||
expect(existsSync(path.join(createdWorktree.worktreePath, "setup-cwd.txt"))).toBe(false);
|
||||
expect(existsSync(path.join(worktree.worktreePath, "setup-cwd.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
test("emits running then completed snapshots for no-setup workspaces without auto-starting scripts", async () => {
|
||||
|
||||
Reference in New Issue
Block a user