mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 05:02:58 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c4bb45761 |
@@ -358,14 +358,6 @@ 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.
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
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,7 +14,6 @@ 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";
|
||||
|
||||
@@ -1242,7 +1241,6 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const adaptiveTheme = await runAdaptiveThemeRegression(artifactDir);
|
||||
await runAppearanceFontSizeRegression(page);
|
||||
|
||||
const callerAgentId = await createCallerAgent(daemonPort);
|
||||
@@ -1267,12 +1265,7 @@ async function main() {
|
||||
artifactDir,
|
||||
});
|
||||
const pluginLinks = await checkPluginLinks();
|
||||
writeJson(path.join(artifactDir, "result.json"), {
|
||||
...report,
|
||||
settingsMemory,
|
||||
pluginLinks,
|
||||
adaptiveTheme,
|
||||
});
|
||||
writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory, pluginLinks });
|
||||
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.`,
|
||||
);
|
||||
|
||||
@@ -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 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: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: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",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Opt-in diagnostic for #3905. Run from the repository root:
|
||||
// node --import tsx packages/server/scripts/reproduce-codex-history-string-limit.mts 520 [legacy|paginated]
|
||||
// Requires Codex 0.148+ on PATH and approximately 2 GiB of available memory.
|
||||
// `paginated` migrates the synthetic rollout with `codex migrate-rollouts` first.
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, mkdir, open, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { CodexAppServerAgentSession } from "../src/server/agent/providers/codex-app-server-agent.js";
|
||||
|
||||
const megabytes = Number(process.argv[2] ?? "520");
|
||||
if (!Number.isSafeInteger(megabytes) || megabytes < 1 || megabytes > 600) {
|
||||
throw new Error("Expected an integer size between 1 and 600 MiB");
|
||||
}
|
||||
const historyMode = process.argv[3] ?? "legacy";
|
||||
if (historyMode !== "legacy" && historyMode !== "paginated") {
|
||||
throw new Error("Expected history mode legacy or paginated");
|
||||
}
|
||||
const root = await mkdtemp(path.join(process.cwd(), ".codex-history-repro-"));
|
||||
const home = path.join(root, "codex-home");
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString();
|
||||
const threadId = randomUUID();
|
||||
const turnId = randomUUID();
|
||||
const sessions = path.join(home, "sessions", ...timestamp.slice(0, 10).split("-"));
|
||||
await mkdir(sessions, { recursive: true });
|
||||
const rollout = await open(
|
||||
path.join(sessions, `rollout-${timestamp.slice(0, 19).replaceAll(":", "-")}-${threadId}.jsonl`),
|
||||
"w",
|
||||
);
|
||||
async function write(type: string, payload: unknown) {
|
||||
await rollout.write(`${JSON.stringify({ timestamp, type, payload })}\n`);
|
||||
}
|
||||
await write("session_meta", {
|
||||
id: threadId,
|
||||
timestamp,
|
||||
cwd: root,
|
||||
originator: "codex_cli_rs",
|
||||
cli_version: "0.159.0",
|
||||
source: "cli",
|
||||
model_provider: "openai",
|
||||
history_mode: "legacy",
|
||||
});
|
||||
await write("event_msg", { type: "task_started", turn_id: turnId, model_context_window: 200000 });
|
||||
await write("event_msg", {
|
||||
type: "user_message",
|
||||
message: "Synthetic oversized history",
|
||||
images: [],
|
||||
local_images: [],
|
||||
text_elements: [],
|
||||
});
|
||||
for (let index = 0; index < megabytes; index++) {
|
||||
await write("event_msg", {
|
||||
type: "agent_message",
|
||||
message: "x".repeat(1024 * 1024),
|
||||
phase: "commentary",
|
||||
});
|
||||
}
|
||||
await write("event_msg", { type: "task_complete", turn_id: turnId, last_agent_message: "done" });
|
||||
await rollout.close();
|
||||
if (historyMode === "paginated") {
|
||||
execFileSync("codex", ["migrate-rollouts", "--apply", "--thread", threadId], {
|
||||
env: { ...process.env, CODEX_HOME: home },
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
`Opening synthetic ${megabytes} MiB ${historyMode} history through the real Codex app-server`,
|
||||
);
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{ provider: "codex", cwd: root },
|
||||
{ sessionId: threadId },
|
||||
pino({ level: "silent" }),
|
||||
async () =>
|
||||
spawn(
|
||||
"codex",
|
||||
["app-server", "--disable", "plugins", "--disable", "apps", "--disable", "remote_plugin"],
|
||||
{
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: { ...process.env, CODEX_HOME: home },
|
||||
},
|
||||
),
|
||||
{ codexHome: home },
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
"history",
|
||||
);
|
||||
try {
|
||||
await session.connect();
|
||||
let count = 0;
|
||||
for await (const event of session.streamHistory()) {
|
||||
if (event.type !== "timeline") continue;
|
||||
count++;
|
||||
if (event.item.type === "notification") console.log(event.item.message);
|
||||
}
|
||||
console.log(`History loaded: ${count} timeline rows`);
|
||||
} catch (error) {
|
||||
// A recoverable session error is acceptable for the crash-isolation assertion.
|
||||
console.log(
|
||||
`History rejected without crashing: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
@@ -8059,60 +8059,6 @@ 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,7 +236,6 @@ 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 }
|
||||
| {
|
||||
@@ -3520,15 +3519,6 @@ 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);
|
||||
}
|
||||
@@ -5386,7 +5376,7 @@ export class AgentManager {
|
||||
for (const subscriber of this.subscribers) {
|
||||
if (
|
||||
subscriber.agentId &&
|
||||
(event.type === "agent_stream" || event.type === "agent_removed") &&
|
||||
event.type === "agent_stream" &&
|
||||
subscriber.agentId !== event.agentId
|
||||
) {
|
||||
continue;
|
||||
@@ -5422,7 +5412,6 @@ 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,6 +1,4 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
@@ -253,52 +251,23 @@ 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 { result: tail, blockingMs } = await measureTimelineTail(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;
|
||||
},
|
||||
);
|
||||
expect(tail.rows).toHaveLength(200);
|
||||
expect(tail.startSeq).toBe(99_801);
|
||||
expect(tail.endSeq).toBe(100_000);
|
||||
|
||||
@@ -88,6 +88,7 @@ describe("Codex executable discovery", () => {
|
||||
import { CodexAppServerClient } from "./codex/app-server-transport.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
fakeCodexThreadHistory,
|
||||
type FakeCodexAppServer,
|
||||
waitForNextPermission,
|
||||
waitForNextTimelineItem,
|
||||
@@ -1991,7 +1992,7 @@ describe("Codex app-server provider", () => {
|
||||
purpose: "history",
|
||||
});
|
||||
|
||||
expect(threadRequests).toEqual(["thread/read"]);
|
||||
expect(threadRequests).toEqual(["thread/read", "thread/read"]);
|
||||
await session.close();
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
@@ -2068,6 +2069,7 @@ describe("Codex app-server provider", () => {
|
||||
"thread/loaded/list",
|
||||
"thread/resume",
|
||||
"thread/read",
|
||||
"thread/read",
|
||||
]);
|
||||
expect(codex.threadMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]);
|
||||
expect(codex.manager.getTimeline(codex.agentId)).toEqual(projected);
|
||||
@@ -2427,7 +2429,7 @@ describe("Codex app-server provider", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4",
|
||||
serviceTier: null,
|
||||
excludeTurns: false,
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
@@ -2441,8 +2443,9 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("rewinds a paginated conversation through the public session capability", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/read": () => ({
|
||||
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
|
||||
...fakeCodexThreadHistory({
|
||||
"thread-1": { historyMode: "paginated", turns: [] },
|
||||
"forked-thread": { historyMode: "paginated", turns: [] },
|
||||
}),
|
||||
"thread/rollback": () => {
|
||||
throw new Error("paginated threads do not support thread/rollback");
|
||||
@@ -2487,7 +2490,7 @@ describe("Codex app-server provider", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4",
|
||||
serviceTier: null,
|
||||
excludeTurns: false,
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
@@ -2504,11 +2507,10 @@ describe("Codex app-server provider", () => {
|
||||
async (historyMode) => {
|
||||
const appServer = createFakeCodexAppServer(
|
||||
historyMode === "paginated"
|
||||
? {
|
||||
"thread/read": () => ({
|
||||
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
|
||||
}),
|
||||
}
|
||||
? fakeCodexThreadHistory({
|
||||
"thread-1": { historyMode: "paginated", turns: [] },
|
||||
"forked-thread": { historyMode: "paginated", turns: [] },
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
const customCodexConfig = {
|
||||
@@ -4947,6 +4949,7 @@ describe("Codex app-server provider", () => {
|
||||
}
|
||||
|
||||
expect(requests.map((request) => [request.method, request.params])).toEqual([
|
||||
["thread/read", { threadId: "test-thread", includeTurns: false }],
|
||||
["thread/read", { threadId: "test-thread", includeTurns: true }],
|
||||
]);
|
||||
expect(history).toEqual([
|
||||
|
||||
@@ -58,7 +58,6 @@ import {
|
||||
mapCodexToolCallEnvelope,
|
||||
mapCodexToolCallFromThreadItem,
|
||||
normalizeCommandExecutionCommand,
|
||||
normalizeCollabAgentChildStatus,
|
||||
splitCodexMcpToolResultImages,
|
||||
} from "./codex/tool-call-mapper.js";
|
||||
import {
|
||||
@@ -93,12 +92,18 @@ import {
|
||||
type CodexAppServerTraceContext,
|
||||
} from "./codex/app-server-transport.js";
|
||||
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
|
||||
import {
|
||||
type CodexHistoryRow,
|
||||
CodexThreadHistoryTooLargeError,
|
||||
readCodexThreadHistory,
|
||||
} from "./codex/thread-history.js";
|
||||
import {
|
||||
materializeProviderImage,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
type ProviderImageOutput,
|
||||
} from "./provider-image-output.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
|
||||
import { limitAgentTimelineItemContent } from "../agent-timeline-content.js";
|
||||
import {
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
@@ -1805,22 +1810,13 @@ function readCodexHistoryTimestamp(item: unknown): string | null {
|
||||
);
|
||||
}
|
||||
|
||||
function readCodexTurnHistoryTimestamp(
|
||||
turn: unknown,
|
||||
/** Falls back to the row's start time for a user message and its end time otherwise. */
|
||||
function readCodexHistoryRowTimestamp(
|
||||
row: CodexHistoryRow,
|
||||
timelineItem: AgentTimelineItem,
|
||||
): string | null {
|
||||
const record = toObjectRecord(turn);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startedAt =
|
||||
normalizeProviderReplayTimestamp(record.startedAt) ??
|
||||
normalizeProviderReplayTimestamp(record.started_at);
|
||||
const completedAt =
|
||||
normalizeProviderReplayTimestamp(record.completedAt) ??
|
||||
normalizeProviderReplayTimestamp(record.completed_at);
|
||||
|
||||
const startedAt = normalizeProviderReplayTimestamp(row.startedAt);
|
||||
const completedAt = normalizeProviderReplayTimestamp(row.completedAt);
|
||||
if (timelineItem.type === "user_message") {
|
||||
return startedAt ?? completedAt;
|
||||
}
|
||||
@@ -1861,65 +1857,6 @@ function readCodexSubAgentActivity(item: unknown): CodexSubAgentActivity | null
|
||||
};
|
||||
}
|
||||
|
||||
interface CodexCollabAgentUpdate {
|
||||
threadId: string;
|
||||
status: ToolCallTimelineItem["status"];
|
||||
preservesTerminal: boolean;
|
||||
reopen: boolean;
|
||||
}
|
||||
|
||||
interface CodexCollabAgentControl {
|
||||
childThreadIds: string[];
|
||||
updates: CodexCollabAgentUpdate[];
|
||||
}
|
||||
|
||||
function readCodexCollabAgentControl(item: unknown): CodexCollabAgentControl | null {
|
||||
const record = toObjectRecord(item);
|
||||
if (
|
||||
!record ||
|
||||
normalizeCodexThreadItemType(typeof record.type === "string" ? record.type : undefined) !==
|
||||
"collabAgentToolCall" ||
|
||||
record.tool === "spawnAgent"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const updates = Object.entries(toObjectRecord(record.agentsStates) ?? {}).flatMap(
|
||||
([threadId, value]): CodexCollabAgentUpdate[] => {
|
||||
const state = toObjectRecord(value);
|
||||
if (typeof state?.status !== "string") return [];
|
||||
const status = normalizeCollabAgentChildStatus(state.status);
|
||||
return [
|
||||
{
|
||||
threadId,
|
||||
status: record.tool === "closeAgent" && status === "running" ? "canceled" : status,
|
||||
preservesTerminal: state.status === "shutdown" || record.tool === "closeAgent",
|
||||
reopen: record.tool === "sendInput" && record.status === "completed",
|
||||
},
|
||||
];
|
||||
},
|
||||
);
|
||||
const receivers = Array.isArray(record.receiverThreadIds)
|
||||
? record.receiverThreadIds.filter((id): id is string => typeof id === "string")
|
||||
: [];
|
||||
return {
|
||||
childThreadIds: [...new Set([...receivers, ...updates.map((update) => update.threadId)])],
|
||||
updates,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCodexCollabAgentUpdate(
|
||||
previous: ToolCallTimelineItem["status"],
|
||||
update: CodexCollabAgentUpdate,
|
||||
): ToolCallTimelineItem["status"] {
|
||||
if (
|
||||
isTerminalSubAgentStatus(previous) &&
|
||||
(update.preservesTerminal || (update.status === "running" && !update.reopen))
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
return update.status;
|
||||
}
|
||||
|
||||
function shouldIgnoreMirroredLifecycleItem(source: "item" | "codex_event", item: unknown): boolean {
|
||||
return source === "codex_event" && !readCodexSubAgentActivity(item);
|
||||
}
|
||||
@@ -2123,111 +2060,19 @@ async function threadItemToTimelineEntries(
|
||||
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
|
||||
}
|
||||
|
||||
const CodexThreadReadResponseSchema = z
|
||||
.object({
|
||||
thread: z
|
||||
.object({
|
||||
turns: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
items: z.array(z.unknown()).default([]),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
.passthrough()
|
||||
.default({ turns: [] }),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type CodexThreadReadResponse = z.infer<typeof CodexThreadReadResponseSchema>;
|
||||
type CodexThreadReadRequest = (threadId: string) => Promise<unknown>;
|
||||
|
||||
async function requestCodexThreadHistory(
|
||||
requestThread: CodexThreadReadRequest,
|
||||
threadId: string,
|
||||
): Promise<CodexThreadReadResponse> {
|
||||
const response = await requestThread(threadId);
|
||||
return CodexThreadReadResponseSchema.parse(response);
|
||||
}
|
||||
|
||||
function updateHistoricalCollabAgentCalls(
|
||||
timeline: PersistedTimelineEntry[],
|
||||
timelineIndexByThreadId: Map<string, number>,
|
||||
item: unknown,
|
||||
): boolean {
|
||||
const control = readCodexCollabAgentControl(item);
|
||||
if (!control) return false;
|
||||
for (const update of control.updates) {
|
||||
const existingIndex = timelineIndexByThreadId.get(update.threadId);
|
||||
const existing = existingIndex === undefined ? undefined : timeline[existingIndex];
|
||||
if (existingIndex === undefined || existing?.item.type !== "tool_call") continue;
|
||||
const status = resolveCodexCollabAgentUpdate(existing.item.status, update);
|
||||
const toolCall: ToolCallTimelineItem =
|
||||
status === "failed"
|
||||
? { ...existing.item, status, error: { message: "Sub-agent failed" } }
|
||||
: { ...existing.item, status, error: null };
|
||||
timeline[existingIndex] = { ...existing, item: toolCall };
|
||||
}
|
||||
return (
|
||||
control.childThreadIds.length > 0 &&
|
||||
control.childThreadIds.every((id) => timelineIndexByThreadId.has(id))
|
||||
);
|
||||
}
|
||||
|
||||
async function loadCodexThreadHistoryTimeline(params: {
|
||||
client: CodexAppServerClientLike;
|
||||
threadId: string;
|
||||
cwd: string | null;
|
||||
requestThread: CodexThreadReadRequest;
|
||||
}): Promise<CodexThreadHistoryProjection> {
|
||||
const response = await requestCodexThreadHistory(params.requestThread, params.threadId);
|
||||
const timeline: PersistedTimelineEntry[] = [];
|
||||
const subAgentTimelineIndexByThreadId = new Map<string, number>();
|
||||
for (const turn of response.thread.turns) {
|
||||
for (const item of turn.items) {
|
||||
if (updateHistoricalCollabAgentCalls(timeline, subAgentTimelineIndexByThreadId, item)) {
|
||||
continue;
|
||||
}
|
||||
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
|
||||
if (historicalSubAgentActivity) {
|
||||
const existingIndex = subAgentTimelineIndexByThreadId.get(
|
||||
historicalSubAgentActivity.agentThreadId,
|
||||
);
|
||||
if (existingIndex !== undefined) {
|
||||
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
|
||||
updateHistoricalSubAgentActivity(
|
||||
timeline,
|
||||
existingIndex,
|
||||
historicalSubAgentActivity.kind,
|
||||
activityTimelineItem?.type === "tool_call" &&
|
||||
activityTimelineItem.detail.type === "sub_agent"
|
||||
? activityTimelineItem.detail.subAgentType
|
||||
: undefined,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
|
||||
const timestamp =
|
||||
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
|
||||
const settledTimelineItem =
|
||||
historicalSubAgentActivity && timelineItem.type === "tool_call"
|
||||
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
|
||||
: timelineItem;
|
||||
timeline.push({
|
||||
item: settledTimelineItem,
|
||||
timestamp: timestamp ?? undefined,
|
||||
...(timelineItem.type === "user_message" && typeof turn.id === "string"
|
||||
? { providerTurnId: turn.id }
|
||||
: {}),
|
||||
});
|
||||
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
|
||||
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await projectCodexThreadHistory(params, { timeline, subAgentTimelineIndexByThreadId });
|
||||
} catch (error) {
|
||||
if (!(error instanceof CodexThreadHistoryTooLargeError)) throw error;
|
||||
// The session stays usable; the row says why the history stops here.
|
||||
timeline.push({ item: { type: "notification", level: "warning", message: error.message } });
|
||||
}
|
||||
const subAgentRoutes = Array.from(subAgentTimelineIndexByThreadId.entries()).flatMap(
|
||||
([childThreadId, timelineIndex]): PersistedSubAgentRoute[] => {
|
||||
@@ -2240,11 +2085,55 @@ async function loadCodexThreadHistoryTimeline(params: {
|
||||
return { timeline, subAgentRoutes };
|
||||
}
|
||||
|
||||
function readCodexThread(client: CodexAppServerClientLike, threadId: string): Promise<unknown> {
|
||||
return client.request("thread/read", {
|
||||
threadId,
|
||||
includeTurns: true,
|
||||
});
|
||||
async function projectCodexThreadHistory(
|
||||
params: { client: CodexAppServerClientLike; threadId: string; cwd: string | null },
|
||||
projection: {
|
||||
timeline: PersistedTimelineEntry[];
|
||||
subAgentTimelineIndexByThreadId: Map<string, number>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { timeline, subAgentTimelineIndexByThreadId } = projection;
|
||||
for await (const row of readCodexThreadHistory(params.client, params.threadId)) {
|
||||
const { item } = row;
|
||||
const historicalSubAgentActivity = readCodexSubAgentActivity(item);
|
||||
if (historicalSubAgentActivity) {
|
||||
const existingIndex = subAgentTimelineIndexByThreadId.get(
|
||||
historicalSubAgentActivity.agentThreadId,
|
||||
);
|
||||
if (existingIndex !== undefined) {
|
||||
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
|
||||
updateHistoricalSubAgentActivity(
|
||||
timeline,
|
||||
existingIndex,
|
||||
historicalSubAgentActivity.kind,
|
||||
activityTimelineItem?.type === "tool_call" &&
|
||||
activityTimelineItem.detail.type === "sub_agent"
|
||||
? activityTimelineItem.detail.subAgentType
|
||||
: undefined,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
|
||||
const timestamp =
|
||||
readCodexHistoryTimestamp(item) ?? readCodexHistoryRowTimestamp(row, timelineItem);
|
||||
const settledTimelineItem =
|
||||
historicalSubAgentActivity && timelineItem.type === "tool_call"
|
||||
? settleHistoricalSubAgentActivity(timelineItem, historicalSubAgentActivity.kind)
|
||||
: timelineItem;
|
||||
timeline.push({
|
||||
// Cap each item as it is projected so a large history is never held uncapped.
|
||||
item: limitAgentTimelineItemContent(settledTimelineItem),
|
||||
timestamp: timestamp ?? undefined,
|
||||
...(timelineItem.type === "user_message" && row.turnId !== undefined
|
||||
? { providerTurnId: row.turnId }
|
||||
: {}),
|
||||
});
|
||||
for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) {
|
||||
subAgentTimelineIndexByThreadId.set(childThreadId, timeline.length - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function forkCodexThread(
|
||||
@@ -4084,9 +3973,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return { timeline: [], providerSubagentEvents: [] };
|
||||
}
|
||||
const { timeline, subAgentRoutes } = await loadCodexThreadHistoryTimeline({
|
||||
client,
|
||||
threadId: this.currentThreadId,
|
||||
cwd: this.config.cwd ?? null,
|
||||
requestThread: (threadIdToRead) => readCodexThread(client, threadIdToRead),
|
||||
});
|
||||
this.subAgentCallsByCallId.clear();
|
||||
this.subAgentCallIdByChildThreadId.clear();
|
||||
@@ -4142,9 +4031,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
try {
|
||||
const childHistory = await loadCodexThreadHistoryTimeline({
|
||||
client,
|
||||
threadId: next.route.childThreadId,
|
||||
cwd: this.config.cwd ?? null,
|
||||
requestThread: (childThreadId) => readCodexThread(client, childThreadId),
|
||||
});
|
||||
for (const entry of childHistory.timeline) {
|
||||
this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp);
|
||||
@@ -5848,23 +5737,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private handleRegisteredSubAgentActivity(rawItem: { [key: string]: unknown }): boolean {
|
||||
const control = readCodexCollabAgentControl(rawItem);
|
||||
if (control) {
|
||||
for (const update of control.updates) {
|
||||
const callId = this.subAgentCallIdByChildThreadId.get(update.threadId);
|
||||
if (!callId) continue;
|
||||
const state = this.subAgentCallsByCallId.get(callId);
|
||||
if (!state) continue;
|
||||
const status = resolveCodexCollabAgentUpdate(state.toolCall.status, update);
|
||||
if (status !== state.toolCall.status) {
|
||||
this.emitSubAgentActivityUpdate(callId, status, { reopen: update.reopen });
|
||||
}
|
||||
}
|
||||
return (
|
||||
control.childThreadIds.length > 0 &&
|
||||
control.childThreadIds.every((id) => this.subAgentCallIdByChildThreadId.has(id))
|
||||
);
|
||||
}
|
||||
const activity = readCodexSubAgentActivity(rawItem);
|
||||
if (!activity) {
|
||||
return false;
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
// Regression for #3905. Real Codex, synthetic on-disk history, no model turns.
|
||||
// Opt in because the oversized cases need approximately 2 GiB of memory and over
|
||||
// 1 GiB of temporary storage.
|
||||
const enabled = process.env.PASEO_CODEX_HISTORY_STRING_LIMIT_REPRO === "1";
|
||||
const script = fileURLToPath(
|
||||
new URL("../../../../scripts/reproduce-codex-history-string-limit.mts", import.meta.url),
|
||||
);
|
||||
|
||||
test.runIf(enabled).each([
|
||||
{ megabytes: 2, historyMode: "legacy", expected: "History loaded: 3 timeline rows" },
|
||||
{ megabytes: 520, historyMode: "legacy", expected: "codex migrate-rollouts --apply --thread" },
|
||||
{ megabytes: 520, historyMode: "paginated", expected: "History loaded: 521 timeline rows" },
|
||||
])(
|
||||
"opening $megabytes MiB of $historyMode Codex history does not crash its hosting process",
|
||||
async ({ megabytes, historyMode, expected }) => {
|
||||
const root = await mkdtemp(path.join(process.cwd(), ".codex-history-test-"));
|
||||
try {
|
||||
const result = await new Promise<{ code: number; stdout: string; stderr: string }>(
|
||||
(resolve) => {
|
||||
const child = execFile(
|
||||
process.execPath,
|
||||
["--import", "tsx", script, String(megabytes), historyMode],
|
||||
{
|
||||
cwd: root,
|
||||
detached: true,
|
||||
timeout: 120_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
try {
|
||||
if (child.pid) process.kill(-child.pid, "SIGKILL");
|
||||
} catch (killError) {
|
||||
if ((killError as NodeJS.ErrnoException).code !== "ESRCH") throw killError;
|
||||
}
|
||||
resolve({ code: error ? Number(error.code ?? 1) : 0, stdout, stderr });
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain(expected);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
||||
}
|
||||
},
|
||||
180_000,
|
||||
);
|
||||
@@ -185,14 +185,41 @@ describe("Codex app-server transport", () => {
|
||||
});
|
||||
|
||||
describe("Codex stdout byte admission", () => {
|
||||
test("fails pending requests and the session before joining an oversized line", async () => {
|
||||
test("an oversized response fails only its own request", async () => {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
const terminations: Error[] = [];
|
||||
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
|
||||
const oversized = client.request("thread/items/list", {}).catch((error: Error) => error);
|
||||
const other = client.request("model/list", {});
|
||||
const chunk = Buffer.alloc(64 * 1024, "x");
|
||||
child.stdout.write('{"id":1,"result":"');
|
||||
for (let index = 0; index < 2049; index++) child.stdout.write(chunk);
|
||||
child.stdout.write('"}\n{"id":2,"result":{"data":[]}}\n');
|
||||
|
||||
expect(await oversized).toMatchObject({
|
||||
name: "CodexAppServerResponseTooLargeError",
|
||||
method: "thread/items/list",
|
||||
limitBytes: 128 * 1024 * 1024,
|
||||
});
|
||||
await expect(other).resolves.toEqual({ data: [] });
|
||||
const next = client.request("model/list", {});
|
||||
child.stdout.write('{"id":3,"result":{"data":["next"]}}\n');
|
||||
await expect(next).resolves.toEqual({ data: ["next"] });
|
||||
expect(terminations).toEqual([]);
|
||||
await client.dispose();
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
});
|
||||
|
||||
test("an oversized notification fails pending requests and the session", async () => {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
const terminations: Error[] = [];
|
||||
client.setUnexpectedTerminationHandler((error) => terminations.push(error));
|
||||
const request = client.request("thread/resume", {}, 1000).catch((error: Error) => error);
|
||||
const chunk = Buffer.alloc(64 * 1024, "x");
|
||||
child.stdout.write('{"id":1,"result":"');
|
||||
child.stdout.write('{"method":"item/completed","params":"');
|
||||
for (let index = 0; index < 2048; index++) child.stdout.write(chunk);
|
||||
const error = await request;
|
||||
expect(error).toMatchObject({
|
||||
|
||||
@@ -33,11 +33,26 @@ interface JsonRpcNotification {
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
method: string;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
/** A response past the provider line cap. Only its request fails; the session stays up. */
|
||||
export class CodexAppServerResponseTooLargeError extends Error {
|
||||
constructor(
|
||||
readonly method: string,
|
||||
readonly limitBytes: number,
|
||||
) {
|
||||
super(`Codex app-server response to ${method} exceeds ${limitBytes} bytes`);
|
||||
this.name = "CodexAppServerResponseTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
// Codex serializes a response with `id` first, so its head names the request.
|
||||
const OVERSIZED_RESPONSE_HEAD = /^\{"id":(\d+),"result":/;
|
||||
|
||||
export class CodexAppServerRpcError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -258,7 +273,7 @@ export class CodexAppServerClient {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Codex app-server request timed out for ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.pending.set(id, { method, resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -304,8 +319,21 @@ export class CodexAppServerClient {
|
||||
}
|
||||
|
||||
private handleStreamLimit(failure: ProviderStreamLimitError): void {
|
||||
// A rejected response/notification cannot be replayed safely. Fail the session,
|
||||
const responseId = OVERSIZED_RESPONSE_HEAD.exec(failure.lineHead)?.[1];
|
||||
const pending = responseId === undefined ? undefined : this.pending.get(Number(responseId));
|
||||
if (pending) {
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(Number(responseId));
|
||||
this.logger.warn(
|
||||
{ method: pending.method, limitBytes: failure.limitBytes },
|
||||
"Codex app-server response exceeded the line cap",
|
||||
);
|
||||
pending.reject(new CodexAppServerResponseTooLargeError(pending.method, failure.limitBytes));
|
||||
return;
|
||||
}
|
||||
// A lost notification or server request cannot be replayed. Fail the session,
|
||||
// including live turns with no pending RPC, rather than dropping a stream unit.
|
||||
this.stdoutLineReader.close();
|
||||
this.handleUnexpectedTermination(failure);
|
||||
void this.dispose().catch((err) => {
|
||||
this.logger.warn({ err }, "Failed to terminate oversized Codex stdout producer");
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { CodexAppServerAgentSession } from "../codex-app-server-agent.js";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { createFakeCodexAppServer } from "./test-utils/fake-app-server.js";
|
||||
|
||||
function collab(tool: string, states: Record<string, string>, id = tool) {
|
||||
return {
|
||||
type: "collabAgentToolCall",
|
||||
id,
|
||||
tool,
|
||||
status: "completed",
|
||||
prompt: tool === "spawnAgent" ? "Reply with a short result." : null,
|
||||
receiverThreadIds: Object.keys(states),
|
||||
agentsStates: Object.fromEntries(
|
||||
Object.entries(states).map(([threadId, status]) => [threadId, { status }]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function replay(items: unknown[]) {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/read": (params) => ({
|
||||
thread: {
|
||||
turns: [
|
||||
{
|
||||
items: (params as { threadId: string }).threadId === "parent" ? items : [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{ provider: "codex", cwd: process.cwd(), modeId: "full-access" },
|
||||
{ sessionId: "parent" },
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
{},
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
"history",
|
||||
);
|
||||
try {
|
||||
await session.connect();
|
||||
return await Array.fromAsync(session.streamHistory());
|
||||
} finally {
|
||||
await session.close();
|
||||
appServer.assertNoErrors();
|
||||
}
|
||||
}
|
||||
|
||||
describe("Codex collaboration history", () => {
|
||||
test("restores completed children from nullable wait and close results", async () => {
|
||||
const history = await replay([
|
||||
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
|
||||
collab("spawnAgent", { second: "pendingInit" }, "spawn-second"),
|
||||
collab("wait", { first: "completed" }, "wait-first"),
|
||||
collab("wait", { second: "completed" }, "wait-second"),
|
||||
collab("closeAgent", { first: "completed" }, "close-first"),
|
||||
collab("closeAgent", { second: "completed" }, "close-second"),
|
||||
]);
|
||||
expect(
|
||||
history.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "upsert"
|
||||
? [{ id: event.event.id, status: event.event.status }]
|
||||
: [],
|
||||
),
|
||||
).toEqual([
|
||||
{ id: "first", status: "completed" },
|
||||
{ id: "second", status: "completed" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("stops counting a child closed while running", async () => {
|
||||
const history = await replay([
|
||||
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
|
||||
collab("closeAgent", { first: "running" }),
|
||||
]);
|
||||
expect(
|
||||
history.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "upsert"
|
||||
? [{ id: event.event.id, status: event.event.status }]
|
||||
: [],
|
||||
),
|
||||
).toEqual([{ id: "first", status: "canceled" }]);
|
||||
});
|
||||
|
||||
test("stops counting an errored child whose close record reports failed", async () => {
|
||||
const history = await replay([
|
||||
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
|
||||
{ ...collab("closeAgent", { first: "errored" }), status: "failed" },
|
||||
]);
|
||||
expect(
|
||||
history.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "upsert"
|
||||
? [{ id: event.event.id, status: event.event.status }]
|
||||
: [],
|
||||
),
|
||||
).toEqual([{ id: "first", status: "canceled" }]);
|
||||
});
|
||||
|
||||
test("preserves completion when a close carries an older running snapshot", async () => {
|
||||
const history = await replay([
|
||||
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
|
||||
collab("wait", { first: "completed" }),
|
||||
collab("closeAgent", { first: "running" }),
|
||||
]);
|
||||
expect(
|
||||
history.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "upsert"
|
||||
? [{ id: event.event.id, status: event.event.status }]
|
||||
: [],
|
||||
),
|
||||
).toEqual([{ id: "first", status: "completed" }]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["shutdown", "canceled"],
|
||||
["notFound", "failed"],
|
||||
])("restores a %s child as %s and preserves its running sibling", async (status, expected) => {
|
||||
const history = await replay([
|
||||
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
|
||||
collab("spawnAgent", { second: "pendingInit" }, "spawn-second"),
|
||||
collab("closeAgent", { first: status }),
|
||||
]);
|
||||
expect(
|
||||
history.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "upsert"
|
||||
? [{ id: event.event.id, status: event.event.status }]
|
||||
: [],
|
||||
),
|
||||
).toEqual([
|
||||
{ id: "first", status: expected },
|
||||
{ id: "second", status: "running" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps completed live children settled until an explicit follow-up", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{ provider: "codex", cwd: process.cwd(), modeId: "full-access" },
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
const statuses = new Map<string, string>();
|
||||
const timelineCallIds = new Set<string>();
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "provider_subagent" && event.event.type === "upsert") {
|
||||
statuses.set(event.event.id, event.event.status);
|
||||
}
|
||||
if (event.type === "timeline" && event.item.type === "tool_call") {
|
||||
timelineCallIds.add(event.item.callId);
|
||||
}
|
||||
});
|
||||
function complete(tool: string, states: Record<string, string>, id = tool) {
|
||||
if (tool !== "spawnAgent") {
|
||||
appServer.child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
method: "item/started",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
item: { ...collab(tool, states, id), status: "inProgress", agentsStates: {} },
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
appServer.child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
method: "item/completed",
|
||||
params: { threadId: "thread-1", item: collab(tool, states, id) },
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = session.run("Delegate the work.");
|
||||
await appServer.waitForTurnStart();
|
||||
complete("spawnAgent", { first: "pendingInit" }, "spawn-first");
|
||||
complete("spawnAgent", { second: "pendingInit" }, "spawn-second");
|
||||
complete("wait", { first: "completed", second: "running" });
|
||||
await vi.waitFor(() => {
|
||||
expect(statuses.get("first")).toBe("completed");
|
||||
expect(statuses.get("second")).toBe("running");
|
||||
});
|
||||
complete("closeAgent", { first: "shutdown" });
|
||||
complete("wait", { first: "running" }, "stale-wait");
|
||||
appServer.says({ threadId: "thread-1", text: "Still waiting." });
|
||||
appServer.completeTurn();
|
||||
await result;
|
||||
expect(statuses.get("first")).toBe("completed");
|
||||
complete("sendInput", { first: "running" });
|
||||
await vi.waitFor(() => expect(statuses.get("first")).toBe("running"));
|
||||
expect([...timelineCallIds]).toEqual(["spawn-first", "spawn-second"]);
|
||||
} finally {
|
||||
await session.close();
|
||||
appServer.assertNoErrors();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -108,7 +108,7 @@ describe("Codex Rewind", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
excludeTurns: false,
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
@@ -190,7 +190,7 @@ describe("Codex Rewind", () => {
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
excludeTurns: false,
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
parseCodexThreadForkResponse,
|
||||
parseCodexThreadRollbackResponse,
|
||||
} from "./app-server-transport.js";
|
||||
import { readCodexThreadHistoryMode } from "./thread-history.js";
|
||||
|
||||
export interface CodexRewindClient {
|
||||
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
|
||||
@@ -20,26 +21,6 @@ export interface CodexUserMessageTurnIndex {
|
||||
count(): number;
|
||||
}
|
||||
|
||||
type CodexThreadHistoryMode = "legacy" | "paginated";
|
||||
|
||||
async function readCodexThreadHistoryMode(
|
||||
client: CodexRewindClient,
|
||||
threadId: string,
|
||||
): Promise<CodexThreadHistoryMode> {
|
||||
const response = await client.request("thread/read", { threadId, includeTurns: false });
|
||||
if (typeof response !== "object" || response === null || !("thread" in response)) {
|
||||
throw new Error("Codex thread/read did not return thread metadata");
|
||||
}
|
||||
const thread = response.thread;
|
||||
if (typeof thread !== "object" || thread === null || !("historyMode" in thread)) {
|
||||
return "legacy";
|
||||
}
|
||||
if (thread.historyMode === "legacy" || thread.historyMode === "paginated") {
|
||||
return thread.historyMode;
|
||||
}
|
||||
throw new Error(`Codex thread/read returned unknown history mode ${String(thread.historyMode)}`);
|
||||
}
|
||||
|
||||
async function forkCodexThread(
|
||||
client: CodexRewindClient,
|
||||
params: CodexThreadForkParams,
|
||||
@@ -95,7 +76,8 @@ export async function revertCodexConversation(input: {
|
||||
model: input.model ?? null,
|
||||
serviceTier: input.serviceTier ?? null,
|
||||
...(input.config ? { config: input.config } : {}),
|
||||
excludeTurns: false,
|
||||
// Paseo re-reads the rewound thread through paged history, not the fork reply.
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import type { AgentSession, AgentStreamEvent } from "../../../agent-sdk-types.js";
|
||||
import { CODEX_PROVIDER_LINE_BYTES } from "../app-server-transport.js";
|
||||
|
||||
/** A handler result that the fake writes as a response line past the provider line cap. */
|
||||
export const OVERSIZED_RESPONSE = Symbol("oversized Codex response");
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
type FakeCodexAppServerHandler = (params: unknown) => unknown;
|
||||
@@ -229,8 +233,18 @@ export function createFakeCodexAppServer(
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.resolve(handler(message.params))
|
||||
let response: unknown;
|
||||
try {
|
||||
response = handler(message.params);
|
||||
} catch (error) {
|
||||
response = Promise.reject(error);
|
||||
}
|
||||
Promise.resolve(response)
|
||||
.then((result) => {
|
||||
if (result === OVERSIZED_RESPONSE) {
|
||||
writeOversizedResponse(message.id as number);
|
||||
return undefined;
|
||||
}
|
||||
const rpcError = toJsonObject(result).__jsonRpcError;
|
||||
if (rpcError) {
|
||||
child.stdout.write(`${JSON.stringify({ id: message.id, error: rpcError })}\n`);
|
||||
@@ -313,6 +327,16 @@ export function createFakeCodexAppServer(
|
||||
});
|
||||
}
|
||||
|
||||
// Codex writes `id` first, then a result past the provider line cap.
|
||||
function writeOversizedResponse(id: number): void {
|
||||
child.stdout.write(`{"id":${id},"result":{"data":[{"item":{"text":"`);
|
||||
const chunk = Buffer.alloc(1024 * 1024, "x");
|
||||
for (let written = 0; written <= CODEX_PROVIDER_LINE_BYTES; written += chunk.byteLength) {
|
||||
child.stdout.write(chunk);
|
||||
}
|
||||
child.stdout.write('"}}]}}\n');
|
||||
}
|
||||
|
||||
function writeNotification(method: string, params: JsonObject): void {
|
||||
child.stdout.write(`${JSON.stringify({ method, params })}\n`);
|
||||
}
|
||||
@@ -696,3 +720,81 @@ export function waitForProviderSubagent(
|
||||
): Promise<ProviderSubagentEvent> {
|
||||
return waitForNextEvent(session, "provider_subagent", (event) => event.event.id === id);
|
||||
}
|
||||
|
||||
export interface FakeCodexHistoryTurn {
|
||||
id: string;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
items: JsonObject[];
|
||||
}
|
||||
|
||||
export interface FakeCodexHistoryThread {
|
||||
/** Omitted on Codex builds older than 0.143, which only have legacy history. */
|
||||
historyMode?: "legacy" | "paginated";
|
||||
turns: FakeCodexHistoryTurn[];
|
||||
}
|
||||
|
||||
export interface FakeCodexHistoryOptions {
|
||||
/** Any response carrying one of these items is written past the line cap. */
|
||||
oversizedItemIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex's persisted thread history: `thread/read` for metadata and legacy full
|
||||
* reads, `thread/items/list` for paginated threads. Paginated threads reject
|
||||
* the full read as Codex 0.145 and 0.146 do.
|
||||
*/
|
||||
export function fakeCodexThreadHistory(
|
||||
threads: Record<string, FakeCodexHistoryThread>,
|
||||
options: FakeCodexHistoryOptions = {},
|
||||
): Record<string, FakeCodexAppServerHandler> {
|
||||
const oversizedItemIds = new Set(options.oversizedItemIds ?? []);
|
||||
const isOversized = (items: JsonObject[]) =>
|
||||
items.some((item) => oversizedItemIds.has(String(item.id)));
|
||||
|
||||
function readThread(params: unknown): { id: string; thread: FakeCodexHistoryThread } {
|
||||
const id = String(toJsonObject(params).threadId);
|
||||
const thread = threads[id];
|
||||
if (!thread) throw new Error(`thread not found: ${id}`);
|
||||
return { id, thread };
|
||||
}
|
||||
|
||||
return {
|
||||
"thread/read": (params) => {
|
||||
const { id, thread } = readThread(params);
|
||||
if (!toJsonObject(params).includeTurns) {
|
||||
return {
|
||||
thread: { id, ...(thread.historyMode ? { historyMode: thread.historyMode } : {}) },
|
||||
};
|
||||
}
|
||||
if (thread.historyMode === "paginated") {
|
||||
throw new Error("paginated threads do not support thread/read(includeTurns=true)");
|
||||
}
|
||||
if (isOversized(thread.turns.flatMap((turn) => turn.items))) return OVERSIZED_RESPONSE;
|
||||
return { thread: { id, turns: thread.turns } };
|
||||
},
|
||||
"thread/items/list": (params) => {
|
||||
const { thread } = readThread(params);
|
||||
if (thread.historyMode !== "paginated") {
|
||||
throw new Error("thread/items/list is not supported for legacy threads");
|
||||
}
|
||||
const request = toJsonObject(params);
|
||||
// Codex records each item's own times; the fake gives every item its turn's.
|
||||
const rows = thread.turns.flatMap((turn) =>
|
||||
turn.items.map((item) => ({
|
||||
turnId: turn.id,
|
||||
item,
|
||||
startedAtMs: turn.startedAt === undefined ? null : turn.startedAt * 1000,
|
||||
completedAtMs: turn.completedAt === undefined ? null : turn.completedAt * 1000,
|
||||
})),
|
||||
);
|
||||
const limit = Math.min(typeof request.limit === "number" ? request.limit : 25, 100);
|
||||
const start =
|
||||
typeof request.cursor === "string" ? Number(request.cursor.slice("cursor-".length)) : 0;
|
||||
const end = Math.min(rows.length, start + limit);
|
||||
const data = rows.slice(start, end);
|
||||
if (isOversized(data.map((row) => row.item))) return OVERSIZED_RESPONSE;
|
||||
return { data, nextCursor: end < rows.length ? `cursor-${end}` : null };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { CodexAppServerAgentClient } from "../codex-app-server-agent.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
type FakeCodexAppServer,
|
||||
type FakeCodexHistoryOptions,
|
||||
type FakeCodexHistoryThread,
|
||||
fakeCodexThreadHistory,
|
||||
} from "./test-utils/fake-app-server.js";
|
||||
|
||||
function userItem(id: string, text: string) {
|
||||
return { type: "userMessage", id, content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
function agentItem(id: string, text: string) {
|
||||
return { type: "agentMessage", id, text };
|
||||
}
|
||||
|
||||
async function openCodexThread(
|
||||
thread: FakeCodexHistoryThread,
|
||||
options?: FakeCodexHistoryOptions,
|
||||
): Promise<{ appServer: FakeCodexAppServer; session: AgentSession }> {
|
||||
const appServer = createFakeCodexAppServer(
|
||||
fakeCodexThreadHistory({ "saved-thread": thread }, options),
|
||||
);
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger());
|
||||
const internals = provider as unknown as {
|
||||
goalsEnabledPromise: Promise<boolean> | null;
|
||||
autoReviewEnabledPromise: Promise<boolean> | null;
|
||||
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
|
||||
};
|
||||
internals.goalsEnabledPromise = Promise.resolve(false);
|
||||
internals.autoReviewEnabledPromise = Promise.resolve(false);
|
||||
internals.spawnAppServer = async () => appServer.child;
|
||||
const session = await provider.resumeSession({ sessionId: "saved-thread" });
|
||||
return { appServer, session };
|
||||
}
|
||||
|
||||
async function readHistory(session: AgentSession): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of session.streamHistory()) events.push(event);
|
||||
return events;
|
||||
}
|
||||
|
||||
function timelineItems(events: AgentStreamEvent[]) {
|
||||
return events.flatMap((event) => (event.type === "timeline" ? [event.item] : []));
|
||||
}
|
||||
|
||||
function historyRequests(appServer: FakeCodexAppServer) {
|
||||
return appServer
|
||||
.requests()
|
||||
.filter(
|
||||
(request) =>
|
||||
request.method === "thread/read" ||
|
||||
request.method === "thread/turns/list" ||
|
||||
request.method === "thread/items/list",
|
||||
)
|
||||
.map((request) => ({ method: request.method, params: request.params }));
|
||||
}
|
||||
|
||||
describe("Codex thread history", () => {
|
||||
test("loads a paginated thread through pages", async () => {
|
||||
const answers = Array.from({ length: 150 }, (_, index) =>
|
||||
agentItem(`agent-${index}`, `Answer ${index}`),
|
||||
);
|
||||
const { appServer, session } = await openCodexThread({
|
||||
historyMode: "paginated",
|
||||
turns: [
|
||||
{
|
||||
id: "turn-1",
|
||||
startedAt: 1_778_832_941,
|
||||
completedAt: 1_778_833_000,
|
||||
items: [userItem("user-1", "First"), ...answers.slice(0, 120)],
|
||||
},
|
||||
{
|
||||
id: "turn-2",
|
||||
startedAt: 1_778_833_094,
|
||||
completedAt: 1_778_833_100,
|
||||
items: [userItem("user-2", "Second"), ...answers.slice(120)],
|
||||
},
|
||||
],
|
||||
});
|
||||
try {
|
||||
const events = await readHistory(session);
|
||||
|
||||
expect(timelineItems(events)).toEqual([
|
||||
{ type: "user_message", text: "First", messageId: "user-1" },
|
||||
...answers.slice(0, 120).map((answer) => ({
|
||||
type: "assistant_message",
|
||||
text: answer.text,
|
||||
messageId: answer.id,
|
||||
})),
|
||||
{ type: "user_message", text: "Second", messageId: "user-2" },
|
||||
...answers.slice(120).map((answer) => ({
|
||||
type: "assistant_message",
|
||||
text: answer.text,
|
||||
messageId: answer.id,
|
||||
})),
|
||||
]);
|
||||
const timestamps = events.flatMap((event) =>
|
||||
event.type === "timeline" ? [event.timestamp] : [],
|
||||
);
|
||||
expect(timestamps[0]).toBe("2026-05-15T08:15:41.000Z");
|
||||
expect(timestamps[1]).toBe("2026-05-15T08:16:40.000Z");
|
||||
expect(timestamps[121]).toBe("2026-05-15T08:18:14.000Z");
|
||||
const requests = historyRequests(appServer);
|
||||
expect(requests).not.toContainEqual(
|
||||
expect.objectContaining({ params: expect.objectContaining({ includeTurns: true }) }),
|
||||
);
|
||||
const itemPages = requests.filter((request) => request.method === "thread/items/list");
|
||||
expect(itemPages.length).toBeGreaterThan(1);
|
||||
for (const page of itemPages) {
|
||||
expect(page.params).toMatchObject({ threadId: "saved-thread", sortDirection: "asc" });
|
||||
expect((page.params as { limit: number }).limit).toBeLessThanOrEqual(100);
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("loads a paginated thread whose full read Codex 0.145 rejects", async () => {
|
||||
const { appServer, session } = await openCodexThread({
|
||||
historyMode: "paginated",
|
||||
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }],
|
||||
});
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual([
|
||||
{ type: "user_message", text: "Hello", messageId: "user-1" },
|
||||
{ type: "assistant_message", text: "Hi", messageId: "agent-1" },
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a page too big to send shows a warning row and stops loading", async () => {
|
||||
const answers = Array.from({ length: 120 }, (_, index) =>
|
||||
agentItem(`agent-${index}`, `Answer ${index}`),
|
||||
);
|
||||
const { appServer, session } = await openCodexThread(
|
||||
{
|
||||
historyMode: "paginated",
|
||||
turns: [
|
||||
{ id: "turn-1", items: [userItem("user-1", "One"), ...answers] },
|
||||
{ id: "turn-2", items: [userItem("user-2", "Two"), agentItem("huge", "never sent")] },
|
||||
],
|
||||
},
|
||||
{ oversizedItemIds: ["huge"] },
|
||||
);
|
||||
const loaded = [
|
||||
{ type: "user_message", text: "One", messageId: "user-1" },
|
||||
...answers.slice(0, 99).map((answer) => ({
|
||||
type: "assistant_message",
|
||||
text: answer.text,
|
||||
messageId: answer.id,
|
||||
})),
|
||||
{
|
||||
type: "notification",
|
||||
level: "warning",
|
||||
message: "The rest of this Codex thread's history is too large to load.",
|
||||
},
|
||||
];
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual(loaded);
|
||||
// The oversized reply failed only its own request; the session still answers.
|
||||
expect(timelineItems(await readHistory(session))).toEqual(loaded);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("loads a legacy thread that fits in one read", async () => {
|
||||
const { appServer, session } = await openCodexThread({
|
||||
historyMode: "legacy",
|
||||
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("agent-1", "Hi")] }],
|
||||
});
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual([
|
||||
{ type: "user_message", text: "Hello", messageId: "user-1" },
|
||||
{ type: "assistant_message", text: "Hi", messageId: "agent-1" },
|
||||
]);
|
||||
expect(historyRequests(appServer).map((request) => request.method)).not.toContain(
|
||||
"thread/items/list",
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a legacy thread too big for one read shows the migration command", async () => {
|
||||
const { appServer, session } = await openCodexThread(
|
||||
{
|
||||
historyMode: "legacy",
|
||||
turns: [{ id: "turn-1", items: [userItem("user-1", "Hello"), agentItem("huge", "x")] }],
|
||||
},
|
||||
{ oversizedItemIds: ["huge"] },
|
||||
);
|
||||
const migrationRow = {
|
||||
type: "notification",
|
||||
level: "warning",
|
||||
message: expect.stringContaining("codex migrate-rollouts --apply --thread saved-thread"),
|
||||
};
|
||||
try {
|
||||
expect(timelineItems(await readHistory(session))).toEqual([migrationRow]);
|
||||
// The session stays up for the user to prompt until they archive it to migrate.
|
||||
expect(timelineItems(await readHistory(session))).toEqual([migrationRow]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { CodexAppServerResponseTooLargeError } from "./app-server-transport.js";
|
||||
|
||||
// Codex clamps history pages to 100 rows.
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export interface CodexThreadHistoryClient {
|
||||
request(method: string, params?: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
export type CodexThreadHistoryMode = "legacy" | "paginated";
|
||||
|
||||
export interface CodexHistoryRow {
|
||||
item: unknown;
|
||||
turnId: string | undefined;
|
||||
/** The item's times on a paginated thread; its turn's on a legacy thread. */
|
||||
startedAt: unknown;
|
||||
completedAt: unknown;
|
||||
}
|
||||
|
||||
/** A history reply over the line cap; the rows before it were already read. */
|
||||
export class CodexThreadHistoryTooLargeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CodexThreadHistoryTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
const CodexThreadMetadataSchema = z
|
||||
.object({
|
||||
thread: z.object({ historyMode: z.enum(["legacy", "paginated"]).optional() }).passthrough(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CodexLegacyThreadReadSchema = z
|
||||
.object({
|
||||
thread: z
|
||||
.object({
|
||||
turns: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
startedAt: z.unknown().optional(),
|
||||
completedAt: z.unknown().optional(),
|
||||
items: z.array(z.unknown()).default([]),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
.passthrough()
|
||||
.default({ turns: [] }),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CodexItemsPageSchema = z
|
||||
.object({
|
||||
data: z.array(
|
||||
z
|
||||
.object({
|
||||
turnId: z.string(),
|
||||
item: z.unknown(),
|
||||
startedAtMs: z.number().nullable().optional(),
|
||||
completedAtMs: z.number().nullable().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
),
|
||||
nextCursor: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** Codex builds before 0.143 report no history mode; their threads are legacy. */
|
||||
export async function readCodexThreadHistoryMode(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): Promise<CodexThreadHistoryMode> {
|
||||
const response = CodexThreadMetadataSchema.parse(
|
||||
await client.request("thread/read", { threadId, includeTurns: false }),
|
||||
);
|
||||
return response.thread.historyMode ?? "legacy";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a thread's persisted items in order. Paginated threads are read in
|
||||
* pages; a legacy thread arrives in one `thread/read` reply, as Codex offers
|
||||
* no pages for it.
|
||||
*/
|
||||
export async function* readCodexThreadHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): AsyncGenerator<CodexHistoryRow> {
|
||||
if ((await readCodexThreadHistoryMode(client, threadId)) === "paginated") {
|
||||
yield* readPaginatedHistory(client, threadId);
|
||||
} else {
|
||||
yield* readLegacyHistory(client, threadId);
|
||||
}
|
||||
}
|
||||
|
||||
async function* readLegacyHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): AsyncGenerator<CodexHistoryRow> {
|
||||
const response = await requestHistory(
|
||||
client,
|
||||
"thread/read",
|
||||
{ threadId, includeTurns: true },
|
||||
`This Codex thread's history is too large to load in one read. ` +
|
||||
`To move it to paginated history, archive this agent, ` +
|
||||
`run \`codex migrate-rollouts --apply --thread ${threadId}\` (Codex 0.148 or newer), ` +
|
||||
`then unarchive the agent.`,
|
||||
);
|
||||
for (const turn of CodexLegacyThreadReadSchema.parse(response).thread.turns) {
|
||||
for (const item of turn.items) {
|
||||
yield { item, turnId: turn.id, startedAt: turn.startedAt, completedAt: turn.completedAt };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function* readPaginatedHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
threadId: string,
|
||||
): AsyncGenerator<CodexHistoryRow> {
|
||||
const seenCursors = new Set<string>();
|
||||
let cursor: string | undefined;
|
||||
for (;;) {
|
||||
const page = CodexItemsPageSchema.parse(
|
||||
await requestHistory(
|
||||
client,
|
||||
"thread/items/list",
|
||||
{ threadId, limit: PAGE_SIZE, sortDirection: "asc", cursor },
|
||||
"The rest of this Codex thread's history is too large to load.",
|
||||
),
|
||||
);
|
||||
for (const row of page.data) {
|
||||
yield {
|
||||
item: row.item,
|
||||
turnId: row.turnId,
|
||||
startedAt: row.startedAtMs,
|
||||
completedAt: row.completedAtMs,
|
||||
};
|
||||
}
|
||||
if (page.nextCursor == null) return;
|
||||
// Codex's own client guards against a cursor that does not advance.
|
||||
if (seenCursors.has(page.nextCursor)) {
|
||||
throw new Error(`Codex repeated history cursor for thread ${threadId}`);
|
||||
}
|
||||
seenCursors.add(page.nextCursor);
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestHistory(
|
||||
client: CodexThreadHistoryClient,
|
||||
method: string,
|
||||
params: unknown,
|
||||
tooLargeMessage: string,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
return await client.request(method, params);
|
||||
} catch (error) {
|
||||
if (error instanceof CodexAppServerResponseTooLargeError) {
|
||||
throw new CodexThreadHistoryTooLargeError(tooLargeMessage);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -213,20 +213,6 @@ describe("codex tool-call mapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a nullable prompt in completed collaboration control items", () => {
|
||||
expect(
|
||||
mapCodexToolCallFromThreadItem({
|
||||
type: "collabAgentToolCall",
|
||||
id: "wait-child",
|
||||
tool: "wait",
|
||||
prompt: null,
|
||||
status: "completed",
|
||||
receiverThreadIds: ["child"],
|
||||
agentsStates: { child: { status: "completed" } },
|
||||
}),
|
||||
).toMatchObject({ callId: "wait-child", status: "completed" });
|
||||
});
|
||||
|
||||
it("maps collabAgentToolCall into canonical sub-agent detail", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "collabAgentToolCall",
|
||||
|
||||
@@ -186,7 +186,7 @@ const CodexCollabAgentToolCallItemSchema = z
|
||||
id: z.string().min(1),
|
||||
status: z.string().optional(),
|
||||
error: z.unknown().optional(),
|
||||
prompt: z.string().nullish(),
|
||||
prompt: z.string().optional(),
|
||||
tool: z.string().optional(),
|
||||
receiverThreadIds: z.array(z.string()).optional(),
|
||||
agentsStates: z.record(z.string(), z.unknown()).optional(),
|
||||
@@ -545,7 +545,7 @@ function readStatus(value: unknown): string | undefined {
|
||||
return typeof value.status === "string" ? value.status : undefined;
|
||||
}
|
||||
|
||||
export function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
|
||||
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
|
||||
const normalized = status.trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case "error":
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
// Provider-process stdout only; these are not app/daemon wire limits.
|
||||
export const JSONL_PROVIDER_LINE_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
// Enough of an oversized line to read which response it was, e.g. `{"id":12,"result":`.
|
||||
const OVERSIZED_LINE_HEAD_BYTES = 64;
|
||||
|
||||
export class ProviderStreamLimitError extends Error {
|
||||
readonly code = "PROVIDER_STREAM_LINE_TOO_LARGE";
|
||||
|
||||
constructor(readonly limitBytes: number) {
|
||||
constructor(
|
||||
readonly limitBytes: number,
|
||||
/** The first bytes of the oversized line; the rest is discarded unread. */
|
||||
readonly lineHead = "",
|
||||
) {
|
||||
super(
|
||||
`Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`,
|
||||
);
|
||||
@@ -12,10 +19,26 @@ export class ProviderStreamLimitError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Admits raw UTF-8 bytes before retaining, decoding or joining a complete line. */
|
||||
function readLineHead(parts: Buffer[]): string {
|
||||
const head: Buffer[] = [];
|
||||
let bytes = 0;
|
||||
for (const part of parts) {
|
||||
if (bytes >= OVERSIZED_LINE_HEAD_BYTES) break;
|
||||
head.push(part.subarray(0, OVERSIZED_LINE_HEAD_BYTES - bytes));
|
||||
bytes += head[head.length - 1].byteLength;
|
||||
}
|
||||
return Buffer.concat(head).toString("utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Admits raw UTF-8 bytes before retaining, decoding or joining a complete line.
|
||||
* An oversized line is reported with its head and skipped up to its newline;
|
||||
* the owner closes the reader if the stream cannot continue without it.
|
||||
*/
|
||||
export class ProviderLineReader {
|
||||
private parts: Buffer[] = [];
|
||||
private bytes = 0;
|
||||
private skippingOversizedLine = false;
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
@@ -31,11 +54,20 @@ export class ProviderLineReader {
|
||||
while (start < buffer.byteLength && !this.stopped) {
|
||||
const newline = buffer.indexOf(10, start);
|
||||
const end = newline === -1 ? buffer.byteLength : newline;
|
||||
if (this.skippingOversizedLine) {
|
||||
if (newline === -1) return;
|
||||
this.skippingOversizedLine = false;
|
||||
start = newline + 1;
|
||||
continue;
|
||||
}
|
||||
const length = end - start;
|
||||
if (length > this.limitBytes - this.bytes) {
|
||||
this.close();
|
||||
this.onLimit(new ProviderStreamLimitError(this.limitBytes));
|
||||
return;
|
||||
const head = readLineHead([...this.parts, buffer.subarray(start, end)]);
|
||||
this.parts = [];
|
||||
this.bytes = 0;
|
||||
this.skippingOversizedLine = true;
|
||||
this.onLimit(new ProviderStreamLimitError(this.limitBytes, head));
|
||||
continue;
|
||||
}
|
||||
if (length) this.parts.push(buffer.subarray(start, end));
|
||||
this.bytes += length;
|
||||
@@ -150,7 +182,10 @@ export class JsonlFrameDecoder {
|
||||
(line) => {
|
||||
if (line.trim()) this.consumeLine(line);
|
||||
},
|
||||
(error) => this.drop("line-too-large", error),
|
||||
(error) => {
|
||||
this.lines.close();
|
||||
this.drop("line-too-large", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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,8 +1,7 @@
|
||||
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, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import type { z } from "zod";
|
||||
@@ -72,18 +71,15 @@ 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,
|
||||
),
|
||||
// Diagnostics invoke external tools with their own multi-second deadlines.
|
||||
{ timeout: 10_000 },
|
||||
.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,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
@@ -123,16 +119,6 @@ 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[] = [];
|
||||
@@ -239,96 +225,6 @@ 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" });
|
||||
@@ -783,8 +679,7 @@ 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: process.execPath,
|
||||
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
|
||||
command: "/bin/cat",
|
||||
});
|
||||
expect(result.error).toBe(null);
|
||||
await expect
|
||||
@@ -833,8 +728,7 @@ 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: process.execPath,
|
||||
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
|
||||
command: "/bin/cat",
|
||||
});
|
||||
expect(result.error).toBe(null);
|
||||
const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session");
|
||||
@@ -1529,7 +1423,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`, appVersion: "0.8.0" });
|
||||
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await admin.connect();
|
||||
await requester.request({
|
||||
@@ -1568,7 +1462,7 @@ test("request outcomes for import and attention reach only their requesting sock
|
||||
}),
|
||||
);
|
||||
const agent = await admin.createAgent({
|
||||
provider: "mock",
|
||||
provider: "codex",
|
||||
cwd: daemon.staticDir,
|
||||
title: "Attention",
|
||||
});
|
||||
|
||||
@@ -1075,29 +1075,23 @@ export default function contribute(plugin: unknown) {
|
||||
const service = bindTestSessionHost(
|
||||
new PluginService(pino({ level: "silent" }), store, "0.4.0"),
|
||||
);
|
||||
try {
|
||||
await service.start();
|
||||
await service.installDirectory({ path: first });
|
||||
await service.installDirectory({ path: second });
|
||||
expect(catalogIds(service)).toEqual(["first", "second"]);
|
||||
await store.patch({ pluginsEnabled: false });
|
||||
await service.start();
|
||||
await service.installDirectory({ path: first });
|
||||
await service.installDirectory({ path: second });
|
||||
|
||||
expect(service.catalog()).toEqual([]);
|
||||
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
|
||||
// would also conceal a failure to restart it after the global enable.
|
||||
await expect
|
||||
.poll(() => service.listPlugins(), { timeout: 10_000 })
|
||||
.toMatchObject([
|
||||
{ id: "first", status: "running" },
|
||||
{ id: "second", status: "running" },
|
||||
]);
|
||||
} finally {
|
||||
await service.stopAllPlugins();
|
||||
}
|
||||
await store.patch({ pluginsEnabled: false });
|
||||
|
||||
expect(service.catalog()).toEqual([]);
|
||||
await expect(service.invokePluginRpc("second", "anything", {})).rejects.toThrow(
|
||||
"Plugin is not available",
|
||||
);
|
||||
await store.patch({ pluginsEnabled: true });
|
||||
await service.reloadPlugin("first");
|
||||
expect((await service.listPlugins()).map(({ id, status }) => ({ id, status }))).toEqual([
|
||||
{ id: "first", status: "running" },
|
||||
{ id: "second", status: "running" },
|
||||
]);
|
||||
await service.stopAllPlugins();
|
||||
}, 20_000);
|
||||
|
||||
it("does not publish an in-flight start after a later global disable", async () => {
|
||||
|
||||
@@ -1992,24 +1992,6 @@ 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(
|
||||
{
|
||||
@@ -3355,8 +3337,10 @@ export class Session {
|
||||
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||||
this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`);
|
||||
|
||||
const knownAgent =
|
||||
this.agentManager.getAgent(agentId) ?? (await this.agentStorage.get(agentId));
|
||||
const knownWorkspaceId =
|
||||
this.agentManager.getAgent(agentId)?.workspaceId ??
|
||||
(await this.agentStorage.get(agentId))?.workspaceId ??
|
||||
null;
|
||||
|
||||
// File-backed storage still needs an early delete fence before closeAgent().
|
||||
beginAgentDeleteIfSupported(this.agentStorage, agentId);
|
||||
@@ -3377,8 +3361,6 @@ 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}`);
|
||||
}
|
||||
@@ -3390,6 +3372,12 @@ 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,8 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { writeFile, truncate, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { build } from "esbuild";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import {
|
||||
readPaseoConfig,
|
||||
@@ -21,40 +20,21 @@ 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 { stdout, stderr } = await promisify(execFile)(process.execPath, [probePath, directory], {
|
||||
timeout: 15_000,
|
||||
});
|
||||
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 },
|
||||
);
|
||||
process.stderr.write(stderr);
|
||||
return JSON.parse(stdout) as {
|
||||
result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
|
||||
blockingMs: number;
|
||||
filesystemRequests: number;
|
||||
yields: number;
|
||||
workerMessages: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,7 +84,7 @@ describe("paseo config file substrate", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes test-runner and loader activity from the config read budget", async () => {
|
||||
it("excludes unrelated test-runner CPU 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(() => {
|
||||
@@ -120,10 +100,8 @@ describe("paseo config file substrate", () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const { result, blockingMs, filesystemRequests, workerMessages } =
|
||||
await measureConfigRead(tempDir);
|
||||
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
|
||||
await unrelatedWork;
|
||||
expect(workerMessages).toBe(0);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm ci" } },
|
||||
@@ -141,13 +119,11 @@ describe("paseo config file substrate", () => {
|
||||
worktree: { setup: "npm ci" },
|
||||
};
|
||||
await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config));
|
||||
const { result, blockingMs, filesystemRequests, yields, workerMessages } =
|
||||
await measureConfigRead(tempDir);
|
||||
const { result, blockingMs, filesystemRequests, yields } = 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 () => {
|
||||
@@ -155,11 +131,9 @@ describe("paseo config file substrate", () => {
|
||||
join(tempDir, "paseo.json"),
|
||||
JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }),
|
||||
);
|
||||
const { result, blockingMs, filesystemRequests, workerMessages } =
|
||||
await measureConfigRead(tempDir);
|
||||
const { result, blockingMs, filesystemRequests } = 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,24 +1,14 @@
|
||||
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";
|
||||
|
||||
// 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.
|
||||
// Keep Vitest transforms, reporting, and unrelated callbacks 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),
|
||||
);
|
||||
activity.disable();
|
||||
process.stdout.write(JSON.stringify({ ...measured, workerMessages }));
|
||||
process.stdout.write(JSON.stringify(measured));
|
||||
|
||||
@@ -486,74 +486,3 @@ 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
|
||||
|
||||
@@ -24,7 +24,6 @@ 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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
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>`.
|
||||
**User's request:** $ARGUMENTS
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -40,15 +40,13 @@ This is analysis only. Do NOT edit, create, or delete any files. Do NOT write co
|
||||
|
||||
## Forwarded skills
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
- `/paseo-advisor /unslop` → advisor runs `/unslop` on the current diff.
|
||||
- `/paseo-advisor /unslop-risk` → advisor does an unslop-risk review.
|
||||
- `/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 from the user's request (`/<name>`, `/skill:<name>`, or `$<name>`), excluding this skill's own invocation. In the briefing, tell the advisor explicitly:
|
||||
Parse the forwarded skill name out of `$ARGUMENTS` (`/<name>` or `$<name>`). In the briefing, tell the advisor explicitly:
|
||||
|
||||
```
|
||||
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.
|
||||
|
||||
Use the user's request and current conversation as input, including any arguments supplied with the skill invocation.
|
||||
**User's additional context:** $ARGUMENTS
|
||||
|
||||
## 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.
|
||||
|
||||
Use the user's request and current conversation as input, including any arguments supplied with the skill invocation.
|
||||
**User's arguments:** $ARGUMENTS
|
||||
|
||||
## 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.
|
||||
|
||||
Use the user's question and current conversation as input, including any arguments supplied with the skill invocation.
|
||||
**User's question:** $ARGUMENTS
|
||||
|
||||
## 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.
|
||||
|
||||
Use the user's request and current conversation as input, including any arguments supplied with the skill invocation.
|
||||
**User's request:** $ARGUMENTS
|
||||
|
||||
## Check current documentation
|
||||
|
||||
|
||||
Reference in New Issue
Block a user