mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd12b2c133 | ||
|
|
8ff35abdeb |
@@ -63,24 +63,6 @@ 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,
|
||||
@@ -844,192 +826,6 @@ 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,6 +1,4 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
collapseFolder,
|
||||
expandFolder,
|
||||
@@ -10,7 +8,6 @@ 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";
|
||||
|
||||
@@ -52,66 +49,3 @@ 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");
|
||||
});
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
// 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",
|
||||
);
|
||||
@@ -1,44 +0,0 @@
|
||||
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,15 +64,10 @@ 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,
|
||||
@@ -440,7 +435,6 @@ export function FileExplorerPane({
|
||||
|
||||
const {
|
||||
requestDirectoryListing,
|
||||
invalidateDirectoryListings,
|
||||
createEntry,
|
||||
renameEntry,
|
||||
duplicateEntry,
|
||||
@@ -892,11 +886,9 @@ export function FileExplorerPane({
|
||||
refreshExplorerDirectories({
|
||||
hasWorkspaceScope,
|
||||
expandedPaths,
|
||||
showHiddenFiles: usePanelStore.getState().explorerShowHiddenFiles,
|
||||
requestDirectoryListing,
|
||||
invalidateDirectoryListings,
|
||||
}),
|
||||
[invalidateDirectoryListings, expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
||||
[expandedPaths, hasWorkspaceScope, requestDirectoryListing],
|
||||
);
|
||||
const { refetch: refetchExplorer, isFetching: isRefreshFetching } = useQuery({
|
||||
queryKey: ["fileExplorerRefresh", serverId, workspaceStateKey],
|
||||
@@ -1432,7 +1424,7 @@ function toggleDirectory({
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<DirectoryListingResult>;
|
||||
) => Promise<ExplorerDirectory | null>;
|
||||
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
|
||||
}): void {
|
||||
if (!workspaceStateKey) {
|
||||
@@ -1569,7 +1561,7 @@ async function initializeExplorer({
|
||||
requestDirectoryListing: (
|
||||
path: string,
|
||||
opts?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
) => Promise<DirectoryListingResult>;
|
||||
) => Promise<ExplorerDirectory | null>;
|
||||
setExpandedPathsForWorkspace: (workspaceStateKey: string, paths: ExpandedPathsUpdate) => void;
|
||||
}): Promise<void> {
|
||||
if (!hasWorkspaceScope || hasInitializedRef.current) {
|
||||
@@ -1580,7 +1572,6 @@ async function initializeExplorer({
|
||||
recordHistory: false,
|
||||
setCurrentPath: false,
|
||||
});
|
||||
if (rootDirectory === DIRECTORY_LISTING_INVALIDATED) return;
|
||||
if (!rootDirectory) {
|
||||
hasInitializedRef.current = false;
|
||||
return;
|
||||
@@ -1602,10 +1593,7 @@ async function initializeExplorer({
|
||||
const hiddenPersistedPaths = showHiddenFiles
|
||||
? []
|
||||
: Array.from(persistedExpandedPaths).filter(isHiddenExplorerPath);
|
||||
const restoredPathsWithHidden =
|
||||
restoredPaths === DIRECTORY_LISTING_INVALIDATED
|
||||
? DIRECTORY_LISTING_INVALIDATED
|
||||
: [...restoredPaths, ...hiddenPersistedPaths];
|
||||
const restoredPathsWithHidden = [...restoredPaths, ...hiddenPersistedPaths];
|
||||
setExpandedPathsForWorkspace(workspaceStateKey, (currentPaths) =>
|
||||
reconcileRestoredExpandedPaths({
|
||||
persistedExpandedPaths,
|
||||
@@ -1615,6 +1603,39 @@ 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,26 +1,8 @@
|
||||
import { createContext, useContext, useCallback, useState, useId, type ReactNode } from "react";
|
||||
import { useCallback, useState, 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. */
|
||||
@@ -38,24 +20,15 @@ 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}>
|
||||
<OverlayClearanceContext.Provider value={overlayClearance}>
|
||||
<OverlayHostContext.Provider value={floating ? overlayHostName : null}>
|
||||
{children(floating ? headerHeight : 0)}
|
||||
</OverlayHostContext.Provider>
|
||||
</OverlayClearanceContext.Provider>
|
||||
</View>
|
||||
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
|
||||
{floating ? (
|
||||
<View style={styles.overlay} pointerEvents="box-none">
|
||||
<EdgeFade
|
||||
@@ -64,19 +37,11 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea
|
||||
backdrop="surfaceWorkspace"
|
||||
style={styles.fade}
|
||||
/>
|
||||
<View
|
||||
testID="floating-header"
|
||||
collapsable={false}
|
||||
pointerEvents="box-none"
|
||||
onLayout={handleHeaderLayout}
|
||||
>
|
||||
<View pointerEvents="box-none" onLayout={handleHeaderLayout}>
|
||||
{header}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.statusOverlay} pointerEvents="box-none">
|
||||
<PortalHost name={overlayHostName} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -90,13 +55,6 @@ 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,7 +1,3 @@
|
||||
import {
|
||||
FloatingHeaderOverlay,
|
||||
useFloatingHeaderOverlayClearance,
|
||||
} from "@/components/floating-header";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
@@ -194,11 +190,9 @@ 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 : panelClearance;
|
||||
const topOffset = placement === "app-shell" ? insets.top + headerTopPadding + headerHeight : 0;
|
||||
|
||||
const content = (
|
||||
<View
|
||||
@@ -226,7 +220,7 @@ export function ToastViewport({
|
||||
return createPortal(content, getOverlayRoot());
|
||||
}
|
||||
|
||||
return placement === "panel" ? <FloatingHeaderOverlay>{content}</FloatingHeaderOverlay> : content;
|
||||
return 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
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]);
|
||||
},
|
||||
);
|
||||
@@ -1,91 +0,0 @@
|
||||
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,10 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExplorerDirectory, ExplorerEntry } from "@/stores/session-store";
|
||||
import type { ExplorerEntry } from "@/stores/session-store";
|
||||
import {
|
||||
MAX_AUTO_EXPANDED_DIRECTORY_DEPTH,
|
||||
flattenExplorerTree,
|
||||
reconcileRestoredExpandedPaths,
|
||||
refreshExplorerDirectories,
|
||||
restoreExpandedDirectories,
|
||||
setExpandedDirectoryPath,
|
||||
showHiddenFilesAndRestoreExpandedDirectories,
|
||||
@@ -196,76 +195,3 @@ 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,6 +1,5 @@
|
||||
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;
|
||||
@@ -17,13 +16,11 @@ interface FlattenExplorerTreeInput {
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
type RestoredExpandedPaths = string[] | typeof DIRECTORY_LISTING_INVALIDATED;
|
||||
|
||||
interface RestoreExpandedDirectoriesInput {
|
||||
rootDirectory: ExplorerDirectory;
|
||||
persistedExpandedPaths: ReadonlySet<string>;
|
||||
showHiddenFiles: boolean;
|
||||
requestDirectoryListing: (path: string) => Promise<DirectoryListingResult>;
|
||||
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
|
||||
}
|
||||
|
||||
interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
|
||||
@@ -36,7 +33,7 @@ interface ShowHiddenFilesAndRestoreExpandedDirectoriesInput extends Omit<
|
||||
interface ReconcileRestoredExpandedPathsInput {
|
||||
persistedExpandedPaths: ReadonlySet<string>;
|
||||
currentExpandedPaths: ReadonlySet<string>;
|
||||
restoredExpandedPaths: RestoredExpandedPaths;
|
||||
restoredExpandedPaths: string[];
|
||||
}
|
||||
|
||||
interface SetExpandedDirectoryPathInput {
|
||||
@@ -88,7 +85,7 @@ export async function restoreExpandedDirectories({
|
||||
persistedExpandedPaths,
|
||||
showHiddenFiles,
|
||||
requestDirectoryListing,
|
||||
}: RestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
|
||||
}: RestoreExpandedDirectoriesInput): Promise<string[]> {
|
||||
const restoredPaths = ["."];
|
||||
const restoredPathSet = new Set(restoredPaths);
|
||||
let parentDirectories = [rootDirectory];
|
||||
@@ -113,11 +110,9 @@ 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 || directory === DIRECTORY_LISTING_INVALIDATED) {
|
||||
if (!directory) {
|
||||
continue;
|
||||
}
|
||||
restoredPaths.push(directory.path);
|
||||
@@ -133,7 +128,7 @@ export function showHiddenFilesAndRestoreExpandedDirectories({
|
||||
persistedExpandedPaths,
|
||||
showHiddenFiles,
|
||||
requestDirectoryListing,
|
||||
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<RestoredExpandedPaths> {
|
||||
}: ShowHiddenFilesAndRestoreExpandedDirectoriesInput): Promise<string[]> {
|
||||
showHiddenFiles();
|
||||
return restoreExpandedDirectories({
|
||||
rootDirectory,
|
||||
@@ -148,8 +143,6 @@ 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) {
|
||||
@@ -212,7 +205,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<DirectoryListingResult>;
|
||||
requestDirectoryListing: (path: string) => Promise<ExplorerDirectory | null>;
|
||||
expandPaths: (paths: string[]) => void;
|
||||
showHiddenFiles: () => void;
|
||||
}): Promise<void> {
|
||||
@@ -225,51 +218,7 @@ export async function revealExplorerDirectory(input: {
|
||||
}
|
||||
}
|
||||
for (const path of paths) {
|
||||
const directory = await input.requestDirectoryListing(path);
|
||||
if (!directory || directory === DIRECTORY_LISTING_INVALIDATED) return;
|
||||
if (!(await input.requestDirectoryListing(path))) 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,17 +25,12 @@ const iframeStyle = {
|
||||
|
||||
export function FileHtmlPreview({ html, testID }: { html: string; testID?: string }) {
|
||||
const { t } = useTranslation();
|
||||
// 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],
|
||||
);
|
||||
const document = useMemo(() => withPreviewCsp(html), [html]);
|
||||
return (
|
||||
<iframe
|
||||
data-testid={testID}
|
||||
title={t("panels.file.filePanels.builtin.html")}
|
||||
src={source}
|
||||
srcDoc={document}
|
||||
sandbox={SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
style={iframeStyle}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSessionStore, type AgentFileExplorerState } from "@/stores/session-store";
|
||||
import {
|
||||
useSessionStore,
|
||||
type AgentFileExplorerState,
|
||||
type ExplorerDirectory,
|
||||
} 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,
|
||||
@@ -24,6 +22,15 @@ 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;
|
||||
@@ -68,21 +75,16 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
);
|
||||
|
||||
const updateExplorerState = useCallback(
|
||||
(
|
||||
updater: (prev: AgentFileExplorerState) => AgentFileExplorerState,
|
||||
): AgentFileExplorerState | null => {
|
||||
(updater: (prev: AgentFileExplorerState) => AgentFileExplorerState) => {
|
||||
if (!workspaceStateKey) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
let updated: AgentFileExplorerState | null = null;
|
||||
setFileExplorer(serverId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(workspaceStateKey) ?? createExplorerState();
|
||||
updated = updater(current);
|
||||
next.set(workspaceStateKey, updated);
|
||||
next.set(workspaceStateKey, updater(current));
|
||||
return next;
|
||||
});
|
||||
return updated;
|
||||
},
|
||||
[serverId, setFileExplorer, workspaceStateKey],
|
||||
);
|
||||
@@ -91,23 +93,80 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
async (
|
||||
path: string,
|
||||
options?: { recordHistory?: boolean; setCurrentPath?: boolean },
|
||||
): Promise<DirectoryListingResult> => {
|
||||
): Promise<ExplorerDirectory | null> => {
|
||||
if (!workspaceStateKey) {
|
||||
return null;
|
||||
}
|
||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||
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);
|
||||
},
|
||||
});
|
||||
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;
|
||||
}
|
||||
},
|
||||
[client, normalizedWorkspaceRoot, t, updateExplorerState, workspaceStateKey],
|
||||
);
|
||||
@@ -249,17 +308,10 @@ 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,7 +260,6 @@ interface ExplorerRequestState {
|
||||
}
|
||||
|
||||
export interface AgentFileExplorerState {
|
||||
listingGeneration: number;
|
||||
directories: Map<string, ExplorerDirectory>;
|
||||
isLoading: boolean;
|
||||
lastError: string | null;
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { runPaseoCli, startTestDaemon } from "../../../tests/helpers/test-daemon.js";
|
||||
|
||||
describe("paseo logs JSON output", () => {
|
||||
let daemon: Awaited<ReturnType<typeof startTestDaemon>>;
|
||||
let agentId: string;
|
||||
let host: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await mkdir(".artifacts", { recursive: true });
|
||||
daemon = await startTestDaemon({
|
||||
paseoHome: await mkdtemp(resolve(".artifacts/logs-json-home-")),
|
||||
workDir: await mkdtemp(resolve(".artifacts/logs-json-work-")),
|
||||
});
|
||||
host = `127.0.0.1:${daemon.port}`;
|
||||
const client = await connectToDaemon({ target: { kind: "endpoint", host } });
|
||||
try {
|
||||
// Creating an idle agent needs no inference or external provider connection.
|
||||
const agent = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: daemon.workDir,
|
||||
title: "JSON logs",
|
||||
});
|
||||
agentId = agent.id;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await daemon?.stop();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "top-level --json", args: ["logs", "--json"] },
|
||||
{ label: "agent logs --json", args: ["agent", "logs", "--json"] },
|
||||
{ label: "--format json alias", args: ["logs", "--format", "json"] },
|
||||
])(
|
||||
"prints JSON through $label",
|
||||
async ({ args }) => {
|
||||
const result = await runPaseoCli(daemon, [...args, agentId, "--host", host]);
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({ agentId, items: [] });
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
createFollowJsonWriter,
|
||||
createFollowTranscriptWriter,
|
||||
formatAgentActivityTranscript,
|
||||
} from "./logs.js";
|
||||
import { createFollowTranscriptWriter, formatAgentActivityTranscript } from "./logs.js";
|
||||
|
||||
function collect(items: readonly AgentTimelineItem[], turnId?: string): string {
|
||||
let output = "";
|
||||
@@ -153,31 +149,3 @@ describe("createFollowTranscriptWriter", () => {
|
||||
expect(output).toBe("# Title\n\nBody\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFollowJsonWriter", () => {
|
||||
it("writes one JSON item per line and honours the filter", () => {
|
||||
let output = "";
|
||||
const writer = createFollowJsonWriter("tools", (chunk) => {
|
||||
output += chunk;
|
||||
});
|
||||
const toolCall = {
|
||||
type: "tool_call",
|
||||
callId: "call_1",
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
error: null,
|
||||
} as AgentTimelineItem;
|
||||
|
||||
writer.push({ type: "assistant_message", text: "Running it.", messageId: "msg_1" });
|
||||
writer.push(toolCall);
|
||||
writer.end();
|
||||
|
||||
expect(
|
||||
output
|
||||
.trimEnd()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line)),
|
||||
).toEqual([toolCall]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,35 +70,6 @@ export function formatAgentActivityTranscript(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the items `--tail` keeps, the same way the transcript does: the last n
|
||||
* timeline items, before any of them are dropped from the curated view.
|
||||
*/
|
||||
function selectTailItems(
|
||||
timelineItems: AgentTimelineItem[],
|
||||
tailCount?: number,
|
||||
): AgentTimelineItem[] {
|
||||
if (tailCount === undefined) {
|
||||
return timelineItems;
|
||||
}
|
||||
if (tailCount === 0) {
|
||||
return [];
|
||||
}
|
||||
return timelineItems.slice(-tailCount);
|
||||
}
|
||||
|
||||
function formatAgentLogsJson(
|
||||
agentId: string,
|
||||
timelineItems: AgentTimelineItem[],
|
||||
tailCount?: number,
|
||||
): string {
|
||||
return JSON.stringify({ agentId, items: selectTailItems(timelineItems, tailCount) }, null, 2);
|
||||
}
|
||||
|
||||
function usesJsonOutput(options: AgentLogsOptions): boolean {
|
||||
return options.json === true || options.format?.trim().toLowerCase() === "json";
|
||||
}
|
||||
|
||||
type StreamingTextType = "assistant_message" | "reasoning";
|
||||
|
||||
const THOUGHT_PREFIX = "[Thought] ";
|
||||
@@ -227,21 +198,6 @@ export function createFollowTranscriptWriter(
|
||||
};
|
||||
}
|
||||
|
||||
/** Follow mode with `--json`: one JSON timeline item per line (NDJSON). */
|
||||
export function createFollowJsonWriter(
|
||||
filter?: string,
|
||||
write: (chunk: string) => void = (chunk) => void process.stdout.write(chunk),
|
||||
): FollowTranscriptWriter {
|
||||
return {
|
||||
push(item) {
|
||||
if (matchesFilter(item, filter)) {
|
||||
write(`${JSON.stringify(item)}\n`);
|
||||
}
|
||||
},
|
||||
end() {},
|
||||
};
|
||||
}
|
||||
|
||||
function parseTailCount(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
@@ -350,11 +306,6 @@ export async function runLogsCommand(
|
||||
|
||||
await client.close();
|
||||
|
||||
if (usesJsonOutput(options)) {
|
||||
console.log(formatAgentLogsJson(resolvedId, timelineItems, tailCount));
|
||||
return;
|
||||
}
|
||||
|
||||
// Use curateAgentActivity to format the transcript
|
||||
if (tailCount === 0) {
|
||||
return;
|
||||
@@ -380,104 +331,78 @@ async function runFollowMode({
|
||||
options,
|
||||
sinceTimestampMs,
|
||||
}: FollowModeInput): Promise<void> {
|
||||
// Observe shutdown before history writes can encounter a closed reader.
|
||||
// Keep an early stop resolved while subscription setup is in flight.
|
||||
const stopController = new AbortController();
|
||||
const stopped = waitForStop(stopController.signal);
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
const json = usesJsonOutput(options);
|
||||
const writer = json
|
||||
? createFollowJsonWriter(options.filter)
|
||||
: createFollowTranscriptWriter(undefined, options.filter);
|
||||
const DEFAULT_FOLLOW_TAIL = 10;
|
||||
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
|
||||
|
||||
// First, get existing timeline.
|
||||
let existingItems: AgentTimelineItem[] = [];
|
||||
try {
|
||||
const DEFAULT_FOLLOW_TAIL = 10;
|
||||
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
|
||||
|
||||
// First, get existing timeline.
|
||||
let existingItems: AgentTimelineItem[] = [];
|
||||
try {
|
||||
const history = await Promise.race([
|
||||
fetchAgentTimelineItems(client, agentId, {
|
||||
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
||||
sinceTimestampMs,
|
||||
}),
|
||||
stopped,
|
||||
]);
|
||||
if (!history) return;
|
||||
existingItems = history;
|
||||
} catch (error) {
|
||||
console.warn("Warning: failed to fetch existing timeline", error);
|
||||
}
|
||||
|
||||
// Apply filter to existing items
|
||||
if (options.filter) {
|
||||
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
|
||||
}
|
||||
|
||||
// Print existing transcript (tail-like behavior)
|
||||
if (json) {
|
||||
for (const item of selectTailItems(existingItems, tailCount)) {
|
||||
console.log(JSON.stringify(item));
|
||||
}
|
||||
} else if (tailCount > 0) {
|
||||
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount);
|
||||
if (existingTranscript !== NO_ACTIVITY_MESSAGE) {
|
||||
console.log(existingTranscript);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to new events
|
||||
const tailLabel =
|
||||
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
|
||||
|
||||
// Keep stdout parseable in JSON mode: one timeline item per line, notices on stderr.
|
||||
const notice = json ? console.error : console.log;
|
||||
|
||||
const subscription = client.subscribeAgentTimeline(agentId, (message) => {
|
||||
if (message.type === "agent.timeline.replacement") {
|
||||
writer.end();
|
||||
notice("\n[Timeline replaced; earlier output is no longer current]");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "agent.timeline.error") {
|
||||
writer.end();
|
||||
console.error(`Timeline observation stopped: ${message.payload.error}`);
|
||||
return;
|
||||
}
|
||||
if (message.type === "agent.timeline.subscription_restored") {
|
||||
writer.end();
|
||||
notice("\n[Reconnected; live output resumed. Events may have been missed.]");
|
||||
return;
|
||||
}
|
||||
if (message.payload.event.type === "timeline") {
|
||||
const matchesSince =
|
||||
sinceTimestampMs === undefined ||
|
||||
Date.parse(message.payload.timestamp) >= sinceTimestampMs;
|
||||
if (!matchesSince) {
|
||||
return;
|
||||
}
|
||||
const item = message.payload.event.item;
|
||||
writer.push(item, message.payload.event.turnId);
|
||||
} else if (
|
||||
message.payload.event.type === "turn_completed" ||
|
||||
message.payload.event.type === "turn_failed" ||
|
||||
message.payload.event.type === "turn_canceled"
|
||||
) {
|
||||
writer.end();
|
||||
}
|
||||
existingItems = await fetchAgentTimelineItems(client, agentId, {
|
||||
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
||||
sinceTimestampMs,
|
||||
});
|
||||
|
||||
unsubscribe = subscription;
|
||||
const ready = await Promise.race([subscription.ready.then(() => true), stopped]);
|
||||
if (!ready) return;
|
||||
notice(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
|
||||
|
||||
await stopped;
|
||||
} finally {
|
||||
stopController.abort();
|
||||
writer.end();
|
||||
unsubscribe?.();
|
||||
await client.close();
|
||||
} catch (error) {
|
||||
console.warn("Warning: failed to fetch existing timeline", error);
|
||||
}
|
||||
|
||||
// Apply filter to existing items
|
||||
if (options.filter) {
|
||||
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
|
||||
}
|
||||
|
||||
// Print existing transcript (tail-like behavior)
|
||||
if (tailCount > 0) {
|
||||
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount);
|
||||
if (existingTranscript !== NO_ACTIVITY_MESSAGE) {
|
||||
console.log(existingTranscript);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to new events
|
||||
const tailLabel =
|
||||
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
|
||||
|
||||
const writer = createFollowTranscriptWriter(undefined, options.filter);
|
||||
|
||||
const unsubscribe = client.subscribeAgentTimeline(agentId, (message) => {
|
||||
if (message.type === "agent.timeline.replacement") {
|
||||
writer.end();
|
||||
console.log("\n[Timeline replaced; earlier output is no longer current]");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "agent.timeline.error") {
|
||||
writer.end();
|
||||
console.error(`Timeline observation stopped: ${message.payload.error}`);
|
||||
return;
|
||||
}
|
||||
if (message.type === "agent.timeline.subscription_restored") {
|
||||
writer.end();
|
||||
console.log("\n[Reconnected; live output resumed. Events may have been missed.]");
|
||||
return;
|
||||
}
|
||||
if (message.payload.event.type === "timeline") {
|
||||
const matchesSince =
|
||||
sinceTimestampMs === undefined || Date.parse(message.payload.timestamp) >= sinceTimestampMs;
|
||||
if (!matchesSince) {
|
||||
return;
|
||||
}
|
||||
const item = message.payload.event.item;
|
||||
writer.push(item, message.payload.event.turnId);
|
||||
} else if (
|
||||
message.payload.event.type === "turn_completed" ||
|
||||
message.payload.event.type === "turn_failed" ||
|
||||
message.payload.event.type === "turn_canceled"
|
||||
) {
|
||||
writer.end();
|
||||
}
|
||||
});
|
||||
|
||||
await unsubscribe.ready;
|
||||
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
|
||||
|
||||
await waitForStop();
|
||||
writer.end();
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { buildAgentLsFetchOptions, listAgents } from "./ls.js";
|
||||
|
||||
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
||||
|
||||
function createPagingClient(archivedAgentNumber?: number) {
|
||||
function createPagingClient() {
|
||||
const daemonAgents: AgentSnapshotPayload[] = [3, 2, 1].map((n) => ({
|
||||
id: `${n}${n}${n}${n}${n}${n}${n}${n}-0000-4000-8000-000000000000`,
|
||||
provider: "codex",
|
||||
title: `agent ${n}`,
|
||||
status: n === archivedAgentNumber ? "idle" : "closed",
|
||||
archivedAt: n === archivedAgentNumber ? "2026-10-10T00:00:00.000Z" : null,
|
||||
status: "closed",
|
||||
archivedAt: null,
|
||||
cwd: "/tmp/project",
|
||||
createdAt: `2026-10-0${n}T00:00:00.000Z`,
|
||||
updatedAt: `2026-10-0${n}T00:00:00.000Z`,
|
||||
@@ -130,28 +130,4 @@ describe("runLsCommand", () => {
|
||||
|
||||
expect(result.data.map((item) => item.name)).toEqual(["agent 3", "agent 2", "agent 1"]);
|
||||
});
|
||||
|
||||
it("marks archived agents in table and JSON list data", async () => {
|
||||
const result = await listAgents({ daemonTarget, all: true, global: true }, async () =>
|
||||
createPagingClient(2),
|
||||
);
|
||||
|
||||
expect(
|
||||
result.data.map(({ name, archived, archivedAt }) => ({ name, archived, archivedAt })),
|
||||
).toEqual([
|
||||
{ name: "agent 2", archived: true, archivedAt: "2026-10-10T00:00:00.000Z" },
|
||||
{ name: "agent 3", archived: false, archivedAt: null },
|
||||
{ name: "agent 1", archived: false, archivedAt: null },
|
||||
]);
|
||||
|
||||
const archivedColumn = result.schema.columns.find((column) => column.header === "ARCHIVED");
|
||||
expect(archivedColumn).toBeDefined();
|
||||
expect(typeof archivedColumn?.field).toBe("function");
|
||||
if (!archivedColumn || typeof archivedColumn.field !== "function") {
|
||||
throw new Error("Expected the ARCHIVED column to have a display accessor");
|
||||
}
|
||||
expect(result.data[0]!.status).toBe("idle");
|
||||
expect(archivedColumn.field(result.data[0]!)).toBe("yes");
|
||||
expect(archivedColumn.field(result.data[1]!)).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,8 +33,6 @@ export interface AgentListItem {
|
||||
provider: string;
|
||||
thinking: string;
|
||||
status: string;
|
||||
archived: boolean;
|
||||
archivedAt: string | null;
|
||||
cwd: string;
|
||||
created: string;
|
||||
}
|
||||
@@ -86,11 +84,6 @@ export const agentLsSchema: OutputSchema<AgentListItem> = {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "ARCHIVED",
|
||||
field: (item) => (item.archived ? "yes" : "-"),
|
||||
width: 8,
|
||||
},
|
||||
{ header: "CWD", field: "cwd", width: 30 },
|
||||
{ header: "CREATED", field: "created", width: 15 },
|
||||
],
|
||||
@@ -106,8 +99,6 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||
provider: model ? `${agent.provider}/${model}` : agent.provider,
|
||||
thinking: agent.effectiveThinkingOptionId ?? "auto",
|
||||
status: agent.status,
|
||||
archived: agent.archivedAt != null,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
cwd: shortenPath(agent.cwd),
|
||||
created: relativeTime(agent.createdAt),
|
||||
};
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
// their output closes its end, as `| head` does. A closed reader shows up as
|
||||
// EPIPE on the next write to stdout, so the command stops at its next line of
|
||||
// output.
|
||||
export function waitForStop(signal?: AbortSignal): Promise<void> {
|
||||
export function waitForStop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const stop = () => {
|
||||
signal?.removeEventListener("abort", stop);
|
||||
process.off("SIGINT", stop);
|
||||
process.off("SIGTERM", stop);
|
||||
process.stdout.off("error", stopOnClosedStdout);
|
||||
@@ -21,7 +20,5 @@ export function waitForStop(signal?: AbortSignal): Promise<void> {
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
process.stdout.on("error", stopOnClosedStdout);
|
||||
signal?.addEventListener("abort", stop, { once: true });
|
||||
if (signal?.aborted) stop();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,37 +13,6 @@ 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,9 +90,6 @@ 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`);
|
||||
@@ -125,12 +122,6 @@ 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,7 +7317,6 @@ 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)!;
|
||||
@@ -7360,7 +7359,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).toBeUndefined();
|
||||
expect(stillRunning?.lastError).toBe("turn-b marker");
|
||||
expect(stillRunning?.lastUsage).toEqual({ inputTokens: 7 });
|
||||
expect(stillRunning?.pendingPermissions.has("turn-b-permission")).toBe(true);
|
||||
expect(manager.getTimeline(snapshot.id)).toEqual(timelineBeforeStaleTerminals);
|
||||
@@ -8684,118 +8683,6 @@ 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,7 +2840,6 @@ 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
|
||||
@@ -4953,8 +4952,6 @@ 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,16 +399,14 @@ 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") {
|
||||
// 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.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.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,7 +404,6 @@ 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",
|
||||
});
|
||||
@@ -518,7 +517,6 @@ describe("Suite A: Core Fixes", () => {
|
||||
expect(notification.source).toEqual({
|
||||
kind: "agent-notification",
|
||||
agentId: childId,
|
||||
serverId: daemon.daemon.getServerId(),
|
||||
title: "Watched child",
|
||||
event: "errored",
|
||||
});
|
||||
@@ -581,11 +579,7 @@ describe("Suite A: Core Fixes", () => {
|
||||
type: "tool_call",
|
||||
agentMessage: {
|
||||
event: outcome,
|
||||
sender: {
|
||||
id: childId,
|
||||
serverId: daemonHandle.daemon.getServerId(),
|
||||
title: "Follow-up child",
|
||||
},
|
||||
sender: { id: childId, title: "Follow-up child" },
|
||||
text: expect.stringContaining(`(Follow-up child) ${outcome}.`),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -488,6 +488,58 @@ 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" });
|
||||
@@ -1234,6 +1286,11 @@ 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();
|
||||
@@ -1268,6 +1325,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
turnId: "second-turn",
|
||||
state,
|
||||
});
|
||||
emit({ type: "session.closed", sessionId: "reusable-child" });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1294,6 +1352,100 @@ 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,6 +1200,8 @@ 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;
|
||||
@@ -1672,6 +1674,11 @@ 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,
|
||||
@@ -1690,7 +1697,10 @@ class PluginAgentSession implements AgentSession {
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: childId,
|
||||
status: event.type === "session.runtime_failed" || event.error ? "failed" : "completed",
|
||||
status:
|
||||
event.type === "session.runtime_failed" || event.error
|
||||
? "failed"
|
||||
: (this.childTurnOutcomes.get(childId) ?? "completed"),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1708,6 +1718,7 @@ 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,7 +643,6 @@ interface PendingPermission {
|
||||
resolve: (response: RequestPermissionResponse) => void;
|
||||
reject: (error: Error) => void;
|
||||
turnId: string | null;
|
||||
toolCallId: string;
|
||||
}
|
||||
|
||||
interface PendingUserMessage {
|
||||
@@ -2645,7 +2644,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
resolve,
|
||||
reject,
|
||||
turnId: this.activeForegroundTurnId,
|
||||
toolCallId: params.toolCall.toolCallId,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3209,9 +3207,6 @@ 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))];
|
||||
}
|
||||
|
||||
@@ -3419,11 +3414,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
|
||||
): void {
|
||||
this.deliverTranslatedEvents(this.flushPendingUserMessage());
|
||||
// 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,
|
||||
);
|
||||
// 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();
|
||||
}
|
||||
this.activeForegroundTurnId = null;
|
||||
this.fallbackAssistantMessageId = null;
|
||||
if (this.submittedUserMessageTurnId === event.turnId) {
|
||||
@@ -3432,21 +3427,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.pushEvent(event);
|
||||
}
|
||||
|
||||
private cancelPendingPermissions(
|
||||
matches: (pending: PendingPermission) => boolean = () => true,
|
||||
): void {
|
||||
for (const [requestId, pending] of this.pendingPermissions) {
|
||||
if (!matches(pending)) continue;
|
||||
this.pendingPermissions.delete(requestId);
|
||||
private cancelPendingPermissions(): void {
|
||||
for (const pending of this.pendingPermissions.values()) {
|
||||
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,57 +227,6 @@ 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,7 +3483,6 @@ 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
|
||||
@@ -5252,7 +5251,6 @@ 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;
|
||||
@@ -6418,7 +6416,6 @@ 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();
|
||||
@@ -6792,9 +6789,6 @@ 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);
|
||||
@@ -6842,8 +6836,10 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (timelineItem.type === "assistant_message") {
|
||||
this.pendingAssistantMessageBoundary = true;
|
||||
}
|
||||
this.emittedItemCompletedIds.add(itemId);
|
||||
this.emittedItemStartedIds.delete(itemId);
|
||||
if (itemId) {
|
||||
this.emittedItemCompletedIds.add(itemId);
|
||||
this.emittedItemStartedIds.delete(itemId);
|
||||
}
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
return;
|
||||
}
|
||||
@@ -6879,7 +6875,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private consumeStreamedTextCompletion(
|
||||
timelineItem: AgentTimelineItem,
|
||||
itemId: string | null | undefined,
|
||||
): itemId is string {
|
||||
): boolean {
|
||||
if (!itemId) {
|
||||
return false;
|
||||
}
|
||||
@@ -6988,7 +6984,6 @@ 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,
|
||||
@@ -7258,8 +7253,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
private async handleFileChangeApprovalRequest(params: unknown): Promise<unknown> {
|
||||
await this.pendingNotifications;
|
||||
private handleFileChangeApprovalRequest(params: unknown): Promise<unknown> {
|
||||
const parsed = z
|
||||
.object({
|
||||
itemId: z.string(),
|
||||
@@ -7269,25 +7263,20 @@ 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,
|
||||
title: "Apply file changes",
|
||||
description: parsed.reason ?? undefined,
|
||||
detail,
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: {
|
||||
reason: parsed.reason ?? null,
|
||||
},
|
||||
output: null,
|
||||
},
|
||||
metadata: {
|
||||
itemId: parsed.itemId,
|
||||
threadId: parsed.threadId,
|
||||
|
||||
@@ -43,13 +43,6 @@ interface FakeTerminalInput {
|
||||
processId: string;
|
||||
text: string;
|
||||
}
|
||||
interface FakeFileChange {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
callId: string;
|
||||
path: string;
|
||||
diff: string;
|
||||
}
|
||||
interface FakeLegacyPatch {
|
||||
threadId: string;
|
||||
callId: string;
|
||||
@@ -95,7 +88,6 @@ 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;
|
||||
@@ -475,20 +467,6 @@ 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 = [
|
||||
{
|
||||
|
||||
+22
-156
@@ -5,7 +5,6 @@ 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";
|
||||
|
||||
@@ -13,7 +12,6 @@ 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 }) => {
|
||||
@@ -27,16 +25,6 @@ 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");
|
||||
@@ -51,84 +39,6 @@ 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[] {
|
||||
@@ -137,10 +47,6 @@ 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"],
|
||||
@@ -171,32 +77,8 @@ 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"
|
||||
| "end_turn"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "outside_turn"
|
||||
| "outside_fail"
|
||||
| "outside_cancel",
|
||||
ending: "fail" | "cancel",
|
||||
run: (command: [string, ...string[]], cwd: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-permission-turn-failure-"));
|
||||
@@ -209,7 +91,8 @@ async function withFakeACPAgent(
|
||||
}
|
||||
}
|
||||
|
||||
// Turn one ends while its permission request is still open.
|
||||
// Turn one asks for permission, then fails or is cancelled while the request is
|
||||
// still open.
|
||||
// Later turns report whether that request was ever answered.
|
||||
const fakeACPAgentScript = `
|
||||
const readline = require("node:readline");
|
||||
@@ -218,34 +101,15 @@ 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) {
|
||||
if (message.id === "perm-1") permissionAnswer = message.result ?? { error: message.error };
|
||||
if (message.id === "perm-outside") outsidePermissionAnswer = message.result ?? { error: message.error };
|
||||
if (message.method === undefined && message.id === "perm-1") {
|
||||
permissionAnswer = message.result ?? { error: message.error };
|
||||
return;
|
||||
}
|
||||
if (message.method === "initialize") {
|
||||
@@ -253,7 +117,6 @@ 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;
|
||||
}
|
||||
@@ -261,32 +124,35 @@ rl.on("line", (line) => {
|
||||
const sessionId = message.params.sessionId;
|
||||
promptCount += 1;
|
||||
if (promptCount === 1) {
|
||||
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;
|
||||
}
|
||||
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" },
|
||||
],
|
||||
},
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (ending === "cancel" || ending === "outside_cancel") {
|
||||
if (ending === "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 answer = ending.startsWith("outside_") ? outsidePermissionAnswer : permissionAnswer;
|
||||
const requestId = ending.startsWith("outside_") ? "perm-outside" : "perm-1";
|
||||
const state = answer === null ? "unanswered" : "answered: " + JSON.stringify(answer);
|
||||
const state = permissionAnswer === null ? "unanswered" : "answered: " + JSON.stringify(permissionAnswer);
|
||||
write({
|
||||
method: "session/update",
|
||||
params: {
|
||||
sessionId,
|
||||
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: requestId + " " + state } },
|
||||
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "perm-1 " + state } },
|
||||
},
|
||||
});
|
||||
write({ id: message.id, result: { stopReason: "end_turn" } });
|
||||
|
||||
@@ -1600,111 +1600,6 @@ 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");
|
||||
@@ -3845,17 +3740,14 @@ readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
},
|
||||
);
|
||||
|
||||
test("a history resume of an agent whose folder is gone removes its session copy after replay and leaves the original unchanged", async () => {
|
||||
test("a history resume of an agent whose folder is gone removes its session copy on close 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);
|
||||
for await (const _event of session.streamHistory()) {
|
||||
// Completion releases both the runtime and its temporary session copy.
|
||||
}
|
||||
expect(pi.latestSession().closeCalls).toBe(1);
|
||||
await session.close();
|
||||
|
||||
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
|
||||
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
|
||||
|
||||
@@ -253,7 +253,6 @@ interface PiRpcAgentSessionOptions {
|
||||
currentModeId?: string | null;
|
||||
persistedSessionFile?: string;
|
||||
cleanup?: () => void;
|
||||
closeAfterHistory?: boolean;
|
||||
extensionTimeoutMs?: number;
|
||||
logger: Logger;
|
||||
usagePollScheduler?: PiUsagePollScheduler;
|
||||
@@ -1256,7 +1255,6 @@ 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);
|
||||
@@ -1288,7 +1286,6 @@ 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 {
|
||||
@@ -1457,20 +1454,16 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
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();
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||
@@ -2745,7 +2738,6 @@ 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,7 +153,6 @@ 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;
|
||||
@@ -162,7 +161,6 @@ 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,
|
||||
@@ -305,7 +303,6 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
}
|
||||
|
||||
async getMessages(): Promise<PiAgentMessage[]> {
|
||||
if (this.getMessagesError) throw this.getMessagesError;
|
||||
return this.messages;
|
||||
}
|
||||
|
||||
@@ -425,9 +422,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
this.respondToExtensionUiRequest(id, { cancelled: true });
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCalls += 1;
|
||||
}
|
||||
async close(): Promise<void> {}
|
||||
|
||||
emit(event: PiRuntimeEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
|
||||
@@ -2357,74 +2357,3 @@ 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([]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test } from "vitest";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
|
||||
@@ -140,225 +137,3 @@ test("omitted after limit pages forward while explicit zero preserves complete C
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test.each([
|
||||
{ label: "--json", flags: ["--json"] },
|
||||
{ label: "--format json", flags: ["--format", "json"] },
|
||||
])(
|
||||
"logs --follow $label keeps history, live items and notices on their channels",
|
||||
async ({ flags }) => {
|
||||
await mkdir(".artifacts", { recursive: true });
|
||||
const paseoHomeRoot = await mkdtemp(resolve(".artifacts/logs-follow-json-"));
|
||||
const staticDir = join(paseoHomeRoot, "static");
|
||||
await mkdir(staticDir);
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, staticDir });
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
let follow: ReturnType<typeof spawn> | undefined;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
try {
|
||||
await client.connect();
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
|
||||
const history = [
|
||||
{ type: "user_message" as const, text: "Saved JSON prompt" },
|
||||
{ type: "assistant_message" as const, text: "Saved JSON reply", messageId: "saved" },
|
||||
];
|
||||
for (const item of history)
|
||||
await daemon.daemon.agentManager.appendTimelineItem(agent.id, item);
|
||||
follow = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--conditions=source",
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
"logs",
|
||||
agent.id,
|
||||
"--follow",
|
||||
...flags,
|
||||
],
|
||||
{
|
||||
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
const exited = once(follow, "exit");
|
||||
follow.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
follow.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
if (follow?.exitCode !== null)
|
||||
throw new Error(`CLI exited before following logs: ${stderr}`);
|
||||
return stdout + stderr;
|
||||
},
|
||||
{ timeout: 20_000 },
|
||||
)
|
||||
.toContain("Following logs");
|
||||
const live = {
|
||||
type: "assistant_message" as const,
|
||||
text: "Live JSON reply",
|
||||
messageId: "live",
|
||||
};
|
||||
await daemon.daemon.agentManager.appendTimelineItem(agent.id, live);
|
||||
await expect.poll(() => stdout, { timeout: 10_000 }).toContain(live.text);
|
||||
follow.kill("SIGINT");
|
||||
await exited;
|
||||
expect(
|
||||
stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line)),
|
||||
).toEqual([...history, live]);
|
||||
expect(stderr).toContain("Following logs");
|
||||
expect(stdout).not.toContain("Following logs");
|
||||
} finally {
|
||||
follow?.kill("SIGTERM");
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ label: "--json", command: ["logs"], flags: ["--json"] },
|
||||
{ label: "--format json", command: ["agent", "logs"], flags: ["--format", "json"] },
|
||||
])(
|
||||
"logs --follow $label exits when stdout closes before saved history is written",
|
||||
async ({ command, flags }) => {
|
||||
await mkdir(".artifacts", { recursive: true });
|
||||
const paseoHomeRoot = await mkdtemp(resolve(".artifacts/logs-follow-closed-"));
|
||||
const staticDir = join(paseoHomeRoot, "static");
|
||||
await mkdir(staticDir);
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, staticDir });
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
let follow: ReturnType<typeof spawn> | undefined;
|
||||
let stderr = "";
|
||||
try {
|
||||
await client.connect();
|
||||
// No turn is started: the saved history must trigger shutdown on its own.
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
|
||||
await daemon.daemon.agentManager.appendTimelineItem(agent.id, {
|
||||
type: "assistant_message",
|
||||
text: "Saved reply before the reader closes",
|
||||
messageId: "saved",
|
||||
});
|
||||
follow = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--conditions=source",
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
...command,
|
||||
agent.id,
|
||||
"--follow",
|
||||
...flags,
|
||||
],
|
||||
{
|
||||
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
const exited = once(follow, "exit");
|
||||
follow.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
follow.stdout?.destroy();
|
||||
await expect.poll(() => follow?.exitCode, { timeout: 10_000 }).toBe(0);
|
||||
expect(await exited, stderr).toEqual([0, null]);
|
||||
} finally {
|
||||
follow?.kill("SIGKILL");
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
// Windows kill("SIGINT") force-terminates rather than delivering a console interrupt.
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"logs --follow stops on SIGINT while subscription startup is waiting",
|
||||
async () => {
|
||||
await mkdir(".artifacts", { recursive: true });
|
||||
const paseoHomeRoot = await mkdtemp(resolve(".artifacts/logs-follow-startup-"));
|
||||
const staticDir = join(paseoHomeRoot, "static");
|
||||
await mkdir(staticDir);
|
||||
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, staticDir });
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const proxy = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await once(proxy, "listening");
|
||||
const address = proxy.address();
|
||||
if (typeof address === "string" || address === null) throw new Error("Missing proxy port");
|
||||
const upstreams: WebSocket[] = [];
|
||||
let waitingForSubscription = false;
|
||||
proxy.on("connection", (downstream) => {
|
||||
const upstream = new WebSocket(`ws://127.0.0.1:${daemon.port}/ws`);
|
||||
upstreams.push(upstream);
|
||||
const connected = once(upstream, "open");
|
||||
downstream.on("message", async (data, binary) => {
|
||||
await connected;
|
||||
const frame = binary ? undefined : JSON.parse(data.toString());
|
||||
// Hold only the subscription request. All other traffic uses the real daemon.
|
||||
if (frame?.message?.type === "agent.timeline.set_subscription.request") {
|
||||
waitingForSubscription = true;
|
||||
return;
|
||||
}
|
||||
upstream.send(data, { binary });
|
||||
});
|
||||
upstream.on("message", (data, binary) => downstream.send(data, { binary }));
|
||||
downstream.on("close", () => upstream.close());
|
||||
});
|
||||
let follow: ReturnType<typeof spawn> | undefined;
|
||||
try {
|
||||
await client.connect();
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: daemon.paseoHome });
|
||||
follow = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--conditions=source",
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
|
||||
"--host",
|
||||
`127.0.0.1:${address.port}`,
|
||||
"logs",
|
||||
agent.id,
|
||||
"--follow",
|
||||
"--json",
|
||||
],
|
||||
{
|
||||
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
follow.stdout?.resume();
|
||||
follow.stderr?.resume();
|
||||
const exited = once(follow, "exit");
|
||||
await expect.poll(() => waitingForSubscription, { timeout: 10_000 }).toBe(true);
|
||||
follow.kill("SIGINT");
|
||||
await expect.poll(() => follow?.exitCode, { timeout: 5_000 }).toBe(0);
|
||||
expect(await exited).toEqual([0, null]);
|
||||
} finally {
|
||||
if (follow && follow.exitCode === null && follow.signalCode === null) {
|
||||
follow.kill("SIGKILL");
|
||||
await once(follow, "exit");
|
||||
}
|
||||
for (const upstream of upstreams) upstream.terminate();
|
||||
for (const downstream of proxy.clients) downstream.terminate();
|
||||
await new Promise<void>((done) => proxy.close(() => done()));
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
@@ -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, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { DaemonConfigStore } from "../daemon-config-store.js";
|
||||
import { PluginService } from "./index.js";
|
||||
import { BuiltinPluginLoader } from "./builtin/index.js";
|
||||
@@ -638,6 +638,91 @@ 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);
|
||||
@@ -1441,105 +1526,6 @@ 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,7 +1,4 @@
|
||||
import {
|
||||
parseCronExpression,
|
||||
validateCronExpression,
|
||||
} from "@getpaseo/protocol/schedule/cron-expression";
|
||||
import { parseCronExpression } from "@getpaseo/protocol/schedule/cron-expression";
|
||||
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
|
||||
|
||||
interface CronDateParts {
|
||||
@@ -81,10 +78,7 @@ function createCronDatePartsReader(timeZone: string | undefined): (date: Date) =
|
||||
|
||||
export function validateScheduleCadence(cadence: ScheduleCadence): void {
|
||||
if (cadence.type === "cron") {
|
||||
const error = validateCronExpression(cadence.expression);
|
||||
if (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
parseCronExpression(cadence.expression);
|
||||
if (cadence.timezone !== undefined) {
|
||||
assertValidTimeZone(cadence.timezone);
|
||||
}
|
||||
|
||||
@@ -2114,81 +2114,6 @@ 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,11 +6,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import {
|
||||
getCheckoutStatus as readCheckoutStatus,
|
||||
type CheckoutSnapshotFacts,
|
||||
type CheckoutStatusGit,
|
||||
} from "../utils/checkout-git.js";
|
||||
import type { CheckoutSnapshotFacts, 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";
|
||||
@@ -64,49 +60,6 @@ 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,7 +614,6 @@ 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>();
|
||||
@@ -1093,12 +1092,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.workingTreeWatchResolutions.clear();
|
||||
this.workingTreeWatchAliases.clear();
|
||||
this.snapshotUpdatedListeners.clear();
|
||||
// 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);
|
||||
this.disposePromise = this.fileObserver.close();
|
||||
return this.disposePromise;
|
||||
}
|
||||
|
||||
@@ -3218,13 +3212,11 @@ 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,31 +558,34 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
});
|
||||
|
||||
test("runs setup from an exact workspace subdirectory", async () => {
|
||||
const tempDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "worktree-setup-test-")));
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
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(workspaceCwd, "paseo.json"),
|
||||
path.join(sourceWorkspaceCwd, "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(
|
||||
{
|
||||
@@ -597,16 +600,16 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
requestCwd: sourceWorkspaceCwd,
|
||||
repoRoot: repoDir,
|
||||
workspaceId: "ws-subdirectory-setup",
|
||||
worktree,
|
||||
worktree: createdWorktree,
|
||||
shouldBootstrap: true,
|
||||
slug: "feature-subdirectory-setup",
|
||||
worktreePath: worktree.worktreePath,
|
||||
worktreePath: createdWorktree.worktreePath,
|
||||
workspaceCwd,
|
||||
},
|
||||
);
|
||||
|
||||
expect(existsSync(path.join(workspaceCwd, "setup-cwd.txt"))).toBe(true);
|
||||
expect(existsSync(path.join(worktree.worktreePath, "setup-cwd.txt"))).toBe(false);
|
||||
expect(existsSync(path.join(createdWorktree.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