mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
802359152e | ||
|
|
196dc3acd3 | ||
|
|
e867773932 | ||
|
|
bfe38d92f6 |
@@ -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"));
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
+150
@@ -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({
|
||||
|
||||
+2
-1
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user