Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 4a5f98b3a4 test(server): reproduce missing v2 agent plan windows
Preserve the host-versus-agent usage mismatch from #7116. The new assertion intentionally fails until the session descriptor gap is fixed; the reported packaged desktop path still needs verification.
2026-10-29 10:42:28 +00:00
120 changed files with 572 additions and 3414 deletions
-10
View File
@@ -493,16 +493,6 @@ The supervisor alone publishes its ready worker's endpoint in `paseo.pid`, clear
Worker restart retains supervisor arguments/environment and rereads the configuration file. Updating a package and observing its new worker version do not refresh the running supervisor code; the launcher owns full-process replacement. See [CLI lifecycle contracts](../public-docs/cli.md#daemon-management).
## Outbound networking
The daemon and its plugin processes honor `HTTPS_PROXY`, `HTTP_PROXY`, and `NO_PROXY`
(including lowercase forms, which take precedence). Fetch and relay, Hub, and connected-host WebSockets
share this policy. Loopback (`localhost`, `127.0.0.1`, `::1`) always goes direct.
A proxy without a scheme uses HTTP. Invalid values and unsupported proxy schemes
are treated as unset, with one warning naming the variable in `daemon.log`.
Agent and terminal environments are unchanged. System proxy settings and SOCKS-only
`ALL_PROXY` configurations are not supported.
## Deployment models
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
-1
View File
@@ -38,7 +38,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`).
- **Report ID** — Stable daemon-composed identity for one source account: `<sourceId>:<accountKey>`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`).
- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
- **Check usage** — Host-wide switch in Usage Settings, on by default. Turning it off stops login discovery and vendor requests on that host for every device. Usage shows an off state with **Turn on**; the sidebar summary and agent usage sections disappear.
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`.
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
-14
View File
@@ -35,20 +35,6 @@ PASEO_MOBILE_E2E_METRO_PORT=62093 npm run test:e2e:mobile
[native-terminal-basic.ios.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad) and [native-terminal-basic.android.ad](../packages/app/e2e/mobile/agent-device/native-terminal-basic.android.ad) are the smallest examples. Each opens a fresh terminal, types a command at zero delay, submits it, and asserts its distinct output. The app must be connected to a daemon with an active workspace.
For Android background fold recovery, install the release APK on a foldable AVD, open a
connected workspace, close the keyboard, and run:
```bash
ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs
```
The AVD must expose `CLOSED` and `OPENED` device states. The test backgrounds the app,
changes posture, resumes immediately, and asserts compact/wide workspace controls without
restarting the process. It writes snapshots and native failure state under
`.dev/agent-device-artifacts/android-fold`. Set `PASEO_MOBILE_APP_ID=sh.paseo` for a
production package. This needs a native rebuild when the Unistyles patch changes; a Metro
reload cannot update it.
For Android keyboard continuity, run the current checkout in the app, open an idle terminal
with an empty prompt, hide its keyboard, and run:
+1 -1
View File
@@ -550,7 +550,7 @@ string is never inline SVG or a URL.
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons.
When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients.
## Contribute sidebar items
-8
View File
@@ -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 -1
View File
@@ -1 +1 @@
sha256-8NOrrCAxraQ74lP3Vy59MRhloBfzStTc4rW+ltc42Fg=
sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw=
-24
View File
@@ -40690,7 +40690,6 @@
"esbuild": "^0.25.12",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"https-proxy-agent": "^7.0.6",
"lru-cache": "^11.5.1",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
@@ -40708,7 +40707,6 @@
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"undici": "^7.24.8",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
@@ -41282,15 +41280,6 @@
"url": "https://opencollective.com/vitest"
}
},
"packages/server/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"packages/server/node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -41360,19 +41349,6 @@
"@esbuild/win32-x64": "0.25.12"
}
},
"packages/server/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"packages/server/node_modules/isexe": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz",
+1 -1
View File
@@ -102,7 +102,7 @@ export default {
name: variant.name,
slug: "voice-mobile",
version: nativeReleaseVersion.appVersion,
orientation: "default",
orientation: "portrait",
icon: "./assets/images/icon.png",
scheme: "paseo",
userInterfaceStyle: "automatic",
-35
View File
@@ -59,38 +59,3 @@ describe("Android app config", () => {
30_000,
);
});
it("follows system rotation on Android", async () => {
const manifest = await resolveAndroidManifest();
const activity = manifest.application[0].activity.find(
(entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity",
);
expect(activity.$["android:screenOrientation"]).toBe("unspecified");
}, 30_000);
it("allows all orientations on iPhone and iPad", async () => {
const { mkdtemp, rm } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const root = await mkdtemp(join(tmpdir(), "paseo-ios-config-"));
try {
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["ios"] });
await compileModsAsync(exp, {
projectRoot: root,
introspect: true,
platforms: ["ios"],
assertMissingModProviders: false,
});
const plist = exp._internal.modResults.ios.infoPlist;
const orientations = [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationPortraitUpsideDown",
"UIInterfaceOrientationLandscapeLeft",
"UIInterfaceOrientationLandscapeRight",
];
expect(plist.UISupportedInterfaceOrientations).toEqual(orientations);
expect(plist["UISupportedInterfaceOrientations~ipad"]).toEqual(orientations);
} finally {
await rm(root, { recursive: true, force: true });
}
}, 30_000);
@@ -1,69 +0,0 @@
import { expect, test } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
import {
claudeAndCodexReports,
openUsageFromIcon,
openUsageOptions,
closeUsageOptions,
closeUsage,
expectNoUsageItem,
expectPinnedUsage,
} from "../support/helpers/usage-sidebar-item";
test("Check usage applies to the host and Turn on restores cards", async ({
page,
e2eWorkerClient,
}) => {
test.setTimeout(180_000);
const previous = (await e2eWorkerClient.getDaemonConfig()).config.usage ?? { enabled: true };
try {
const usage = await installUsageReportsFixture(page, {
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize({ width: 1440, height: 1000 });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
await openUsageFromIcon(page);
await usage.waitForListRequests(1);
await openUsageOptions(page);
await page.getByRole("switch", { name: "Check usage", exact: true }).click();
await closeUsageOptions(page);
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toHaveCount(0);
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: false });
await expectNoUsageItem(page);
const before = usage.listRequests().length;
await closeUsage(page);
await page.reload();
await openUsageFromIcon(page);
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
expect(usage.listRequests()).toHaveLength(before);
await page.getByRole("button", { name: "Turn on", exact: true }).click();
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: true });
// A separate client changes the policy, as another device would.
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: false } });
await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible();
await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: true } });
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
} finally {
await e2eWorkerClient.patchDaemonConfig({ usage: previous });
}
});
test("older hosts keep usage without a Check usage switch", async ({ page }) => {
test.setTimeout(180_000);
await installUsageReportsFixture(page, {
usageSettingSupported: false,
lists: [() => claudeAndCodexReports()],
});
await page.setViewportSize({ width: 1440, height: 1000 });
await gotoAppShell(page);
await openUsageFromIcon(page);
await expect(page.getByText("Claude", { exact: true })).toBeVisible();
await openUsageOptions(page);
await expect(page.getByRole("switch", { name: "Check usage", exact: true })).toHaveCount(0);
});
@@ -441,31 +441,3 @@ test("released hosts supply source logos through the client conversion", async (
).toHaveAttribute("d", codexPath);
await qaScreenshot(page, "released-host-usage-modal");
});
test("hidden usage sends no requests until opened", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expectNoUsageItem(page);
await expect(page.getByRole("button", { name: "Usage", exact: true })).toBeVisible({
timeout: 90_000,
});
await openSidebarNavSettings(page);
await expectFooterItemSetting(page, "usage", false);
await leaveSettings(page);
expect(usage.listRequests()).toHaveLength(0);
await openUsageFromIcon(page);
await usage.waitForListRequests(1);
expect(usage.listRequests()).toHaveLength(1);
});
test("visible usage summary requests reports on load", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
await page.setViewportSize(WIDE);
await gotoAppShell(page);
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
expect(usage.listRequests()).toHaveLength(1);
});
-104
View File
@@ -1,104 +0,0 @@
// Run a release build with a connected workspace open and the keyboard hidden.
// ANDROID_SERIAL=emulator-5574 node packages/app/e2e/mobile/fold/android.mjs
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const serial = process.env.ANDROID_SERIAL;
assert(serial, "Set ANDROID_SERIAL to a booted Android foldable emulator");
const appId = process.env.PASEO_MOBILE_APP_ID ?? "sh.paseo.debug";
const session = "android-fold";
const artifacts = resolve(".dev/agent-device-artifacts/android-fold");
const env = {
...process.env,
AGENT_DEVICE_STATE_DIR: resolve(".dev/agent-device-android-fold"),
};
mkdirSync(artifacts, { recursive: true });
function run(binary, args) {
return execFileSync(binary, args, { env, encoding: "utf8", timeout: 60_000 });
}
function adb(...args) {
return run("adb", ["-s", serial, ...args]);
}
function ad(...args) {
return run("agent-device", [...args, "--session", session]);
}
function pid() {
return adb("shell", "pidof", appId).trim();
}
function open() {
ad("open", appId, "--platform", "android", "--serial", serial, "--no-test-ime");
}
function stateId(name) {
const states = adb("shell", "cmd", "device_state", "print-states");
const match = states.match(new RegExp(`identifier=(\\d+), name='${name}'`));
assert(match, `Emulator must expose the ${name} posture: ${states}`);
return match[1];
}
async function expectLayout(posture) {
const compact = posture === "CLOSED";
const expectedId = compact ? "workspace-tab-switcher-trigger" : "workspace-tabs-scroll";
const unexpectedId = compact ? "workspace-tabs-scroll" : "workspace-tab-switcher-trigger";
const deadline = Date.now() + 15_000;
let nodes = [];
do {
let data;
try {
({ data } = JSON.parse(ad("snapshot", "-i", "--json")));
} catch (error) {
// During display handoff Android briefly has no capturable app window.
if (!error.stdout?.includes("insufficient foreground app content")) throw error;
await delay(200);
continue;
}
assert.equal(data.appBundleId, appId, "Snapshot must belong to Paseo");
nodes = data.nodes;
if (
nodes.some((node) => node.identifier === expectedId) &&
!nodes.some((node) => node.identifier === unexpectedId)
) {
writeFileSync(resolve(artifacts, `${posture}.json`), JSON.stringify(data, null, 2));
console.log(`PASS: ${posture} uses ${compact ? "compact" : "wide"} workspace controls`);
return;
}
await delay(200);
} while (Date.now() < deadline);
writeFileSync(resolve(artifacts, `${posture}-failure.json`), JSON.stringify(nodes, null, 2));
writeFileSync(
resolve(artifacts, "native-failure.txt"),
adb("shell", "dumpsys", "activity", "top"),
);
assert.fail(`${posture}: expected ${expectedId}, without ${unexpectedId}`);
}
const closed = stateId("CLOSED");
const opened = stateId("OPENED");
const initialState = adb("shell", "cmd", "device_state", "state").match(
/Committed state: DeviceState\{identifier=(\d+)/,
)?.[1];
assert(initialState, "Could not read the initial fold state");
try {
adb("shell", "cmd", "device_state", "state", opened);
open();
await expectLayout("OPENED");
const initialPid = pid();
for (const [posture, id] of [
["CLOSED", closed],
["OPENED", opened],
]) {
adb("shell", "input", "keyevent", "KEYCODE_HOME");
adb("shell", "cmd", "device_state", "state", id);
// Resume immediately: waiting for configuration delivery hides the regression.
open();
await expectLayout(posture);
assert.equal(pid(), initialPid, "Fold/resume must preserve the app process");
}
} finally {
adb("shell", "cmd", "device_state", "state", initialState);
ad("close");
}
@@ -28,10 +28,6 @@ interface SeedProjectDescriptor {
* prefer those wrappers over reaching for this client directly.
*/
export interface SeedDaemonClient {
patchDaemonConfig(
config: import("@getpaseo/protocol/messages").MutableDaemonConfigPatch,
): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
getDaemonConfig(): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>;
connect(): Promise<void>;
close(): Promise<void>;
addProject(cwd: string): Promise<{
@@ -25,7 +25,6 @@ interface UsageReportsFixtureOptions {
* app connects, so a reload can switch it.
*/
usageSupported?: boolean | (() => boolean);
usageSettingSupported?: boolean;
/** Released hosts expose provider.usage.list with no source icons. */
providerUsageListOnly?: boolean;
/** The host daemon's port; defaults to the E2E daemon. */
@@ -64,7 +63,6 @@ function withUsageSupportFeature(
message: WebSocketMessage,
enabled: boolean,
providerUsageListOnly: boolean,
usageSettingSupported: boolean,
): string | null {
const envelope = parseJson(message) as {
type?: unknown;
@@ -88,7 +86,6 @@ function withUsageSupportFeature(
...payload,
features: {
...features,
usageSetting: usageSettingSupported ? true : undefined,
usageSources: enabled && !providerUsageListOnly,
providerUsageList: enabled,
},
@@ -232,12 +229,7 @@ export async function installUsageReportsFixture(
server.onMessage((message) => {
const serverInfo =
typeof message === "string"
? withUsageSupportFeature(
message,
isUsageSupported(),
providerUsageListOnly,
options.usageSettingSupported ?? true,
)
? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly)
: null;
ws.send(serverInfo ?? message);
});
@@ -1,8 +1,8 @@
import { Modal } from "@/components/ui/modal";
import equal from "fast-deep-equal";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import {
FlatList,
Modal,
Pressable,
Text,
View,
@@ -1,4 +1,3 @@
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { router } from "expo-router";
@@ -23,7 +22,14 @@ import {
type ComponentType,
type RefObject,
} from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
Modal,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
+1 -1
View File
@@ -1,8 +1,8 @@
import { Modal } from "@/components/ui/modal";
import {
View,
Text,
Pressable,
Modal,
RefreshControl,
FlatList,
type ListRenderItem,
@@ -1,6 +1,5 @@
import { Modal } from "@/components/ui/modal";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { Modal, Pressable, Text, View } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { StyleSheet } from "react-native-unistyles";
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
+9 -4
View File
@@ -64,7 +64,12 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
import {
UsageSidebarItem,
UsageSidebarRoot,
useHasUsageSummary,
useOpenSidebarUsage,
} from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
@@ -475,10 +480,10 @@ function SidebarUsageIcon({ label }: { label: string }) {
*/
function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
const { items } = useSidebarNavItems("footer");
const usageSummary = useUsageSummary();
const hasUsageSummary = useHasUsageSummary();
const rowsRef = useRef<View | null>(null);
const visibleItems = items.filter(
(item) => item.visible && (item.kind === "plugin" || usageSummary.length > 0),
(item) => item.visible && (item.kind === "plugin" || hasUsageSummary),
);
if (visibleItems.length === 0) return null;
return (
@@ -494,7 +499,7 @@ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void
onBeforeNavigate={onBeforeNavigate}
/>
) : (
<UsageSidebarItem key={item.key} sources={usageSummary} />
<UsageSidebarItem key={item.key} />
),
)}
</View>
@@ -1,6 +1,5 @@
import { Modal } from "@/components/ui/modal";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { View } from "react-native";
import { Modal, View } from "react-native";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
@@ -1,6 +1,6 @@
import { Modal } from "@/components/ui/modal";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Modal,
Pressable,
StyleSheet as RNStyleSheet,
ScrollView,
+1 -1
View File
@@ -1,4 +1,3 @@
import { Modal } from "@/components/ui/modal";
import {
useCallback,
useEffect,
@@ -13,6 +12,7 @@ import {
View,
Text,
Pressable,
Modal,
ScrollView,
Platform,
StatusBar,
@@ -1,4 +1,3 @@
import { Modal } from "@/components/ui/modal";
import {
useCallback,
useEffect,
@@ -13,6 +12,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
Dimensions,
Modal,
Platform,
Pressable,
View,
-13
View File
@@ -1,13 +0,0 @@
import { Modal as NativeModal, type ModalProps } from "react-native";
// React Native defaults iPhone modals to portrait even when the app supports rotation.
const orientations: NonNullable<ModalProps["supportedOrientations"]> = [
"portrait",
"portrait-upside-down",
"landscape-left",
"landscape-right",
];
export function Modal(props: ModalProps) {
return <NativeModal supportedOrientations={orientations} {...props} />;
}
+1 -1
View File
@@ -1,4 +1,3 @@
import { Modal } from "@/components/ui/modal";
import {
Children,
cloneElement,
@@ -17,6 +16,7 @@ import { createPortal } from "react-dom";
import {
Dimensions,
Platform,
Modal,
Pressable,
View,
type PressableProps,
@@ -455,6 +455,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
reportError: (error) => {
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
},
schedule: (task, delayMs) => {
const timeout = setTimeout(task, delayMs);
return () => clearTimeout(timeout);
},
});
viewedTimelineSyncRef.current = sync;
setViewedTimelineSync(serverId, sync);
@@ -292,10 +292,7 @@ const CATALOG_DATA = [
version: "0.6.13",
iconId: "minimax-code",
installLink: "https://agent.minimax.io",
// `-p <pkg>` names the package and `mcode` names its binary. Without the
// explicit binary, npx resolves the executable from the unscoped package
// name (`code`), which this package does not publish.
command: ["npx", "-y", "-p", "@minimax-ai/code@0.6.13", "mcode", "acp"],
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
},
{
id: "minion-code",
@@ -62,9 +62,7 @@ describe("ACP provider catalog", () => {
expect(findProvider("minimax-code")).toMatchObject({
title: "MiniMax Code",
version: "0.6.13",
// The package publishes `mcode`, not `code`, so npx needs the package and
// the binary named separately.
command: ["npx", "-y", "-p", "@minimax-ai/code@0.6.13", "mcode", "acp"],
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
});
expect(findProvider("minimax-code").iconSvg).toContain("<svg");
});
@@ -422,23 +422,6 @@ describe("deriveAgentScreenViewState", () => {
expect(result.memory.lastReadyAgent).toBeNull();
});
it.each(["error", "retrying"] as const)("shows archived history sync %s", (status) => {
const result = deriveAgentScreenViewState({
input: {
...createBaseInput(),
agent: createAgent("agent-1"),
isArchived: true,
hasHydratedHistoryBefore: true,
visibilityCatchUpStatus: status,
},
memory: createBaseMemory(),
});
expect(expectReadyState(result.state).sync).toEqual({
status: "sync_error",
isRetrying: status === "retrying",
});
});
it("renders an archived agent before provider history is initialized", () => {
const result = deriveAgentScreenViewState({
input: {
@@ -175,14 +175,10 @@ function resolveAgentScreenSync(args: {
hadInitialSyncFailure: boolean;
}): AgentScreenReadySyncState {
const { input, hadInitialSyncFailure } = args;
if (
input.isArchived &&
input.visibilityCatchUpStatus !== "error" &&
input.visibilityCatchUpStatus !== "retrying"
) {
if (input.isArchived) {
return { status: "idle" };
}
if (!input.isConnected && !input.isArchived) {
if (!input.isConnected) {
return { status: "reconnecting" };
}
if (input.missingAgentState.kind === "error") {
+8 -2
View File
@@ -1,7 +1,13 @@
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
Modal,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
-5
View File
@@ -2092,11 +2092,6 @@ export const ar: TranslationResources = {
},
},
providerUsage: {
checkUsage: "التحقق من الاستخدام",
checkUsageHint: "ينطبق على {{host}} على جميع الأجهزة",
off: "التحقق من الاستخدام متوقف على {{host}}.",
turnOn: "تشغيل",
options: "الإعدادات",
refreshFailed: "تعذّر تحديث الاستخدام",
noHosts: "لا توجد مضيفات متصلة",
-5
View File
@@ -2117,11 +2117,6 @@ export const en = {
},
},
providerUsage: {
checkUsage: "Check usage",
checkUsageHint: "Applies to {{host}} on every device",
off: "Usage is off on {{host}}.",
turnOn: "Turn on",
options: "Settings",
refreshFailed: "Unable to refresh usage",
noHosts: "No connected hosts",
-5
View File
@@ -2140,11 +2140,6 @@ export const es: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Consultar uso",
checkUsageHint: "Se aplica a {{host}} en todos los dispositivos",
off: "El uso está desactivado en {{host}}.",
turnOn: "Activar",
options: "Configuración",
refreshFailed: "No se pudo actualizar el uso",
noHosts: "No hay hosts conectados",
-5
View File
@@ -2140,11 +2140,6 @@ export const fr: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Vérifier l’utilisation",
checkUsageHint: "S’applique à {{host}} sur tous les appareils",
off: "L’utilisation est désactivée sur {{host}}.",
turnOn: "Activer",
options: "Paramètres",
refreshFailed: "Impossible d’actualiser l’utilisation",
noHosts: "Aucun hôte connecté",
-5
View File
@@ -2113,11 +2113,6 @@ export const ja: TranslationResources = {
},
},
providerUsage: {
checkUsage: "使用状況を確認",
checkUsageHint: "すべてのデバイスで {{host}} に適用",
off: "{{host}} の使用状況確認はオフです。",
turnOn: "オンにする",
options: "設定",
refreshFailed: "使用状況を更新できません",
noHosts: "接続中のホストがありません",
-5
View File
@@ -2102,11 +2102,6 @@ export const ko: TranslationResources = {
},
},
providerUsage: {
checkUsage: "사용량 확인",
checkUsageHint: "모든 기기에서 {{host}}에 적용",
off: "{{host}}의 사용량 확인이 꺼져 있습니다.",
turnOn: "켜기",
options: "설정",
refreshFailed: "사용량을 새로 고칠 수 없습니다",
noHosts: "연결된 호스트가 없습니다",
-5
View File
@@ -2125,11 +2125,6 @@ export const ptBR: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Consultar uso",
checkUsageHint: "Aplica-se a {{host}} em todos os dispositivos",
off: "O uso está desativado em {{host}}.",
turnOn: "Ativar",
options: "Configurações",
refreshFailed: "Não foi possível atualizar o uso",
noHosts: "Nenhum host conectado",
-5
View File
@@ -2125,11 +2125,6 @@ export const ru: TranslationResources = {
},
},
providerUsage: {
checkUsage: "Проверять использование",
checkUsageHint: "Применяется к {{host}} на всех устройствах",
off: "Проверка использования отключена на {{host}}.",
turnOn: "Включить",
options: "Настройки",
refreshFailed: "Не удалось обновить использование",
noHosts: "Нет подключённых хостов",
-5
View File
@@ -2070,11 +2070,6 @@ export const zhCN: TranslationResources = {
},
},
providerUsage: {
checkUsage: "检查用量",
checkUsageHint: "在所有设备上应用于 {{host}}",
off: "{{host}} 的用量检查已关闭。",
turnOn: "开启",
options: "设置",
refreshFailed: "无法刷新用量",
noHosts: "没有已连接的主机",
@@ -174,6 +174,7 @@ it("feeds restored layout to timeline sync as a release signal, not a subscripti
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
onCatchUpEnded: () => undefined,
reportError: () => undefined,
schedule: () => () => undefined,
});
// The edge session-context.tsx owns. Pointing it at subscription instead would resume every
// agent behind a restored tab on the daemon.
@@ -57,6 +57,7 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner {
fetchPage: async () => ({ hasNewer: false, endCursor: null }),
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
reportError: () => undefined,
schedule: () => () => undefined,
},
});
}
@@ -616,6 +617,7 @@ describe("create handoff lifetime", () => {
},
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
reportError: () => undefined,
schedule: () => () => undefined,
},
});
@@ -90,7 +90,7 @@ test("visible catch-up settles before hidden open chats start fetching", async (
world.sync.dispose();
});
test("a failed visible catch-up releases hidden chats without bypassing its error", async () => {
test("a failed visible catch-up releases hidden chats without bypassing its retry delay", async () => {
const world = new TimelineWorld();
world.openedEarlier(["a-hidden"]);
world.sync.replaceVisibleAgentIds("pane", ["z-visible"]);
@@ -103,7 +103,7 @@ test("a failed visible catch-up releases hidden chats without bypassing its erro
hidden.respond({ hasNewer: false });
await world.nextError();
world.expectNoPendingFetch();
world.sync.retryVisibleAgentTimeline("z-visible");
(await world.nextRetry())();
(await world.nextFetch("z-visible")).respond({ hasNewer: false });
world.sync.dispose();
});
@@ -200,6 +200,20 @@ class TimelineWorld {
const waiter = this.errorWaiters.shift();
if (waiter) waiter(this.errors.at(-1) ?? "");
},
schedule: (task, delayMs) => {
const scheduled = { task, delayMs };
this.scheduled.push(scheduled);
const waiterIndex = this.retryWaiters.findIndex((waiter) => waiter.delayMs === delayMs);
if (waiterIndex >= 0) {
const [waiter] = this.retryWaiters.splice(waiterIndex, 1);
this.scheduled.splice(this.scheduled.indexOf(scheduled), 1);
waiter.resolve(task);
}
return () => {
const index = this.scheduled.indexOf(scheduled);
if (index >= 0) this.scheduled.splice(index, 1);
};
},
});
show(sourceId: string, agentIds: string[]): void {
@@ -221,6 +235,12 @@ class TimelineWorld {
}> = [];
private readonly errorWaiters: Array<(message: string) => void> = [];
private readonly cacheRequestWaiters: Array<(agentId: string) => void> = [];
private readonly scheduled: Array<{ task: () => void; delayMs: number }> = [];
private readonly retryWaiters: Array<{
delayMs: number;
resolve(retry: () => void): void;
}> = [];
get pendingFetchCount(): number {
return this.fetches.length;
}
@@ -280,6 +300,22 @@ class TimelineWorld {
return new Promise((resolve) => this.errorWaiters.push(resolve));
}
nextRetry(delayMs = 1_000): Promise<() => void> {
const index = this.scheduled.findIndex((entry) => entry.delayMs === delayMs);
if (index >= 0) return Promise.resolve(this.scheduled.splice(index, 1)[0].task);
return new Promise((resolve) => this.retryWaiters.push({ delayMs, resolve }));
}
elapse(elapsedMs: number): void {
const due = this.scheduled.filter((entry) => entry.delayMs <= elapsedMs);
this.scheduled.splice(
0,
this.scheduled.length,
...this.scheduled.filter((entry) => entry.delayMs > elapsedMs),
);
for (const entry of due) entry.task();
}
private releaseMembershipWaiter(): void {
const waiter = this.membershipWaiters.shift();
if (!waiter) return;
@@ -447,28 +483,6 @@ test("reports a chat current only once its parked follow-up page completes", asy
world.sync.dispose();
});
test("a gap during manual retry is recovered before reporting ready", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
(await world.nextFetch("agent-a")).fail("unavailable");
await world.nextError();
world.sync.retryVisibleAgentTimeline("agent-a");
const retry = await world.nextFetch("agent-a");
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 });
retry.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.pendingFetchCount).toBe(1));
const recovery = await world.nextFetch("agent-a");
expect(recovery.request).toMatchObject({
direction: "after",
cursor: { epoch: "epoch-agent-a", seq: 9 },
});
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
recovery.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("reports a chat current once the latest-tail fallback replaces an overflowing catch-up", async () => {
const world = new TimelineWorld();
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 });
@@ -766,21 +780,6 @@ test("overlapping sources deduplicate membership and retain open hidden chats",
world.expectNoPendingFetch();
});
test("failed timeline fetch does not schedule a retry", async () => {
vi.useFakeTimers();
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
(await world.nextFetch("agent-a")).fail("unavailable");
await world.nextError();
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(120_000);
world.expectNoPendingFetch();
world.expectNoPendingMembership();
vi.useRealTimers();
});
test("a failed catch-up reports once and retries through the explicit retry policy", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
@@ -789,12 +788,12 @@ test("a failed catch-up reports once and retries through the explicit retry poli
membership.succeed();
const failed = await world.nextFetch("agent-a");
failed.fail("timeline unavailable");
const error = await world.nextError();
const [error, retryCatchUp] = await Promise.all([world.nextError(), world.nextRetry()]);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
world.sync.retryVisibleAgentTimeline("agent-a");
retryCatchUp();
const retry = await world.nextFetch("agent-a");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
retry.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
@@ -805,6 +804,35 @@ test("a failed catch-up reports once and retries through the explicit retry poli
world.expectNoPendingMembership();
});
test("a failed catch-up retries with exponential backoff", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const membership = await world.nextMembership();
membership.succeed();
const first = await world.nextFetch("agent-a");
first.fail("timeline unavailable");
const [firstError, retryAfterFirstFailure] = await Promise.all([
world.nextError(),
world.nextRetry(),
]);
expect(firstError).toBe("timeline unavailable");
retryAfterFirstFailure();
const second = await world.nextFetch("agent-a");
second.fail("timeline unavailable");
await world.nextError();
const retryAfterSecondFailure = await world.nextRetry(2_000);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
retryAfterSecondFailure();
const third = await world.nextFetch("agent-a");
third.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("manual retries can immediately re-attempt a failed catch-up", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
@@ -814,7 +842,7 @@ test("manual retries can immediately re-attempt a failed catch-up", async () =>
const failed = await world.nextFetch("agent-a");
failed.fail("timeline unavailable");
await world.nextError();
await world.nextRetry();
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
world.sync.retryVisibleAgentTimeline("agent-a");
@@ -825,7 +853,7 @@ test("manual retries can immediately re-attempt a failed catch-up", async () =>
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("redeclaring unchanged visibility does not bypass catch-up error", async () => {
test("redeclaring unchanged visibility does not bypass catch-up backoff", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
@@ -834,11 +862,9 @@ test("redeclaring unchanged visibility does not bypass catch-up error", async ()
const failed = await world.nextFetch("agent-a");
failed.fail("timeline unavailable");
await world.nextError();
await world.nextRetry();
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.sync.setActive(false);
world.sync.setActive(true);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
@@ -854,7 +880,7 @@ test("a manual retry that fails returns to the error state", async () => {
const failed = await world.nextFetch("agent-a");
failed.fail("timeline unavailable");
await world.nextError();
await world.nextRetry();
world.sync.retryVisibleAgentTimeline("agent-a");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
@@ -917,34 +943,20 @@ test("repeated recovery for the same running gap reuses the in-flight fetch", as
gapPage.respond({ hasNewer: false });
});
test("failed membership does not schedule a retry", async () => {
vi.useFakeTimers();
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).fail("unavailable");
await world.nextError();
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(120_000);
world.expectNoPendingFetch();
world.expectNoPendingMembership();
vi.useRealTimers();
});
test("membership failure waits for manual retry", async () => {
test("membership failure autonomously retries without another visibility declaration", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
const error = await world.nextError();
const [error, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
world.sync.retryVisibleAgentTimeline("agent-a");
retryMembership();
const retry = await world.nextMembership();
retry.succeed();
const catchUp = await world.nextFetch("agent-a");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
catchUp.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
@@ -955,68 +967,44 @@ test("membership failure waits for manual retry", async () => {
});
});
test("retrying membership restores a previously loaded chat", async () => {
test("membership failures retry with exponential backoff", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
(await world.nextFetch("agent-a")).respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
world.show("workspace", ["agent-b"]);
(await world.nextMembership()).fail("subscription unavailable");
await world.nextError();
world.sync.retryVisibleAgentTimeline("agent-a");
(await world.nextMembership()).succeed();
(await world.nextFetch("agent-b")).respond({ hasNewer: false });
const resumed = await world.nextFetch("agent-a");
expect(resumed.request).toMatchObject({
direction: "after",
cursor: { epoch: "epoch-agent-a", seq: 10 },
});
resumed.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
test("a fetch that finishes after membership failure cannot clear the error", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).succeed();
const inFlight = await world.nextFetch("agent-a");
world.show("workspace", ["agent-b"]);
(await world.nextMembership()).fail("subscription unavailable");
await world.nextError();
inFlight.respond({ hasNewer: false });
await Promise.resolve();
await Promise.resolve();
const first = await world.nextMembership();
first.fail("subscription unavailable");
const [firstError, retryAfterFirstFailure] = await Promise.all([
world.nextError(),
world.nextRetry(),
]);
expect(firstError).toBe("subscription unavailable");
retryAfterFirstFailure();
const second = await world.nextMembership();
second.fail("subscription unavailable again");
const retryAfterSecondFailure = await world.nextRetry(2_000);
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
retryAfterSecondFailure();
const retry = await world.nextMembership();
retry.succeed();
const catchUp = await world.nextFetch("agent-a");
catchUp.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
});
test("a failed membership retry returns to error", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.show("workspace", ["agent-a"]);
(await world.nextMembership()).fail("unavailable");
await world.nextError();
world.sync.retryVisibleAgentTimeline("agent-a");
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
(await world.nextMembership()).fail("still unavailable");
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error"));
});
test("redeclaring unchanged visibility does not bypass membership error", async () => {
test("redeclaring unchanged visibility does not bypass membership backoff", async () => {
const world = new TimelineWorld();
world.sync.setConnected(true);
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
await world.nextError();
await world.nextRetry();
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
world.sync.setActive(false);
world.sync.setActive(true);
world.expectNoPendingMembership();
world.expectNoPendingFetch();
@@ -1062,6 +1050,7 @@ test("backgrounding preserves subscriptions and returning verifies the visible c
world.sync.setActive(false);
world.expectNoPendingMembership();
world.elapse(60_000);
world.sync.setActive(true);
world.expectNoPendingMembership();
@@ -1078,10 +1067,11 @@ test("stale membership retry cannot overwrite a newer effective set", async () =
world.show("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
await world.nextError();
const staleRetry = await world.nextRetry();
world.show("workspace", ["agent-b"]);
const current = await world.nextMembership();
staleRetry();
current.succeed();
const agentB = await world.nextFetch("agent-b");
agentB.respond({ hasNewer: false });
@@ -1098,9 +1088,10 @@ test("membership retry cannot run while disconnected", async () => {
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
const failed = await world.nextMembership();
failed.fail("subscription unavailable");
await world.nextError();
const disconnectedRetry = await world.nextRetry();
world.sync.setConnected(false);
disconnectedRetry();
world.expectNoPendingMembership();
world.sync.setConnected(true);
const restored = await world.nextMembership();
@@ -1127,6 +1118,7 @@ test("returning to an open hidden agent stays live after inactivity", async () =
agentBCatchUp.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-b")).toBe("ready"));
world.elapse(60_000);
world.show("workspace", ["agent-a"]);
world.expectNoPendingMembership();
@@ -329,6 +329,7 @@ export interface ViewedTimelineSyncPorts {
*/
onCatchUpEnded(agentId: string): void;
reportError(error: unknown): void;
schedule(task: () => void, delayMs: number): () => void;
}
export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying";
@@ -422,14 +423,26 @@ export function createViewedTimelineOwner(input: {
};
}
const RETRY_DELAY_MS = 1_000;
const MAX_RETRY_DELAY_MS = 30_000;
type CatchUpStatus = "running" | "complete" | "error";
interface CatchUpState {
generation: number;
status: CatchUpStatus;
request?: ProjectedTimelineForwardFetchPlan;
cancelRetry?: () => void;
retryDelayMs?: number;
}
const getNextRetryDelayMs = (previousDelayMs: number | undefined): number => {
if (previousDelayMs == null) {
return RETRY_DELAY_MS;
}
return Math.min(previousDelayMs * 2, MAX_RETRY_DELAY_MS);
};
function isSameCatchUpRequest(
left: ProjectedTimelineForwardFetchPlan | undefined,
right: ProjectedTimelineForwardFetchPlan | undefined,
@@ -459,7 +472,9 @@ function decideCatchUp(input: {
}
return "replace";
}
return "keep";
return input.current.status === "running" || input.current.status === "complete"
? "keep"
: "replace";
}
function normalizeAgentIds(agentIds: string[]): string[] {
@@ -479,7 +494,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
const pendingCatchUps = new Map<string, ProjectedTimelineForwardFetchPlan>();
const visibilityCatchUpPending = new Set<string>();
const visibilityCatchUpErrors = new Map<string, string>();
// A user-initiated retry owes a pending state until it settles.
// User-initiated retries only. Background retries stay silent; a retry the user asked for
// owes them a pending state until it settles.
const manualRetries = new Set<string>();
const loadedCache = new Set<string>();
const cacheLoads = new Map<string, Promise<void>>();
@@ -495,6 +511,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
let acknowledged: string[] = [];
let membershipGeneration = 0;
let membershipNeedsRetry = false;
let membershipRetryDelayMs: number | undefined;
let cancelMembershipRetry: (() => void) | null = null;
// Chats this session has opened. Visibility is the only way in, so an agent is never
// subscribed — and never resumed on the daemon — until the user actually opens it.
// Membership then survives hiding, workspace switches, backgrounding and reconnect;
@@ -545,6 +563,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
const cancelCatchUp = (agentId: string) => {
catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1);
catchUps.get(agentId)?.cancelRetry?.();
catchUps.delete(agentId);
pendingCatchUps.delete(agentId);
};
@@ -588,7 +607,19 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
setVisibilityCatchUpReady(agentId);
} catch (error) {
if (catchUps.get(agentId)?.generation === generation) {
catchUps.set(agentId, { generation, status: "error", request });
const nextRetryDelayMs = getNextRetryDelayMs(catchUps.get(agentId)?.retryDelayMs);
const cancelRetry = ports.schedule(() => {
const current = catchUps.get(agentId);
if (current?.generation !== generation || current.status !== "error") return;
startCatchUp(agentId);
}, nextRetryDelayMs);
catchUps.set(agentId, {
generation,
status: "error",
request,
cancelRetry,
retryDelayMs: nextRetryDelayMs,
});
setVisibilityCatchUpError([agentId], error);
ports.reportError(error);
}
@@ -655,9 +686,17 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
if (decision === "keep") {
return;
}
current?.cancelRetry?.();
const generation = (catchUpGenerations.get(agentId) ?? 0) + 1;
catchUpGenerations.set(agentId, generation);
catchUps.set(agentId, { generation, status: "running", request: nextRequest });
const retryDelayMs =
supersede || current?.status !== "error" ? undefined : current.retryDelayMs;
catchUps.set(agentId, {
generation,
status: "running",
request: nextRequest,
retryDelayMs,
});
pendingCatchUps.delete(agentId);
void fetchUntilCurrent(
agentId,
@@ -676,7 +715,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
for (const agentId of ordered) {
const pendingCatchUp = pendingCatchUps.get(agentId);
const current = catchUps.get(agentId);
// Completion of a different chat must not bypass a failed chat's error or
// Completion of a different chat must not bypass a failed chat's backoff or
// supersede an in-flight tail that owns its parked gap recovery.
if (current && !(current.status === "complete" && pendingCatchUp)) continue;
startCatchUp(agentId, {
@@ -702,31 +741,42 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
} catch (error) {
if (disposed || !connected || generation !== membershipGeneration) return;
membershipNeedsRetry = true;
acknowledged = [];
for (const agentId of requested) cancelCatchUp(agentId);
setVisibilityCatchUpError(requested, error);
cancelMembershipRetry?.();
const nextRetryDelayMs = getNextRetryDelayMs(membershipRetryDelayMs);
cancelMembershipRetry = ports.schedule(() => {
cancelMembershipRetry = null;
if (disposed || !connected || membershipGeneration !== generation) return;
void reconcileMembership();
}, nextRetryDelayMs);
membershipRetryDelayMs = nextRetryDelayMs;
ports.reportError(error);
return;
}
if (disposed || !connected || generation !== membershipGeneration) return;
cancelMembershipRetry?.();
cancelMembershipRetry = null;
membershipRetryDelayMs = undefined;
acknowledged = requested;
startAcknowledgedCatchUps();
};
const retryVisibleAgentTimeline = (agentId: string) => {
if (!connected || !isDesired(agentId) || manualRetries.has(agentId)) return;
if (!isDesired(agentId) || manualRetries.has(agentId)) return;
const catchUp = catchUps.get(agentId);
const membershipRetryable = membershipNeedsRetry && connected;
if (catchUp?.status !== "error" && !membershipRetryable) return;
manualRetries.add(agentId);
notifyListeners();
if (membershipRetryable) {
void reconcileMembership();
if (catchUp?.status === "error") {
catchUp.cancelRetry?.();
startCatchUp(agentId, { request: catchUp.request, supersede: true });
return;
}
if (catchUp?.status === "error") {
startCatchUp(agentId, { request: catchUp.request, supersede: true });
}
cancelMembershipRetry?.();
cancelMembershipRetry = null;
membershipRetryDelayMs = undefined;
void reconcileMembership();
};
const commitDesiredMembership = (
@@ -767,6 +817,8 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
ensureCacheLoaded(agentId);
}
}
cancelMembershipRetry?.();
cancelMembershipRetry = null;
desired = nextDesired;
ports.replaceDemandedAgentIds(desired);
membershipGeneration += 1;
@@ -817,7 +869,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
return;
}
for (const agentId of visibleAgentIds()) {
if (visibilityCatchUpErrors.has(agentId)) continue;
visibilityCatchUpPending.add(agentId);
visibilityCatchUpErrors.delete(agentId);
startCatchUp(agentId, { supersede: true });
@@ -829,6 +880,9 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
connected = nextConnected;
if (!connected) {
commitDesiredMembership(desired, { resetCatchUpStatus: true });
cancelMembershipRetry?.();
cancelMembershipRetry = null;
membershipRetryDelayMs = undefined;
void observation?.handle.release().catch(ports.reportError);
observation = null;
acknowledged = [];
@@ -840,7 +894,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
void reconcileMembership();
},
recoverGap(agentId, cursor) {
if (!isDesired(agentId) || catchUps.get(agentId)?.status === "error") return;
if (!isDesired(agentId)) return;
startCatchUp(agentId, {
request: planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }),
supersede: true,
@@ -850,7 +904,10 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
disposed = true;
void observation?.handle.release().catch(ports.reportError);
observation = null;
cancelMembershipRetry?.();
cancelMembershipRetry = null;
membershipNeedsRetry = false;
membershipRetryDelayMs = undefined;
sources.clear();
membershipGeneration += 1;
for (const agentId of desired) cancelCatchUp(agentId);
+3 -10
View File
@@ -21,12 +21,10 @@ export interface UsageHostSelection {
*/
export function UsageControls({
view,
serverId,
onRefresh,
hostSelection,
}: {
view: UsageView;
serverId: string;
onRefresh: () => void;
hostSelection?: UsageHostSelection;
}) {
@@ -45,8 +43,8 @@ export function UsageControls({
) : null}
{view.kind === "unavailable" ? null : (
<>
{view.kind === "off" ? null : <UsageRefreshButton busy={busy} onRefresh={onRefresh} />}
<UsageOptions serverId={serverId} />
<UsageRefreshButton busy={busy} onRefresh={onRefresh} />
<UsageOptions />
</>
)}
</View>
@@ -86,12 +84,7 @@ export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
const controls = useMemo(
() =>
hasControls ? (
<UsageControls
serverId={hostSelection.serverId}
view={view}
onRefresh={refresh}
hostSelection={hostSelection}
/>
<UsageControls view={view} onRefresh={refresh} hostSelection={hostSelection} />
) : null,
[hasControls, hostSelection, refresh, view],
);
+8
View File
@@ -1,5 +1,7 @@
import { useCallback, useMemo } from "react";
import { useAppSettings } from "@/hooks/use-settings";
import { useUsageHostId } from "./hosts";
import { useUsageHostReports } from "./queries";
import {
isUsagePinned,
setUsageDisplayAs,
@@ -23,6 +25,12 @@ interface UsagePreferencesState {
display: UsageDisplay;
}
/** The device's usage preferences, with pins resolved against a host's reports. */
export function useUsagePreferences(serverId?: string): UsagePreferencesState {
const defaultServerId = useUsageHostId();
return useUsageDisplay(useUsageHostReports(serverId ?? defaultServerId));
}
/** The device's usage preferences, with pins resolved against the given reports. */
export function useUsageDisplay(reports: readonly UsageReportEntry[]): UsagePreferencesState {
const { settings, updateSettings } = useAppSettings();
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { useUsageDisplay } from "./display";
import { useUsagePreferences } from "./display";
import { useHostUsage } from "./queries";
import { UsageSection } from "./usage-section";
@@ -7,7 +7,7 @@ import { UsageSection } from "./usage-section";
export function HostUsageSection({ serverId }: { serverId: string }) {
const { t } = useTranslation();
const { view, refresh } = useHostUsage(serverId);
const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []);
const { display } = useUsagePreferences(serverId);
return (
<UsageSection
serverId={serverId}
+1 -1
View File
@@ -4,6 +4,6 @@ export { useHostReportsUsage } from "./queries";
export {
UsageSidebarItem,
UsageSidebarRoot,
useUsageSummary,
useHasUsageSummary,
useOpenSidebarUsage,
} from "./sidebar-item";
-12
View File
@@ -405,15 +405,3 @@ it("available and retained usage show the same successful completion time", () =
);
expect(upsertReport([failure], success)).toEqual([failure]);
});
it("disabled host shows off even with cached reports", () => {
expect(
resolveUsageView({
hostLabel: "Laptop",
isConnected: true,
supportsUsage: true,
enabled: false,
query: { data: [], error: null, isFetching: false },
}),
).toMatchObject({ kind: "off" });
});
-3
View File
@@ -106,7 +106,6 @@ export interface UsageQueryState {
export function resolveUsageView(
input: {
enabled?: boolean;
hostLabel: string;
isConnected: boolean;
supportsUsage: boolean;
@@ -126,8 +125,6 @@ export function resolveUsageView(
message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }),
};
}
if (input.enabled === false)
return { kind: "off", message: t("providerUsage.off", { host: hostLabel }) };
if (query?.data) {
return { kind: "ready", reports: query.data, isRefreshing: query.isFetching };
}
+10 -27
View File
@@ -8,20 +8,15 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-
import { useIsCompactFormFactor } from "@/constants/layout";
import { SettingsRow, SettingsSwitch } from "@/components/settings";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { Alert } from "@/components/ui/alert";
import { useHosts } from "@/runtime/host-runtime";
import { settingsStyles } from "@/styles/settings";
import { useUsageSetting } from "./setting";
import { useUsageDisplay } from "./display";
import { useUsagePreferences } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
import type { UsageDisplayAs } from "./preferences";
const ThemedSettings = withUnistyles(Settings);
/** The shared usage preferences, opened from a cog as a popover or compact sheet. */
export function UsageOptions({ serverId }: { serverId: string | null }) {
const setting = useUsageSetting(serverId);
const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId;
export function UsageOptions() {
const { t } = useTranslation();
const displayAsOptions: SegmentedControlOption<UsageDisplayAs>[] = [
{ value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" },
@@ -32,7 +27,7 @@ export function UsageOptions({ serverId }: { serverId: string | null }) {
},
];
const compact = useIsCompactFormFactor();
const { display } = useUsageDisplay([]);
const { display } = useUsagePreferences();
const { inSidebar, setInSidebar } = useUsageInSidebar();
return (
<DropdownMenu compactMode="sheet">
@@ -54,25 +49,13 @@ export function UsageOptions({ serverId }: { serverId: string | null }) {
testID="usage-options-surface"
>
<View testID="usage-options-fields">
{setting.error ? <Alert variant="error" description={setting.error.message} /> : null}
{setting.supported ? (
<SettingsSwitch
label={t("providerUsage.checkUsage")}
hint={t("providerUsage.checkUsageHint", { host: hostLabel })}
disabled={!setting.ready}
value={setting.enabled}
onValueChange={setting.setEnabled}
/>
) : null}
<View style={setting.supported ? settingsStyles.rowBorder : undefined}>
<SettingsSwitch
label={t("providerUsage.showInSidebar")}
hint={t("providerUsage.showInSidebarHint")}
value={inSidebar}
onValueChange={setInSidebar}
testID="usage-show-in-sidebar"
/>
</View>
<SettingsSwitch
label={t("providerUsage.showInSidebar")}
hint={t("providerUsage.showInSidebarHint")}
value={inSidebar}
onValueChange={setInSidebar}
testID="usage-show-in-sidebar"
/>
<View style={settingsStyles.rowBorder}>
<SettingsRow label={t("providerUsage.displayAs")}>
<SegmentedControl
+3 -9
View File
@@ -17,7 +17,6 @@ import {
useHosts,
} from "@/runtime/host-runtime";
import { useSessionStore, type SessionState } from "@/stores/session-store";
import { useUsageSetting } from "./setting";
import { usageCopy } from "./copy";
import {
replaceReport,
@@ -133,14 +132,13 @@ function toQueryState(query: {
/** Usage reports for one host, as shown on its settings page. */
export function useHostUsage(serverId: string): { view: UsageView; refresh: () => void } {
const setting = useUsageSetting(serverId);
const queryClient = useQueryClient();
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
const query = useFetchQuery({
queryKey: usageReportsQueryKey(serverId),
queryFn: () => listReports(queryClient, serverId),
enabled: isConnected && isSupported && setting.enabled,
enabled: isConnected && isSupported,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
});
@@ -152,7 +150,6 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
const view = resolveUsageView(
{
hostLabel,
enabled: setting.ready ? setting.enabled : undefined,
isConnected,
supportsUsage: isSupported,
query: toQueryState(query),
@@ -169,16 +166,14 @@ const NO_REPORTS: UsageReportEntry[] = [];
* load, or without a host.
*/
export function useUsageHostReports(serverId: string | null): UsageReportEntry[] {
const setting = useUsageSetting(serverId);
const queryClient = useQueryClient();
const query = useFetchQuery({
enabled: setting.enabled,
queryKey: usageReportsQueryKey(serverId ?? ""),
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
dataShape: "list",
staleTimeMs: REPORTS_STALE_TIME_MS,
});
return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS;
return query.data ?? NO_REPORTS;
}
/**
@@ -214,8 +209,7 @@ export function useAgentUsage(serverId: string, agentId: string): AgentUsageView
export function useHostReportsUsage(serverId: string): boolean {
const isConnected = useHostRuntimeIsConnected(serverId);
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
const setting = useUsageSetting(serverId);
return isConnected && isSupported && setting.enabled;
return isConnected && isSupported;
}
/** Every host with whether it is connected and reports usage, in host order. */
-26
View File
@@ -1,26 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { useCallback } from "react";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useSessionStore } from "@/stores/session-store";
/** Host policy comes from the live daemon config replica. */
export function useUsageSetting(serverId: string | null) {
const supported = useSessionStore((state) =>
Boolean(serverId && state.sessions[serverId]?.serverInfo?.features?.usageSetting),
);
const { config, patchConfig } = useDaemonConfig(supported ? serverId : null);
const mutation = useMutation({
mutationFn: (enabled: boolean) => patchConfig({ usage: { enabled } }),
});
const setEnabled = mutation.mutate;
const turnOn = useCallback(() => setEnabled(true), [setEnabled]);
return {
supported,
error: mutation.error,
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
enabled: !supported || (config !== null && config.usage?.enabled !== false),
ready: !supported || config !== null,
setEnabled,
turnOn,
};
}
+11 -8
View File
@@ -17,8 +17,7 @@ import {
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useUsageDisplay } from "./display";
import { useUsageInSidebar } from "./in-sidebar";
import { useUsagePreferences } from "./display";
import { useUsageHostId } from "./hosts";
import { useUsageHostReports } from "./queries";
import { UsageSourceIcon } from "./source-icon";
@@ -34,24 +33,28 @@ import {
import { UsageModal } from "./usage-modal";
/** Each summary window with data on the usage host, under its source; empty while none has. */
export function useUsageSummary(): readonly PinnedUsageSource[] {
function useUsageSummary(): readonly PinnedUsageSource[] {
const { t } = useTranslation();
const { inSidebar } = useUsageInSidebar();
const serverId = useUsageHostId();
const reports = useUsageHostReports(inSidebar ? serverId : null);
const { preferences } = useUsageDisplay(reports);
const { preferences } = useUsagePreferences();
const reports = useUsageHostReports(useUsageHostId());
return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]);
}
/** Whether the sidebar Usage item has anything to show. */
export function useHasUsageSummary(): boolean {
return useUsageSummary().length > 0;
}
/**
* The sidebar footer's usage entry: each summary window's source icon and percent, and nothing
* while no summary window has data, since the footer's Usage icon already opens Usage.
* Pressing it opens the Usage modal.
*/
export function UsageSidebarItem({ sources }: { sources: readonly PinnedUsageSource[] }) {
export function UsageSidebarItem() {
const { t } = useTranslation();
const label = t(builtinSidebarNavLabelKey("usage"));
const openUsage = useOpenSidebarUsage();
const sources = useUsageSummary();
if (sources.length === 0) return null;
return <PinnedUsageTrigger label={label} sources={sources} onPress={openUsage} />;
}
-1
View File
@@ -10,7 +10,6 @@ export type UsageBalanceUnit = UsageBalance["unit"];
/** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */
export type UsageView =
| { kind: "off"; message: string }
| { kind: "unavailable"; message: string }
| { kind: "loading" }
| { kind: "error"; message: string }
+2 -2
View File
@@ -4,7 +4,7 @@ import { View } from "react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
import { useHostUsageWithControls } from "./controls";
import { useUsageDisplay } from "./display";
import { useUsagePreferences } from "./display";
import { useUsageHostSelection } from "./hosts";
import type { UsageHost } from "./model";
import { UsageBody, UsageMessage } from "./usage-section";
@@ -52,12 +52,12 @@ function HostUsageModal({
hosts: UsageHost[];
onSelectHost: (serverId: string) => void;
}) {
const { display } = useUsagePreferences();
const hostSelection = useMemo(
() => ({ hosts, serverId, onSelect: onSelectHost }),
[hosts, onSelectHost, serverId],
);
const { view, refresh, controls } = useHostUsageWithControls(hostSelection);
const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []);
return (
<UsageModalFrame {...props} actions={controls}>
<View testID={`usage-host-${serverId}`}>
+2 -12
View File
@@ -7,7 +7,6 @@ import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import type { UsageDisplay } from "./display";
import { useUsageSetting } from "./setting";
import { UsageControls } from "./controls";
import { UsageList } from "./list";
import type { UsageView } from "./types";
@@ -28,8 +27,8 @@ export function UsageSection({
testID?: string;
}) {
const trailing = useMemo(
() => <UsageControls serverId={serverId} view={view} onRefresh={onRefresh} />,
[onRefresh, view, serverId],
() => <UsageControls view={view} onRefresh={onRefresh} />,
[onRefresh, view],
);
return (
@@ -52,15 +51,6 @@ export function UsageBody({
onRefresh: () => void;
}) {
const { t } = useTranslation();
const setting = useUsageSetting(serverId);
if (view.kind === "off")
return (
<View>
<UsageMessage text={view.message} />
{setting.error ? <Alert variant="error" description={setting.error.message} /> : null}
<Button onPress={setting.turnOn}>{t("providerUsage.turnOn")}</Button>
</View>
);
if (view.kind === "unavailable") {
return <UsageMessage text={view.message} />;
}
+6 -14
View File
@@ -2,7 +2,6 @@ export { createHostChannelTransport } from "../hosts/index.js";
import { HostTransportBudget } from "./buffering.js";
export { HostTransportBudget } from "./buffering.js";
import { homedir } from "node:os";
import type { Agent } from "node:http";
import { WebSocket } from "ws";
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
import { createSshTunnel, type SshTunnel } from "./ssh-tunnel.js";
@@ -20,15 +19,10 @@ export { parseHostTarget };
export type { SshTunnel } from "./ssh-tunnel.js";
export { createSshTunnel, resolveSshFailureDetail } from "./ssh-tunnel.js";
export interface HostTransportOptions {
webSocketAgent?: (url: string) => Agent | undefined;
}
/** A factory owns one attempt, including a fresh SSH process and its teardown. */
export function createHostTransportFactory(
connection: string,
budget = new HostTransportBudget(),
options: HostTransportOptions = {},
): DaemonTransportFactory {
const target = parseHostTarget(connection);
// Rises to "protected" once the daemon negotiates it, so a later attempt
@@ -74,13 +68,12 @@ export function createHostTransportFactory(
return;
}
const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir());
let factory = createWebSocketTransportFactory((url, socketOptions) => {
let factory = createWebSocketTransportFactory((url, options) => {
const socket = new WebSocket(
socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url,
socketOptions?.protocols,
options?.protocols,
{
headers: socketOptions?.headers,
agent: socketPath ? undefined : options.webSocketAgent?.(url),
headers: options?.headers,
...(socketPath ? { socketPath } : {}),
maxPayload: 100 * 1024 * 1024,
},
@@ -198,15 +191,14 @@ export function createHostTransportFactory(
}
export function createHostClient(
connection: string,
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
config: Omit<DaemonClientConfig, "url" | "transportFactory">,
budget?: HostTransportBudget,
): DaemonClient {
const target = parseHostTarget(connection);
const { webSocketAgent, ...clientConfig } = config;
return new DaemonClient({
...clientConfig,
...config,
url: "ws://host-connector/ws",
...(target.password ? { password: target.password } : {}),
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
transportFactory: createHostTransportFactory(connection, budget),
});
}
@@ -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);
}
}
+1 -8
View File
@@ -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.`,
);
-5
View File
@@ -229,8 +229,6 @@ export const MutableDaemonConfigSchema = z
.optional(),
app: z.object({ baseUrl: z.string() }).optional(),
catalogRefreshTimeoutMs: z.number().int().positive().optional(),
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
usage: z.object({ enabled: z.boolean() }).optional(),
browserTools: MutableBrowserToolsConfigSchema.default({ enabled: false }),
providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}),
metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }),
@@ -258,7 +256,6 @@ export const MutableDaemonConfigPatchSchema = z
.object({
relay: MutableRelayConfigSchema.partial().optional(),
mcp: z.object({ injectIntoAgents: z.boolean().optional() }).passthrough().optional(),
usage: z.object({ enabled: z.boolean() }).optional(),
browserTools: MutableBrowserToolsConfigSchema.partial().optional(),
providers: z
.record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough())
@@ -3676,8 +3673,6 @@ export const ServerInfoStatusPayloadSchema = z
providersSnapshot: z.boolean().optional(),
connectedHosts: z.boolean().optional(),
hostMultiplexing: z.boolean().optional(),
// COMPAT(usageSetting): added in v0.14, remove after 2027-05-01.
usageSetting: z.boolean().optional(),
usageSources: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: z.boolean().optional(),
+1 -3
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.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",
@@ -124,7 +124,6 @@
"esbuild": "^0.25.12",
"express": "^4.18.2",
"fast-deep-equal": "^3.1.3",
"https-proxy-agent": "^7.0.6",
"lru-cache": "^11.5.1",
"markdown-it": "^10.0.0",
"mnemonic-id": "^3.2.7",
@@ -142,7 +141,6 @@
"strip-ansi": "^7.1.2",
"tree-kill": "^1.2.2",
"typescript": "^5.9.3",
"undici": "^7.24.8",
"uuid": "^9.0.1",
"which": "^5.0.0",
"ws": "^8.14.2",
@@ -1,108 +0,0 @@
// 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,7 +88,6 @@ describe("Codex executable discovery", () => {
import { CodexAppServerClient } from "./codex/app-server-transport.js";
import {
createFakeCodexAppServer,
fakeCodexThreadHistory,
type FakeCodexAppServer,
waitForNextPermission,
waitForNextTimelineItem,
@@ -1992,7 +1991,7 @@ describe("Codex app-server provider", () => {
purpose: "history",
});
expect(threadRequests).toEqual(["thread/read", "thread/read"]);
expect(threadRequests).toEqual(["thread/read"]);
await session.close();
appServer.assertNoErrors();
});
@@ -2069,7 +2068,6 @@ 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);
@@ -2429,7 +2427,7 @@ describe("Codex app-server provider", () => {
cwd: "/workspace/project",
model: "gpt-5.4",
serviceTier: null,
excludeTurns: true,
excludeTurns: false,
persistExtendedHistory: true,
},
]);
@@ -2443,9 +2441,8 @@ describe("Codex app-server provider", () => {
test("rewinds a paginated conversation through the public session capability", async () => {
const appServer = createFakeCodexAppServer({
...fakeCodexThreadHistory({
"thread-1": { historyMode: "paginated", turns: [] },
"forked-thread": { historyMode: "paginated", turns: [] },
"thread/read": () => ({
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
}),
"thread/rollback": () => {
throw new Error("paginated threads do not support thread/rollback");
@@ -2490,7 +2487,7 @@ describe("Codex app-server provider", () => {
cwd: "/workspace/project",
model: "gpt-5.4",
serviceTier: null,
excludeTurns: true,
excludeTurns: false,
persistExtendedHistory: true,
},
]);
@@ -2507,10 +2504,11 @@ describe("Codex app-server provider", () => {
async (historyMode) => {
const appServer = createFakeCodexAppServer(
historyMode === "paginated"
? fakeCodexThreadHistory({
"thread-1": { historyMode: "paginated", turns: [] },
"forked-thread": { historyMode: "paginated", turns: [] },
})
? {
"thread/read": () => ({
thread: { id: "thread-1", historyMode: "paginated", turns: [] },
}),
}
: undefined,
);
const customCodexConfig = {
@@ -4949,7 +4947,6 @@ 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([
@@ -92,18 +92,12 @@ 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,
@@ -1810,13 +1804,22 @@ function readCodexHistoryTimestamp(item: unknown): string | null {
);
}
/** Falls back to the row's start time for a user message and its end time otherwise. */
function readCodexHistoryRowTimestamp(
row: CodexHistoryRow,
function readCodexTurnHistoryTimestamp(
turn: unknown,
timelineItem: AgentTimelineItem,
): string | null {
const startedAt = normalizeProviderReplayTimestamp(row.startedAt);
const completedAt = normalizeProviderReplayTimestamp(row.completedAt);
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);
if (timelineItem.type === "user_message") {
return startedAt ?? completedAt;
}
@@ -2060,19 +2063,84 @@ 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);
}
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>();
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 } });
for (const turn of response.thread.turns) {
for (const item of turn.items) {
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);
}
}
}
}
const subAgentRoutes = Array.from(subAgentTimelineIndexByThreadId.entries()).flatMap(
([childThreadId, timelineIndex]): PersistedSubAgentRoute[] => {
@@ -2085,55 +2153,11 @@ async function loadCodexThreadHistoryTimeline(params: {
return { timeline, subAgentRoutes };
}
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);
}
}
}
function readCodexThread(client: CodexAppServerClientLike, threadId: string): Promise<unknown> {
return client.request("thread/read", {
threadId,
includeTurns: true,
});
}
export async function forkCodexThread(
@@ -3973,9 +3997,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();
@@ -4031,9 +4055,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);
@@ -1,53 +0,0 @@
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,41 +185,14 @@ describe("Codex app-server transport", () => {
});
describe("Codex stdout byte admission", () => {
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 () => {
test("fails pending requests and the session before joining an oversized line", 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('{"method":"item/completed","params":"');
child.stdout.write('{"id":1,"result":"');
for (let index = 0; index < 2048; index++) child.stdout.write(chunk);
const error = await request;
expect(error).toMatchObject({
@@ -33,26 +33,11 @@ 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,
@@ -273,7 +258,7 @@ export class CodexAppServerClient {
this.pending.delete(id);
reject(new Error(`Codex app-server request timed out for ${method}`));
}, timeoutMs);
this.pending.set(id, { method, resolve, reject, timer });
this.pending.set(id, { resolve, reject, timer });
});
}
@@ -319,21 +304,8 @@ export class CodexAppServerClient {
}
private handleStreamLimit(failure: ProviderStreamLimitError): void {
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,
// A rejected response/notification cannot be replayed safely. 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");
@@ -108,7 +108,7 @@ describe("Codex Rewind", () => {
cwd: "/workspace/project",
model: "gpt-5.4-mini",
serviceTier: null,
excludeTurns: true,
excludeTurns: false,
persistExtendedHistory: true,
},
]);
@@ -190,7 +190,7 @@ describe("Codex Rewind", () => {
cwd: "/workspace/project",
model: "gpt-5.4-mini",
serviceTier: null,
excludeTurns: true,
excludeTurns: false,
persistExtendedHistory: true,
},
]);
@@ -8,7 +8,6 @@ import {
parseCodexThreadForkResponse,
parseCodexThreadRollbackResponse,
} from "./app-server-transport.js";
import { readCodexThreadHistoryMode } from "./thread-history.js";
export interface CodexRewindClient {
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
@@ -21,6 +20,26 @@ 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,
@@ -76,8 +95,7 @@ export async function revertCodexConversation(input: {
model: input.model ?? null,
serviceTier: input.serviceTier ?? null,
...(input.config ? { config: input.config } : {}),
// Paseo re-reads the rewound thread through paged history, not the fork reply.
excludeTurns: true,
excludeTurns: false,
persistExtendedHistory: true,
};
@@ -3,10 +3,6 @@ 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;
@@ -233,18 +229,8 @@ export function createFakeCodexAppServer(
return;
}
let response: unknown;
try {
response = handler(message.params);
} catch (error) {
response = Promise.reject(error);
}
Promise.resolve(response)
Promise.resolve(handler(message.params))
.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`);
@@ -327,16 +313,6 @@ 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`);
}
@@ -720,81 +696,3 @@ 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 };
},
};
}
@@ -1,220 +0,0 @@
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();
});
});
@@ -1,168 +0,0 @@
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;
}
}
@@ -1,17 +1,10 @@
// 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,
/** The first bytes of the oversized line; the rest is discarded unread. */
readonly lineHead = "",
) {
constructor(readonly limitBytes: number) {
super(
`Provider stdout line exceeds ${limitBytes} bytes. Start a new thread or reduce the provider response size.`,
);
@@ -19,26 +12,10 @@ export class ProviderStreamLimitError extends Error {
}
}
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.
*/
/** Admits raw UTF-8 bytes before retaining, decoding or joining a complete line. */
export class ProviderLineReader {
private parts: Buffer[] = [];
private bytes = 0;
private skippingOversizedLine = false;
private stopped = false;
constructor(
@@ -54,20 +31,11 @@ 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) {
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;
this.close();
this.onLimit(new ProviderStreamLimitError(this.limitBytes));
return;
}
if (length) this.parts.push(buffer.subarray(start, end));
this.bytes += length;
@@ -182,10 +150,7 @@ export class JsonlFrameDecoder {
(line) => {
if (line.trim()) this.consumeLine(line);
},
(error) => {
this.lines.close();
this.drop("line-too-large", error);
},
(error) => this.drop("line-too-large", error),
);
}
@@ -6,6 +6,47 @@ import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
import type { V2Api } from "./api.js";
import { UsageSourceRegistry } from "../../../../plugins/usage-sources/index.js";
test("#7116: v2 agent usage includes the plan windows available on the host", async () => {
const harness = new V2Harness();
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
model: "openai/test-model",
});
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
hasAgent: (id) => id === session.id,
usageSession: (id) => (id === session.id ? (session.usageSession?.() ?? null) : null),
});
const report = {
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }],
};
registry.register({
id: "test-plan",
label: "Test plan",
discover: async (scope) =>
scope.kind === "global" ||
(scope.provider === "opencode" && scope.model === "openai/test-model")
? [{ key: "account", input: {} }]
: [],
fetch: async () => report,
});
try {
const host = await registry.listReports();
expect(host.map((entry) => entry.report)).toEqual([report]);
const agent = await registry.listReports({ agentId: session.id });
expect(agent.map((entry) => entry.report)).toEqual([report]);
} finally {
await session.close();
await client.shutdown();
}
});
function terminalEvents(events: AgentStreamEvent[]) {
return events.filter(
@@ -3643,114 +3643,3 @@ test("closing Pi during image conversion rejects its pending start", async () =>
await session.close();
await rejected;
});
describe("Pi tool-result images", () => {
test.each([false, true])(
"tool-result images precede the reply and completion (isError=%s)",
async (isError) => {
const { pi, session, events } = await createSession();
onTestFinished(() => session.close());
const fakeSession = pi.latestSession();
await session.startTurn("run");
fakeSession.emit({
type: "tool_execution_start",
toolCallId: "shot-1",
toolName: "screenshot",
args: {},
});
fakeSession.emit({
type: "tool_execution_end",
toolCallId: "shot-1",
toolName: "screenshot",
result: {
content: [
{ type: "text", text: "captured" },
{ type: "image", data: Buffer.from("hello").toString("base64"), mimeType: "image/png" },
],
structuredContent: {
type: "image",
data: Buffer.from("hello").toString("base64"),
mimeType: "image/png",
note: "captured",
},
},
isError,
});
fakeSession.emit({
type: "message_update",
assistantMessageEvent: { type: "text_delta", delta: "Image read complete." },
});
fakeSession.finishTurn();
await events.nextTurnCompletion();
const items = events.timelineItems();
const toolIndex = items.findIndex(
(item) =>
item.type === "tool_call" &&
item.callId === "shot-1" &&
item.status === (isError ? "failed" : "completed"),
);
expect(toolIndex).toBeGreaterThanOrEqual(0);
// Base64 must never reach the tool output stringifier.
expect(JSON.stringify(items[toolIndex])).not.toContain(
Buffer.from("hello").toString("base64"),
);
expect(items.slice(toolIndex + 1)).toEqual([
expect.objectContaining({
type: "assistant_message",
text: expect.stringContaining("![Image]("),
}),
expect.objectContaining({ type: "assistant_message", text: "Image read complete." }),
]);
expect(events.timelineAndCompletionEvents().at(-1)).toEqual({ type: "turn_completed" });
},
);
});
test("Pi image rendering preserves turn ownership across an interrupt and follow-up", async () => {
const { pi, session, events } = await createSession();
onTestFinished(() => session.close());
const emitted: AgentStreamEvent[] = [];
session.subscribe((event) => emitted.push(event));
const native = pi.latestSession();
const first = await session.startTurn("read image");
native.emit({
type: "tool_execution_end",
toolCallId: "large-image",
toolName: "screenshot",
result: {
content: [
{
type: "image",
data: Buffer.alloc(8 * 1024 * 1024, 73).toString("base64"),
mimeType: "image/png",
},
],
},
isError: false,
});
native.finishTurn();
await session.interrupt();
const followup = await session.startTurn("follow-up after image");
await waitForImmediate();
await waitForImmediate();
await expect
.poll(() =>
events
.timelineItems()
.some((item) => item.type === "assistant_message" && item.text.includes("![Image](")),
)
.toBe(true);
expect(events.turnCompletedEvents().some((event) => event.turnId === followup.turnId)).toBe(
false,
);
const imageEvent = emitted.find(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text.includes("![Image]("),
);
expect(imageEvent).toMatchObject({ turnId: first.turnId });
native.finishTurn();
await events.nextTurnCompletion();
});
@@ -64,11 +64,7 @@ import {
streamPiHistory,
type PiCapturedUserMessageEntry,
} from "./history-mapper.js";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
type ProviderImageOutput,
} from "../provider-image-output.js";
import { materializeProviderImage } from "../provider-image-output.js";
import { PiCliRuntime } from "./cli-runtime.js";
import {
createPiExtensionHost,
@@ -93,7 +89,6 @@ import {
mapToolDetail,
parseToolArgs,
parseToolResult,
splitPiToolResultImages,
type PiToolResult,
type PiTrackedToolCall,
} from "./tool-call-mapper.js";
@@ -1206,7 +1201,6 @@ export class PiRpcAgentSession implements AgentSession {
private closed = false;
private readonly closeController = new AbortController();
private readonly pendingExtensionHydrations = new Set<Promise<void>>();
private pendingOutput: Promise<void> | null = null;
// Pi publishes the terminal before acknowledging abort. Autonomous runs have no
// turn ID; retain their errors too until the cancellation request settles.
private interruptingTurn: { turnId: string | undefined; error: string | null } | null = null;
@@ -1608,7 +1602,6 @@ export class PiRpcAgentSession implements AgentSession {
this.pendingExtensionUiRequests.size ||
this.pendingExtensionResults.size ||
this.pendingExtensionHydrations.size ||
this.pendingOutput ||
this.pendingSteerSubmissions.length ||
this.pendingNoTurnOutputs.length ||
this.interruptingTurn
@@ -1629,7 +1622,7 @@ export class PiRpcAgentSession implements AgentSession {
try {
await this.runtimeSession.close();
} finally {
await Promise.all([this.pendingOutput, ...this.pendingExtensionHydrations]);
await Promise.all(this.pendingExtensionHydrations);
this.rejectAllExtensionResults(new Error("Pi session closed"));
this.cleanup?.();
}
@@ -1693,31 +1686,9 @@ export class PiRpcAgentSession implements AgentSession {
}
private emit(event: AgentStreamEvent): void {
if (this.pendingOutput) {
this.emitAsync(() => Promise.resolve([event]));
return;
for (const subscriber of this.subscribers) {
subscriber(event);
}
this.publish(event);
}
private publish(event: AgentStreamEvent): void {
for (const subscriber of this.subscribers) subscriber(event);
}
// Keep timeline order while images are materialized, without delaying provider state changes.
private emitAsync(render: () => Promise<AgentStreamEvent[]>): void {
const pending = (this.pendingOutput ?? Promise.resolve())
.then(render)
.then((events) => {
if (!this.closed) for (const event of events) this.publish(event);
return undefined;
})
.catch((error) => this.logger.error({ err: error }, "Pi output rendering failed"));
this.pendingOutput = pending;
void pending.then(() => {
if (this.pendingOutput === pending) this.pendingOutput = null;
return undefined;
});
}
private emitExtensionOutput(output: PiExtensionEventOutput | undefined, turnId?: string): void {
@@ -2344,11 +2315,8 @@ export class PiRpcAgentSession implements AgentSession {
this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null);
this.activeToolCalls.delete(event.toolCallId);
// Pull image blocks out of the result so base64 never reaches the tool output, then render
// each one as an assistant_message markdown image after the tool_call (mirroring the Claude
// provider, upstream #1717).
const { images, result } = splitPiToolResultImages(parseToolResult(event.result));
const error = event.isError ? result : null;
const result = parseToolResult(event.result);
const error = event.isError ? event.result : null;
const status = event.isError ? "failed" : "completed";
this.extensionHost.onToolEnd({
callId: event.toolCallId,
@@ -2358,30 +2326,6 @@ export class PiRpcAgentSession implements AgentSession {
result,
});
this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error);
if (images.length === 0) return;
const turnId = this.currentTurnIdForEvent();
this.emitAsync(() => this.renderToolResultImages(images, turnId));
}
private async renderToolResultImages(
images: ProviderImageOutput[],
turnId: string | undefined,
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for (const image of images) {
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
});
if (imageItem && !this.closed) {
events.push({
type: "timeline",
provider: this.provider,
turnId,
item: imageItem,
});
}
}
return events;
}
private emitCompactionTimeline(input: {
@@ -21,11 +21,11 @@ export function mapPiChildSession(
return new PiChildSessionFollower(id, file, maxBytes).readNew();
}
async function mapChildLine(
function mapChildLine(
mapper: PiHistoryMapper,
id: string,
line: string,
): Promise<ProviderSubagentInputEvent[]> {
): ProviderSubagentInputEvent[] {
let entry: { message?: PiAgentMessage; timestamp?: string };
try {
entry = JSON.parse(line);
@@ -33,7 +33,7 @@ async function mapChildLine(
return [];
}
if (!entry.message || typeof entry.message !== "object" || !("role" in entry.message)) return [];
return (await mapper.mapMessages([entry.message])).flatMap((mapped) =>
return mapper.mapMessages([entry.message]).flatMap((mapped) =>
mapped.type === "timeline"
? [
{
@@ -83,10 +83,7 @@ export class PiChildSessionFollower {
const events: ProviderSubagentInputEvent[] = [];
for (const line of data.subarray(0, lastNewline).toString("utf8").split("\n")) {
if (this.items >= MAX_ITEMS) break;
const mapped = (await mapChildLine(this.mapper, this.id, line)).slice(
0,
MAX_ITEMS - this.items,
);
const mapped = mapChildLine(this.mapper, this.id, line).slice(0, MAX_ITEMS - this.items);
this.items += mapped.length;
events.push(...mapped);
}
@@ -83,7 +83,7 @@ describe("Pi extension host", () => {
test("declines a throwing adapter and preserves generic history", async () => {
const host = createPiExtensionHost(undefined, [throwingAdapter]);
const mapper = new PiHistoryMapper("pi", [], {}, host);
const events = await mapper.mapMessages([
const events = mapper.mapMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call-1", name: "other", arguments: {} }],
@@ -193,7 +193,7 @@ describe("Pi extension host", () => {
await output?.hydration;
expect(reads).toHaveLength(8);
});
test("claims a tool, declines an unrelated one, and maps history like live events", async () => {
test("claims a tool, declines an unrelated one, and maps history like live events", () => {
const host = createPiExtensionHost();
const args = { agent: "scout", task: "Inspect files" };
const result = parseToolResult({ content: [{ type: "text", text: "Found two files" }] });
@@ -225,7 +225,7 @@ describe("Pi extension host", () => {
).toBeUndefined();
const mapper = new PiHistoryMapper("pi");
const events = await mapper.mapMessages([
const events = mapper.mapMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call-1", name: "subagent", arguments: args }],
@@ -79,7 +79,7 @@ test("cancellation declines each remaining real RPC dialog", () => {
}
});
test("captured result renders in live and history tool rows, while foreign shapes decline", async () => {
test("captured result renders in live and history tool rows, while foreign shapes decline", () => {
const call = {
callId: end.toolCallId,
toolName: end.toolName,
@@ -93,7 +93,7 @@ test("captured result renders in live and history tool rows, while foreign shape
label: "Questions",
text: "Which snack?: Cookie\nWhich colors?: Red, Blue\nWhat project name?: Nebula",
});
const history = await new PiHistoryMapper("pi").mapMessages([
const history = new PiHistoryMapper("pi").mapMessages([
{
role: "assistant",
content: [
@@ -89,7 +89,7 @@ export async function verifyTaskFixture(path: URL, expectedStatuses: string[]):
for (const event of fixture.events) pi.latestSession().emit(event);
pi.latestSession().finishTurn();
expect(todos(live)).toEqual(mapped);
const replay = await new PiHistoryMapper("pi").mapMessages(fixture.messages);
const replay = new PiHistoryMapper("pi").mapMessages(fixture.messages);
expect(todos(replay)).toEqual(mapped);
expect(
replay.filter(
@@ -221,75 +221,3 @@ describe("Pi history mapper", () => {
]);
});
});
describe("Pi tool-result image replay", () => {
test("recreates an image from native tool-result history without its source file", async () => {
const events = await collectHistory([
{
role: "assistant",
content: [
{
type: "toolCall",
id: "image-read",
name: "read",
arguments: { path: "missing-preview.png" },
},
],
},
{
role: "toolResult",
toolCallId: "image-read",
toolName: "read",
content: [
{ type: "text", text: "Read image file [image/png]" },
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
],
},
]);
expect(events.map((event) => event.item?.type)).toEqual([
"tool_call",
"tool_call",
"assistant_message",
]);
const image = events.find(
(event) => event.type === "timeline" && event.item.type === "assistant_message",
);
expect(image).toMatchObject({
type: "timeline",
item: { type: "assistant_message", text: expect.stringContaining("![Image](file://") },
});
if (image?.type !== "timeline" || image.item.type !== "assistant_message")
throw new Error("Missing image");
const url = image.item.text.match(/\((file:[^)]+)\)/)![1];
const { readFile, unlink } = await import("node:fs/promises");
expect(await readFile(new URL(url))).toEqual(Buffer.from("hello"));
await unlink(new URL(url));
const replay = await collectHistory([
{
role: "toolResult",
toolCallId: "image-read",
toolName: "read",
content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }],
},
]);
expect(replay).toContainEqual(image);
expect(await readFile(new URL(url))).toEqual(Buffer.from("hello"));
});
});
test("replayed Pi images keep their position before the following reply", async () => {
const events = await collectHistory([
{
role: "toolResult",
toolCallId: "image-position",
toolName: "read",
content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }],
},
{ role: "assistant", content: [{ type: "text", text: "Image read complete." }] },
]);
expect(
events.map((event) =>
event.item?.type === "assistant_message" ? event.item.text : event.item?.type,
),
).toEqual(["tool_call", expect.stringContaining("![Image]("), "Image read complete."]);
});
@@ -1,9 +1,5 @@
import { mapCustomMessageToToolCall } from "../custom-message.js";
import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
} from "../provider-image-output.js";
import {
createPiExtensionHost,
type PiExtensionEventOutput,
@@ -15,7 +11,6 @@ import {
mapToolDetail,
parseToolArgs,
parseToolResult,
splitPiToolResultImages,
type PiToolResult,
type PiTrackedToolCall,
} from "./tool-call-mapper.js";
@@ -76,7 +71,7 @@ export class PiHistoryMapper {
private readonly extensionHost: PiExtensionHost = createPiExtensionHost(),
) {}
async mapMessages(messages: readonly PiAgentMessage[]): Promise<AgentStreamEvent[]> {
mapMessages(messages: readonly PiAgentMessage[]): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
for (const message of messages) {
@@ -91,7 +86,7 @@ export class PiHistoryMapper {
events.push(...this.mapAssistantMessage(message));
break;
case "toolResult": {
events.push(...(await this.mapToolResultMessage(message)));
events.push(...this.mapToolResultMessage(message));
break;
}
case "bashExecution":
@@ -212,35 +207,24 @@ export class PiHistoryMapper {
return events;
}
private async mapToolResultMessage(
private mapToolResultMessage(
message: Extract<PiAgentMessage, { role: "toolResult" }>,
): Promise<AgentStreamEvent[]> {
): AgentStreamEvent[] {
const tracked =
this.pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
this.pendingToolCalls.delete(message.toolCallId);
const result = parseToolResult({ content: message.content, details: message.details });
// Pull image blocks out of the result so base64 never reaches the tool output, then render
// each one as an assistant_message markdown image after the tool_call (mirroring the live
// path and the Claude provider, upstream #1717).
const { images, result: splitResult } = splitPiToolResultImages(result);
const mapping = this.extensionHost.mapToolCall({
callId: message.toolCallId,
toolName: tracked.toolName,
args: tracked.args,
status: message.isError ? "failed" : "completed",
result: splitResult,
result,
});
const detail = this.mapToolDetail(message.toolCallId, tracked, splitResult, mapping?.detail);
const detail = this.mapToolDetail(message.toolCallId, tracked, result, mapping?.detail);
if (!detail) {
return [];
}
const imageEvents: AgentStreamEvent[] = [];
for (const image of images) {
const item = await renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
});
if (item) imageEvents.push({ type: "timeline", provider: this.provider, item });
}
return [
{
type: "timeline",
@@ -250,10 +234,9 @@ export class PiHistoryMapper {
name: mapping?.name ?? tracked.toolName,
isError: Boolean(message.isError),
detail,
errorText: extractTextFromToolResult(splitResult) ?? "Tool call failed",
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
}),
},
...imageEvents,
...this.extensionEvents(mapping),
];
}
@@ -314,10 +297,9 @@ export async function* streamPiHistory(
signal?: AbortSignal,
): AsyncGenerator<AgentStreamEvent> {
const mapper = new PiHistoryMapper(provider, userEntries, hooks, extensionHost);
for (const message of messages) {
for (const event of mapper.mapMessages(messages)) {
if (signal?.aborted) return;
for (const event of await mapper.mapMessages([message])) {
if (signal?.aborted) return;
if (event) {
yield event;
}
}
@@ -1,12 +1,6 @@
import { describe, expect, test } from "vitest";
import {
extractTextFromToolResult,
mapToolDetail,
parseToolArgs,
parseToolResult,
splitPiToolResultImages,
} from "./tool-call-mapper.js";
import { mapToolDetail, parseToolArgs, parseToolResult } from "./tool-call-mapper.js";
describe("Pi tool call mapper", () => {
test("maps bash args and result to shell detail", () => {
@@ -62,77 +56,3 @@ describe("Pi tool call mapper", () => {
});
});
});
describe("splitPiToolResultImages", () => {
test("extracts image blocks and replaces them with [image] placeholders", () => {
const parsed = parseToolResult({
content: [
{ type: "text", text: "shot taken" },
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
],
});
const { images, result } = splitPiToolResultImages(parsed);
expect(images).toEqual([{ data: "aGVsbG8=", mimeType: "image/png" }]);
expect(result).toEqual({
content: [
{ type: "text", text: "shot taken" },
{ type: "text", text: "[image]" },
],
});
});
test("replaces image-only results with a non-empty placeholder", () => {
const parsed = parseToolResult({
content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/jpeg" }],
});
const { images, result } = splitPiToolResultImages(parsed);
expect(images).toHaveLength(1);
expect(extractTextFromToolResult(result)).toBe("[image]");
});
test("passes through string results and results without images", () => {
expect(splitPiToolResultImages("plain output")).toEqual({
images: [],
result: "plain output",
});
const textOnly = parseToolResult({ content: [{ type: "text", text: "no images" }] });
expect(splitPiToolResultImages(textOnly)).toEqual({ images: [], result: textOnly });
expect(splitPiToolResultImages(null)).toEqual({ images: [], result: null });
});
test("keeps other fields on the result object", () => {
const parsed = parseToolResult({
exitCode: 0,
content: [{ type: "image", data: "aGVsbG8=" }],
});
const { result } = splitPiToolResultImages(parsed);
expect(result).toMatchObject({ exitCode: 0 });
});
});
test("tool-result images remove nested structured image data while preserving metadata", () => {
const data = Buffer.from("nested screenshot bytes").toString("base64");
const result = parseToolResult({
content: [{ type: "image", data, mimeType: "image/png" }],
structuredContent: {
content: [{ type: "image", data, mimeType: "image/png", width: 64 }],
captureId: "capture-1",
read: { type: "image", data, mimeType: "image/png", note: "Read image file" },
extra: { data: "keep ordinary data" },
},
});
const split = splitPiToolResultImages(result);
const detail = mapToolDetail(parseToolArgs("screenshot", {}), split.result);
expect(JSON.stringify(detail)).not.toContain(data);
expect(split.images).toEqual([{ data, mimeType: "image/png" }]);
expect(split.result).toMatchObject({
structuredContent: {
content: [{ type: "image", data: "[image]", mimeType: "image/png", width: 64 }],
captureId: "capture-1",
read: { type: "image", data: "[image]", mimeType: "image/png", note: "Read image file" },
extra: { data: "keep ordinary data" },
},
});
});
@@ -1,7 +1,6 @@
import { z } from "zod";
import type { ToolCallDetail } from "../../agent-sdk-types.js";
import type { ProviderImageOutput } from "../provider-image-output.js";
interface BashToolInput {
command: string;
@@ -53,7 +52,6 @@ interface PiToolResultObject {
stdout?: string;
text?: string;
content?: PiToolResultContent[];
structuredContent?: unknown;
exitCode?: number;
code?: number;
details?: PiToolResultDetails;
@@ -69,12 +67,6 @@ interface PiToolResultTextContent {
text: string;
}
const PiToolResultImageContentSchema = z.object({
type: z.literal("image"),
data: z.string().min(1),
mimeType: z.string().optional(),
});
interface PiToolResultUnknownContent {
type: string;
}
@@ -244,61 +236,6 @@ export function parseToolResult(rawResult: unknown): PiToolResult {
return null;
}
// Pi returns images inside tool-result content as base64 blocks. Left in place they reach the
// tool-output stringifier, which dumps base64 into the tool output. We pull those blocks out to
// render them as image markdown and leave a "[image]" placeholder so image-only results still
// produce non-empty output (mirroring splitClaudeToolResultImages).
export function splitPiToolResultImages(result: PiToolResult): {
images: ProviderImageOutput[];
result: PiToolResult;
} {
if (!result || typeof result === "string" || !result.content) {
return { images: [], result };
}
const images: ProviderImageOutput[] = [];
let replaced = false;
const content = result.content.map((block) => {
const image = toPiImageOutput(block);
if (image) {
images.push(image);
replaced = true;
return { type: "text", text: "[image]" } satisfies PiToolResultTextContent;
}
return block;
});
if (!replaced) {
return { images: [], result };
}
return {
images,
result: {
...result,
content,
...(result.structuredContent === undefined
? {}
: { structuredContent: withoutImageData(result.structuredContent) }),
},
};
}
function withoutImageData(value: unknown): unknown {
if (Array.isArray(value)) return value.map(withoutImageData);
if (value === null || typeof value !== "object") return value;
const image = toPiImageOutput(value);
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
image && key === "data" ? "[image]" : withoutImageData(child),
]),
);
}
function toPiImageOutput(block: unknown): ProviderImageOutput | null {
const parsed = PiToolResultImageContentSchema.safeParse(block);
if (!parsed.success) return null;
return { data: parsed.data.data, mimeType: parsed.data.mimeType ?? null };
}
export function extractTextFromToolResult(result: PiToolResult): string | undefined {
if (typeof result === "string") {
return result;
@@ -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));
-2
View File
@@ -420,7 +420,6 @@ export interface PaseoDaemonConfig {
trustedProxies?: true | string[];
mcpEnabled?: boolean;
mcpInjectIntoAgents?: boolean;
usageEnabled?: boolean;
browserToolsEnabled?: boolean;
git?: {
maxProcessesPerSecond: number;
@@ -591,7 +590,6 @@ function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): Mutab
...(config.providerCatalogRefreshTimeoutMs !== undefined
? { catalogRefreshTimeoutMs: config.providerCatalogRefreshTimeoutMs }
: {}),
usage: { enabled: config.usageEnabled !== false },
browserTools: { enabled: config.browserToolsEnabled ?? false },
providers,
metadataGeneration: {
-6
View File
@@ -523,10 +523,6 @@ function resolveAppendSystemPrompt(persisted: ReturnType<typeof loadPersistedCon
return persisted.daemon?.appendSystemPrompt ?? "";
}
function resolveUsageEnabled(persisted: ReturnType<typeof loadPersistedConfig>): boolean {
return persisted.daemon?.usage?.enabled !== false;
}
function resolveBrowserToolsEnabled(persisted: ReturnType<typeof loadPersistedConfig>): boolean {
return persisted.daemon?.browserTools?.enabled ?? false;
}
@@ -552,7 +548,6 @@ function resolveStaticLoadConfigSettings(
mcpEnabled: cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true,
mcpInjectIntoAgents:
cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false,
usageEnabled: resolveUsageEnabled(persisted),
browserToolsEnabled: resolveBrowserToolsEnabled(persisted),
autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false,
appendSystemPrompt: resolveAppendSystemPrompt(persisted),
@@ -581,7 +576,6 @@ export type ResolvedDaemonSettings = Pick<
| "trustedProxies"
| "mcpEnabled"
| "mcpInjectIntoAgents"
| "usageEnabled"
| "browserToolsEnabled"
| "git"
| "autoArchiveAfterMerge"
@@ -21,7 +21,6 @@ import {
type HostUpdate,
} from "@getpaseo/protocol/hosts";
import type { DaemonPermission, WSHelloMessage } from "@getpaseo/protocol/messages";
import { webSocketProxyAgent } from "../network/index.js";
export class HostError extends Error {
constructor(
@@ -243,7 +242,6 @@ export class ConnectedHosts {
{
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
connectTimeoutMs: 10000,
webSocketAgent: webSocketProxyAgent,
},
this.budget,
);
@@ -10,10 +10,6 @@ import { readSpeechSettings } from "./speech/speech-settings.js";
import type { PersistedConfig } from "./persisted-config.js";
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
function usageEnabled(persisted: PersistedConfig): boolean {
return persisted.daemon?.usage?.enabled !== false;
}
function reloadableConfig(
persisted: PersistedConfig,
options: { relayEnabledFallback?: boolean } = {},
@@ -27,7 +23,6 @@ function reloadableConfig(
enabled: relay.enabled ?? options.relayEnabledFallback ?? true,
},
mcp: { enabled: true, injectIntoAgents: false },
usage: { enabled: usageEnabled(persisted) },
browserTools: { enabled: daemon.browserTools?.enabled ?? false },
providers: (agents.providers ?? {}) as MutableDaemonConfig["providers"],
metadataGeneration: { providers: agents.metadataGeneration?.providers ?? [] },
@@ -1577,31 +1572,3 @@ describe("DaemonConfigStore speech settings", () => {
expect(JSON.stringify(other.get())).not.toContain("secret");
});
});
test("usage policy persists across restart and live reload", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "usage-policy-"));
try {
const initial = reloadableConfig({});
const store = new DaemonConfigStore(paseoHome, initial, undefined, {
reloadSource: {
resolve: (persisted) => ({
mutable: reloadableConfig(persisted),
overrideControlledPaths: [],
}),
},
});
await store.patch({ usage: { enabled: false } });
expect(loadConfig(paseoHome, { env: {} }).usageEnabled).toBe(false);
const persisted = loadPersistedConfig(paseoHome);
const restarted = new DaemonConfigStore(paseoHome, reloadableConfig(persisted));
expect(restarted.get().usage).toEqual({ enabled: false });
writeFileSync(
path.join(paseoHome, "config.json"),
JSON.stringify({ daemon: { usage: { enabled: true } } }),
);
await store.reload();
expect(store.get().usage).toEqual({ enabled: true });
} finally {
rmSync(paseoHome, { recursive: true, force: true });
}
});
@@ -22,7 +22,6 @@ type ProviderOverride = import("./agent/provider-launch-config.js").ProviderOver
interface SupportedMutableConfigPatch {
relay?: { enabled?: boolean; requireProtectedSecurity?: boolean };
mcp?: { injectIntoAgents?: boolean };
usage?: { enabled: boolean };
browserTools?: { enabled?: boolean };
providers?: MutableDaemonConfig["providers"];
removeProviders?: string[];
@@ -183,7 +182,6 @@ const RELOADABLE_PATHS = [
"daemon.relay.requireProtectedSecurity",
"daemon.mcp.enabled",
"daemon.mcp.injectIntoAgents",
"daemon.usage.enabled",
"daemon.browserTools.enabled",
"daemon.hostnames",
"daemon.cors.allowedOrigins",
@@ -211,7 +209,6 @@ const PERSISTED_TO_MUTABLE_PATH = new Map<string, string>([
["daemon.relay.requireProtectedSecurity", "relay.requireProtectedSecurity"],
["daemon.mcp.enabled", "mcp.enabled"],
["daemon.mcp.injectIntoAgents", "mcp.injectIntoAgents"],
["daemon.usage.enabled", "usage.enabled"],
["daemon.browserTools.enabled", "browserTools.enabled"],
["daemon.hostnames", "hostnames"],
["daemon.cors.allowedOrigins", "cors.allowedOrigins"],
@@ -284,7 +281,6 @@ function pickRelayPatch(
function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMutableConfigPatch {
return {
...pickRelayPatch(patch.relay),
usage: patch.usage,
...(patch.mcp?.injectIntoAgents !== undefined
? { mcp: { injectIntoAgents: patch.mcp.injectIntoAgents } }
: {}),
@@ -771,7 +767,6 @@ function mergeMutableDaemonPatch(
if (patch.mcp?.injectIntoAgents !== undefined) {
next.mcp = { ...next.mcp, injectIntoAgents: patch.mcp.injectIntoAgents };
}
if (patch.usage) next.usage = patch.usage;
if (patch.browserTools?.enabled !== undefined) {
next.browserTools = { ...next.browserTools, enabled: patch.browserTools.enabled };
}
@@ -1,6 +1,5 @@
import { appendFileSync, mkdirSync } from "node:fs";
import path from "node:path";
import { initializeEnvironmentProxy } from "./network/index.js";
import { createPaseoDaemon, formatListenTarget } from "./bootstrap.js";
import { loadConfig } from "./config.js";
import { resolvePaseoHome } from "./paseo-home.js";
@@ -133,11 +132,6 @@ function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
async function main() {
const { paseoHome, logger, config } = bootstrapFromEnvironment();
for (const variable of initializeEnvironmentProxy()) {
writeWorkerLifecycleLog(paseoHome, "Ignoring invalid or unsupported HTTP proxy variable", {
variable,
});
}
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
let shutdownPromise: Promise<number> | null = null;
let exitHookInstalled = false;
@@ -1,6 +1,5 @@
import { WebSocket } from "ws";
import { z } from "zod";
import { webSocketProxyAgent } from "../network/index.js";
import type { WebSocketLike } from "../websocket-server.js";
const SOCKET_PING_INTERVAL_MS = 10_000;
@@ -167,7 +166,6 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection {
let sessionProtocol: "legacy" | "session-v1" = "legacy";
const socket = new WebSocket(input.webSocketUrl, {
agent: webSocketProxyAgent(input.webSocketUrl),
handshakeTimeout: this.requestTimeoutMs,
headers: {
authorization: `Bearer ${input.credential}`,
-116
View File
@@ -1,116 +0,0 @@
import type { Agent } from "node:http";
import { EnvHttpProxyAgent, Pool, setGlobalDispatcher } from "undici";
import { HttpsProxyAgent } from "https-proxy-agent";
interface ProxyEnvironment {
httpProxy: string;
httpsProxy: string;
noProxy: string;
}
let environment = readEnvironment(process.env, process.platform).environment;
function readEnvironment(
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform,
): {
environment: ProxyEnvironment;
ignored: string[];
} {
// process.env lookups are case-insensitive on Windows. Read each actual entry once.
const values = new Map(
Object.entries(env).map(([name, value]) => [
platform === "win32" ? name.toLowerCase() : name,
value,
]),
);
const ignored: string[] = [];
function proxy(variable: string): string | undefined {
const value = values.get(variable);
if (value === undefined) return undefined;
if (!value.trim()) return undefined;
try {
const url = new URL(value.includes("://") ? value : `http://${value}`);
if (url.protocol !== "http:" && url.protocol !== "https:")
throw new Error("Unsupported proxy protocol");
decodeURIComponent(url.username);
decodeURIComponent(url.password);
return url.href;
} catch {
ignored.push(variable);
return undefined;
}
}
const httpProxy = proxy("http_proxy") ?? proxy("HTTP_PROXY") ?? "";
return {
environment: {
httpProxy,
httpsProxy: proxy("https_proxy") ?? proxy("HTTPS_PROXY") ?? httpProxy,
noProxy: values.get("no_proxy") ?? values.get("NO_PROXY") ?? "",
},
ignored,
};
}
function bypassList(noProxy: string): string {
return noProxy.split(/[,\s]+/).includes("*") ? "*" : `${noProxy},localhost,127.0.0.1,[::1],::1`;
}
function proxyClient(origin: URL, options: object) {
const pool = new Pool(origin, options);
if (!origin.username && !origin.password) return pool;
const credentials = `${decodeURIComponent(origin.username)}:${decodeURIComponent(origin.password)}`;
const authorization = `Basic ${Buffer.from(credentials).toString("base64")}`;
// This pool only sends CONNECT requests to this proxy, never requests to the origin.
// undici's URL auth requires both fields; HTTP Basic also permits either field to be empty.
return pool.compose(
(dispatch) => (request, handler) =>
dispatch(
{
...request,
headers: { ...request.headers, "proxy-authorization": authorization },
},
handler,
),
);
}
/** Install once at startup; return ignored variable names for logging without exposing credentials. */
export function initializeEnvironmentProxy(
env: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
): string[] {
const resolved = readEnvironment(env, platform);
environment = resolved.environment;
setGlobalDispatcher(
new EnvHttpProxyAgent({
httpProxy: environment.httpProxy,
httpsProxy: environment.httpsProxy,
noProxy: bypassList(environment.noProxy),
clientFactory: proxyClient,
}),
);
return resolved.ignored;
}
/** ws uses Node HTTP agents rather than the fetch dispatcher. */
export function webSocketProxyAgent(address: string): Agent | undefined {
const url = new URL(address);
const hostname = url.hostname.toLowerCase();
const port = url.port || (url.protocol === "wss:" ? "443" : "80");
const entries = bypassList(environment.noProxy)
.toLowerCase()
.split(/[,\s]+/);
if (
entries.some((entry) => {
if (entry === "*") return true;
const match = entry.match(/^(.+):(\d+)$/);
if (match && Number(match[2]) !== Number(port)) return false;
const host = (match ? match[1]! : entry).replace(/^\*?\./, "");
return host !== "" && (hostname === host || hostname.endsWith(`.${host}`));
})
)
return undefined;
const proxy = url.protocol === "wss:" ? environment.httpsProxy : environment.httpProxy;
return proxy ? new HttpsProxyAgent(proxy) : undefined;
}
@@ -1,341 +0,0 @@
import { fork } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createServer, type Server } from "node:http";
import { once } from "node:events";
import { connect } from "node:net";
import { afterEach, expect, test } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import { getGlobalDispatcher, setGlobalDispatcher } from "undici";
import { initializeEnvironmentProxy, webSocketProxyAgent } from "./index.js";
import { ConnectedHosts } from "../connected-hosts/index.js";
const original = getGlobalDispatcher();
const servers: Server[] = [];
afterEach(async () => {
const installed = getGlobalDispatcher();
setGlobalDispatcher(original);
if (installed !== original) await installed.close();
await Promise.all(
servers.splice(0).map(
(server) =>
new Promise<void>((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
}),
),
);
});
async function listen(server: Server) {
servers.push(server);
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing address");
return `127.0.0.1:${address.port}`;
}
async function proxy() {
const tunnels: string[] = [];
const server = createServer();
server.on("connect", (request, socket) => {
tunnels.push(request.url!);
socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
});
return { url: `http://${await listen(server)}`, tunnels };
}
test("loopback fetch and WebSockets bypass HTTP_PROXY with empty NO_PROXY", async () => {
const recorder = await proxy();
initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, NO_PROXY: "" });
const server = createServer((_, response) => response.end("direct"));
const sockets = new WebSocketServer({ server });
sockets.on("connection", (socket) => socket.send("direct"));
const address = await listen(server);
expect(await (await fetch(`http://${address}`)).text()).toBe("direct");
const socket = new WebSocket(`ws://${address}`, {
agent: webSocketProxyAgent(`ws://${address}`),
});
expect((await once(socket, "message"))[0].toString()).toBe("direct");
socket.close();
await once(socket, "close");
await new Promise<void>((resolve) => sockets.close(() => resolve()));
for (const host of ["localhost", "127.0.0.1", "[::1]"])
expect(webSocketProxyAgent(`wss://${host}:1234`)).toBeUndefined();
expect(recorder.tunnels).toEqual([]);
});
test("fetch and WebSockets send non-loopback traffic through CONNECT", async () => {
const recorder = await proxy();
initializeEnvironmentProxy({ http_proxy: recorder.url });
await expect(
fetch("https://vendor.invalid/", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
const socket = new WebSocket("wss://vendor.invalid", {
agent: webSocketProxyAgent("wss://vendor.invalid"),
});
await once(socket, "error");
expect(
recorder.tunnels.filter((host) => host === "vendor.invalid:443").length,
).toBeGreaterThanOrEqual(2);
});
test("NO_PROXY and absent variables keep matching WebSockets direct", async () => {
const recorder = await proxy();
initializeEnvironmentProxy({
HTTPS_PROXY: recorder.url,
NO_PROXY: "vendor.invalid,.example.invalid:443",
});
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined();
await expect(
fetch("https://vendor.invalid/", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
expect(webSocketProxyAgent("wss://sub.example.invalid")).toBeUndefined();
expect(webSocketProxyAgent("wss://example.invalid:444")).toBeDefined();
initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, no_proxy: "*" });
expect(webSocketProxyAgent("ws://vendor.invalid")).toBeUndefined();
initializeEnvironmentProxy({});
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined();
const address = await listen(createServer((_, response) => response.end("direct")));
expect(await (await fetch(`http://${address}`)).text()).toBe("direct");
expect(recorder.tunnels).toEqual([]);
});
test.each(["127.0.0.1", "localhost"])(
"scheme-less %s proxies work for fetch and WebSockets",
async (host) => {
const recorder = await proxy();
const address = new URL(recorder.url);
initializeEnvironmentProxy({ https_proxy: `${host}:${address.port}` });
await expect(
fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
const socket = new WebSocket("wss://vendor.invalid", {
agent: webSocketProxyAgent("wss://vendor.invalid"),
});
await once(socket, "error");
expect(recorder.tunnels).toContain("vendor.invalid:443");
},
);
test.each([
"http://[",
"http://user%zz:secret@proxy.corp",
"socks5://127.0.0.1:7890",
"ftp://proxy.corp:8080",
])("ignores invalid or unsupported proxy %s without stopping startup", async (value) => {
const recorder = await proxy();
expect(initializeEnvironmentProxy({ HTTP_PROXY: recorder.url, https_proxy: value })).toEqual([
"https_proxy",
]);
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined();
await expect(
fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
const socket = new WebSocket("wss://vendor.invalid", {
agent: webSocketProxyAgent("wss://vendor.invalid"),
});
await once(socket, "error");
expect(recorder.tunnels).toEqual(["vendor.invalid:443", "vendor.invalid:443"]);
});
test("daemon starts with an ignored proxy and writes one redacted warning to daemon.log", async () => {
const home = await mkdtemp(path.join(os.tmpdir(), "daemon-invalid-proxy-"));
const env = Object.fromEntries(
Object.entries(process.env).filter(
([key]) => !["http_proxy", "https_proxy"].includes(key.toLowerCase()),
),
);
const worker = fork(fileURLToPath(new URL("../daemon-worker.ts", import.meta.url)), [], {
execArgv: ["--import", "tsx"],
silent: true,
env: {
...env,
PASEO_HOME: home,
HOME: home,
USERPROFILE: home,
PASEO_LISTEN: "127.0.0.1:0",
PASEO_RELAY_ENABLED: "0",
https_proxy: "socks5://private:secret@127.0.0.1:7890",
},
});
worker.stdout?.resume();
let stderr = "";
worker.stderr?.on("data", (chunk) => {
stderr += String(chunk);
});
try {
await new Promise<void>((resolve, reject) => {
worker.on("message", (message) => {
if (
typeof message === "object" &&
message !== null &&
"type" in message &&
message.type === "paseo:ready"
)
resolve();
});
worker.on("exit", (code) => reject(new Error(`Worker exited ${code}: ${stderr}`)));
});
const log = await readFile(path.join(home, "daemon.log"), "utf8");
const warnings = log
.split("\n")
.filter((line) => line.includes("Ignoring invalid or unsupported HTTP proxy variable"));
expect(warnings).toHaveLength(1);
expect(JSON.parse(warnings[0]!).variable).toBe("https_proxy");
expect(warnings[0]).not.toContain("secret");
} finally {
if (worker.exitCode === null && worker.signalCode === null) {
const exited = once(worker, "exit");
worker.kill("SIGTERM");
await exited;
}
await rm(home, { recursive: true, force: true });
}
}, 60_000);
test("accepts a scheme-less corporate proxy hostname", () => {
expect(initializeEnvironmentProxy({ https_proxy: "proxy.corp:8080" })).toEqual([]);
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined();
});
test("an ignored lowercase variable falls back to the uppercase variable", async () => {
const recorder = await proxy();
expect(
initializeEnvironmentProxy(
{
https_proxy: "socks5://proxy.invalid",
HTTPS_PROXY: recorder.url,
},
"linux",
),
).toEqual(["https_proxy"]);
await expect(
fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
const socket = new WebSocket("wss://vendor.invalid", {
agent: webSocketProxyAgent("wss://vendor.invalid"),
});
await once(socket, "error");
expect(recorder.tunnels).toEqual(["vendor.invalid:443", "vendor.invalid:443"]);
});
test("case-insensitive environment access reports a variable only once", () => {
const env = new Proxy(
{ https_proxy: "socks5://127.0.0.1:7890" },
{
get(target, key) {
return Reflect.get(target, typeof key === "string" ? key.toLowerCase() : key);
},
},
);
expect(initializeEnvironmentProxy(env)).toEqual(["https_proxy"]);
});
test("connected-host links send remote WebSockets through the environment proxy", async () => {
const recorder = await proxy();
initializeEnvironmentProxy({ HTTPS_PROXY: recorder.url });
const home = await mkdtemp(path.join(os.tmpdir(), "connected-host-proxy-"));
const hosts = new ConnectedHosts(home, { serverId: "self", label: "Self" });
try {
await hosts.add(
{ connection: "wss://remote.invalid/ws" },
{ sourceId: "test", manageSources: true },
);
await expect.poll(() => recorder.tunnels).toContain("remote.invalid:443");
} finally {
await hosts.close();
await rm(home, { recursive: true, force: true });
}
});
test.each(["corp.internal,*", "vendor.invalid:0443"])(
"fetch and WebSockets share NO_PROXY=%s",
async (noProxy) => {
const recorder = await proxy();
initializeEnvironmentProxy({ HTTPS_PROXY: recorder.url, NO_PROXY: noProxy });
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeUndefined();
await expect(
fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
expect(recorder.tunnels).toEqual([]);
},
);
test("Windows mixed-case environment names route and bypass both transports", async () => {
const recorder = await proxy();
// Windows preserves spelling but treats environment names without regard to case.
initializeEnvironmentProxy({ Https_Proxy: recorder.url, No_Proxy: "other.invalid" }, "win32");
expect(webSocketProxyAgent("wss://other.invalid")).toBeUndefined();
await expect(
fetch("https://other.invalid", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined();
await expect(
fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
const socket = new WebSocket("wss://vendor.invalid", {
agent: webSocketProxyAgent("wss://vendor.invalid"),
});
await once(socket, "error");
expect(recorder.tunnels).toEqual(["vendor.invalid:443", "vendor.invalid:443"]);
});
test("POSIX mixed-case names do not override standard proxy names", async () => {
const recorder = await proxy();
initializeEnvironmentProxy(
{ Https_Proxy: recorder.url, HTTPS_PROXY: recorder.url, No_Proxy: "*" },
"linux",
);
expect(webSocketProxyAgent("wss://vendor.invalid")).toBeDefined();
await expect(
fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
expect(recorder.tunnels).toEqual(["vendor.invalid:443"]);
});
test("proxy credentials stay on CONNECT and do not reach the HTTP origin", async () => {
let originAuthorization: string | undefined;
const origin = createServer((request, response) => {
originAuthorization = request.headers["proxy-authorization"];
response.end("through proxy");
});
const originAddress = new URL(`http://${await listen(origin)}`);
const authorizations: (string | undefined)[] = [];
const server = createServer();
server.on("connect", (request, socket, head) => {
authorizations.push(request.headers["proxy-authorization"]);
const upstream = connect(Number(originAddress.port), "127.0.0.1", () => {
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head.length) upstream.write(head);
socket.pipe(upstream).pipe(socket);
});
socket.on("error", () => upstream.destroy());
upstream.on("error", () => socket.destroy());
socket.on("close", () => upstream.destroy());
});
initializeEnvironmentProxy({ HTTP_PROXY: `http://user:@${await listen(server)}` });
const response = await fetch("http://vendor.invalid", { signal: AbortSignal.timeout(3000) });
expect(await response.text()).toBe("through proxy");
expect(authorizations).toEqual([`Basic ${Buffer.from("user:").toString("base64")}`]);
expect(originAuthorization).toBeUndefined();
});
test.each(["user:@", ":secret@", "user:secret@"])(
"fetch and WebSockets authenticate proxy credentials %s",
async (credentials) => {
const authorizations: (string | undefined)[] = [];
const server = createServer();
server.on("connect", (request, socket) => {
authorizations.push(request.headers["proxy-authorization"]);
socket.end("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
});
initializeEnvironmentProxy({ HTTPS_PROXY: `http://${credentials}${await listen(server)}` });
await expect(
fetch("https://vendor.invalid", { signal: AbortSignal.timeout(500) }),
).rejects.toThrow();
const socket = new WebSocket("wss://vendor.invalid", {
agent: webSocketProxyAgent("wss://vendor.invalid"),
});
await once(socket, "error");
const expected = `Basic ${Buffer.from(credentials.slice(0, -1)).toString("base64")}`;
expect(authorizations).toEqual([expected, expected]);
},
);
@@ -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",
});
@@ -259,7 +259,6 @@ export const PersistedConfigSchema = z
})
.passthrough()
.optional(),
usage: z.object({ enabled: z.boolean().optional() }).optional(),
browserTools: z
.object({
enabled: z.boolean().optional(),

Some files were not shown because too many files have changed in this diff Show More