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
5 changed files with 140 additions and 0 deletions
@@ -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"));
}
@@ -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();
@@ -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 }