mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 05:02:58 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deb1bdc266 | ||
|
|
9c7925de58 | ||
|
|
4351445467 | ||
|
|
9503537041 | ||
|
|
1aef1ec662 | ||
|
|
946b6e58fd |
@@ -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
|
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
|
||||||
Electron running the old code.
|
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
|
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
|
||||||
`patches/` is inert unless that script knows which installed package activates
|
`patches/` is inert unless that script knows which installed package activates
|
||||||
it.
|
it.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|||||||
import { chromium } from "playwright";
|
import { chromium } from "playwright";
|
||||||
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
|
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
|
||||||
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
|
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
|
||||||
|
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
|
||||||
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
|
import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs";
|
||||||
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
|
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
|
||||||
|
|
||||||
@@ -1241,6 +1242,7 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adaptiveTheme = await runAdaptiveThemeRegression(artifactDir);
|
||||||
await runAppearanceFontSizeRegression(page);
|
await runAppearanceFontSizeRegression(page);
|
||||||
|
|
||||||
const callerAgentId = await createCallerAgent(daemonPort);
|
const callerAgentId = await createCallerAgent(daemonPort);
|
||||||
@@ -1265,7 +1267,12 @@ async function main() {
|
|||||||
artifactDir,
|
artifactDir,
|
||||||
});
|
});
|
||||||
const pluginLinks = await checkPluginLinks();
|
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(
|
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.`,
|
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||||
"test": "npm run test:unit && npm run test:integration",
|
"test": "npm run test:unit && npm run test:integration",
|
||||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
"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:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||||
"test:integration:all": "npm run test:e2e",
|
"test:integration:all": "npm run test:e2e",
|
||||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
"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();
|
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 () => {
|
test("getAgent returns internal agents by ID", async () => {
|
||||||
const internalAgentId = "00000000-0000-4000-8000-000000000107";
|
const internalAgentId = "00000000-0000-4000-8000-000000000107";
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ export type {
|
|||||||
|
|
||||||
export type AgentManagerEvent =
|
export type AgentManagerEvent =
|
||||||
| { type: "agent_state"; agent: ManagedAgent }
|
| { type: "agent_state"; agent: ManagedAgent }
|
||||||
|
| { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean }
|
||||||
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
|
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
|
||||||
| { type: "timeline_replacement"; agentId: string; epoch: string }
|
| { type: "timeline_replacement"; agentId: string; epoch: string }
|
||||||
| {
|
| {
|
||||||
@@ -3519,6 +3520,15 @@ export class AgentManager {
|
|||||||
await this.deleteCommittedTimeline(agentId);
|
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> {
|
async deleteCommittedTimeline(agentId: string): Promise<void> {
|
||||||
await this.durableTimelineStore?.deleteAgent(agentId);
|
await this.durableTimelineStore?.deleteAgent(agentId);
|
||||||
}
|
}
|
||||||
@@ -5376,7 +5386,7 @@ export class AgentManager {
|
|||||||
for (const subscriber of this.subscribers) {
|
for (const subscriber of this.subscribers) {
|
||||||
if (
|
if (
|
||||||
subscriber.agentId &&
|
subscriber.agentId &&
|
||||||
event.type === "agent_stream" &&
|
(event.type === "agent_stream" || event.type === "agent_removed") &&
|
||||||
subscriber.agentId !== event.agentId
|
subscriber.agentId !== event.agentId
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
@@ -5412,6 +5422,7 @@ export class AgentManager {
|
|||||||
|
|
||||||
private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean {
|
private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean {
|
||||||
if (event.type === "agent_state") return event.agent.internal === true;
|
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 === "agent_stream") return this.agents.get(event.agentId)?.internal === true;
|
||||||
if (event.type !== "provider_subagent") return false;
|
if (event.type !== "provider_subagent") return false;
|
||||||
const parentAgentId =
|
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 { describe, expect, it } from "vitest";
|
||||||
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
|
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
|
||||||
import type { AgentTimelineItem } from "./agent-sdk-types.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 });
|
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])(
|
it.each([false, true])(
|
||||||
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
|
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
|
||||||
async (wholeTurns) => {
|
async (wholeTurns) => {
|
||||||
const store = new InMemoryAgentTimelineStore();
|
const { result: tail, blockingMs } = await measureTimelineTail(wholeTurns);
|
||||||
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;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
expect(tail.rows).toHaveLength(200);
|
expect(tail.rows).toHaveLength(200);
|
||||||
expect(tail.startSeq).toBe(99_801);
|
expect(tail.startSeq).toBe(99_801);
|
||||||
expect(tail.endSeq).toBe(100_000);
|
expect(tail.endSeq).toBe(100_000);
|
||||||
|
|||||||
@@ -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 { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js";
|
||||||
import { createPaseoApi } from "@getpaseo/client";
|
import { createPaseoApi } from "@getpaseo/client";
|
||||||
|
import type { AgentManager } from "./agent/agent-manager.js";
|
||||||
import { execFileSync } from "node:child_process";
|
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 path from "node:path";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
@@ -71,15 +72,18 @@ class SubscriptionPeer {
|
|||||||
async request(message: z.input<typeof SessionInboundMessageSchema>): Promise<void> {
|
async request(message: z.input<typeof SessionInboundMessageSchema>): Promise<void> {
|
||||||
this.socket.send(JSON.stringify({ type: "session", message }));
|
this.socket.send(JSON.stringify({ type: "session", message }));
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(
|
||||||
this.frames.some(
|
() =>
|
||||||
(frame) =>
|
this.frames.some(
|
||||||
frame.type === "session" &&
|
(frame) =>
|
||||||
"payload" in frame.message &&
|
frame.type === "session" &&
|
||||||
"requestId" in frame.message.payload &&
|
"payload" in frame.message &&
|
||||||
"requestId" in message &&
|
"requestId" in frame.message.payload &&
|
||||||
frame.message.payload.requestId === message.requestId,
|
"requestId" in message &&
|
||||||
),
|
frame.message.payload.requestId === message.requestId,
|
||||||
|
),
|
||||||
|
// Diagnostics invoke external tools with their own multi-second deadlines.
|
||||||
|
{ timeout: 10_000 },
|
||||||
)
|
)
|
||||||
.toBe(true);
|
.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 () => {
|
test("a pure list reply belongs only to its source socket in a shared logical session", async () => {
|
||||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||||
const peers: SubscriptionPeer[] = [];
|
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 () => {
|
test("workspace filters and identical queries coexist until their own release", async () => {
|
||||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||||
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
|
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;
|
peer.frames.length = 0;
|
||||||
const result = await admin.createTerminal(daemon.staticDir, "Observed terminal", undefined, {
|
const result = await admin.createTerminal(daemon.staticDir, "Observed terminal", undefined, {
|
||||||
workspaceId: workspace.id,
|
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);
|
expect(result.error).toBe(null);
|
||||||
await expect
|
await expect
|
||||||
@@ -728,7 +833,8 @@ test("terminal output has separate server IDs and binary slots, including in a s
|
|||||||
).workspace!;
|
).workspace!;
|
||||||
const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, {
|
const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, {
|
||||||
workspaceId: workspace.id,
|
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);
|
expect(result.error).toBe(null);
|
||||||
const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session");
|
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 requester = await SubscriptionPeer.connect(daemon.port, "reply-shared");
|
||||||
const sibling = 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 {
|
try {
|
||||||
await admin.connect();
|
await admin.connect();
|
||||||
await requester.request({
|
await requester.request({
|
||||||
@@ -1462,7 +1568,7 @@ test("request outcomes for import and attention reach only their requesting sock
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const agent = await admin.createAgent({
|
const agent = await admin.createAgent({
|
||||||
provider: "codex",
|
provider: "mock",
|
||||||
cwd: daemon.staticDir,
|
cwd: daemon.staticDir,
|
||||||
title: "Attention",
|
title: "Attention",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1075,23 +1075,29 @@ export default function contribute(plugin: unknown) {
|
|||||||
const service = bindTestSessionHost(
|
const service = bindTestSessionHost(
|
||||||
new PluginService(pino({ level: "silent" }), store, "0.4.0"),
|
new PluginService(pino({ level: "silent" }), store, "0.4.0"),
|
||||||
);
|
);
|
||||||
await service.start();
|
try {
|
||||||
await service.installDirectory({ path: first });
|
await service.start();
|
||||||
await service.installDirectory({ path: second });
|
await service.installDirectory({ path: first });
|
||||||
|
await service.installDirectory({ path: second });
|
||||||
|
expect(catalogIds(service)).toEqual(["first", "second"]);
|
||||||
|
await store.patch({ pluginsEnabled: false });
|
||||||
|
|
||||||
await store.patch({ pluginsEnabled: false });
|
expect(service.catalog()).toEqual([]);
|
||||||
|
await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow(
|
||||||
expect(service.catalog()).toEqual([]);
|
"Plugin is not available",
|
||||||
await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow(
|
);
|
||||||
"Plugin is not available",
|
await store.patch({ pluginsEnabled: true });
|
||||||
);
|
// Observe recovery itself. Reloading here restarts a healthy plugin and
|
||||||
await store.patch({ pluginsEnabled: true });
|
// would also conceal a failure to restart it after the global enable.
|
||||||
await service.reloadPlugin("first");
|
await expect
|
||||||
expect((await service.listPlugins()).map(({ id, status }) => ({ id, status }))).toEqual([
|
.poll(() => service.listPlugins(), { timeout: 10_000 })
|
||||||
{ id: "first", status: "running" },
|
.toMatchObject([
|
||||||
{ id: "second", status: "running" },
|
{ id: "first", status: "running" },
|
||||||
]);
|
{ id: "second", status: "running" },
|
||||||
await service.stopAllPlugins();
|
]);
|
||||||
|
} finally {
|
||||||
|
await service.stopAllPlugins();
|
||||||
|
}
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
it("does not publish an in-flight start after a later global disable", async () => {
|
it("does not publish an in-flight start after a later global disable", async () => {
|
||||||
|
|||||||
@@ -1992,6 +1992,24 @@ export class Session {
|
|||||||
return;
|
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") {
|
if (event.type === "agent_state") {
|
||||||
this.sessionLogger.trace(
|
this.sessionLogger.trace(
|
||||||
{
|
{
|
||||||
@@ -3337,10 +3355,8 @@ export class Session {
|
|||||||
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
|
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||||||
this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`);
|
this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`);
|
||||||
|
|
||||||
const knownWorkspaceId =
|
const knownAgent =
|
||||||
this.agentManager.getAgent(agentId)?.workspaceId ??
|
this.agentManager.getAgent(agentId) ?? (await this.agentStorage.get(agentId));
|
||||||
(await this.agentStorage.get(agentId))?.workspaceId ??
|
|
||||||
null;
|
|
||||||
|
|
||||||
// File-backed storage still needs an early delete fence before closeAgent().
|
// File-backed storage still needs an early delete fence before closeAgent().
|
||||||
beginAgentDeleteIfSupported(this.agentStorage, agentId);
|
beginAgentDeleteIfSupported(this.agentStorage, agentId);
|
||||||
@@ -3361,6 +3377,8 @@ export class Session {
|
|||||||
try {
|
try {
|
||||||
await this.agentStorage.remove(agentId);
|
await this.agentStorage.remove(agentId);
|
||||||
await this.agentManager.deleteAgentState(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) {
|
} catch (error) {
|
||||||
this.sessionLogger.error({ err: error, agentId }, `Failed to fully delete agent ${agentId}`);
|
this.sessionLogger.error({ err: error, agentId }, `Failed to fully delete agent ${agentId}`);
|
||||||
}
|
}
|
||||||
@@ -3372,12 +3390,6 @@ export class Session {
|
|||||||
requestId,
|
requestId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.agentUpdates.removeAgent(agentId);
|
|
||||||
|
|
||||||
if (knownWorkspaceId) {
|
|
||||||
await this.emitWorkspaceUpdateForWorkspaceId(knownWorkspaceId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
|
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 { writeFile, truncate, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
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 { isPlatform } from "../test-utils/platform.js";
|
||||||
import {
|
import {
|
||||||
readPaseoConfig,
|
readPaseoConfig,
|
||||||
@@ -20,21 +21,40 @@ import {
|
|||||||
writePaseoConfigForEdit,
|
writePaseoConfigForEdit,
|
||||||
} from "./paseo-config-file.js";
|
} 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) {
|
async function measureConfigRead(directory: string) {
|
||||||
const fixture = fileURLToPath(
|
const { stdout, stderr } = await promisify(execFile)(process.execPath, [probePath, directory], {
|
||||||
new URL("./test-fixtures/config-responsiveness.ts", import.meta.url),
|
timeout: 15_000,
|
||||||
);
|
});
|
||||||
const { stdout, stderr } = await promisify(execFile)(
|
|
||||||
process.execPath,
|
|
||||||
["--import", "tsx", fixture, directory],
|
|
||||||
{ timeout: 15_000 },
|
|
||||||
);
|
|
||||||
process.stderr.write(stderr);
|
process.stderr.write(stderr);
|
||||||
return JSON.parse(stdout) as {
|
return JSON.parse(stdout) as {
|
||||||
result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
|
result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
|
||||||
blockingMs: number;
|
blockingMs: number;
|
||||||
filesystemRequests: number;
|
filesystemRequests: number;
|
||||||
yields: 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" } }));
|
await writeFile(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
|
||||||
const unrelatedWork = new Promise<void>((resolve) => {
|
const unrelatedWork = new Promise<void>((resolve) => {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
@@ -100,8 +120,10 @@ describe("paseo config file substrate", () => {
|
|||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
|
const { result, blockingMs, filesystemRequests, workerMessages } =
|
||||||
|
await measureConfigRead(tempDir);
|
||||||
await unrelatedWork;
|
await unrelatedWork;
|
||||||
|
expect(workerMessages).toBe(0);
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
config: { worktree: { setup: "npm ci" } },
|
config: { worktree: { setup: "npm ci" } },
|
||||||
@@ -119,11 +141,13 @@ describe("paseo config file substrate", () => {
|
|||||||
worktree: { setup: "npm ci" },
|
worktree: { setup: "npm ci" },
|
||||||
};
|
};
|
||||||
await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config));
|
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(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) });
|
||||||
expect(blockingMs).toBeLessThan(20);
|
expect(blockingMs).toBeLessThan(20);
|
||||||
expect(filesystemRequests).toBeGreaterThan(0);
|
expect(filesystemRequests).toBeGreaterThan(0);
|
||||||
expect(yields).toBeGreaterThan(0);
|
expect(yields).toBeGreaterThan(0);
|
||||||
|
expect(workerMessages).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a 32MiB config with main-thread work below 20ms", async () => {
|
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"),
|
join(tempDir, "paseo.json"),
|
||||||
JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }),
|
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(blockingMs).toBeLessThan(20);
|
||||||
expect(filesystemRequests).toBeGreaterThan(0);
|
expect(filesystemRequests).toBeGreaterThan(0);
|
||||||
|
expect(workerMessages).toBe(0);
|
||||||
expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } });
|
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 assert from "node:assert/strict";
|
||||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
import { setImmediate as nextTurn } from "node:timers/promises";
|
||||||
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
|
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
|
||||||
import { readPaseoConfigForEdit } from "../paseo-config-file.js";
|
import { readPaseoConfigForEdit } from "../paseo-config-file.js";
|
||||||
|
|
||||||
// Keep Vitest transforms, reporting, and unrelated callbacks outside the measured
|
// The parent bundles this fixture before launching plain Node, keeping test
|
||||||
// thread. Read the real file once, without warming the config snapshot or schema.
|
// 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];
|
const directory = process.argv[2];
|
||||||
assert.ok(directory, "Config directory is required");
|
assert.ok(directory, "Config directory is required");
|
||||||
await nextTurn();
|
await nextTurn();
|
||||||
|
let workerMessages = 0;
|
||||||
|
const activity = createHook({
|
||||||
|
before() {
|
||||||
|
if (executionAsyncResource() instanceof MessagePort) workerMessages++;
|
||||||
|
},
|
||||||
|
}).enable();
|
||||||
const measured = await measureMainThreadWork("project config main-thread work", () =>
|
const measured = await measureMainThreadWork("project config main-thread work", () =>
|
||||||
readPaseoConfigForEdit(directory),
|
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 ? {
|
...(narrowedProps.style ? {
|
||||||
style: styleSecrets.styles
|
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
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const patchedPackages = [
|
|||||||
},
|
},
|
||||||
// Registry cleanup: remove after react-native-unistyles ships
|
// Registry cleanup: remove after react-native-unistyles ships
|
||||||
// https://github.com/jpudysz/react-native-unistyles/pull/1203.
|
// 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.
|
// withUnistyles: also keep the uniProps filter until upstream consumes its control prop.
|
||||||
{
|
{
|
||||||
nodeModulesPath: "node_modules/react-native-unistyles",
|
nodeModulesPath: "node_modules/react-native-unistyles",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ argument-hint: "[--profile <name>] <question or topic>"
|
|||||||
|
|
||||||
Single agent. Reads the situation you're in. Gives a judgment. You decide what to do — the advisor doesn't drive the work.
|
Single agent. Reads the situation you're in. Gives a judgment. You decide what to do — the advisor doesn't drive the work.
|
||||||
|
|
||||||
**User's request:** $ARGUMENTS
|
Use the user's request and current conversation as input, including any arguments supplied with the skill invocation. Optional syntax: `[--profile <name>] <question or topic>`.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -40,13 +40,15 @@ This is analysis only. Do NOT edit, create, or delete any files. Do NOT write co
|
|||||||
|
|
||||||
## Forwarded skills
|
## Forwarded skills
|
||||||
|
|
||||||
If `$ARGUMENTS` contains another skill reference — `/unslop`, `/unslop-risk`, `$unslop`, etc. — the user is asking the advisor to run that skill against the current task. Examples:
|
If the user's request contains another skill reference, such as `/unslop`, `/skill:unslop`, or `$unslop`, the user is asking the advisor to run that skill against the current task. Examples:
|
||||||
|
|
||||||
- `/paseo-advisor /unslop` → advisor runs `/unslop` on the current diff.
|
- `/paseo-advisor /unslop` → advisor runs `/unslop` on the current diff.
|
||||||
- `/paseo-advisor /unslop-risk` → advisor does an unslop-risk review.
|
- `/paseo-advisor /unslop-risk` → advisor does an unslop-risk review.
|
||||||
- `/paseo-advisor $diagnose this build failure` → advisor invokes `/diagnose`.
|
- `/paseo-advisor $diagnose this build failure` → advisor invokes `/diagnose`.
|
||||||
|
- `/skill:paseo-advisor /skill:unslop` → advisor runs the `unslop` skill on the current diff in Pi.
|
||||||
|
- `$paseo-advisor $unslop` → advisor runs the `unslop` skill on the current diff in Codex.
|
||||||
|
|
||||||
Parse the forwarded skill name out of `$ARGUMENTS` (`/<name>` or `$<name>`). In the briefing, tell the advisor explicitly:
|
Parse the forwarded skill name from the user's request (`/<name>`, `/skill:<name>`, or `$<name>`), excluding this skill's own invocation. In the briefing, tell the advisor explicitly:
|
||||||
|
|
||||||
```
|
```
|
||||||
Invoke the `<name>` skill against this task. Load it via the Skill tool before doing anything else.
|
Invoke the `<name>` skill against this task. Load it via the Skill tool before doing anything else.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ user-invocable: true
|
|||||||
|
|
||||||
Two agents from contrasting profiles, fresh context, planning a solution in parallel.
|
Two agents from contrasting profiles, fresh context, planning a solution in parallel.
|
||||||
|
|
||||||
**User's additional context:** $ARGUMENTS
|
Use the user's request and current conversation as input, including any arguments supplied with the skill invocation.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ user-invocable: true
|
|||||||
|
|
||||||
Transfer the current task — context, decisions, failed attempts, constraints — to a fresh agent. The receiving agent starts with **zero context**, so the handoff prompt must be a self-contained briefing.
|
Transfer the current task — context, decisions, failed attempts, constraints — to a fresh agent. The receiving agent starts with **zero context**, so the handoff prompt must be a self-contained briefing.
|
||||||
|
|
||||||
**User's arguments:** $ARGUMENTS
|
Use the user's request and current conversation as input, including any arguments supplied with the skill invocation.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ description: Answer questions about the Paseo product and app, including setup,
|
|||||||
|
|
||||||
You are helping a user understand, configure, or troubleshoot Paseo itself. Answer their question directly, verify the answer against the current public documentation, and include the relevant documentation link. Do not send the user away to read the docs in place of helping them.
|
You are helping a user understand, configure, or troubleshoot Paseo itself. Answer their question directly, verify the answer against the current public documentation, and include the relevant documentation link. Do not send the user away to read the docs in place of helping them.
|
||||||
|
|
||||||
**User's question:** $ARGUMENTS
|
Use the user's question and current conversation as input, including any arguments supplied with the skill invocation.
|
||||||
|
|
||||||
## Use current documentation
|
## Use current documentation
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ description: Build and manage trusted local Paseo plugins. Use when the user ask
|
|||||||
|
|
||||||
Build or manage the requested plugin directly. Use the current public docs to catch contract changes, but keep working from this skill if the network is unavailable.
|
Build or manage the requested plugin directly. Use the current public docs to catch contract changes, but keep working from this skill if the network is unavailable.
|
||||||
|
|
||||||
**User's request:** $ARGUMENTS
|
Use the user's request and current conversation as input, including any arguments supplied with the skill invocation.
|
||||||
|
|
||||||
## Check current documentation
|
## Check current documentation
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user