Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 802359152e test(app): retain Android workspace memory diagnostic
Preserve the device assertion for a supplemental LOW_MEMORY exit seen with 800 projects. This does not establish the cause of the cold-start ANR reported in #4408 and changes no production behavior.
2026-10-13 00:41:58 +02:00
paseo-bot[bot] 196dc3acd3 fix(pi): update thinking levels when extensions change them (#6741)
Publish the existing thinking-option event as soon as Pi changes its effective level, keeping subscribers and persistence in sync before the next state refresh.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 23:56:31 +02:00
e867773932 Restore native app startup (#6734)
* fix(app): install navigator polyfill before any app module loads

@xterm/headless reads navigator.userAgent at module evaluation. Since
#6455 the root layout transitively imports it, so the layout's
module-level polyfillNavigator() ran too late and native builds crashed
at launch with "Cannot read property 'includes' of undefined".

Install the crypto, navigator and screen.orientation polyfills from a
side-effect module imported first by index.ts. Calls written in index.ts
itself ran after all hoisted imports had evaluated.

Fixes #6504

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

* test(app): isolate native startup regression from shared globals

Exercise the real navigator polyfill and terminal dependency in fresh VM contexts, including the pre-initialization failure. Preserve a check that entry imports install navigator before the app loads.

Co-authored-by: panrafal <rl@stamina.pl>

* test(app): follow startup import lint rule

---------

Co-authored-by: panrafal <rl@stamina.pl>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 22:59:25 +02:00
Sevn ChenandChen-DaYi bfe38d92f6 fix(pi): mark turn-limited tintinweb subagents as completed (#6594)
Co-authored-by: Chen-DaYi <12897415+Chen-DaYi@users.noreply.github.com>
2026-10-12 22:01:25 +02:00
paseo-bot[bot]andmistop 9551b2cc11 Open loaded Linux desktop windows when hidden rendering stalls (#6726)
* fix(desktop): show the window when ready-to-show never fires on Linux

Reveal hidden Linux windows that do not receive an initial compositor frame.

* fix(desktop): open loaded Linux windows without waiting for a frame

A hidden Linux window can finish loading without painting, so waiting only for ready-to-show leaves the application invisible. Reveal the loaded document and retain the frame-ready path on other platforms.

Co-authored-by: mistop <jhlim@mistop.org>

---------

Co-authored-by: mistop <jhlim@mistop.org>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 18:05:04 +02:00
Elowen 6f62125ee0 fix: return dot-prefixed paths in workspace suggestions (#6720)
* fix(paths): allow double-dot names in contained paths

* test(paths): cover dot-prefixed workspace suggestions
2026-10-12 17:14:54 +02:00
20 changed files with 631 additions and 17 deletions
+16
View File
@@ -311,6 +311,22 @@ frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
battery while the app is idle — so do not re-add it. The probe's visibility
guards already prevent throttling from causing a false stall.
### Desktop Linux hidden-window fallback
On Wayland (seen on ChromeOS Linux) a window created with `show: false` can sit
forever without a single compositor frame: the renderer loads and runs, but
`ready-to-show` never fires, so the window is never shown and the app looks like
it did not start while every process is alive. `showWindowWhenReady`
(`packages/desktop/src/window/show-when-ready.ts`) shows the main window when
the document finishes loading if the event has not arrived. The fallback is
Linux-only; macOS and Windows keep waiting for `ready-to-show`.
Run the startup regression on Linux with an isolated display:
```bash
xvfb-run -a npm run test:e2e:window-startup --workspace=@getpaseo/desktop
```
### Daemon logs
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
@@ -0,0 +1,21 @@
# Android workspace memory diagnostic
Supplemental reproduction for [#4408](https://github.com/getpaseo/paseo/issues/4408), using the production 0.11.2 APK on an Android 16 emulator. This tests an adapted failure: opening the sidebar with 800 projects, each containing one workspace, produced a `LOW_MEMORY` exit after 66–90 seconds. It does not reproduce the reported 18-second cold-start ANR or establish its cause.
Before running:
- Use a dedicated emulator with the production APK installed and notifications permission settled. Bind the agent-device session to that emulator before running.
- Pair the app with an isolated daemon containing 800 active workspaces, each in its own project. Use Project grouping and leave projects expanded. The observed fixture used existing directories inside a Git checkout, with workspace titles permuted by `(index * 317) % 800`, no agents, and no terminals.
- Record the daemon revision, emulator RAM and Java heap settings, and the project/workspace distribution. Device memory pressure controls whether Android kills the app.
Run from the repository root with the agent-device session, Android serial, and an artifact directory:
```sh
node packages/app/e2e/mobile/workspace-memory/android.mjs workspace-memory emulator-5556 .dev/workspace-memory
```
The test cold-starts the paired app, opens its sidebar, samples memory and the process ID every five seconds for two minutes, and verifies that closing the sidebar responds. A process exit or restart fails the assertion. Memory, exit records, and logs stay in the supplied artifact directory.
Control: repeat with 800 workspaces grouped into one project. That distribution survived the two-minute automated check and responded to closing the sidebar, with total PSS near 300 MB. The project-per-workspace fixture reached about 2.5 GB PSS before Android recorded `LOW_MEMORY`; its Java heap rose and fell rather than showing the reporter's full-heap ANR. Neither result proves that shared collators would fix the report.
This diagnostic is deliberately outside CI and contains no production change.
@@ -0,0 +1,65 @@
// Requires a paired production app and the workspace fixture described in README.md.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, serial, artifacts] = process.argv;
assert(session && serial && artifacts, "Pass session, Android serial, and artifact directory");
mkdirSync(artifacts, { recursive: true });
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8",
timeout: 60_000,
});
}
function adb(...args) {
return execFileSync("adb", ["-s", serial, ...args], {
encoding: "utf8",
timeout: 15_000,
});
}
function pid() {
try {
return adb("shell", "pidof", "sh.paseo").trim();
} catch {
return "";
}
}
ad("open", "sh.paseo", "--relaunch", "--platform", "android");
ad("wait", "text", "Open menu", "15000");
assert.equal(
JSON.parse(ad("perf", "memory", "sample", "--json")).data.deviceId,
serial,
"Session and adb serial must address the same device",
);
const initialPid = pid();
assert(initialPid, "App must have a process before opening the sidebar");
ad("press", 'label="Open menu"', "--settle");
const startedAt = Date.now();
const samples = [];
try {
while (Date.now() - startedAt < 120_000) {
const currentPid = pid();
samples.push({
elapsedMs: Date.now() - startedAt,
pid: currentPid,
meminfo: currentPid ? adb("shell", "dumpsys", "meminfo", "sh.paseo") : "",
});
writeFileSync(join(artifacts, "samples.json"), JSON.stringify(samples, null, 2));
assert.equal(currentPid, initialPid, "Opening the workspace sidebar must keep the app alive");
await delay(5_000);
}
ad("press", 'label="Close sidebar"', "--settle");
ad("wait", "text", "Open menu", "10000");
console.log("PASS: app survived two minutes with the sidebar open and responded to Close");
} finally {
writeFileSync(
join(artifacts, "exit-info.txt"),
adb("shell", "dumpsys", "activity", "exit-info", "sh.paseo"),
);
writeFileSync(join(artifacts, "logcat.txt"), adb("logcat", "-d", "-t", "4000"));
}
+3 -7
View File
@@ -1,10 +1,6 @@
// Polyfill crypto.randomUUID for React Native before any other imports
import { polyfillCrypto } from "./src/polyfills/crypto";
polyfillCrypto();
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
polyfillScreenOrientation();
// Install runtime polyfills before any other module evaluates. Keep this the first import.
// oxlint-disable-next-line import/no-unassigned-import -- Polyfills install on import.
import "./src/polyfills/install-early";
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
import "./src/styles/unistyles";
-2
View File
@@ -103,7 +103,6 @@ import {
useKeyboardActionDispatcher,
} from "@/keyboard/keyboard-action-dispatcher-context";
import { polyfillCrypto } from "@/polyfills/crypto";
import { polyfillNavigator } from "@/polyfills/navigator";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
@@ -139,7 +138,6 @@ import {
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
polyfillNavigator();
polyfillCrypto();
export interface HostRuntimeBootstrapState {
@@ -0,0 +1,12 @@
// Side-effect module imported first by index.ts. Babel hoists imports above
// statements, so polyfill calls written in index.ts would run only after every
// imported module had evaluated. Modules that read these globals at load time,
// such as @xterm/headless reading navigator.userAgent, need them installed here.
import { polyfillCrypto } from "./crypto";
import { polyfillNavigator } from "./navigator";
import { polyfillScreenOrientation } from "./screen-orientation";
polyfillCrypto();
polyfillNavigator();
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
polyfillScreenOrientation();
@@ -0,0 +1,56 @@
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { createContext, runInContext } from "node:vm";
import ts from "typescript";
import { expect, it } from "vitest";
it("installs navigator before the app entry loads its other dependencies", () => {
const entry = ts.createSourceFile(
"index.ts",
readFileSync(new URL("../../index.ts", import.meta.url), "utf8"),
ts.ScriptTarget.Latest,
);
const firstImport = entry.statements.find(ts.isImportDeclaration);
expect(firstImport?.moduleSpecifier.getText(entry)).toBe('"./src/polyfills/install-early"');
const early = ts.createSourceFile(
"install-early.ts",
readFileSync(new URL("./install-early.ts", import.meta.url), "utf8"),
ts.ScriptTarget.Latest,
);
expect(
early.statements.some(
(statement) =>
ts.isExpressionStatement(statement) &&
ts.isCallExpression(statement.expression) &&
statement.expression.expression.getText(early) === "polyfillNavigator",
),
).toBe(true);
});
it("initializes the native navigator before evaluating the real terminal dependency", () => {
const require = createRequire(import.meta.url);
const terminalSource = readFileSync(require.resolve("@xterm/headless"), "utf8");
const navigatorSource = ts.transpileModule(
readFileSync(new URL("./navigator.ts", import.meta.url), "utf8"),
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } },
).outputText;
// Each VM owns its globals, so the native navigator shape never replaces
// globals in the test runner or needs a native-module mock.
const uninitialized = createContext({
navigator: {},
window: {},
module: { exports: {} },
exports: {},
});
expect(() => runInContext(terminalSource, uninitialized)).toThrow(/includes/);
const initialized = createContext({
navigator: {},
window: {},
module: { exports: {} },
exports: {},
});
runInContext(navigatorSource, initialized);
runInContext("exports.polyfillNavigator()", initialized);
expect(() => runInContext(terminalSource, initialized)).not.toThrow();
});
@@ -0,0 +1,48 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { _electron as electron, expect } from "playwright/test";
const root = await mkdtemp(path.join(tmpdir(), "paseo-window-startup-"));
const modulePath = fileURLToPath(new URL("../dist/window/show-when-ready.js", import.meta.url));
async function launchWindowWithoutHiddenFrames() {
const main = path.join(root, "main.cjs");
await writeFile(
main,
`const { app, BrowserWindow } = require("electron");
const { showWindowWhenReady } = require(${JSON.stringify(modulePath)});
app.setPath("userData", ${JSON.stringify(path.join(root, "user-data"))});
app.whenReady().then(async () => {
const win = global.mainWindow = new BrowserWindow({ show: false, paintWhenInitiallyHidden: false });
showWindowWhenReady(win, { showOnLoad: true });
await win.loadURL("data:text/html,<h1>Desktop ready</h1>");
global.visibleAfterLoad = win.isVisible();
});`,
);
const env = Object.fromEntries(
Object.entries(process.env).filter(([key]) => key !== "ELECTRON_RUN_AS_NODE"),
);
const displayArgs = process.platform === "linux" ? ["--ozone-platform=x11", "--disable-gpu"] : [];
return electron.launch({
args: ["--no-sandbox", ...displayArgs, main],
env,
});
}
async function expectLoadedDesktopIsShown(desktop) {
await expect.poll(() => desktop.evaluate(() => global.visibleAfterLoad)).toBe(true);
const page = await desktop.firstWindow();
await expect(page.getByRole("heading", { name: "Desktop ready" })).toBeVisible();
}
let desktop;
try {
desktop = await launchWindowWithoutHiddenFrames();
await expectLoadedDesktopIsShown(desktop);
console.log("Loaded desktop is visible without hidden compositor frames");
} finally {
await desktop?.close();
await rm(root, { recursive: true, force: true });
}
+2 -1
View File
@@ -25,7 +25,8 @@
"verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs",
"test": "install-electron && vitest run --exclude \"e2e/**\"",
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs"
"test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs",
"test:e2e:window-startup": "npm run build:main && node ./e2e/window-startup.e2e.mjs"
},
"dependencies": {
"@getpaseo/cli": "*",
+3 -2
View File
@@ -43,6 +43,7 @@ import {
buildStandardContextMenuItems,
} from "./window/window-manager.js";
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
import { showWindowWhenReady } from "./window/show-when-ready.js";
import { resolveDesktopWindowChromeMode, windowChromeModeArgument } from "./window/chrome.js";
import { registerDialogHandlers } from "./features/dialogs.js";
import {
@@ -773,8 +774,8 @@ async function createWindow(
registerBrowserWebviewNavigationGuards(contents);
});
mainWindow.once("ready-to-show", () => {
mainWindow.show();
showWindowWhenReady(mainWindow, {
showOnLoad: process.platform === "linux",
});
if (!app.isPackaged) {
@@ -0,0 +1,57 @@
import { EventEmitter } from "node:events";
import { describe, expect, it } from "vitest";
import { showWindowWhenReady } from "./show-when-ready.js";
class Window extends EventEmitter {
webContents = new EventEmitter();
shows = 0;
destroyed = false;
show() {
this.shows += 1;
}
isDestroyed() {
return this.destroyed;
}
close() {
this.destroyed = true;
this.emit("closed");
}
}
describe("showWindowWhenReady", () => {
it("shows a loaded Linux window even when no initial frame was painted", () => {
const win = new Window();
showWindowWhenReady(win, { showOnLoad: true });
expect(win.shows).toBe(0);
win.webContents.emit("did-finish-load");
expect(win.shows).toBe(1);
win.emit("ready-to-show");
expect(win.shows).toBe(1);
});
it("shows on the first frame without showing again after load", () => {
const win = new Window();
showWindowWhenReady(win, { showOnLoad: true });
win.emit("ready-to-show");
win.webContents.emit("did-finish-load");
expect(win.shows).toBe(1);
});
it("preserves waiting for the initial frame when show on load is disabled", () => {
const win = new Window();
showWindowWhenReady(win, { showOnLoad: false });
win.webContents.emit("did-finish-load");
expect(win.shows).toBe(0);
win.emit("ready-to-show");
expect(win.shows).toBe(1);
});
it("keeps a closed window closed when readiness arrives later", () => {
const win = new Window();
showWindowWhenReady(win, { showOnLoad: true });
win.close();
win.webContents.emit("did-finish-load");
win.emit("ready-to-show");
expect(win.shows).toBe(0);
});
});
@@ -0,0 +1,25 @@
interface ReadyToShowWindow {
once(event: "ready-to-show", listener: () => void): unknown;
webContents: {
once(event: "did-finish-load", listener: () => void): unknown;
};
show(): void;
isDestroyed(): boolean;
}
export function showWindowWhenReady(
win: ReadyToShowWindow,
options: { showOnLoad: boolean },
): void {
let shown = false;
const show = () => {
if (shown || win.isDestroyed()) return;
shown = true;
win.show();
};
win.once("ready-to-show", show);
// On Linux a hidden window can wait for compositor frames until it is shown.
// Loading the document is sufficient to reveal it without depending on a frame.
if (options.showOnLoad) win.webContents.once("did-finish-load", show);
}
@@ -1943,6 +1943,48 @@ describe("PiRpcAgentSession", () => {
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("off");
});
test.each(["high", "off", "max"] as const)(
"publishes an extension's thinking change to %s before refreshing state",
async (level) => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const changes: AgentStreamEvent[] = [];
session.subscribe((event) => {
if (event.type === "thinking_option_changed") {
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(level);
changes.push(event);
}
});
const runtime = pi.latestSession();
runtime.state = { ...runtime.state, thinkingLevel: level };
runtime.emit({ type: "thinking_level_changed", level });
expect(changes).toEqual([
{ type: "thinking_option_changed", provider: "pi", thinkingOptionId: level },
]);
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(level);
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: level });
},
);
test.each([undefined, null, "invalid", 123])(
"ignores a thinking change with invalid level %s",
async (level) => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const initialThinking = session.describePersistence()?.metadata?.thinkingOptionId;
const changes: AgentStreamEvent[] = [];
session.subscribe((event) => {
if (event.type === "thinking_option_changed") changes.push(event);
});
pi.latestSession().emit({ type: "thinking_level_changed", level });
expect(changes).toEqual([]);
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(initialThinking);
},
);
test("surfaces state refresh failures after a Pi thinking update", async () => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
@@ -1005,6 +1005,8 @@ function isProcessExitEvent(
function isPiAgentSessionEvent(event: PiRuntimeEvent): event is PiAgentSessionEvent {
switch (event.type) {
case "thinking_level_changed":
return typeof event.level === "string" && normalizePiThinkingOption(event.level) !== null;
case "agent_start":
case "turn_start":
case "message_start":
@@ -2136,6 +2138,15 @@ export class PiRpcAgentSession implements AgentSession {
}
switch (event.type) {
case "thinking_level_changed":
this.state = { ...this.state, thinkingLevel: event.level };
this.config.thinkingOptionId = event.level;
this.emit({
type: "thinking_option_changed",
provider: this.provider,
thinkingOptionId: event.level,
});
return;
case "agent_start":
this.activeTurnStarted = true;
this.clearNoTurnBuffers();
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
import { fileURLToPath } from "node:url";
import { createPiExtensionHost } from "../index.js";
import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js";
import type { PiAgentMessage } from "../../rpc-types.js";
describe("@tintinweb/pi-subagents adapter", () => {
test("exposes the background output file while the child is running", () => {
@@ -22,6 +23,27 @@ describe("@tintinweb/pi-subagents adapter", () => {
]);
expect(mapping?.childSessions).toEqual([{ id: "call-1", file: "/tmp/child.output" }]);
});
test("marks a foreground child that wrapped up at its turn limit as completed", () => {
const mapping = createPiExtensionHost().mapToolCall({
callId: "call-1",
toolName: "Agent",
args: { subagent_type: "Explore", prompt: "Inspect" },
status: "completed",
result: {
details: { agentId: "native-1", status: "steered" },
content: [{ type: "text", text: "Wrapped up (turn limit)" }],
},
});
expect(mapping?.subagents).toEqual([
expect.objectContaining({ id: "call-1", status: "completed" }),
]);
expect(mapping?.detail).toEqual({
type: "sub_agent",
subAgentType: "Explore",
description: "Inspect",
log: "Wrapped up (turn limit)",
});
});
test("maps captured foreground lifecycle live and on replay", async () => {
const events = await verifySubagentFixture(
readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)),
@@ -65,6 +87,134 @@ describe("@tintinweb/pi-subagents adapter", () => {
call_277179: "completed",
});
});
test.each(["subagent-notification", "subagent-update"])(
"completes turn-limited children in a grouped %s",
(customType) => {
const host = createPiExtensionHost();
for (const id of ["1", "2", "3"]) {
host.mapToolCall({
callId: `call-${id}`,
toolName: "Agent",
args: { subagent_type: "Explore", prompt: "Inspect" },
status: "completed",
result: { details: { agentId: `native-${id}`, status: "background" } },
});
}
const mapping = host.mapCustomMessage({
role: "custom",
customType,
content: "Wrapped up (turn limit)",
display: true,
timestamp: 0,
details: {
id: "native-1",
status: "steered",
others: [
{ id: "native-2", status: "steered" },
{ id: "native-3", status: "running" },
],
},
});
expect(mapping?.subagents).toEqual([
expect.objectContaining({ id: "call-1", status: "completed" }),
expect.objectContaining({ id: "call-2", status: "completed" }),
expect.objectContaining({ id: "call-3", status: "running" }),
]);
},
);
test.each(["get_subagent_result", "steer_subagent"])(
"completes a turn-limited child when collected through %s",
(toolName) => {
const host = createPiExtensionHost();
host.mapToolCall({
callId: "call-1",
toolName: "Agent",
args: { subagent_type: "Explore", prompt: "Inspect" },
status: "completed",
result: { details: { agentId: "native-1", status: "background" } },
});
const mapping = host.mapToolCall({
callId: "followup-1",
toolName,
args: { agent_id: "native-1" },
status: "completed",
result: {
details: { agentId: "native-1", status: "steered" },
content: [{ type: "text", text: "Wrapped up (turn limit)" }],
},
});
expect(mapping?.subagents).toEqual([{ type: "upsert", id: "call-1", status: "completed" }]);
expect(mapping?.detail).toEqual({
type: "sub_agent",
description: undefined,
log: "Wrapped up (turn limit)",
});
},
);
test.each([
["background", "running"],
["queued", "running"],
["running", "running"],
["completed", "completed"],
["error", "failed"],
["aborted", "canceled"],
["stopped", "canceled"],
["unknown", "running"],
])("preserves the mapping of %s to %s", (status, expected) => {
const mapping = createPiExtensionHost().mapToolCall({
callId: "call-1",
toolName: "Agent",
args: { subagent_type: "Explore", prompt: "Inspect" },
status: "completed",
result: { details: { agentId: "native-1", status } },
});
expect(mapping?.subagents).toEqual([
expect.objectContaining({ id: "call-1", status: expected }),
]);
});
test("completes a turn-limited background child live and on history replay", async () => {
const path = new URL("./fixtures/background.json", import.meta.url);
const source = readSubagentFixture(path);
const notification = source.messages.find((message) => message.role === "custom");
const notificationDetails = notification?.details;
if (
!notificationDetails ||
typeof notificationDetails !== "object" ||
!("outputFile" in notificationDetails) ||
typeof notificationDetails.outputFile !== "string"
) {
throw new Error("Background fixture is missing its child output file");
}
const fixture = readSubagentFixture(path, {
from: notificationDetails.outputFile,
to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)),
});
function wrapUp(message: PiAgentMessage): PiAgentMessage {
if (message.role !== "custom") return message;
const details = message.details;
if (!details || typeof details !== "object") {
throw new Error("Background fixture is missing its notification details");
}
return {
...message,
content: "Wrapped up (turn limit)",
details: { ...details, status: "steered" },
};
}
const events = await verifySubagentFixture({
...fixture,
events: fixture.events.map((event) =>
event.type === "message_end" ? { ...event, message: wrapUp(event.message) } : event,
),
messages: fixture.messages.map(wrapUp),
});
expect(
events
.filter((event) => event.event.type === "upsert")
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
).toEqual(["running", "running", "completed"]);
expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0);
});
test("declines foreign Agent results", () => {
expect(
createPiExtensionHost().mapToolCall({
@@ -33,7 +33,8 @@ const Notification = NotificationEntry.extend({
others: z.array(NotificationEntry).optional(),
});
const status = (value: string): "running" | "completed" | "failed" | "canceled" => {
if (value === "completed") return "completed";
// A soft turn-limit wrap-up has finished; it is not an in-flight steering request.
if (value === "completed" || value === "steered") return "completed";
if (value === "error") return "failed";
if (value === "aborted" || value === "stopped") return "canceled";
return "running";
@@ -160,6 +160,7 @@ export type PiAssistantMessageEvent =
| { type: "start" | "text_start" | "text_end" | "thinking_start" | "thinking_end" | "done" };
export type PiAgentSessionEvent =
| { type: "thinking_level_changed"; level: PiThinkingLevel }
| { type: "agent_start" }
| { type: "turn_start" }
| { type: "message_start"; message: PiAgentMessage }
@@ -23,6 +23,89 @@ afterEach(async () => {
}, 60000);
describe("file explorer", () => {
test.each(["..", "..."])(
"workspace suggestions resolve exact paths starting with %s",
async (prefix) => {
const cwd = tmpCwd();
const directoryName = `${prefix}docs`;
const nestedPath = `${directoryName}/notes.md`;
const fileName = `${prefix}notes.md`;
try {
mkdirSync(path.join(cwd, directoryName));
writeFileSync(path.join(cwd, nestedPath), "nested payload\n");
writeFileSync(path.join(cwd, fileName), "direct payload\n");
const file = await ctx.client.readFile(cwd, nestedPath);
expect(new TextDecoder().decode(file.bytes)).toBe("nested payload\n");
const nestedEntry = { path: nestedPath, kind: "file" };
const entries = [
nestedEntry,
{ path: directoryName, kind: "directory" },
{ path: fileName, kind: "file" },
];
const cases = entries.flatMap((entry) => [
{ query: `./${entry.path}`, entry },
{ query: path.resolve(cwd, entry.path), entry },
]);
cases.unshift({ query: nestedPath, entry: nestedEntry });
for (const { query, entry } of cases) {
const response = await ctx.client.getDirectorySuggestions({
cwd,
query,
limit: 1,
includeFiles: true,
includeDirectories: true,
matchMode: "suffix",
});
expect(response.error).toBeNull();
expect(response.entries).toEqual([entry]);
}
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
);
test("workspace suggestions exclude exact parent and sibling paths", async () => {
const directory = tmpCwd();
const cwd = path.join(directory, "workspace");
const sibling = path.join(directory, "workspace-other");
try {
mkdirSync(cwd);
mkdirSync(sibling);
writeFileSync(path.join(cwd, "visible.txt"), "inside payload\n");
writeFileSync(path.join(sibling, "outside.txt"), "outside payload\n");
for (const query of [
"./..",
"../workspace-other/outside.txt",
path.join(sibling, "outside.txt"),
]) {
const response = await ctx.client.getDirectorySuggestions({
cwd,
query,
limit: 1,
includeFiles: true,
includeDirectories: true,
matchMode: "suffix",
});
expect(response.error).toBeNull();
expect(response.entries).toEqual([]);
}
const control = await ctx.client.getDirectorySuggestions({
cwd,
query: "./visible.txt",
limit: 1,
includeFiles: true,
includeDirectories: false,
matchMode: "suffix",
});
expect(control.error).toBeNull();
expect(control.entries).toEqual([{ path: "visible.txt", kind: "file" }]);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test.each([
{ description: "a long ASCII name", fileName: `${"a".repeat(236)}.txt` },
{ description: "an ordinary name", fileName: "notes.txt" },
+29
View File
@@ -42,6 +42,35 @@ describe("path equivalence", () => {
);
});
test.each([
{ root: "/repo", candidate: "/repo/..notes.txt", inside: true },
{ root: "/repo", candidate: "/repo/..drafts/notes.md", inside: true },
{ root: "/repo", candidate: "/repo/...drafts/notes.md", inside: true },
{ root: "/repo", candidate: "/repo/docs/..notes.txt", inside: true },
{ root: "/repo", candidate: "/repo", inside: true },
{ root: "/repo", candidate: "/repo/../sibling/notes.md", inside: false },
{ root: "/repo", candidate: "/repo/..drafts/../../outside.txt", inside: false },
{ root: "/repo", candidate: "/repo-other/notes.md", inside: false },
{ root: "C:\\Repo", candidate: "c:/repo/..notes.txt", inside: true },
{ root: "C:\\Repo", candidate: "C:\\Repo\\..drafts\\notes.md", inside: true },
{ root: "C:\\Repo", candidate: "C:\\Repo\\...drafts\\notes.md", inside: true },
{ root: "C:\\Repo", candidate: "C:\\Repo\\..\\sibling\\notes.md", inside: false },
{ root: "C:\\Repo", candidate: "C:\\Repo-other\\notes.md", inside: false },
{ root: "C:\\Repo", candidate: "D:\\Repo\\..notes.txt", inside: false },
{
root: String.raw`\\server\share\repo`,
candidate: String.raw`\\server\share\repo\..drafts\notes.md`,
inside: true,
},
{
root: String.raw`\\?\C:\Repo`,
candidate: "C:\\Repo\\..notes.txt",
inside: true,
},
])("checks named path containment for $candidate", ({ root, candidate, inside }) => {
expect(isPathInsideRoot(root, candidate)).toBe(inside);
});
test("preserves the casing of Windows relative suffixes", () => {
expect(getRealpathAwareRelativePath("C:\\Repo\\.git", "c:\\repo\\.git\\HEAD")).toBe("HEAD");
expect(
+5 -4
View File
@@ -114,10 +114,11 @@ function getRelativePathInsideRoot(root: string, candidate: string): string | nu
const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows);
const comparableRelative = platformPath.relative(normalizedRoot, normalizedCandidate);
if (
comparableRelative !== "" &&
(comparableRelative.startsWith("..") || platformPath.isAbsolute(comparableRelative))
) {
const isOutsideRoot =
comparableRelative === ".." ||
comparableRelative.startsWith(`..${platformPath.sep}`) ||
platformPath.isAbsolute(comparableRelative);
if (isOutsideRoot) {
return null;
}