Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 15635531c6 fix(app): keep tablet orientation policy in activity
Android resolves manifest resources without screen size qualifiers, so preserve the portrait declaration and unlock tablets when the activity starts.
2026-10-30 03:50:14 +01:00
b678e122d5 fix(app): allow Android tablets to rotate
Choose the orientation before the first activity frame, and update it when the available screen size changes.

Co-authored-by: yz <43207690+yzim@users.noreply.github.com>
Co-authored-by: magiclaw <930198090@qq.com>
2026-10-30 03:42:37 +01:00
4351445467 Keep other windows in sync when an agent is deleted (#8248)
* fix(server): broadcast agent removals across logical sessions

* test(server): make subscription fixtures portable on Windows

The newly enabled integration suite assumed /bin/cat and a one-second reply deadline, so Windows CI failed before completing its subscription coverage. Use the running Node executable for echo terminals and let diagnostics finish their bounded external tool probes.

Co-authored-by: loganmeyer <50949542+loganmeyer@users.noreply.github.com>

---------

Co-authored-by: loganmeyer <50949542+loganmeyer@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-10-30 00:05:34 +00:00
paseo-bot[bot] 9503537041 test(server): exclude loader activity from config responsiveness (#8218)
* test(server): trace recurring config responsiveness failures

* test(server): keep loader messages out of config responsiveness probes

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 21:02:58 +00:00
paseo-bot[bot] 1aef1ec662 test(server): isolate timeline fetch responsiveness from runner work (#8197)
* test(server): isolate timeline fetch responsiveness from runner work

Measure the existing cold tail workload in a separate process after fixture collection. Keep the 50ms gate and row assertions, and reproduce unrelated callback CPU contaminating the old measurement.

* test(server): keep timeline probe private and exercise overlapping CPU

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 12:21:29 +00:00
946b6e58fd Keep transcript colors in sync after a hidden theme change (#8199)
* fix(app): preserve adaptive theme events while hidden

Read the current scheme through fresh matchMedia lists so hidden runtime
reads do not consume the pending events on listener-owned lists.

Carry Dan Sabin's fix from #7172 into the current combined Unistyles patch,
and cover both transition directions with a real Electron regression in
the existing desktop browser suite.

Co-authored-by: Dan Sabin <dan@freethinking.it>

* test(desktop): run adaptive theme regression on Linux

Electron retains --no-sandbox in argv, so the positional lookup used the script filename as the artifact directory. Read the final argument so the same real Electron regression exercises Linux as well as macOS.

Co-authored-by: Nurşat Bülbül <85260051+Nurshot@users.noreply.github.com>
Co-authored-by: Dan Sabin <dan@freethinking.it>

---------

Co-authored-by: Nurshot <85260051+Nurshot@users.noreply.github.com>
Co-authored-by: Dan Sabin <dan@freethinking.it>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-29 11:16:48 +00:00
18 changed files with 646 additions and 99 deletions
+8
View File
@@ -358,6 +358,14 @@ export to `src`, but browser and Electron Metro resolve its `browser` export to
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
Electron running the old code.
The adaptive-theme patch keeps `colorScheme` reads off the media-query lists
owned by Unistyles' change listeners. Chromium defers their events while a window
is hidden; reading `matches` on those same lists can consume the pending change.
CSS variables then follow the OS theme while `withUnistyles` props retain the old
colors. Keep this patch until upstream reads the scheme through separate lists.
The desktop browser E2E checks both transition directions in an isolated Electron
process, because Playwright's focus emulation prevents real hidden-page behavior.
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
`patches/` is inert unless that script knows which installed package activates
it.
+2
View File
@@ -1,3 +1,4 @@
const withAndroidOrientation = require("./plugins/with-android-orientation");
const fs = require("node:fs");
const path = require("node:path");
const pkg = require("./package.json");
@@ -142,6 +143,7 @@ export default {
searchPaths: ["../../node_modules", "./node_modules"],
},
plugins: [
withAndroidOrientation,
"expo-router",
withPasteInput,
withAndroidScroll,
+28
View File
@@ -59,3 +59,31 @@ describe("Android app config", () => {
30_000,
);
});
it("keeps the Android manifest portrait for phone startup", async () => {
const manifest = await resolveAndroidManifest();
const activity = manifest.application[0].activity.find(
(entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity",
);
expect(activity.$["android:screenOrientation"]).toBe("portrait");
}, 30_000);
it("updates the native orientation before launch and when screen size changes", async () => {
const plugin = createRequire(import.meta.url)("./plugins/with-android-orientation.js");
const config = plugin({ name: "test", slug: "test" });
const result = await config.mods.android.mainActivity({
modRequest: {},
modResults: {
language: "kt",
contents:
"class MainActivity {\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(null)\n }\n}",
},
});
expect(result.modResults.contents).toContain("smallestScreenWidthDp >= 600");
expect(result.modResults.contents).toContain("SCREEN_ORIENTATION_UNSPECIFIED");
expect(result.modResults.contents).toContain("SCREEN_ORIENTATION_PORTRAIT");
expect(result.modResults.contents).toContain(
"updateOrientation(resources.configuration)\n super.onCreate(null)",
);
expect(result.modResults.contents).toContain("updateOrientation(newConfig)");
});
@@ -0,0 +1,38 @@
const { withMainActivity } = require("expo/config-plugins");
module.exports = function withAndroidOrientation(config) {
return withMainActivity(config, (mod) => {
const activity = mod.modResults;
if (activity.language !== "kt") throw new Error("Expected Kotlin MainActivity");
const marker = " // Paseo orientation policy";
if (!activity.contents.includes(marker)) {
const anchor = " override fun onCreate(savedInstanceState: Bundle?)";
if (!activity.contents.includes(anchor)) throw new Error("MainActivity anchor missing");
activity.contents = activity.contents.replace(
anchor,
`${marker}
private fun updateOrientation(configuration: android.content.res.Configuration) {
val orientation = if (configuration.smallestScreenWidthDp >= 600)
android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
else
android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
if (requestedOrientation != orientation) requestedOrientation = orientation
}
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
updateOrientation(newConfig)
}
${anchor}`,
);
if (!activity.contents.includes(" super.onCreate(null)"))
throw new Error("MainActivity onCreate missing");
activity.contents = activity.contents.replace(
" super.onCreate(null)",
" updateOrientation(resources.configuration)\n super.onCreate(null)",
);
}
return mod;
});
};
@@ -0,0 +1,158 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import electronPath from "electron";
import { build } from "esbuild";
export async function runAdaptiveThemeRegression(artifactDir) {
const { outputFiles } = await build({
stdin: {
resolveDir: fileURLToPath(new URL("../../app", import.meta.url)),
contents: `
import { createElement } from "react";
import { createRoot } from "react-dom/client";
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
StyleSheet.configure({
themes: {
dark: { foreground: "#fafafa", background: "#181b1a" },
light: { foreground: "#1a1a1e", background: "#ffffff" },
},
settings: { adaptiveThemes: true },
});
const Message = withUnistyles(({ color }) =>
createElement("p", { style: { color } }, "Theme-reactive message"));
createRoot(document.getElementById("root")).render(createElement(Message, {
style: { margin: 0 },
uniProps: theme => ({ color: theme.foreground }),
}));
window.readThemeForTest = () => UnistylesRuntime.themeName;
`,
},
bundle: true,
write: false,
format: "iife",
platform: "browser",
alias: { "react-native": "react-native-web" },
define: { __DEV__: "false" },
resolveExtensions: [".web.js", ".js", ".json"],
});
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-adaptive-theme-"));
try {
await fs.writeFile(path.join(directory, "renderer.js"), outputFiles[0].text);
await fs.writeFile(path.join(directory, "main.cjs"), `(${electronRegression.toString()})()`);
// A separate process avoids Playwright's focus emulation, which keeps hidden pages visible.
const env = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
const args = [
...(process.platform === "linux" ? ["--no-sandbox"] : []),
path.join(directory, "main.cjs"),
artifactDir,
];
const command = process.platform === "linux" ? "xvfb-run" : electronPath;
const commandArgs =
process.platform === "linux"
? ["-a", "--server-args=-screen 0 1280x800x24", electronPath, ...args]
: args;
await promisify(execFile)(command, commandArgs, {
env,
timeout: 30_000,
});
return JSON.parse(await fs.readFile(path.join(artifactDir, "adaptive-theme.json"), "utf8"));
} finally {
await fs.rm(directory, { recursive: true, force: true });
}
}
async function electronRegression() {
const { app, BrowserWindow, nativeTheme } = require("electron");
const assert = require("node:assert/strict");
const files = require("node:fs");
const paths = require("node:path");
const { setTimeout: delay } = require("node:timers/promises");
app.setPath("userData", paths.join(__dirname, "profile"));
app.on("window-all-closed", () => {});
// Electron keeps Linux's --no-sandbox in argv; the artifact directory is always last.
const artifactDir = process.argv.at(-1);
let window;
const observations = [];
const colors = {
dark: { foreground: "rgb(250, 250, 250)", background: "rgb(24, 27, 26)" },
light: { foreground: "rgb(26, 26, 30)", background: "rgb(255, 255, 255)" },
};
async function read(expression) {
return window.webContents.executeJavaScript(expression);
}
async function expectValue(expression, expected) {
let actual;
for (let attempt = 0; attempt < 50; attempt++) {
actual = await read(expression);
if (actual === expected) return;
await delay(50);
}
assert.equal(actual, expected, expression);
}
try {
await app.whenReady();
if (process.platform === "darwin") {
app.setActivationPolicy("accessory");
app.dock.hide();
}
for (const [from, to] of [
["dark", "light"],
["light", "dark"],
]) {
nativeTheme.themeSource = from;
window = new BrowserWindow({ width: 600, height: 200, show: false, skipTaskbar: true });
await window.loadURL(
"data:text/html," +
encodeURIComponent(`<!doctype html>
<style>body { background: var(--background); } p { font: 20px system-ui; }</style>
<div id="root"></div>`),
);
await read(files.readFileSync(paths.join(__dirname, "renderer.js"), "utf8"));
window.showInactive();
const foreground =
"document.querySelector('p') && getComputedStyle(document.querySelector('p')).color";
const background = "getComputedStyle(document.body).backgroundColor";
await expectValue(foreground, colors[from].foreground);
await expectValue(background, colors[from].background);
window.hide();
await expectValue("document.visibilityState", "hidden");
nativeTheme.themeSource = to;
await expectValue(`matchMedia('(prefers-color-scheme: ${to})').matches`, true);
// Reading the public runtime while hidden must not consume the pending theme notification.
assert.equal(await read("window.readThemeForTest()"), to);
window.showInactive();
await expectValue("document.visibilityState", "visible");
await expectValue(background, colors[to].background);
try {
await expectValue(foreground, colors[to].foreground);
} finally {
files.writeFileSync(
paths.join(artifactDir, `theme-${from}-to-${to}.png`),
(await window.webContents.capturePage()).toPNG(),
);
}
observations.push({
from,
to,
foreground: await read(foreground),
background: await read(background),
});
window.destroy();
window = undefined;
}
files.writeFileSync(
paths.join(artifactDir, "adaptive-theme.json"),
JSON.stringify(observations, null, 2),
);
app.exit(0);
} catch (error) {
console.error(error);
window?.destroy();
app.exit(1);
}
}
+8 -1
View File
@@ -14,6 +14,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import { chromium } from "playwright";
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
@@ -1241,6 +1242,7 @@ async function main() {
return;
}
const adaptiveTheme = await runAdaptiveThemeRegression(artifactDir);
await runAppearanceFontSizeRegression(page);
const callerAgentId = await createCallerAgent(daemonPort);
@@ -1265,7 +1267,12 @@ async function main() {
artifactDir,
});
const pluginLinks = await checkPluginLinks();
writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory, pluginLinks });
writeJson(path.join(artifactDir, "result.json"), {
...report,
settingsMemory,
pluginLinks,
adaptiveTheme,
});
console.log(
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`,
);
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -8059,6 +8059,60 @@ test("global subscribers receive internal agent events only when they opt in", a
unsubscribeOptedIn();
});
test("agent removal notifications respect scoped subscriptions and internal visibility", () => {
const manager = new AgentManager({ clients: {}, logger });
const removedId = "00000000-0000-4000-8000-000000000901";
const internalId = "00000000-0000-4000-8000-000000000902";
const otherId = "00000000-0000-4000-8000-000000000903";
const publicEvents: AgentManagerEvent[] = [];
const allEvents: AgentManagerEvent[] = [];
const scopedEvents: AgentManagerEvent[] = [];
const internalEvents: AgentManagerEvent[] = [];
const unrelatedEvents: AgentManagerEvent[] = [];
const releases = [
manager.subscribe((event) => publicEvents.push(event), { replayState: false }),
manager.subscribe((event) => allEvents.push(event), {
replayState: false,
includeInternal: true,
}),
manager.subscribe((event) => scopedEvents.push(event), {
replayState: false,
agentId: removedId,
}),
manager.subscribe((event) => internalEvents.push(event), {
replayState: false,
agentId: internalId,
}),
manager.subscribe((event) => unrelatedEvents.push(event), {
replayState: false,
agentId: otherId,
}),
];
try {
manager.notifyAgentRemoved({ id: removedId, workspaceId: "workspace" });
manager.notifyAgentRemoved({ id: internalId, workspaceId: "workspace", internal: true });
const publicRemoval = {
type: "agent_removed",
agentId: removedId,
workspaceId: "workspace",
internal: false,
};
const internalRemoval = {
type: "agent_removed",
agentId: internalId,
workspaceId: "workspace",
internal: true,
};
expect(publicEvents).toEqual([publicRemoval]);
expect(allEvents).toEqual([publicRemoval, internalRemoval]);
expect(scopedEvents).toEqual([publicRemoval]);
expect(internalEvents).toEqual([internalRemoval]);
expect(unrelatedEvents).toEqual([]);
} finally {
for (const release of releases) release();
}
});
test("getAgent returns internal agents by ID", async () => {
const internalAgentId = "00000000-0000-4000-8000-000000000107";
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
@@ -236,6 +236,7 @@ export type {
export type AgentManagerEvent =
| { type: "agent_state"; agent: ManagedAgent }
| { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean }
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
| { type: "timeline_replacement"; agentId: string; epoch: string }
| {
@@ -3519,6 +3520,15 @@ export class AgentManager {
await this.deleteCommittedTimeline(agentId);
}
notifyAgentRemoved(agent: Pick<StoredAgentRecord, "id" | "workspaceId" | "internal">): void {
this.dispatch({
type: "agent_removed",
agentId: agent.id,
workspaceId: agent.workspaceId,
internal: agent.internal === true,
});
}
async deleteCommittedTimeline(agentId: string): Promise<void> {
await this.durableTimelineStore?.deleteAgent(agentId);
}
@@ -5376,7 +5386,7 @@ export class AgentManager {
for (const subscriber of this.subscribers) {
if (
subscriber.agentId &&
event.type === "agent_stream" &&
(event.type === "agent_stream" || event.type === "agent_removed") &&
subscriber.agentId !== event.agentId
) {
continue;
@@ -5412,6 +5422,7 @@ export class AgentManager {
private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean {
if (event.type === "agent_state") return event.agent.internal === true;
if (event.type === "agent_removed") return event.internal;
if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true;
if (event.type !== "provider_subagent") return false;
const parentAgentId =
@@ -1,4 +1,6 @@
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
@@ -251,23 +253,52 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
});
async function measureTimelineTail(wholeTurns: boolean) {
const fixture = fileURLToPath(
new URL("./test-utils/timeline-responsiveness.ts", import.meta.url),
);
const { stdout, stderr } = await promisify(execFile)(
process.execPath,
["--expose-gc", "--import", "tsx", fixture, String(wholeTurns)],
{ timeout: 15_000 },
);
process.stderr.write(stderr);
return JSON.parse(stdout) as {
result: ReturnType<InMemoryAgentTimelineStore["fetch"]>;
blockingMs: number;
};
}
it("excludes unrelated test-runner CPU from the timeline fetch budget", async () => {
let unrelatedWork: ReturnType<typeof setImmediate>;
const burnCpu = () => {
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
process,
"threadCpuUsage",
);
const start = threadCpuUsage();
let elapsed: NodeJS.CpuUsage;
do {
elapsed = threadCpuUsage(start);
} while (elapsed.user + elapsed.system < 80_000);
unrelatedWork = setImmediate(burnCpu);
};
unrelatedWork = setImmediate(burnCpu);
try {
const { result: tail, blockingMs } = await measureTimelineTail(false);
expect(tail.rows).toHaveLength(200);
expect(tail.startSeq).toBe(99_801);
expect(tail.endSeq).toBe(100_000);
expect(blockingMs).toBeLessThan(50);
} finally {
clearImmediate(unrelatedWork);
}
});
it.each([false, true])(
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
async (wholeTurns) => {
const store = new InMemoryAgentTimelineStore();
store.initialize("large");
for (let index = 0; index < 100_000; index++) {
store.append("large", { type: "user_message", text: String(index) });
}
const { result: tail, blockingMs } = await measureMainThreadWork(
`timeline tail main-thread work (wholeTurns=${wholeTurns})`,
() => {
let page = store.fetch("large", { limit: 200, wholeTurns });
for (let repeat = 1; repeat < 8; repeat++)
page = store.fetch("large", { limit: 200, wholeTurns });
return page;
},
);
const { result: tail, blockingMs } = await measureTimelineTail(wholeTurns);
expect(tail.rows).toHaveLength(200);
expect(tail.startSeq).toBe(99_801);
expect(tail.endSeq).toBe(100_000);
@@ -6,47 +6,6 @@ import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
import type { V2Api } from "./api.js";
import { UsageSourceRegistry } from "../../../../plugins/usage-sources/index.js";
test("#7116: v2 agent usage includes the plan windows available on the host", async () => {
const harness = new V2Harness();
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
model: "openai/test-model",
});
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
hasAgent: (id) => id === session.id,
usageSession: (id) => (id === session.id ? (session.usageSession?.() ?? null) : null),
});
const report = {
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }],
};
registry.register({
id: "test-plan",
label: "Test plan",
discover: async (scope) =>
scope.kind === "global" ||
(scope.provider === "opencode" && scope.model === "openai/test-model")
? [{ key: "account", input: {} }]
: [],
fetch: async () => report,
});
try {
const host = await registry.listReports();
expect(host.map((entry) => entry.report)).toEqual([report]);
const agent = await registry.listReports({ agentId: session.id });
expect(agent.map((entry) => entry.report)).toEqual([report]);
} finally {
await session.close();
await client.shutdown();
}
});
function terminalEvents(events: AgentStreamEvent[]) {
return events.filter(
@@ -0,0 +1,25 @@
import { setImmediate as nextTurn } from "node:timers/promises";
import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js";
import { InMemoryAgentTimelineStore } from "../agent-timeline-store.js";
// Measure a cold fetch on its own thread, without Vitest reporting or callbacks.
const wholeTurns = process.argv[2] === "true";
const store = new InMemoryAgentTimelineStore();
store.initialize("large");
for (let index = 0; index < 100_000; index++) {
store.append("large", { type: "user_message", text: String(index) });
}
// Finish fixture allocation and collect its temporary garbage before measuring.
globalThis.gc!();
await nextTurn();
const measured = await measureMainThreadWork(
`timeline tail main-thread work (wholeTurns=${wholeTurns})`,
() => {
let page = store.fetch("large", { limit: 200, wholeTurns });
for (let repeat = 1; repeat < 8; repeat++)
page = store.fetch("large", { limit: 200, wholeTurns });
return page;
},
);
process.stdout.write(JSON.stringify(measured));
@@ -1,7 +1,8 @@
import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js";
import { createPaseoApi } from "@getpaseo/client";
import type { AgentManager } from "./agent/agent-manager.js";
import { execFileSync } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "vitest";
import type { z } from "zod";
@@ -71,15 +72,18 @@ class SubscriptionPeer {
async request(message: z.input<typeof SessionInboundMessageSchema>): Promise<void> {
this.socket.send(JSON.stringify({ type: "session", message }));
await expect
.poll(() =>
this.frames.some(
(frame) =>
frame.type === "session" &&
"payload" in frame.message &&
"requestId" in frame.message.payload &&
"requestId" in message &&
frame.message.payload.requestId === message.requestId,
),
.poll(
() =>
this.frames.some(
(frame) =>
frame.type === "session" &&
"payload" in frame.message &&
"requestId" in frame.message.payload &&
"requestId" in message &&
frame.message.payload.requestId === message.requestId,
),
// Diagnostics invoke external tools with their own multi-second deadlines.
{ timeout: 10_000 },
)
.toBe(true);
}
@@ -119,6 +123,16 @@ class SubscriptionPeer {
}
}
function removedAgentIds(peer: SubscriptionPeer): string[] {
return peer.frames.flatMap((frame) =>
frame.type === "session" &&
frame.message.type === "agent_update" &&
frame.message.payload.kind === "remove"
? [frame.message.payload.agentId]
: [],
);
}
test("a pure list reply belongs only to its source socket in a shared logical session", async () => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const peers: SubscriptionPeer[] = [];
@@ -225,6 +239,96 @@ test("combined filtered agent lists create independent server-owned observations
}
});
test.each([
{
state: "live",
prepare: async (_manager: AgentManager, _agentId: string) => {},
},
{
state: "stored-only",
prepare: (manager: AgentManager, agentId: string) => manager.closeAgent(agentId),
},
])(
"$state agent deletion reaches directory observers across logical sessions without broadcasting its reply",
async ({ prepare }) => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const admin = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.8.0",
});
const peers: SubscriptionPeer[] = [];
try {
await admin.connect();
const workspace = (
await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } })
).workspace;
if (!workspace) throw new Error("Workspace creation failed");
const removed = await admin.createAgent({
provider: "codex",
cwd: daemon.staticDir,
workspaceId: workspace.id,
});
const survivor = await admin.createAgent({
provider: "codex",
cwd: daemon.staticDir,
workspaceId: workspace.id,
});
const sentinel = path.join(daemon.staticDir, "keep-project-file.txt");
await writeFile(sentinel, "keep this project file\n");
await prepare(daemon.daemon.agentManager, removed.id);
const requester = await SubscriptionPeer.connect(daemon.port, "deletion-origin");
const sibling = await SubscriptionPeer.connect(daemon.port, "deletion-origin");
const observer = await SubscriptionPeer.connect(daemon.port, "deletion-observer");
const legacy = await SubscriptionPeer.connect(daemon.port, "deletion-legacy", {
owned_subscriptions: false,
});
peers.push(requester, sibling, observer, legacy);
for (const [index, peer] of peers.entries()) {
await peer.request({
type: "fetch_agents_request",
requestId: `directory-${index}`,
subscribe: {},
});
const entries = peer.frames.flatMap((frame) =>
frame.type === "session" && frame.message.type === "fetch_agents_response"
? frame.message.payload.entries.map((entry) => entry.agent.id)
: [],
);
expect(entries.sort()).toEqual([removed.id, survivor.id].sort());
peer.frames.length = 0;
}
await requester.request({
type: "delete_agent_request",
requestId: "delete-observed-agent",
agentId: removed.id,
});
await expect
.poll(() => peers.map(removedAgentIds))
.toEqual([[removed.id], [removed.id], [removed.id], [removed.id]]);
const replies = peers.map((peer) =>
peer.frames.flatMap((frame) =>
frame.type === "session" && frame.message.type === "agent_deleted"
? [frame.message.payload]
: [],
),
);
expect(replies).toEqual([
[{ agentId: removed.id, requestId: "delete-observed-agent" }],
[],
[],
[],
]);
expect(await daemon.daemon.agentStorage.get(removed.id)).toBeNull();
expect((await daemon.daemon.agentStorage.get(survivor.id))?.id).toBe(survivor.id);
expect(await readFile(sentinel, "utf8")).toBe("keep this project file\n");
} finally {
for (const peer of peers) peer.close();
await admin.close();
await daemon.close();
}
},
);
test("workspace filters and identical queries coexist until their own release", async () => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
@@ -679,7 +783,8 @@ test("same-workspace terminal directory observers do not share a teardown slot",
peer.frames.length = 0;
const result = await admin.createTerminal(daemon.staticDir, "Observed terminal", undefined, {
workspaceId: workspace.id,
command: "/bin/cat",
command: process.execPath,
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
});
expect(result.error).toBe(null);
await expect
@@ -728,7 +833,8 @@ test("terminal output has separate server IDs and binary slots, including in a s
).workspace!;
const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, {
workspaceId: workspace.id,
command: "/bin/cat",
command: process.execPath,
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
});
expect(result.error).toBe(null);
const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session");
@@ -1423,7 +1529,7 @@ test("request outcomes for import and attention reach only their requesting sock
});
const requester = await SubscriptionPeer.connect(daemon.port, "reply-shared");
const sibling = await SubscriptionPeer.connect(daemon.port, "reply-shared");
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
try {
await admin.connect();
await requester.request({
@@ -1462,7 +1568,7 @@ test("request outcomes for import and attention reach only their requesting sock
}),
);
const agent = await admin.createAgent({
provider: "codex",
provider: "mock",
cwd: daemon.staticDir,
title: "Attention",
});
+22 -10
View File
@@ -1992,6 +1992,24 @@ export class Session {
return;
}
if (event.type === "agent_removed") {
if (event.internal) return;
void this.agentUpdates
.removeAgent(event.agentId)
.then(() => {
if (event.workspaceId)
return this.emitWorkspaceUpdateForWorkspaceId(event.workspaceId);
return undefined;
})
.catch((error) => {
this.sessionLogger.error(
{ err: error, agentId: event.agentId },
"Failed to emit agent removal",
);
});
return;
}
if (event.type === "agent_state") {
this.sessionLogger.trace(
{
@@ -3337,10 +3355,8 @@ export class Session {
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`);
const knownWorkspaceId =
this.agentManager.getAgent(agentId)?.workspaceId ??
(await this.agentStorage.get(agentId))?.workspaceId ??
null;
const knownAgent =
this.agentManager.getAgent(agentId) ?? (await this.agentStorage.get(agentId));
// File-backed storage still needs an early delete fence before closeAgent().
beginAgentDeleteIfSupported(this.agentStorage, agentId);
@@ -3361,6 +3377,8 @@ export class Session {
try {
await this.agentStorage.remove(agentId);
await this.agentManager.deleteAgentState(agentId);
// Directory removal is daemon-wide; agent_deleted below stays a source-scoped RPC reply.
if (knownAgent) this.agentManager.notifyAgentRemoved(knownAgent);
} catch (error) {
this.sessionLogger.error({ err: error, agentId }, `Failed to fully delete agent ${agentId}`);
}
@@ -3372,12 +3390,6 @@ export class Session {
requestId,
},
});
await this.agentUpdates.removeAgent(agentId);
if (knownWorkspaceId) {
await this.emitWorkspaceUpdateForWorkspaceId(knownWorkspaceId);
}
}
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
@@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url";
import { writeFile, truncate, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { build } from "esbuild";
import { isPlatform } from "../test-utils/platform.js";
import {
readPaseoConfig,
@@ -20,21 +21,40 @@ import {
writePaseoConfigForEdit,
} from "./paseo-config-file.js";
let probeDirectory: string;
let probePath: string;
beforeAll(async () => {
probeDirectory = mkdtempSync(join(tmpdir(), "paseo-config-probe-"));
probePath = join(probeDirectory, "config-responsiveness.mjs");
// Compile before starting the measured process. A runtime TypeScript loader
// owns MessagePorts that the responsiveness probe otherwise charges as work.
await build({
entryPoints: [
fileURLToPath(new URL("./test-fixtures/config-responsiveness.ts", import.meta.url)),
],
outfile: probePath,
bundle: true,
platform: "node",
format: "esm",
});
});
afterAll(() => {
rmSync(probeDirectory, { recursive: true, force: true });
});
async function measureConfigRead(directory: string) {
const fixture = fileURLToPath(
new URL("./test-fixtures/config-responsiveness.ts", import.meta.url),
);
const { stdout, stderr } = await promisify(execFile)(
process.execPath,
["--import", "tsx", fixture, directory],
{ timeout: 15_000 },
);
const { stdout, stderr } = await promisify(execFile)(process.execPath, [probePath, directory], {
timeout: 15_000,
});
process.stderr.write(stderr);
return JSON.parse(stdout) as {
result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
blockingMs: number;
filesystemRequests: number;
yields: number;
workerMessages: number;
};
}
@@ -84,7 +104,7 @@ describe("paseo config file substrate", () => {
);
});
it("excludes unrelated test-runner CPU from the config read budget", async () => {
it("excludes test-runner and loader activity from the config read budget", async () => {
await writeFile(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
const unrelatedWork = new Promise<void>((resolve) => {
setImmediate(() => {
@@ -100,8 +120,10 @@ describe("paseo config file substrate", () => {
resolve();
});
});
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
const { result, blockingMs, filesystemRequests, workerMessages } =
await measureConfigRead(tempDir);
await unrelatedWork;
expect(workerMessages).toBe(0);
expect(result).toEqual({
ok: true,
config: { worktree: { setup: "npm ci" } },
@@ -119,11 +141,13 @@ describe("paseo config file substrate", () => {
worktree: { setup: "npm ci" },
};
await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config));
const { result, blockingMs, filesystemRequests, yields } = await measureConfigRead(tempDir);
const { result, blockingMs, filesystemRequests, yields, workerMessages } =
await measureConfigRead(tempDir);
expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) });
expect(blockingMs).toBeLessThan(20);
expect(filesystemRequests).toBeGreaterThan(0);
expect(yields).toBeGreaterThan(0);
expect(workerMessages).toBe(0);
});
it("rejects a 32MiB config with main-thread work below 20ms", async () => {
@@ -131,9 +155,11 @@ describe("paseo config file substrate", () => {
join(tempDir, "paseo.json"),
JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }),
);
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
const { result, blockingMs, filesystemRequests, workerMessages } =
await measureConfigRead(tempDir);
expect(blockingMs).toBeLessThan(20);
expect(filesystemRequests).toBeGreaterThan(0);
expect(workerMessages).toBe(0);
expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } });
});
@@ -1,14 +1,24 @@
import { createHook, executionAsyncResource } from "node:async_hooks";
import { MessagePort } from "node:worker_threads";
import assert from "node:assert/strict";
import { setImmediate as nextTurn } from "node:timers/promises";
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
import { readPaseoConfigForEdit } from "../paseo-config-file.js";
// Keep Vitest transforms, reporting, and unrelated callbacks outside the measured
// thread. Read the real file once, without warming the config snapshot or schema.
// The parent bundles this fixture before launching plain Node, keeping test
// runner callbacks and runtime loader messages outside the measured thread.
// Read the real file once, without warming the config snapshot or schema.
const directory = process.argv[2];
assert.ok(directory, "Config directory is required");
await nextTurn();
let workerMessages = 0;
const activity = createHook({
before() {
if (executionAsyncResource() instanceof MessagePort) workerMessages++;
},
}).enable();
const measured = await measureMainThreadWork("project config main-thread work", () =>
readPaseoConfigForEdit(directory),
);
process.stdout.write(JSON.stringify(measured));
activity.disable();
process.stdout.write(JSON.stringify({ ...measured, workerMessages }));
@@ -486,3 +486,74 @@ diff --git a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles
...(narrowedProps.style ? {
style: styleSecrets.styles
} : {}),
diff --git a/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js b/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js
index 305fa49..5957aa5 100644
--- a/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js
+++ b/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js
@@ -35,10 +35,17 @@ class UnistylesRuntime {
return this.darkMedia;
}
get colorScheme() {
+ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers
+ // MediaQueryList change events while the document is hidden and drops the pending event
+ // of any list whose `matches` was read before the document became visible again, which
+ // would silence the adaptive-theme listeners registered on those two lists.
+ if ((0, _utils.isServer)()) {
+ return _types.ColorScheme.Unspecified;
+ }
switch (true) {
- case this.getLightMedia()?.matches:
+ case window.matchMedia('(prefers-color-scheme: light)').matches:
return _types.ColorScheme.Light;
- case this.getDarkMedia()?.matches:
+ case window.matchMedia('(prefers-color-scheme: dark)').matches:
return _types.ColorScheme.Dark;
default:
return _types.ColorScheme.Unspecified;
diff --git a/node_modules/react-native-unistyles/lib/module/web/runtime.js b/node_modules/react-native-unistyles/lib/module/web/runtime.js
index 7f13a06..c487bee 100644
--- a/node_modules/react-native-unistyles/lib/module/web/runtime.js
+++ b/node_modules/react-native-unistyles/lib/module/web/runtime.js
@@ -31,10 +31,17 @@ export class UnistylesRuntime {
return this.darkMedia;
}
get colorScheme() {
+ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers
+ // MediaQueryList change events while the document is hidden and drops the pending event
+ // of any list whose `matches` was read before the document became visible again, which
+ // would silence the adaptive-theme listeners registered on those two lists.
+ if (isServer()) {
+ return ColorScheme.Unspecified;
+ }
switch (true) {
- case this.getLightMedia()?.matches:
+ case window.matchMedia('(prefers-color-scheme: light)').matches:
return ColorScheme.Light;
- case this.getDarkMedia()?.matches:
+ case window.matchMedia('(prefers-color-scheme: dark)').matches:
return ColorScheme.Dark;
default:
return ColorScheme.Unspecified;
diff --git a/node_modules/react-native-unistyles/src/web/runtime.ts b/node_modules/react-native-unistyles/src/web/runtime.ts
index a16dac0..6519858 100644
--- a/node_modules/react-native-unistyles/src/web/runtime.ts
+++ b/node_modules/react-native-unistyles/src/web/runtime.ts
@@ -41,8 +41,16 @@ export class UnistylesRuntime {
get colorScheme() {
+ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers
+ // MediaQueryList change events while the document is hidden and drops the pending event
+ // of any list whose `matches` was read before the document became visible again, which
+ // would silence the adaptive-theme listeners registered on those two lists.
+ if (isServer()) {
+ return ColorScheme.Unspecified
+ }
+
switch (true) {
- case this.getLightMedia()?.matches:
+ case window.matchMedia('(prefers-color-scheme: light)').matches:
return ColorScheme.Light
- case this.getDarkMedia()?.matches:
+ case window.matchMedia('(prefers-color-scheme: dark)').matches:
return ColorScheme.Dark
default:
return ColorScheme.Unspecified
+1
View File
@@ -24,6 +24,7 @@ const patchedPackages = [
},
// Registry cleanup: remove after react-native-unistyles ships
// https://github.com/jpudysz/react-native-unistyles/pull/1203.
// Adaptive themes: keep until colorScheme reads no longer consume listener-owned media changes.
// withUnistyles: also keep the uniProps filter until upstream consumes its control prop.
{
nodeModulesPath: "node_modules/react-native-unistyles",