mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 21:23:04 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41d9bb4294 | ||
|
|
b044201f62 | ||
|
|
cacf186a94 | ||
|
|
47b18ffb50 | ||
|
|
ab55377458 | ||
|
|
f3d8718c6a | ||
|
|
26db1a4672 | ||
|
|
f28f0fee3d | ||
|
|
98f667f93e | ||
|
|
8c2a2a9a88 | ||
|
|
a4f696620b | ||
|
|
184ab66cde | ||
|
|
fbe24fcdb6 | ||
|
|
787edb1bf9 | ||
|
|
385c29b655 | ||
|
|
9477fe0c00 | ||
|
|
531370c4ce | ||
|
|
e299b21e94 | ||
|
|
1f79efcd62 | ||
|
|
e3682ba18c | ||
|
|
a8e0effb32 |
@@ -493,6 +493,16 @@ 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`
|
||||
|
||||
@@ -38,6 +38,7 @@ 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`).
|
||||
|
||||
@@ -35,6 +35,20 @@ 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
@@ -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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Contribute sidebar items
|
||||
|
||||
|
||||
@@ -52,6 +52,33 @@ On capable daemons, connecting creates no timeline or event demand. Client subsc
|
||||
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
|
||||
without requesting streams. Application caches and which agents are visible remain caller-owned.
|
||||
|
||||
## Binary chunking
|
||||
|
||||
`binary_chunking_v1` in hello and optional `server_info.features.binaryChunkingV1`
|
||||
negotiate connection-scoped framing. Application serializers and routed envelopes use
|
||||
the same boundary. Small messages and handshakes keep their existing representation.
|
||||
Older peers retain existing frame limits.
|
||||
|
||||
The reserved binary opcode `0x31` is followed by version `1`, original kind
|
||||
(`0` text, `1` binary), and START/END flag bits `0`/`1`. Remaining bytes are an
|
||||
unchanged slice. There is no total length or chunk count; a future receiver can
|
||||
consume slices incrementally. Today the receiver buffers up to the existing
|
||||
100 MiB logical limit and decodes UTF-8 only after END. Transfers cannot interleave.
|
||||
Disconnect clears partial state and negotiation.
|
||||
|
||||
The lower transport supplies its plaintext frame budget, including encryption
|
||||
overhead. The sender waits for drain between chunks while ordinary messages keep
|
||||
the existing delivery path. Slow-client protections count messages queued behind
|
||||
an active transfer as well as bytes already in the socket.
|
||||
The framing module never selects an encryption construction.
|
||||
|
||||
Native uploads retain the platform's existing queue limits. React Native's
|
||||
WebSocket does not report its queued bytes, so the client cannot wait for native
|
||||
drain. Android's installed OkHttp transport closes when a send would put more
|
||||
than 16 MiB in its queue. The native transport disables outbound chunking, including
|
||||
on routed channels, and retains its existing single-frame admission errors.
|
||||
Inbound chunking remains enabled; daemon-to-native responses use the daemon's paced writes.
|
||||
|
||||
## Owned observations
|
||||
|
||||
`owned_subscriptions` and `server_info.features.ownedSubscriptions` negotiate the source-owned
|
||||
|
||||
+10
-4
@@ -21,10 +21,16 @@ The invariants are:
|
||||
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable
|
||||
> through backward pagination.
|
||||
|
||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
||||
to 64 KiB, and the same bounded item is used for runtime timeline rows and live stream events.
|
||||
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
|
||||
tool payload.
|
||||
Every item is bounded before it enters either delivery path, because a timeline page is one
|
||||
WebSocket message and the daemon closes a socket whose outbound buffer passes 64 MiB. Tool calls get
|
||||
64 KiB and other items 256 KiB. Over budget, the largest strings are cut to a shared length with a
|
||||
`…[truncated 12.3 MB]` marker, and an oversized `data:` URL is replaced by the marker. Strings up
|
||||
to 1 KiB stay whole, so ids and paths survive. Arrays drop their trailing elements without a marker
|
||||
element, so typed arrays still match the wire schema. Limiting an item twice gives the same item. The
|
||||
budget does not bound wide objects or multi-byte text. The same
|
||||
bounded item is used for runtime timeline rows and live stream events. Provider history hydration
|
||||
applies the same rule so reopening an agent cannot restore an oversized payload. Fetches apply it
|
||||
again to assistant and reasoning rows the projection merges from many chunks.
|
||||
|
||||
## Presence is not delivery
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-6C4Rjh0mRVrC9akBVGK0NZaCaLJhwKObT7HrgPmcvyw=
|
||||
sha256-8NOrrCAxraQ74lP3Vy59MRhloBfzStTc4rW+ltc42Fg=
|
||||
|
||||
Generated
+24
@@ -40690,6 +40690,7 @@
|
||||
"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",
|
||||
@@ -40707,6 +40708,7 @@
|
||||
"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",
|
||||
@@ -41280,6 +41282,15 @@
|
||||
"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",
|
||||
@@ -41349,6 +41360,19 @@
|
||||
"@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",
|
||||
|
||||
@@ -102,7 +102,7 @@ export default {
|
||||
name: variant.name,
|
||||
slug: "voice-mobile",
|
||||
version: nativeReleaseVersion.appVersion,
|
||||
orientation: "portrait",
|
||||
orientation: "default",
|
||||
icon: "./assets/images/icon.png",
|
||||
scheme: "paseo",
|
||||
userInterfaceStyle: "automatic",
|
||||
|
||||
@@ -59,3 +59,38 @@ 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);
|
||||
|
||||
@@ -18,7 +18,7 @@ function visibleMainPane(page: Page) {
|
||||
}
|
||||
|
||||
function composerChangesPill(page: Page) {
|
||||
return page.getByTestId("composer-diff-stat-pill");
|
||||
return page.getByRole("button", { name: "Open Changes tab", exact: true });
|
||||
}
|
||||
|
||||
async function revealComposerChangesInExplorer(page: Page) {
|
||||
@@ -498,6 +498,7 @@ test.describe("send review feedback", () => {
|
||||
test("only open tabs in this workspace appear in compact and wide recipient menus", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
const workspace = await seedChangedAgent("review-feedback-multiple-");
|
||||
const other = await seedChangedAgent("review-feedback-other-");
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { chooseModel, startWithoutRememberedModel } from "../support/helpers/model-memory";
|
||||
import {
|
||||
composerLocator,
|
||||
enterComposerFullscreen,
|
||||
@@ -97,6 +98,7 @@ test.describe("New workspace composer draft", () => {
|
||||
const project = await seedWorkspace({ repoPrefix: "new-workspace-submitted-draft-" });
|
||||
|
||||
try {
|
||||
await startWithoutRememberedModel(page);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
@@ -104,6 +106,7 @@ test.describe("New workspace composer draft", () => {
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
});
|
||||
|
||||
await chooseModel(page, "mock", "Ten second stream");
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
const createButton = page.getByTestId("workspace-create-submit");
|
||||
await expect(composer).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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,3 +441,31 @@ 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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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,6 +28,10 @@ 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,6 +25,7 @@ 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. */
|
||||
@@ -63,6 +64,7 @@ function withUsageSupportFeature(
|
||||
message: WebSocketMessage,
|
||||
enabled: boolean,
|
||||
providerUsageListOnly: boolean,
|
||||
usageSettingSupported: boolean,
|
||||
): string | null {
|
||||
const envelope = parseJson(message) as {
|
||||
type?: unknown;
|
||||
@@ -86,6 +88,7 @@ function withUsageSupportFeature(
|
||||
...payload,
|
||||
features: {
|
||||
...features,
|
||||
usageSetting: usageSettingSupported ? true : undefined,
|
||||
usageSources: enabled && !providerUsageListOnly,
|
||||
providerUsageList: enabled,
|
||||
},
|
||||
@@ -229,7 +232,12 @@ export async function installUsageReportsFixture(
|
||||
server.onMessage((message) => {
|
||||
const serverInfo =
|
||||
typeof message === "string"
|
||||
? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly)
|
||||
? withUsageSupportFeature(
|
||||
message,
|
||||
isUsageSupported(),
|
||||
providerUsageListOnly,
|
||||
options.usageSettingSupported ?? true,
|
||||
)
|
||||
: 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,3 +1,4 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { router } from "expo-router";
|
||||
@@ -22,14 +23,7 @@ import {
|
||||
type ComponentType,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import {
|
||||
EditingTextInput as TextInput,
|
||||
type EditingTextInputHandle,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Modal,
|
||||
RefreshControl,
|
||||
FlatList,
|
||||
type ListRenderItem,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Modal, Pressable, Text, View } from "react-native";
|
||||
import { 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";
|
||||
|
||||
@@ -64,12 +64,7 @@ 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,
|
||||
useHasUsageSummary,
|
||||
useOpenSidebarUsage,
|
||||
} from "@/usage";
|
||||
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
@@ -480,10 +475,10 @@ function SidebarUsageIcon({ label }: { label: string }) {
|
||||
*/
|
||||
function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) {
|
||||
const { items } = useSidebarNavItems("footer");
|
||||
const hasUsageSummary = useHasUsageSummary();
|
||||
const usageSummary = useUsageSummary();
|
||||
const rowsRef = useRef<View | null>(null);
|
||||
const visibleItems = items.filter(
|
||||
(item) => item.visible && (item.kind === "plugin" || hasUsageSummary),
|
||||
(item) => item.visible && (item.kind === "plugin" || usageSummary.length > 0),
|
||||
);
|
||||
if (visibleItems.length === 0) return null;
|
||||
return (
|
||||
@@ -499,7 +494,7 @@ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
) : (
|
||||
<UsageSidebarItem key={item.key} />
|
||||
<UsageSidebarItem key={item.key} sources={usageSummary} />
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Modal, View } from "react-native";
|
||||
import { 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,
|
||||
|
||||
@@ -64,7 +64,7 @@ function resolveShouldFill(
|
||||
): boolean {
|
||||
if (!fillAvailableHeight) return false;
|
||||
const t = detail?.type;
|
||||
return t === "shell" || t === "edit" || t === "write" || t === "read" || t === "sub_agent";
|
||||
return t === "shell" || t === "edit" || t === "write" || t === "read";
|
||||
}
|
||||
|
||||
function useDetailStyles(
|
||||
@@ -186,184 +186,6 @@ function ShellDetailSection({ command, output, ds }: ShellDetailProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSubAgentFallbackHeader(
|
||||
subAgentType: string | null | undefined,
|
||||
description: string | null | undefined,
|
||||
fallbackText: string,
|
||||
): string {
|
||||
if (subAgentType && description) {
|
||||
return `${subAgentType}: ${description}`;
|
||||
}
|
||||
return subAgentType ?? description ?? fallbackText;
|
||||
}
|
||||
|
||||
interface SubAgentDetailProps {
|
||||
log: string;
|
||||
childSessionId: string | null | undefined;
|
||||
subAgentType: string | null | undefined;
|
||||
description: string | null | undefined;
|
||||
ds: DetailStyles;
|
||||
}
|
||||
|
||||
interface SubAgentActivityRow {
|
||||
index: number;
|
||||
toolName: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
interface ParsedSubAgentLog {
|
||||
actions: SubAgentActivityRow[];
|
||||
remainingLog: string;
|
||||
}
|
||||
|
||||
function parseBracketedSubAgentLine(line: string, index: number): SubAgentActivityRow | null {
|
||||
const match = line.match(/^\[([^\]]+)\](?:\s+(.*))?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const toolName = match[1]?.trim();
|
||||
if (!toolName) {
|
||||
return null;
|
||||
}
|
||||
const summary = match[2]?.trim();
|
||||
return {
|
||||
index,
|
||||
toolName,
|
||||
...(summary ? { summary } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSubAgentLog(log: string): ParsedSubAgentLog {
|
||||
const actions: SubAgentActivityRow[] = [];
|
||||
const remainingLines: string[] = [];
|
||||
for (const line of log.replace(/^\n+/, "").split("\n")) {
|
||||
const normalizedLine = line.trim();
|
||||
if (!normalizedLine) {
|
||||
continue;
|
||||
}
|
||||
const parsedAction = parseBracketedSubAgentLine(normalizedLine, actions.length + 1);
|
||||
if (parsedAction) {
|
||||
actions.push(parsedAction);
|
||||
} else {
|
||||
remainingLines.push(line);
|
||||
}
|
||||
}
|
||||
return {
|
||||
actions,
|
||||
remainingLog: remainingLines.join("\n").replace(/^\n+/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
function SubAgentActionRow({ action }: { action: SubAgentActivityRow }) {
|
||||
return (
|
||||
<View style={styles.subAgentActionRow}>
|
||||
<Text selectable style={styles.subAgentActionTool}>
|
||||
{formatSubAgentToolName(action.toolName)}
|
||||
</Text>
|
||||
{action.summary ? (
|
||||
<Text selectable style={styles.subAgentActionSummary}>
|
||||
{action.summary}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSubAgentToolName(toolName: string): string {
|
||||
const trimmed = toolName.trim();
|
||||
if (!trimmed) {
|
||||
return toolName;
|
||||
}
|
||||
return trimmed
|
||||
.replace(/[._-]+/g, " ")
|
||||
.split(" ")
|
||||
.filter((segment) => segment.length > 0)
|
||||
.map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function SubAgentLogText({
|
||||
activityLog,
|
||||
fallbackHeader,
|
||||
hasActions,
|
||||
}: {
|
||||
activityLog: string;
|
||||
fallbackHeader: string;
|
||||
hasActions: boolean;
|
||||
}) {
|
||||
if (activityLog.length > 0) {
|
||||
return (
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{activityLog}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (!hasActions) {
|
||||
return (
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{fallbackHeader}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function SubAgentDetailSection({
|
||||
log,
|
||||
childSessionId,
|
||||
subAgentType,
|
||||
description,
|
||||
ds,
|
||||
}: SubAgentDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const { actions, remainingLog } = useMemo(() => parseSubAgentLog(log), [log]);
|
||||
const fallbackHeader = resolveSubAgentFallbackHeader(
|
||||
subAgentType,
|
||||
description,
|
||||
t("toolCallDetails.subAgentActivity"),
|
||||
);
|
||||
const hasActions = actions.length > 0;
|
||||
return (
|
||||
<View style={ds.sectionFillStyle}>
|
||||
<View style={ds.codeBlockFillStyle}>
|
||||
<ScrollView
|
||||
style={ds.codeVerticalScrollStyle}
|
||||
contentContainerStyle={styles.codeVerticalContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
contentContainerStyle={styles.codeHorizontalContent}
|
||||
>
|
||||
<View style={styles.codeLine} dataSet={CODE_SURFACE_DATASET}>
|
||||
{childSessionId ? (
|
||||
<Text selectable style={styles.subAgentSessionText}>
|
||||
session {childSessionId}
|
||||
</Text>
|
||||
) : null}
|
||||
{hasActions ? (
|
||||
<View style={styles.subAgentActions}>
|
||||
{actions.map((action) => (
|
||||
<SubAgentActionRow key={action.index} action={action} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<SubAgentLogText
|
||||
activityLog={remainingLog}
|
||||
fallbackHeader={fallbackHeader}
|
||||
hasActions={hasActions}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditDetailProps {
|
||||
diffLines: DiffLine[] | undefined;
|
||||
ds: DetailStyles;
|
||||
@@ -636,19 +458,7 @@ function buildDetailSections(
|
||||
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
|
||||
];
|
||||
}
|
||||
if (detail.type === "worktree_setup") return [];
|
||||
if (detail.type === "sub_agent") {
|
||||
return [
|
||||
<SubAgentDetailSection
|
||||
key="sub-agent"
|
||||
log={detail.log}
|
||||
childSessionId={detail.childSessionId}
|
||||
subAgentType={detail.subAgentType}
|
||||
description={detail.description}
|
||||
ds={ds}
|
||||
/>,
|
||||
];
|
||||
}
|
||||
if (detail.type === "worktree_setup" || detail.type === "sub_agent") return [];
|
||||
if (detail.type === "edit") {
|
||||
return [<EditDetailSection key="edit" diffLines={diffLines} ds={ds} />];
|
||||
}
|
||||
@@ -900,34 +710,6 @@ const styles = StyleSheet.create((theme) => {
|
||||
shellPrompt: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
subAgentSessionText: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 18,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
subAgentActions: {
|
||||
gap: theme.spacing[1],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
subAgentActionRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
subAgentActionTool: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 18,
|
||||
},
|
||||
subAgentActionSummary: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.code,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 18,
|
||||
},
|
||||
jsonScroll: {
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
Modal,
|
||||
ScrollView,
|
||||
Platform,
|
||||
StatusBar,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -12,7 +13,6 @@ import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dimensions,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
View,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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,3 +1,4 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
@@ -16,7 +17,6 @@ import { createPortal } from "react-dom";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
Modal,
|
||||
Pressable,
|
||||
View,
|
||||
type PressableProps,
|
||||
|
||||
@@ -455,10 +455,6 @@ 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,7 +292,10 @@ const CATALOG_DATA = [
|
||||
version: "0.6.13",
|
||||
iconId: "minimax-code",
|
||||
installLink: "https://agent.minimax.io",
|
||||
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
|
||||
// `-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"],
|
||||
},
|
||||
{
|
||||
id: "minion-code",
|
||||
|
||||
@@ -62,7 +62,9 @@ describe("ACP provider catalog", () => {
|
||||
expect(findProvider("minimax-code")).toMatchObject({
|
||||
title: "MiniMax Code",
|
||||
version: "0.6.13",
|
||||
command: ["npx", "-y", "@minimax-ai/code@0.6.13", "acp"],
|
||||
// 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"],
|
||||
});
|
||||
expect(findProvider("minimax-code").iconSvg).toContain("<svg");
|
||||
});
|
||||
|
||||
@@ -422,6 +422,23 @@ 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,10 +175,14 @@ function resolveAgentScreenSync(args: {
|
||||
hadInitialSyncFailure: boolean;
|
||||
}): AgentScreenReadySyncState {
|
||||
const { input, hadInitialSyncFailure } = args;
|
||||
if (input.isArchived) {
|
||||
if (
|
||||
input.isArchived &&
|
||||
input.visibilityCatchUpStatus !== "error" &&
|
||||
input.visibilityCatchUpStatus !== "retrying"
|
||||
) {
|
||||
return { status: "idle" };
|
||||
}
|
||||
if (!input.isConnected) {
|
||||
if (!input.isConnected && !input.isArchived) {
|
||||
return { status: "reconnecting" };
|
||||
}
|
||||
if (input.missingAgentState.kind === "error") {
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import {
|
||||
EditingTextInput as TextInput,
|
||||
type EditingTextInputHandle,
|
||||
|
||||
@@ -832,7 +832,6 @@ describe("translation resources", () => {
|
||||
expect(en.common.placeholders.search).toBe("Search...");
|
||||
expect(en.common.empty.noResults).toBe("No results found");
|
||||
expect(en.common.empty.noOptionsMatchSearch).toBe("No options match your search.");
|
||||
expect(en.toolCallDetails.subAgentActivity).toBe("Sub-agent activity");
|
||||
expect(en.panels.file.failedToLoadPreview).toBe("Failed to load file preview");
|
||||
});
|
||||
|
||||
|
||||
@@ -2013,7 +2013,6 @@ export const ar: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "خطأ",
|
||||
empty: "لا توجد تفاصيل إضافية متاحة",
|
||||
subAgentActivity: "نشاط الوكيل الفرعي",
|
||||
input: "مدخل",
|
||||
output: "الإخراج",
|
||||
},
|
||||
@@ -2092,6 +2091,11 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "التحقق من الاستخدام",
|
||||
checkUsageHint: "ينطبق على {{host}} على جميع الأجهزة",
|
||||
off: "التحقق من الاستخدام متوقف على {{host}}.",
|
||||
turnOn: "تشغيل",
|
||||
|
||||
options: "الإعدادات",
|
||||
refreshFailed: "تعذّر تحديث الاستخدام",
|
||||
noHosts: "لا توجد مضيفات متصلة",
|
||||
|
||||
@@ -2038,7 +2038,6 @@ export const en = {
|
||||
toolCallDetails: {
|
||||
error: "Error",
|
||||
empty: "No additional details available",
|
||||
subAgentActivity: "Sub-agent activity",
|
||||
input: "Input",
|
||||
output: "Output",
|
||||
},
|
||||
@@ -2117,6 +2116,11 @@ 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",
|
||||
|
||||
@@ -2061,7 +2061,6 @@ export const es: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "Error",
|
||||
empty: "No hay detalles adicionales disponibles",
|
||||
subAgentActivity: "Actividad de subagente",
|
||||
input: "Aporte",
|
||||
output: "Producción",
|
||||
},
|
||||
@@ -2140,6 +2139,11 @@ 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",
|
||||
|
||||
@@ -2060,7 +2060,6 @@ export const fr: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "Erreur",
|
||||
empty: "Aucun détail supplémentaire disponible",
|
||||
subAgentActivity: "Activité du sous-agent",
|
||||
input: "Entrée",
|
||||
output: "Sortie",
|
||||
},
|
||||
@@ -2140,6 +2139,11 @@ 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é",
|
||||
|
||||
@@ -2033,7 +2033,6 @@ export const ja: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "エラー",
|
||||
empty: "追加の詳細はありません",
|
||||
subAgentActivity: "サブエージェントアクティビティ",
|
||||
input: "入力",
|
||||
output: "出力",
|
||||
},
|
||||
@@ -2113,6 +2112,11 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "使用状況を確認",
|
||||
checkUsageHint: "すべてのデバイスで {{host}} に適用",
|
||||
off: "{{host}} の使用状況確認はオフです。",
|
||||
turnOn: "オンにする",
|
||||
|
||||
options: "設定",
|
||||
refreshFailed: "使用状況を更新できません",
|
||||
noHosts: "接続中のホストがありません",
|
||||
|
||||
@@ -2023,7 +2023,6 @@ export const ko: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "오류",
|
||||
empty: "추가 세부 정보가 없습니다",
|
||||
subAgentActivity: "서브에이전트 활동",
|
||||
input: "입력",
|
||||
output: "출력",
|
||||
},
|
||||
@@ -2102,6 +2101,11 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "사용량 확인",
|
||||
checkUsageHint: "모든 기기에서 {{host}}에 적용",
|
||||
off: "{{host}}의 사용량 확인이 꺼져 있습니다.",
|
||||
turnOn: "켜기",
|
||||
|
||||
options: "설정",
|
||||
refreshFailed: "사용량을 새로 고칠 수 없습니다",
|
||||
noHosts: "연결된 호스트가 없습니다",
|
||||
|
||||
@@ -2046,7 +2046,6 @@ export const ptBR: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "Erro",
|
||||
empty: "Nenhum detalhe adicional disponível",
|
||||
subAgentActivity: "Atividade do subagente",
|
||||
input: "Entrada",
|
||||
output: "Saída",
|
||||
},
|
||||
@@ -2125,6 +2124,11 @@ 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",
|
||||
|
||||
@@ -2046,7 +2046,6 @@ export const ru: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "Ошибка",
|
||||
empty: "Дополнительные сведения отсутствуют",
|
||||
subAgentActivity: "Активность субагента",
|
||||
input: "Входные данные",
|
||||
output: "Выходные данные",
|
||||
},
|
||||
@@ -2125,6 +2124,11 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "Проверять использование",
|
||||
checkUsageHint: "Применяется к {{host}} на всех устройствах",
|
||||
off: "Проверка использования отключена на {{host}}.",
|
||||
turnOn: "Включить",
|
||||
|
||||
options: "Настройки",
|
||||
refreshFailed: "Не удалось обновить использование",
|
||||
noHosts: "Нет подключённых хостов",
|
||||
|
||||
@@ -1991,7 +1991,6 @@ export const zhCN: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "错误",
|
||||
empty: "没有可用的更多详情",
|
||||
subAgentActivity: "Sub-agent 活动",
|
||||
input: "输入",
|
||||
output: "输出",
|
||||
},
|
||||
@@ -2070,6 +2069,11 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "检查用量",
|
||||
checkUsageHint: "在所有设备上应用于 {{host}}",
|
||||
off: "{{host}} 的用量检查已关闭。",
|
||||
turnOn: "开启",
|
||||
|
||||
options: "设置",
|
||||
refreshFailed: "无法刷新用量",
|
||||
noHosts: "没有已连接的主机",
|
||||
|
||||
@@ -174,7 +174,6 @@ 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,7 +57,6 @@ function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner {
|
||||
fetchPage: async () => ({ hasNewer: false, endCursor: null }),
|
||||
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
|
||||
reportError: () => undefined,
|
||||
schedule: () => () => undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -617,7 +616,6 @@ 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 retry delay", async () => {
|
||||
test("a failed visible catch-up releases hidden chats without bypassing its error", 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 retr
|
||||
hidden.respond({ hasNewer: false });
|
||||
await world.nextError();
|
||||
world.expectNoPendingFetch();
|
||||
(await world.nextRetry())();
|
||||
world.sync.retryVisibleAgentTimeline("z-visible");
|
||||
(await world.nextFetch("z-visible")).respond({ hasNewer: false });
|
||||
world.sync.dispose();
|
||||
});
|
||||
@@ -200,20 +200,6 @@ 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 {
|
||||
@@ -235,12 +221,6 @@ 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;
|
||||
}
|
||||
@@ -300,22 +280,6 @@ 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;
|
||||
@@ -483,6 +447,28 @@ 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 });
|
||||
@@ -780,6 +766,21 @@ 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);
|
||||
@@ -788,12 +789,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, retryCatchUp] = await Promise.all([world.nextError(), world.nextRetry()]);
|
||||
const error = await world.nextError();
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||
|
||||
retryCatchUp();
|
||||
world.sync.retryVisibleAgentTimeline("agent-a");
|
||||
const retry = await world.nextFetch("agent-a");
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
|
||||
retry.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
@@ -804,35 +805,6 @@ 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);
|
||||
@@ -842,7 +814,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.nextRetry();
|
||||
await world.nextError();
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||
|
||||
world.sync.retryVisibleAgentTimeline("agent-a");
|
||||
@@ -853,7 +825,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 backoff", async () => {
|
||||
test("redeclaring unchanged visibility does not bypass catch-up error", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
@@ -862,9 +834,11 @@ test("redeclaring unchanged visibility does not bypass catch-up backoff", async
|
||||
|
||||
const failed = await world.nextFetch("agent-a");
|
||||
failed.fail("timeline unavailable");
|
||||
await world.nextRetry();
|
||||
await world.nextError();
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.setActive(false);
|
||||
world.sync.setActive(true);
|
||||
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
@@ -880,7 +854,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.nextRetry();
|
||||
await world.nextError();
|
||||
|
||||
world.sync.retryVisibleAgentTimeline("agent-a");
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
|
||||
@@ -943,20 +917,34 @@ test("repeated recovery for the same running gap reuses the in-flight fetch", as
|
||||
gapPage.respond({ hasNewer: false });
|
||||
});
|
||||
|
||||
test("membership failure autonomously retries without another visibility declaration", async () => {
|
||||
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 () => {
|
||||
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, retryMembership] = await Promise.all([world.nextError(), world.nextRetry()]);
|
||||
const error = await world.nextError();
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||
|
||||
retryMembership();
|
||||
world.sync.retryVisibleAgentTimeline("agent-a");
|
||||
const retry = await world.nextMembership();
|
||||
retry.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("retrying");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
|
||||
@@ -967,44 +955,68 @@ test("membership failure autonomously retries without another visibility declara
|
||||
});
|
||||
});
|
||||
|
||||
test("membership failures retry with exponential backoff", async () => {
|
||||
test("retrying membership restores a previously loaded chat", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
|
||||
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 });
|
||||
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"));
|
||||
});
|
||||
|
||||
test("redeclaring unchanged visibility does not bypass membership backoff", async () => {
|
||||
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();
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("error");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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.nextRetry();
|
||||
await world.nextError();
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.setActive(false);
|
||||
world.sync.setActive(true);
|
||||
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
@@ -1050,7 +1062,6 @@ 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();
|
||||
@@ -1067,11 +1078,10 @@ 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");
|
||||
const staleRetry = await world.nextRetry();
|
||||
await world.nextError();
|
||||
|
||||
world.show("workspace", ["agent-b"]);
|
||||
const current = await world.nextMembership();
|
||||
staleRetry();
|
||||
current.succeed();
|
||||
const agentB = await world.nextFetch("agent-b");
|
||||
agentB.respond({ hasNewer: false });
|
||||
@@ -1088,10 +1098,9 @@ test("membership retry cannot run while disconnected", async () => {
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const failed = await world.nextMembership();
|
||||
failed.fail("subscription unavailable");
|
||||
const disconnectedRetry = await world.nextRetry();
|
||||
await world.nextError();
|
||||
|
||||
world.sync.setConnected(false);
|
||||
disconnectedRetry();
|
||||
world.expectNoPendingMembership();
|
||||
world.sync.setConnected(true);
|
||||
const restored = await world.nextMembership();
|
||||
@@ -1118,7 +1127,6 @@ 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,7 +329,6 @@ export interface ViewedTimelineSyncPorts {
|
||||
*/
|
||||
onCatchUpEnded(agentId: string): void;
|
||||
reportError(error: unknown): void;
|
||||
schedule(task: () => void, delayMs: number): () => void;
|
||||
}
|
||||
|
||||
export type ViewedTimelineStatus = "ready" | "pending" | "error" | "retrying";
|
||||
@@ -423,26 +422,14 @@ 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,
|
||||
@@ -472,9 +459,7 @@ function decideCatchUp(input: {
|
||||
}
|
||||
return "replace";
|
||||
}
|
||||
return input.current.status === "running" || input.current.status === "complete"
|
||||
? "keep"
|
||||
: "replace";
|
||||
return "keep";
|
||||
}
|
||||
|
||||
function normalizeAgentIds(agentIds: string[]): string[] {
|
||||
@@ -494,8 +479,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
const pendingCatchUps = new Map<string, ProjectedTimelineForwardFetchPlan>();
|
||||
const visibilityCatchUpPending = new Set<string>();
|
||||
const visibilityCatchUpErrors = new Map<string, string>();
|
||||
// User-initiated retries only. Background retries stay silent; a retry the user asked for
|
||||
// owes them a pending state until it settles.
|
||||
// A user-initiated retry owes a pending state until it settles.
|
||||
const manualRetries = new Set<string>();
|
||||
const loadedCache = new Set<string>();
|
||||
const cacheLoads = new Map<string, Promise<void>>();
|
||||
@@ -511,8 +495,6 @@ 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;
|
||||
@@ -563,7 +545,6 @@ 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);
|
||||
};
|
||||
@@ -607,19 +588,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
setVisibilityCatchUpReady(agentId);
|
||||
} catch (error) {
|
||||
if (catchUps.get(agentId)?.generation === generation) {
|
||||
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,
|
||||
});
|
||||
catchUps.set(agentId, { generation, status: "error", request });
|
||||
setVisibilityCatchUpError([agentId], error);
|
||||
ports.reportError(error);
|
||||
}
|
||||
@@ -686,17 +655,9 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
if (decision === "keep") {
|
||||
return;
|
||||
}
|
||||
current?.cancelRetry?.();
|
||||
const generation = (catchUpGenerations.get(agentId) ?? 0) + 1;
|
||||
catchUpGenerations.set(agentId, generation);
|
||||
const retryDelayMs =
|
||||
supersede || current?.status !== "error" ? undefined : current.retryDelayMs;
|
||||
catchUps.set(agentId, {
|
||||
generation,
|
||||
status: "running",
|
||||
request: nextRequest,
|
||||
retryDelayMs,
|
||||
});
|
||||
catchUps.set(agentId, { generation, status: "running", request: nextRequest });
|
||||
pendingCatchUps.delete(agentId);
|
||||
void fetchUntilCurrent(
|
||||
agentId,
|
||||
@@ -715,7 +676,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 backoff or
|
||||
// Completion of a different chat must not bypass a failed chat's error or
|
||||
// supersede an in-flight tail that owns its parked gap recovery.
|
||||
if (current && !(current.status === "complete" && pendingCatchUp)) continue;
|
||||
startCatchUp(agentId, {
|
||||
@@ -741,42 +702,31 @@ 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 (!isDesired(agentId) || manualRetries.has(agentId)) return;
|
||||
if (!connected || !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 (catchUp?.status === "error") {
|
||||
catchUp.cancelRetry?.();
|
||||
startCatchUp(agentId, { request: catchUp.request, supersede: true });
|
||||
if (membershipRetryable) {
|
||||
void reconcileMembership();
|
||||
return;
|
||||
}
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
membershipRetryDelayMs = undefined;
|
||||
void reconcileMembership();
|
||||
if (catchUp?.status === "error") {
|
||||
startCatchUp(agentId, { request: catchUp.request, supersede: true });
|
||||
}
|
||||
};
|
||||
|
||||
const commitDesiredMembership = (
|
||||
@@ -817,8 +767,6 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
ensureCacheLoaded(agentId);
|
||||
}
|
||||
}
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
desired = nextDesired;
|
||||
ports.replaceDemandedAgentIds(desired);
|
||||
membershipGeneration += 1;
|
||||
@@ -869,6 +817,7 @@ 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 });
|
||||
@@ -880,9 +829,6 @@ 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 = [];
|
||||
@@ -894,7 +840,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
void reconcileMembership();
|
||||
},
|
||||
recoverGap(agentId, cursor) {
|
||||
if (!isDesired(agentId)) return;
|
||||
if (!isDesired(agentId) || catchUps.get(agentId)?.status === "error") return;
|
||||
startCatchUp(agentId, {
|
||||
request: planTimelineCatchUpAfter({ epoch: cursor.epoch, seq: cursor.endSeq }),
|
||||
supersede: true,
|
||||
@@ -904,10 +850,7 @@ 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);
|
||||
|
||||
@@ -21,10 +21,12 @@ export interface UsageHostSelection {
|
||||
*/
|
||||
export function UsageControls({
|
||||
view,
|
||||
serverId,
|
||||
onRefresh,
|
||||
hostSelection,
|
||||
}: {
|
||||
view: UsageView;
|
||||
serverId: string;
|
||||
onRefresh: () => void;
|
||||
hostSelection?: UsageHostSelection;
|
||||
}) {
|
||||
@@ -43,8 +45,8 @@ export function UsageControls({
|
||||
) : null}
|
||||
{view.kind === "unavailable" ? null : (
|
||||
<>
|
||||
<UsageRefreshButton busy={busy} onRefresh={onRefresh} />
|
||||
<UsageOptions />
|
||||
{view.kind === "off" ? null : <UsageRefreshButton busy={busy} onRefresh={onRefresh} />}
|
||||
<UsageOptions serverId={serverId} />
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
@@ -84,7 +86,12 @@ export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
|
||||
const controls = useMemo(
|
||||
() =>
|
||||
hasControls ? (
|
||||
<UsageControls view={view} onRefresh={refresh} hostSelection={hostSelection} />
|
||||
<UsageControls
|
||||
serverId={hostSelection.serverId}
|
||||
view={view}
|
||||
onRefresh={refresh}
|
||||
hostSelection={hostSelection}
|
||||
/>
|
||||
) : null,
|
||||
[hasControls, hostSelection, refresh, view],
|
||||
);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useUsageHostId } from "./hosts";
|
||||
import { useUsageHostReports } from "./queries";
|
||||
import {
|
||||
isUsagePinned,
|
||||
setUsageDisplayAs,
|
||||
@@ -25,12 +23,6 @@ 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 { useUsagePreferences } from "./display";
|
||||
import { useUsageDisplay } 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 } = useUsagePreferences(serverId);
|
||||
const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []);
|
||||
return (
|
||||
<UsageSection
|
||||
serverId={serverId}
|
||||
|
||||
@@ -4,6 +4,6 @@ export { useHostReportsUsage } from "./queries";
|
||||
export {
|
||||
UsageSidebarItem,
|
||||
UsageSidebarRoot,
|
||||
useHasUsageSummary,
|
||||
useUsageSummary,
|
||||
useOpenSidebarUsage,
|
||||
} from "./sidebar-item";
|
||||
|
||||
@@ -405,3 +405,15 @@ 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" });
|
||||
});
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface UsageQueryState {
|
||||
|
||||
export function resolveUsageView(
|
||||
input: {
|
||||
enabled?: boolean;
|
||||
hostLabel: string;
|
||||
isConnected: boolean;
|
||||
supportsUsage: boolean;
|
||||
@@ -125,6 +126,8 @@ 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 };
|
||||
}
|
||||
|
||||
@@ -8,15 +8,20 @@ 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 { useUsagePreferences } from "./display";
|
||||
import { useUsageSetting } from "./setting";
|
||||
import { useUsageDisplay } 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() {
|
||||
export function UsageOptions({ serverId }: { serverId: string | null }) {
|
||||
const setting = useUsageSetting(serverId);
|
||||
const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const { t } = useTranslation();
|
||||
const displayAsOptions: SegmentedControlOption<UsageDisplayAs>[] = [
|
||||
{ value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" },
|
||||
@@ -27,7 +32,7 @@ export function UsageOptions() {
|
||||
},
|
||||
];
|
||||
const compact = useIsCompactFormFactor();
|
||||
const { display } = useUsagePreferences();
|
||||
const { display } = useUsageDisplay([]);
|
||||
const { inSidebar, setInSidebar } = useUsageInSidebar();
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
@@ -49,13 +54,25 @@ export function UsageOptions() {
|
||||
testID="usage-options-surface"
|
||||
>
|
||||
<View testID="usage-options-fields">
|
||||
<SettingsSwitch
|
||||
label={t("providerUsage.showInSidebar")}
|
||||
hint={t("providerUsage.showInSidebarHint")}
|
||||
value={inSidebar}
|
||||
onValueChange={setInSidebar}
|
||||
testID="usage-show-in-sidebar"
|
||||
/>
|
||||
{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>
|
||||
<View style={settingsStyles.rowBorder}>
|
||||
<SettingsRow label={t("providerUsage.displayAs")}>
|
||||
<SegmentedControl
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||
import { useUsageSetting } from "./setting";
|
||||
import { usageCopy } from "./copy";
|
||||
import {
|
||||
replaceReport,
|
||||
@@ -132,13 +133,14 @@ 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,
|
||||
enabled: isConnected && isSupported && setting.enabled,
|
||||
dataShape: "list",
|
||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
||||
});
|
||||
@@ -150,6 +152,7 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () =
|
||||
const view = resolveUsageView(
|
||||
{
|
||||
hostLabel,
|
||||
enabled: setting.ready ? setting.enabled : undefined,
|
||||
isConnected,
|
||||
supportsUsage: isSupported,
|
||||
query: toQueryState(query),
|
||||
@@ -166,14 +169,16 @@ 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 query.data ?? NO_REPORTS;
|
||||
return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,7 +214,8 @@ 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]));
|
||||
return isConnected && isSupported;
|
||||
const setting = useUsageSetting(serverId);
|
||||
return isConnected && isSupported && setting.enabled;
|
||||
}
|
||||
|
||||
/** Every host with whether it is connected and reports usage, in host order. */
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model";
|
||||
import { useUsagePreferences } from "./display";
|
||||
import { useUsageDisplay } from "./display";
|
||||
import { useUsageInSidebar } from "./in-sidebar";
|
||||
import { useUsageHostId } from "./hosts";
|
||||
import { useUsageHostReports } from "./queries";
|
||||
import { UsageSourceIcon } from "./source-icon";
|
||||
@@ -33,28 +34,24 @@ import {
|
||||
import { UsageModal } from "./usage-modal";
|
||||
|
||||
/** Each summary window with data on the usage host, under its source; empty while none has. */
|
||||
function useUsageSummary(): readonly PinnedUsageSource[] {
|
||||
export function useUsageSummary(): readonly PinnedUsageSource[] {
|
||||
const { t } = useTranslation();
|
||||
const { preferences } = useUsagePreferences();
|
||||
const reports = useUsageHostReports(useUsageHostId());
|
||||
const { inSidebar } = useUsageInSidebar();
|
||||
const serverId = useUsageHostId();
|
||||
const reports = useUsageHostReports(inSidebar ? serverId : null);
|
||||
const { preferences } = useUsageDisplay(reports);
|
||||
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() {
|
||||
export function UsageSidebarItem({ sources }: { sources: readonly PinnedUsageSource[] }) {
|
||||
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} />;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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 }
|
||||
|
||||
@@ -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 { useUsagePreferences } from "./display";
|
||||
import { useUsageDisplay } 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}`}>
|
||||
|
||||
@@ -7,6 +7,7 @@ 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";
|
||||
@@ -27,8 +28,8 @@ export function UsageSection({
|
||||
testID?: string;
|
||||
}) {
|
||||
const trailing = useMemo(
|
||||
() => <UsageControls view={view} onRefresh={onRefresh} />,
|
||||
[onRefresh, view],
|
||||
() => <UsageControls serverId={serverId} view={view} onRefresh={onRefresh} />,
|
||||
[onRefresh, view, serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -51,6 +52,15 @@ 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} />;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,25 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
probe = new FakeDaemonProbe();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ id: "direct:lan:6767", type: "directTcp" as const, endpoint: "lan:6767" },
|
||||
{
|
||||
id: "relay:test",
|
||||
type: "relay" as const,
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
},
|
||||
])("adoptable $type probe clients use the app WebSocket factory", async (connection) => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const { createAppWebSocketFactory } = await import("@/runtime/websocket-factory");
|
||||
const result = await connectToDaemon(connection, { serverId: "srv_probe_test" }, probe.deps);
|
||||
try {
|
||||
expect(result.client.config.webSocketFactory).toBe(createAppWebSocketFactory());
|
||||
} finally {
|
||||
await result.client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses the app clientId for direct connections", async () => {
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const first = await connectToDaemon(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { HostConnection } from "@/types/host-connection";
|
||||
import { createAppWebSocketFactory } from "@/runtime/websocket-factory";
|
||||
import { getOrCreateClientId } from "./client-id";
|
||||
import { resolveAppVersion } from "./app-version";
|
||||
import {
|
||||
@@ -156,6 +157,7 @@ export async function buildClientConfig(
|
||||
clientType: "mobile" as const,
|
||||
appVersion: deps.resolveAppVersion() ?? undefined,
|
||||
suppressSendErrors: true,
|
||||
webSocketFactory: createAppWebSocketFactory(),
|
||||
reconnect: { enabled: false },
|
||||
...resolveConnectionCredentials(connection, options),
|
||||
...(options.capabilities ? { capabilities: options.capabilities } : {}),
|
||||
|
||||
@@ -40,12 +40,6 @@ function hasMeaningfulFetchDetail(detail: Extract<ToolCallDetail, { type: "fetch
|
||||
return Boolean(detail.url || detail.result || detail.codeText);
|
||||
}
|
||||
|
||||
function hasMeaningfulSubAgentDetail(
|
||||
detail: Extract<ToolCallDetail, { type: "sub_agent" }>,
|
||||
): boolean {
|
||||
return Boolean(detail.subAgentType || detail.description || detail.log);
|
||||
}
|
||||
|
||||
function hasMeaningfulPlainTextDetail(
|
||||
detail: Extract<ToolCallDetail, { type: "plain_text" }>,
|
||||
): boolean {
|
||||
@@ -75,9 +69,9 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
|
||||
case "fetch":
|
||||
return hasMeaningfulFetchDetail(detail);
|
||||
case "worktree_setup":
|
||||
return false;
|
||||
// A sub-agent card is a launch marker; the child's activity lives in the sub-agent panel.
|
||||
case "sub_agent":
|
||||
return hasMeaningfulSubAgentDetail(detail);
|
||||
return false;
|
||||
case "plain_text":
|
||||
return hasMeaningfulPlainTextDetail(detail);
|
||||
case "plan":
|
||||
@@ -92,7 +86,7 @@ export function isPendingToolCallDetail(params: {
|
||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||
error: unknown;
|
||||
}): boolean {
|
||||
if (params.detail?.type === "worktree_setup") return false;
|
||||
if (params.detail?.type === "worktree_setup" || params.detail?.type === "sub_agent") return false;
|
||||
const isRunning = params.status === "running" || params.status === "executing";
|
||||
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ export class ConnectionSubscriptions extends OwnedSubscriptions {
|
||||
// Protocol support belongs to the installed client. Only browser hosting needs
|
||||
// a resource supplied by the caller. Keep this exhaustive as the protocol evolves.
|
||||
export const DEFAULT_CLIENT_CAPABILITIES = {
|
||||
[CLIENT_CAPS.binaryChunkingV1]: true,
|
||||
[CLIENT_CAPS.helloRejection]: true,
|
||||
[CLIENT_CAPS.ownedSubscriptions]: true,
|
||||
[CLIENT_CAPS.allProviders]: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { maxSecuredPlaintextByteLength } from "@getpaseo/relay";
|
||||
import { MAX_RELAY_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit";
|
||||
import {
|
||||
openClientSecureChannel,
|
||||
@@ -155,18 +156,15 @@ export function createEncryptedTransport(
|
||||
const relayTransport: RelayTransport = {
|
||||
send: (data) => {
|
||||
if (typeof data === "string") {
|
||||
base.send(data);
|
||||
return;
|
||||
return base.send(data);
|
||||
}
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
base.send(normalizeTransportPayload(data));
|
||||
return;
|
||||
return base.send(normalizeTransportPayload(data));
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
base.send(data);
|
||||
return;
|
||||
return base.send(data);
|
||||
}
|
||||
base.send(String(data));
|
||||
return base.send(String(data));
|
||||
},
|
||||
// The channel decides the close code. Report it now: a socket close event
|
||||
// arrives asynchronously, and the consumer must not act on an earlier
|
||||
@@ -226,6 +224,8 @@ export function createEncryptedTransport(
|
||||
});
|
||||
|
||||
return {
|
||||
supportsDrain: base.supportsDrain,
|
||||
maxFrameBytes: maxSecuredPlaintextByteLength(MAX_RELAY_WEBSOCKET_MESSAGE_BYTES),
|
||||
send: (data) => {
|
||||
if (!channel) {
|
||||
throw new Error("Encrypted channel not ready");
|
||||
@@ -239,8 +239,9 @@ export function createEncryptedTransport(
|
||||
// Encryption and its transport handoff are synchronous up to send's first await.
|
||||
// Reject capacity failures here so a routed sender loses only its own channel.
|
||||
assertOutboundCapacity(wireBytes);
|
||||
void channel.send(payload).catch((error) => {
|
||||
return channel.send(payload).catch((error) => {
|
||||
emitError(error);
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
close: (code?: number, reason?: string) => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export interface DaemonTransport {
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
||||
/** False when the underlying socket cannot report write drain. */
|
||||
supportsDrain?: boolean;
|
||||
maxFrameBytes?: number;
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void | Promise<void>;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
onMessage: (handler: (data: unknown, isBinary: boolean) => void) => () => void;
|
||||
onOpen: (handler: () => void) => () => void;
|
||||
@@ -13,12 +16,16 @@ export type DaemonTransportFactory = (options: {
|
||||
protocols?: string[];
|
||||
}) => DaemonTransport;
|
||||
|
||||
export type WebSocketFactory = (
|
||||
url: string,
|
||||
options?: { headers?: Record<string, string>; protocols?: string[] },
|
||||
) => WebSocketLike;
|
||||
export interface WebSocketFactory {
|
||||
(
|
||||
url: string,
|
||||
options?: { headers?: Record<string, string>; protocols?: string[] },
|
||||
): WebSocketLike;
|
||||
supportsDrain?: boolean;
|
||||
}
|
||||
|
||||
export interface WebSocketLike {
|
||||
bufferedAmount?: number;
|
||||
readyState: number;
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
|
||||
@@ -175,3 +175,18 @@ describe("daemon-client transport helpers", () => {
|
||||
expect(decodeMessageData(bytes)).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
test("native drain capability survives WebSocket and encryption wrappers", async () => {
|
||||
const { nativeWebSocketFactory } = await import("./daemon-client-websocket-transport.js");
|
||||
expect(nativeWebSocketFactory.supportsDrain).toBe(false);
|
||||
const factory = Object.assign(() => ({ readyState: 1, send() {}, close() {} }), {
|
||||
supportsDrain: false,
|
||||
});
|
||||
const base = createWebSocketTransportFactory(factory)({ url: "ws://test" });
|
||||
expect(base.supportsDrain).toBe(false);
|
||||
const encrypted = createEncryptedTransport(base, {
|
||||
daemonPublicKeyB64: "unused-key",
|
||||
logger: { warn() {} },
|
||||
});
|
||||
expect(encrypted.supportsDrain).toBe(false);
|
||||
});
|
||||
|
||||
@@ -39,6 +39,9 @@ export function nativeWebSocketFactory(
|
||||
return new (getGlobalWebSocket())(url, options?.protocols, { headers: options?.headers });
|
||||
}
|
||||
|
||||
// React Native leaves bufferedAmount at zero, including while native output is queued.
|
||||
nativeWebSocketFactory.supportsDrain = false;
|
||||
|
||||
export function createWebSocketTransportFactory(factory: WebSocketFactory): DaemonTransportFactory {
|
||||
return ({ url, headers, protocols }) => {
|
||||
const ws = factory(url, { headers, protocols });
|
||||
@@ -50,11 +53,28 @@ export function createWebSocketTransportFactory(factory: WebSocketFactory): Daem
|
||||
}
|
||||
}
|
||||
return {
|
||||
supportsDrain: factory.supportsDrain !== false,
|
||||
send: (data) => {
|
||||
if (typeof ws.readyState === "number" && ws.readyState !== 1) {
|
||||
throw new Error(`WebSocket not open (readyState=${ws.readyState})`);
|
||||
}
|
||||
ws.send(data);
|
||||
if ((ws.bufferedAmount ?? 0) > 1024 * 1024) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const drain = () => {
|
||||
if (ws.readyState !== 1) {
|
||||
reject(new Error("WebSocket closed while draining"));
|
||||
return;
|
||||
}
|
||||
if ((ws.bufferedAmount ?? 0) <= 1024 * 1024) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
setTimeout(drain, 10);
|
||||
};
|
||||
drain();
|
||||
});
|
||||
}
|
||||
},
|
||||
close: (code?: number, reason?: string) => {
|
||||
// Node's `ws` may emit an `error` when a connecting socket is closed before the
|
||||
|
||||
@@ -1094,6 +1094,7 @@ test("advertises client capabilities in hello", async () => {
|
||||
provider_snapshot_references: true,
|
||||
explicit_event_subscriptions: true,
|
||||
owned_subscriptions: true,
|
||||
binary_chunking_v1: true,
|
||||
compact_provider_snapshots: true,
|
||||
custom_mode_icons: true,
|
||||
project_updates: true,
|
||||
@@ -6817,91 +6818,102 @@ test("drops legacy agent_stream tool_call payloads and logs validation warning",
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("parses canonical fetch_agent_timeline_response payloads without crashing", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
test.each([false, true])(
|
||||
"parses canonical timeline responses with chunking=%s",
|
||||
async (chunked) => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen({ features: { ownedSubscriptions: true, binaryChunkingV1: chunked } });
|
||||
await connectPromise;
|
||||
|
||||
const received: unknown[] = [];
|
||||
const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => {
|
||||
received.push(msg);
|
||||
});
|
||||
const received: unknown[] = [];
|
||||
const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => {
|
||||
received.push(msg);
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "fetch_agent_timeline_response",
|
||||
const command = chunked ? "x".repeat(50 * 1024 * 1024) : "pwd";
|
||||
const { ChunkedTransport } = await import("@getpaseo/protocol/chunked-transport");
|
||||
const sender = new ChunkedTransport({
|
||||
frameBytes: 24 * 1024 * 1024,
|
||||
send: (frame) => mock.triggerMessage(frame),
|
||||
deliver: () => {},
|
||||
});
|
||||
sender.enabled = chunked;
|
||||
await sender.send(
|
||||
wrapSessionMessage({
|
||||
type: "fetch_agent_timeline_response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
agentId: "agent_cli",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
||||
endCursor: { epoch: "epoch-1", seq: 1 },
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
timestamp: "2026-02-08T20:20:00.000Z",
|
||||
provider: "codex",
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
||||
collapsed: [],
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call_cli_snapshot",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toMatchObject({
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
agentId: "agent_cli",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
||||
endCursor: { epoch: "epoch-1", seq: 1 },
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
timestamp: "2026-02-08T20:20:00.000Z",
|
||||
provider: "codex",
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
||||
collapsed: [],
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call_cli_snapshot",
|
||||
name: "shell",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "pwd",
|
||||
},
|
||||
error: null,
|
||||
detail: { type: "shell", command },
|
||||
},
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
unsubscribe();
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toMatchObject({
|
||||
payload: {
|
||||
entries: [
|
||||
{
|
||||
item: {
|
||||
type: "tool_call",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "shell" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects and logs a correlated response that violates the protocol schema", async () => {
|
||||
const mock = createMockTransport();
|
||||
@@ -8471,3 +8483,302 @@ test("closes before admission when a protected-capable daemon is reached in lega
|
||||
expect(daemonReceived).toHaveLength(1);
|
||||
expect(JSON.parse(daemonReceived[0] as string)).toMatchObject({ type: "hello" });
|
||||
});
|
||||
|
||||
test("uses the frame budget selected by an asynchronously opened transport", async () => {
|
||||
const mock = createMockTransport();
|
||||
mock.transport.maxFrameBytes = 1024;
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "dynamic-budget",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
mock.transport.maxFrameBytes = 16;
|
||||
mock.triggerOpen({ features: { binaryChunkingV1: true } });
|
||||
await connecting;
|
||||
client.sendHostFrame("route", new Uint8Array(100));
|
||||
await vi.waitFor(() => expect(mock.sent.length).toBeGreaterThan(1));
|
||||
expect(mock.sent.every((frame) => typeof frame !== "string" && frame.byteLength <= 16)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test.each([false, true])("routed sends wait for origin drain (binary=%s)", async (binary) => {
|
||||
const mock = createMockTransport();
|
||||
let drain!: () => void;
|
||||
let blocked = false;
|
||||
const send = mock.transport.send;
|
||||
mock.transport.send = (frame) => {
|
||||
send(frame);
|
||||
if (blocked)
|
||||
return new Promise<void>((resolve) => {
|
||||
drain = resolve;
|
||||
});
|
||||
};
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "routed-drain",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
mock.triggerOpen({ features: { binaryChunkingV1: true } });
|
||||
await connecting;
|
||||
blocked = true;
|
||||
let finished = false;
|
||||
const write = Promise.resolve(
|
||||
client.sendHostFrame("route", binary ? new Uint8Array([1]) : "hello"),
|
||||
).then(() => {
|
||||
finished = true;
|
||||
return undefined;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(finished).toBe(false);
|
||||
drain();
|
||||
await write;
|
||||
expect(finished).toBe(true);
|
||||
});
|
||||
|
||||
test("a queued admission failure rejects its RPC without closing the client", async () => {
|
||||
const mock = createMockTransport();
|
||||
const send = mock.transport.send;
|
||||
let block = false;
|
||||
let drain!: () => void;
|
||||
mock.transport.send = (frame) => {
|
||||
if (!block) return send(frame);
|
||||
if (typeof frame !== "string")
|
||||
return new Promise<void>((resolve) => {
|
||||
drain = resolve;
|
||||
});
|
||||
throw new Error("capacity");
|
||||
};
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "queued-admission",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connecting;
|
||||
block = true;
|
||||
const first = client.sendHostFrame("route", new Uint8Array([1]));
|
||||
const ping = client.ping({ timeoutMs: 1000 });
|
||||
void ping.catch(() => {});
|
||||
drain();
|
||||
await first;
|
||||
await expect(ping).rejects.toThrow("capacity");
|
||||
expect(client.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
test("RPC deadlines remain active while a preceding frame waits for drain", async () => {
|
||||
const mock = createMockTransport();
|
||||
const send = mock.transport.send;
|
||||
let blocked = false;
|
||||
let drain!: () => void;
|
||||
mock.transport.send = (frame) => {
|
||||
if (!blocked) return send(frame);
|
||||
if (typeof frame !== "string")
|
||||
return new Promise<void>((resolve) => {
|
||||
drain = resolve;
|
||||
});
|
||||
};
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "drain-deadline",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connecting;
|
||||
blocked = true;
|
||||
const first = client.sendHostFrame("route", new Uint8Array([1]));
|
||||
try {
|
||||
await expect(client.ping({ timeoutMs: 20 })).rejects.toThrow("Timeout waiting for message");
|
||||
expect(client.isConnected).toBe(true);
|
||||
} finally {
|
||||
drain();
|
||||
await first;
|
||||
}
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"event listener errors do not close the transport (chunked=%s)",
|
||||
async (chunked) => {
|
||||
const mock = createMockTransport();
|
||||
const closed = vi.fn();
|
||||
mock.transport.close = closed;
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "listener-error",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
mock.triggerOpen({ features: { binaryChunkingV1: chunked } });
|
||||
await connecting;
|
||||
const unsubscribe = client.subscribe(() => {
|
||||
throw new Error("listener failed");
|
||||
});
|
||||
const text = wrapSessionMessage({
|
||||
type: "agent_deleted",
|
||||
payload: { agentId: "agent-test", requestId: "listener-test" },
|
||||
});
|
||||
const payload = new TextEncoder().encode(text);
|
||||
const frame = chunked ? new Uint8Array([0x31, 1, 0, 3, ...payload]) : text;
|
||||
expect(() => mock.triggerMessage(frame)).toThrow("listener failed");
|
||||
expect(closed).not.toHaveBeenCalled();
|
||||
unsubscribe();
|
||||
const events: string[] = [];
|
||||
client.subscribe((event) => {
|
||||
events.push(event.type);
|
||||
});
|
||||
mock.triggerMessage(frame);
|
||||
expect(events).toEqual(["agent_deleted"]);
|
||||
await client.sendHostFrame("route", "still connected");
|
||||
expect(client.isConnected).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test("malformed chunk metadata still closes the client transport", async () => {
|
||||
const mock = createMockTransport();
|
||||
const closed = vi.fn();
|
||||
mock.transport.close = closed;
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "malformed-chunk",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
mock.triggerOpen({ features: { binaryChunkingV1: true } });
|
||||
await connecting;
|
||||
mock.triggerMessage(new Uint8Array([0x31, 2, 0, 3, 65]));
|
||||
expect(closed).toHaveBeenCalledWith(4003, "Invalid chunked transport");
|
||||
});
|
||||
|
||||
test.each([FileTransferOpcode.FileBegin, FileTransferOpcode.FileChunk, FileTransferOpcode.FileEnd])(
|
||||
"upload stops at a queued pre-write rejection (opcode=%s)",
|
||||
async (opcode) => {
|
||||
const mock = createMockTransport();
|
||||
const send = mock.transport.send;
|
||||
let drain!: () => void;
|
||||
let blocked = false;
|
||||
const attempts: number[] = [];
|
||||
mock.transport.send = (frame) => {
|
||||
if (typeof frame !== "string") {
|
||||
const decoded = decodeFileTransferFrame(assertUint8Array(frame));
|
||||
if (decoded) {
|
||||
attempts.push(decoded.opcode);
|
||||
if (decoded.opcode === opcode) throw new Error("upload capacity");
|
||||
} else if (blocked)
|
||||
return new Promise<void>((resolve) => {
|
||||
drain = resolve;
|
||||
});
|
||||
}
|
||||
return send(frame);
|
||||
};
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "upload-admission",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connecting;
|
||||
blocked = true;
|
||||
const first = client.sendHostFrame("route", new Uint8Array([1]));
|
||||
const upload = client.uploadFile({
|
||||
fileName: "file.bin",
|
||||
mimeType: "application/octet-stream",
|
||||
bytes: new Uint8Array([1, 2]),
|
||||
chunkSize: 1,
|
||||
});
|
||||
let rejected: unknown;
|
||||
void upload.catch((error) => {
|
||||
rejected = error;
|
||||
});
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
drain();
|
||||
await first;
|
||||
await vi.waitFor(() => expect(rejected).toMatchObject({ message: "upload capacity" }), {
|
||||
timeout: 500,
|
||||
});
|
||||
expect(attempts.at(-1)).toBe(opcode);
|
||||
expect(client.isConnected).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["latency", "dictation start", "dictation finish"])(
|
||||
"queued control send rejection reaches its caller (%s)",
|
||||
async (operation) => {
|
||||
const mock = createMockTransport();
|
||||
const send = mock.transport.send;
|
||||
let blocked = false;
|
||||
let drain!: () => void;
|
||||
mock.transport.send = (frame) => {
|
||||
if (!blocked) return send(frame);
|
||||
if (typeof frame !== "string")
|
||||
return new Promise<void>((resolve) => {
|
||||
drain = resolve;
|
||||
});
|
||||
throw new Error("control capacity");
|
||||
};
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "control-admission",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connecting;
|
||||
blocked = true;
|
||||
const first = client.sendHostFrame("route", new Uint8Array([1]));
|
||||
let result: Promise<unknown>;
|
||||
if (operation === "latency") result = client.measureLatency({ timeoutMs: 1000 });
|
||||
else if (operation === "dictation start")
|
||||
result = client.startDictationStream("dictation", "pcm");
|
||||
else result = client.finishDictationStream("dictation", 0);
|
||||
const rejected = expect(result).rejects.toThrow("control capacity");
|
||||
drain();
|
||||
await first;
|
||||
await rejected;
|
||||
expect(client.isConnected).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
test("routed channels inherit an origin without drain support", async () => {
|
||||
const { createHostChannelTransport } = await import("./hosts/index.js");
|
||||
const mock = createMockTransport();
|
||||
mock.transport.supportsDrain = false;
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "unpaced-route",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
const connecting = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connecting;
|
||||
const route = createHostChannelTransport(
|
||||
client,
|
||||
"srv_destination",
|
||||
"scope",
|
||||
"route",
|
||||
)({ url: "ws://route" });
|
||||
expect(route.supportsDrain).toBe(false);
|
||||
route.close();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
ChunkedTransport,
|
||||
ChunkedTransportProtocolError,
|
||||
} from "@getpaseo/protocol/chunked-transport";
|
||||
import {
|
||||
HOST_ROUTE_OPCODE,
|
||||
encodeHostRouteFrame,
|
||||
@@ -1266,7 +1270,7 @@ function toReasonCode(reason: string | null | undefined): string | null {
|
||||
}
|
||||
|
||||
interface PendingSend {
|
||||
send: () => void;
|
||||
send: () => void | Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutHandle: ReturnType<typeof setTimeout>;
|
||||
@@ -1366,6 +1370,7 @@ export class DaemonClient {
|
||||
failed: (error) => this.logger.error({ err: error }, "Subscription failed"),
|
||||
});
|
||||
private transport: DaemonTransport | null = null;
|
||||
private chunkedTransport: ChunkedTransport | null = null;
|
||||
private helloAuth: HelloAuth;
|
||||
private transportCleanup: Array<() => void> = [];
|
||||
private rawMessageListeners: Set<(message: SessionOutboundMessage) => void> = new Set();
|
||||
@@ -1560,6 +1565,17 @@ export class DaemonClient {
|
||||
});
|
||||
this.traceConnectionAttempt += 1;
|
||||
this.transport = transport;
|
||||
this.chunkedTransport = new ChunkedTransport({
|
||||
get supportsDrain() {
|
||||
return transport.supportsDrain;
|
||||
},
|
||||
get frameBytes() {
|
||||
return transport.maxFrameBytes ?? 32 * 1024 * 1024;
|
||||
},
|
||||
send: (frame) => transport.send(frame),
|
||||
onFailure: () => transport.close(4003, "Transport send failed"),
|
||||
deliver: (frame) => this.handleTransportMessage(frame),
|
||||
});
|
||||
this.lastServerInfoMessage = null;
|
||||
this.authFailureReasonValue = null;
|
||||
|
||||
@@ -1666,7 +1682,16 @@ export class DaemonClient {
|
||||
});
|
||||
}),
|
||||
transport.onMessage((data) => {
|
||||
if (this.transport === transport) this.handleTransportMessage(data);
|
||||
if (this.transport !== transport) return;
|
||||
const bytes = asUint8Array(data);
|
||||
try {
|
||||
if (typeof data === "string" || bytes)
|
||||
this.chunkedTransport!.receive(typeof data === "string" ? data : bytes!);
|
||||
else this.handleTransportMessage(data);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ChunkedTransportProtocolError)) throw error;
|
||||
transport.close(4003, "Invalid chunked transport");
|
||||
}
|
||||
}),
|
||||
];
|
||||
} catch (error) {
|
||||
@@ -1867,9 +1892,9 @@ export class DaemonClient {
|
||||
})
|
||||
).routeId;
|
||||
}
|
||||
sendHostFrame(routeId: string, data: string | Uint8Array): void {
|
||||
sendHostFrame(routeId: string, data: string | Uint8Array): Promise<void> {
|
||||
if (typeof data === "string") {
|
||||
this.sendSessionMessageStrict({
|
||||
return this.sendSessionMessageStrict({
|
||||
type: "hosts.route.frame.request",
|
||||
routeId,
|
||||
data,
|
||||
@@ -1877,7 +1902,7 @@ export class DaemonClient {
|
||||
});
|
||||
} else {
|
||||
if (!this.isConnected || !this.transport) throw this.unavailableError();
|
||||
this.sendTransportFrame(encodeHostRouteFrame(routeId, data));
|
||||
return this.sendTransportFrame(encodeHostRouteFrame(routeId, data));
|
||||
}
|
||||
}
|
||||
private readonly hostFrameListeners = new Set<(frame: HostRouteFrame) => void>();
|
||||
@@ -1935,6 +1960,11 @@ export class DaemonClient {
|
||||
// Message Subscription
|
||||
// ============================================================================
|
||||
|
||||
/** Drain capability inherited by transports routed through this connection. */
|
||||
get supportsDrain(): boolean {
|
||||
return this.transport?.supportsDrain !== false;
|
||||
}
|
||||
|
||||
subscribe(handler: DaemonEventHandler): () => void {
|
||||
this.eventListeners.add(handler);
|
||||
return () => {
|
||||
@@ -2043,7 +2073,7 @@ export class DaemonClient {
|
||||
messageType: string,
|
||||
message: unknown,
|
||||
submitted?: () => void,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const isOpen =
|
||||
this.config.trace?.isEnabled() === true &&
|
||||
this.beginTraceSection("paseo.ws.message.outbound", {
|
||||
@@ -2052,25 +2082,30 @@ export class DaemonClient {
|
||||
...describeRequestIdentity(message),
|
||||
});
|
||||
try {
|
||||
this.sendTransportFrame(JSON.stringify(message), submitted);
|
||||
return this.sendTransportFrame(JSON.stringify(message), submitted);
|
||||
} finally {
|
||||
this.endTraceSection(isOpen);
|
||||
}
|
||||
}
|
||||
|
||||
private sendSessionPayload(payload: SessionInboundMessage): void {
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload }, () => {
|
||||
if (!("requestId" in payload) || typeof payload.requestId !== "string") return;
|
||||
for (const waiter of this.waiters) {
|
||||
if (waiter.requestId === payload.requestId) waiter.submitted = true;
|
||||
}
|
||||
});
|
||||
private sendSessionPayload(payload: SessionInboundMessage): Promise<void> {
|
||||
return this.sendJsonMessage(
|
||||
"session",
|
||||
payload.type,
|
||||
{ type: "session", message: payload },
|
||||
() => {
|
||||
if (!("requestId" in payload) || typeof payload.requestId !== "string") return;
|
||||
for (const waiter of this.waiters) {
|
||||
if (waiter.requestId === payload.requestId) waiter.submitted = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private sendTransportFrame(
|
||||
frame: string | Uint8Array | ArrayBuffer,
|
||||
submitted?: () => void,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (!this.transport) {
|
||||
throw this.unavailableError();
|
||||
}
|
||||
@@ -2083,7 +2118,8 @@ export class DaemonClient {
|
||||
});
|
||||
try {
|
||||
submitted?.();
|
||||
this.transport.send(frame);
|
||||
const payload = typeof frame === "string" ? frame : asUint8Array(frame)!;
|
||||
return this.chunkedTransport!.send(payload);
|
||||
} finally {
|
||||
this.endTraceSection(isOpen);
|
||||
}
|
||||
@@ -2112,10 +2148,10 @@ export class DaemonClient {
|
||||
}
|
||||
}
|
||||
|
||||
private sendBinaryFrame(frame: Uint8Array): void {
|
||||
private sendBinaryFrame(frame: Uint8Array): Promise<void> {
|
||||
if (!this.transport || this.connectionState.status !== "connected") {
|
||||
if (this.config.suppressSendErrors) {
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
|
||||
}
|
||||
@@ -2124,10 +2160,10 @@ export class DaemonClient {
|
||||
envelopeType: "binary",
|
||||
messageType: "binary",
|
||||
});
|
||||
this.sendTransportFrame(frame);
|
||||
return this.sendTransportFrame(frame);
|
||||
} catch (error) {
|
||||
if (this.config.suppressSendErrors) {
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
@@ -2142,7 +2178,7 @@ export class DaemonClient {
|
||||
private sendSessionMessageOrThrow(message: SessionInboundMessage): Promise<void> {
|
||||
return this.sendWhenConnected(() => {
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
this.sendSessionPayload(payload);
|
||||
return this.sendSessionPayload(payload);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2151,13 +2187,12 @@ export class DaemonClient {
|
||||
return this.sendWhenConnected(() => undefined);
|
||||
}
|
||||
|
||||
private sendWhenConnected(send: () => void): Promise<void> {
|
||||
private sendWhenConnected(send: () => void | Promise<void>): Promise<void> {
|
||||
const status = this.connectionState.status;
|
||||
|
||||
// If connected, send immediately
|
||||
if (this.transport && status === "connected") {
|
||||
send();
|
||||
return Promise.resolve();
|
||||
return Promise.resolve(send());
|
||||
}
|
||||
|
||||
// If connecting, queue the message to be sent once connected
|
||||
@@ -2196,8 +2231,7 @@ export class DaemonClient {
|
||||
clearTimeout(pending.timeoutHandle);
|
||||
try {
|
||||
if (this.transport && this.connectionState.status === "connected") {
|
||||
pending.send();
|
||||
pending.resolve();
|
||||
void Promise.resolve(pending.send()).then(pending.resolve, pending.reject);
|
||||
} else {
|
||||
pending.reject(new DaemonConnectionError("Connection lost before message could be sent"));
|
||||
}
|
||||
@@ -2253,19 +2287,17 @@ export class DaemonClient {
|
||||
{ ...params.options, requestId: params.requestId },
|
||||
);
|
||||
|
||||
// The response deadline still applies while the transport waits for drain.
|
||||
void promise.catch(() => undefined);
|
||||
try {
|
||||
await this.sendSessionMessageOrThrow(wire.message);
|
||||
const sent = this.sendSessionMessageOrThrow(wire.message);
|
||||
const result = await Promise.race([promise, sent.then(() => promise)]);
|
||||
if (result.kind === "error") throw result.error;
|
||||
return result.value;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
cancel(err);
|
||||
void promise.catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await promise;
|
||||
if (result.kind === "error") throw result.error;
|
||||
return result.value;
|
||||
} finally {
|
||||
await wire.finish();
|
||||
}
|
||||
@@ -2355,13 +2387,13 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
private sendSessionMessageStrict(message: SessionInboundMessage): void {
|
||||
private sendSessionMessageStrict(message: SessionInboundMessage): Promise<void> {
|
||||
if (!this.transport || this.connectionState.status !== "connected") {
|
||||
throw this.unavailableError();
|
||||
}
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
try {
|
||||
this.sendSessionPayload(payload);
|
||||
return this.sendSessionPayload(payload);
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
@@ -2559,15 +2591,16 @@ export class DaemonClient {
|
||||
};
|
||||
this.pingProbe = probe;
|
||||
|
||||
try {
|
||||
this.sendJsonMessage("ping", "ping", { type: "ping" });
|
||||
} catch (error) {
|
||||
this.clearPingProbe();
|
||||
const rejectSend = (error: unknown) => {
|
||||
if (this.pingProbe !== probe) return;
|
||||
const sendError = error instanceof Error ? error : new Error(String(error));
|
||||
if (probe.drivesLivenessFailure) {
|
||||
this.recordLivenessFailure(sendError);
|
||||
}
|
||||
return Promise.reject(sendError);
|
||||
this.rejectPingProbe(sendError);
|
||||
if (probe.drivesLivenessFailure) this.recordLivenessFailure(sendError);
|
||||
};
|
||||
try {
|
||||
void this.sendJsonMessage("ping", "ping", { type: "ping" }).catch(rejectSend);
|
||||
} catch (error) {
|
||||
rejectSend(error);
|
||||
}
|
||||
|
||||
return promise;
|
||||
@@ -4222,7 +4255,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async sendVoiceAudioChunk(audio: string, format: string, isLast = false): Promise<void> {
|
||||
this.sendSessionMessage({ type: "voice_audio_chunk", audio, format, isLast });
|
||||
return this.sendSessionMessageStrict({ type: "voice_audio_chunk", audio, format, isLast });
|
||||
}
|
||||
|
||||
async startDictationStream(dictationId: string, format: string): Promise<void> {
|
||||
@@ -4263,8 +4296,13 @@ export class DaemonClient {
|
||||
|
||||
const cleanupError = new Error("Cancelled dictation start waiter");
|
||||
try {
|
||||
this.sendSessionMessageStrict({ type: "dictation_stream_start", dictationId, format });
|
||||
await Promise.race([ackPromise, errorPromise]);
|
||||
const sent = this.sendSessionMessageStrict({
|
||||
type: "dictation_stream_start",
|
||||
dictationId,
|
||||
format,
|
||||
});
|
||||
const response = Promise.race([ackPromise, errorPromise]);
|
||||
await Promise.race([response, sent.then(() => response)]);
|
||||
} finally {
|
||||
ack.cancel(cleanupError);
|
||||
streamError.cancel(cleanupError);
|
||||
@@ -4399,12 +4437,17 @@ export class DaemonClient {
|
||||
|
||||
const cleanupError = new Error("Cancelled dictation finish waiter");
|
||||
try {
|
||||
this.sendSessionMessageStrict({ type: "dictation_stream_finish", dictationId, finalSeq });
|
||||
const firstOutcome = await Promise.race([
|
||||
const sent = this.sendSessionMessageStrict({
|
||||
type: "dictation_stream_finish",
|
||||
dictationId,
|
||||
finalSeq,
|
||||
});
|
||||
const response = Promise.race([
|
||||
finalOutcomePromise,
|
||||
errorOutcomePromise,
|
||||
finishAcceptedOutcomePromise,
|
||||
]);
|
||||
const firstOutcome = await Promise.race([response, sent.then(() => response)]);
|
||||
|
||||
if (firstOutcome.kind === "final") {
|
||||
return firstOutcome.payload;
|
||||
@@ -5320,8 +5363,10 @@ export class DaemonClient {
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
const sendFrame = (frame: Uint8Array) =>
|
||||
Promise.race([this.sendBinaryFrame(frame), responsePromise]);
|
||||
try {
|
||||
this.sendBinaryFrame(
|
||||
await sendFrame(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId: resolvedRequestId,
|
||||
@@ -5335,6 +5380,7 @@ export class DaemonClient {
|
||||
}),
|
||||
);
|
||||
|
||||
if (settled) return await responsePromise;
|
||||
const chunkSize = input.chunkSize ?? 128 * 1024;
|
||||
for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) {
|
||||
// Native WebSocket.send encodes binary synchronously. Let rendering and
|
||||
@@ -5344,7 +5390,7 @@ export class DaemonClient {
|
||||
if (this.transport !== uploadTransport || this.connectionState.status !== "connected") {
|
||||
throw new DaemonConnectionError("Connection changed during file upload");
|
||||
}
|
||||
this.sendBinaryFrame(
|
||||
await sendFrame(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId: resolvedRequestId,
|
||||
@@ -5353,7 +5399,7 @@ export class DaemonClient {
|
||||
);
|
||||
}
|
||||
|
||||
this.sendBinaryFrame(
|
||||
await sendFrame(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileEnd,
|
||||
requestId: resolvedRequestId,
|
||||
@@ -6763,6 +6809,8 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
private disposeTransport(code = 1001, reason = "Reconnecting"): void {
|
||||
this.chunkedTransport?.close();
|
||||
this.chunkedTransport = null;
|
||||
this.owned.disconnected();
|
||||
this.providerSnapshotUpdates.clear();
|
||||
this.stopLivenessHeartbeat();
|
||||
@@ -7232,15 +7280,6 @@ export class DaemonClient {
|
||||
probe.resolve(perfNow() - probe.startedAt);
|
||||
}
|
||||
|
||||
private clearPingProbe(): void {
|
||||
const probe = this.pingProbe;
|
||||
if (!probe) {
|
||||
return;
|
||||
}
|
||||
this.pingProbe = null;
|
||||
clearTimeout(probe.timeoutHandle);
|
||||
}
|
||||
|
||||
private rejectPingProbe(error: Error): void {
|
||||
const probe = this.pingProbe;
|
||||
if (!probe) {
|
||||
@@ -7289,6 +7328,10 @@ export class DaemonClient {
|
||||
if (consumerMessage.type === "status") {
|
||||
const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload);
|
||||
if (serverInfo) {
|
||||
if (this.chunkedTransport)
|
||||
this.chunkedTransport.enabled =
|
||||
serverInfo.features?.binaryChunkingV1 === true &&
|
||||
this.config.capabilities?.[CLIENT_CAPS.binaryChunkingV1] !== false;
|
||||
this.lastServerInfoMessage = serverInfo;
|
||||
this.destinationServerId = serverInfo.serverId;
|
||||
this.connectionFailure = undefined;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MAX_HOST_CHANNEL_FRAME_BYTES } from "@getpaseo/protocol/binary-frames/index";
|
||||
import { safeRandomId } from "../daemon-client-transport-utils.js";
|
||||
import { DaemonClient } from "../daemon-client.js";
|
||||
import type { DaemonTransportFactory } from "../daemon-client-transport-types.js";
|
||||
@@ -84,13 +85,17 @@ export function createHostChannelTransport(
|
||||
})
|
||||
.catch(close);
|
||||
return {
|
||||
get supportsDrain() {
|
||||
return origin.supportsDrain;
|
||||
},
|
||||
maxFrameBytes: MAX_HOST_CHANNEL_FRAME_BYTES,
|
||||
send(data) {
|
||||
if (closed || !routeId)
|
||||
throw Object.assign(new Error(`Host ${serverId} is offline`), {
|
||||
code: "host_offline",
|
||||
serverId,
|
||||
});
|
||||
origin.sendHostFrame(
|
||||
return origin.sendHostFrame(
|
||||
routeId,
|
||||
typeof data === "string" || data instanceof Uint8Array ? data : new Uint8Array(data),
|
||||
);
|
||||
|
||||
@@ -44,12 +44,14 @@ test("a slow consumer cannot exceed the shared byte budget and close releases re
|
||||
socket.pause();
|
||||
await ready;
|
||||
const frame = new Uint8Array(1024 * 1024);
|
||||
for (let index = 0; index < 4; index++) transport.send(frame);
|
||||
const writes = Array.from({ length: 4 }, () => transport.send(frame));
|
||||
const drained = Promise.allSettled(writes);
|
||||
expect(budget.bufferedBytes).toBe(4 * 1024 * 1024);
|
||||
expect(() => transport.send(frame)).toThrow("Connected host buffer limit reached");
|
||||
expect(budget.bufferedBytes).toBeLessThanOrEqual(budget.limit);
|
||||
transport.close();
|
||||
await expect.poll(() => budget.bufferedBytes).toBe(0);
|
||||
await drained;
|
||||
});
|
||||
test("direct connector preserves 25 MiB binary frames in both directions", async () => {
|
||||
const server = await endpoint();
|
||||
@@ -65,12 +67,13 @@ test("direct connector preserves 25 MiB binary frames in both directions", async
|
||||
await ready;
|
||||
const bytes = new Uint8Array(25 * 1024 * 1024).fill(173);
|
||||
const echoed = once(socket, "message");
|
||||
transport.send(bytes);
|
||||
const sending = transport.send(bytes);
|
||||
const [sent, binary] = await echoed;
|
||||
expect(binary).toBe(true);
|
||||
expect(Buffer.from(sent as Uint8Array).equals(bytes)).toBe(true);
|
||||
socket.send(bytes);
|
||||
expect(Buffer.from(await received).equals(bytes)).toBe(true);
|
||||
await sending;
|
||||
});
|
||||
|
||||
import {
|
||||
@@ -124,24 +127,29 @@ test("encrypted connector rejects overload synchronously and preserves the physi
|
||||
});
|
||||
await new Promise<void>((resolve) => transport.onOpen(resolve));
|
||||
await ready;
|
||||
expect(transport.maxFrameBytes).toBeLessThan(32 * 1024 * 1024);
|
||||
peer.pause();
|
||||
const frame = new Uint8Array(1024 * 1024).fill(173);
|
||||
let rejected: unknown;
|
||||
const writes: (void | Promise<void>)[] = [];
|
||||
for (let index = 0; index < 8; index++) {
|
||||
try {
|
||||
transport.send(frame);
|
||||
writes.push(transport.send(frame));
|
||||
} catch (error) {
|
||||
rejected = error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const drained = Promise.allSettled(writes);
|
||||
expect(rejected).toMatchObject({ code: "resource_limit" });
|
||||
expect(budget.bufferedBytes).toBeLessThanOrEqual(budget.limit);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(errors).toEqual([]);
|
||||
expect(closed).toBe(false);
|
||||
transport.send("sibling control");
|
||||
const sibling = transport.send("sibling control");
|
||||
peer.resume();
|
||||
await drained;
|
||||
await sibling;
|
||||
await expect.poll(() => received.includes("sibling control")).toBe(true);
|
||||
expect(received.filter((data) => data instanceof ArrayBuffer).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
@@ -19,10 +20,15 @@ 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
|
||||
@@ -68,12 +74,13 @@ export function createHostTransportFactory(
|
||||
return;
|
||||
}
|
||||
const socketPath = socketTarget.socketPath?.replace(/^~(?=\/)/, homedir());
|
||||
let factory = createWebSocketTransportFactory((url, options) => {
|
||||
let factory = createWebSocketTransportFactory((url, socketOptions) => {
|
||||
const socket = new WebSocket(
|
||||
socketPath && url.startsWith("ws+unix:") ? `ws+unix://${socketPath}:/ws` : url,
|
||||
options?.protocols,
|
||||
socketOptions?.protocols,
|
||||
{
|
||||
headers: options?.headers,
|
||||
headers: socketOptions?.headers,
|
||||
agent: socketPath ? undefined : options.webSocketAgent?.(url),
|
||||
...(socketPath ? { socketPath } : {}),
|
||||
maxPayload: 100 * 1024 * 1024,
|
||||
},
|
||||
@@ -165,9 +172,15 @@ export function createHostTransportFactory(
|
||||
close();
|
||||
});
|
||||
return {
|
||||
get supportsDrain() {
|
||||
return transport?.supportsDrain;
|
||||
},
|
||||
get maxFrameBytes() {
|
||||
return transport?.maxFrameBytes;
|
||||
},
|
||||
send(data) {
|
||||
if (closed || !transport) throw new Error("Host transport is unavailable");
|
||||
transport.send(data);
|
||||
return transport.send(data);
|
||||
},
|
||||
close,
|
||||
onOpen(handler) {
|
||||
@@ -191,14 +204,15 @@ export function createHostTransportFactory(
|
||||
}
|
||||
export function createHostClient(
|
||||
connection: string,
|
||||
config: Omit<DaemonClientConfig, "url" | "transportFactory">,
|
||||
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
|
||||
budget?: HostTransportBudget,
|
||||
): DaemonClient {
|
||||
const target = parseHostTarget(connection);
|
||||
const { webSocketAgent, ...clientConfig } = config;
|
||||
return new DaemonClient({
|
||||
...config,
|
||||
...clientConfig,
|
||||
url: "ws://host-connector/ws",
|
||||
...(target.password ? { password: target.password } : {}),
|
||||
transportFactory: createHostTransportFactory(connection, budget),
|
||||
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ export type ToolCallDetail =
|
||||
subAgentType?: string;
|
||||
description?: string;
|
||||
childSessionId?: string;
|
||||
log: string;
|
||||
log?: string;
|
||||
actions?: Array<{
|
||||
index: number;
|
||||
toolName: string;
|
||||
|
||||
@@ -3,6 +3,11 @@ import { MAX_WEBSOCKET_MESSAGE_BYTES } from "../websocket-message-limit.js";
|
||||
export const HOST_ROUTE_OPCODE = 0x30;
|
||||
export const MAX_HOST_ROUTE_ID_BYTES = 256;
|
||||
export const MAX_HOST_ROUTE_HEADER_BYTES = 3 + MAX_HOST_ROUTE_ID_BYTES;
|
||||
// A logical host channel crosses the 32 MiB route admission boundary. Text can
|
||||
// expand sixfold when enclosed in JSON; binary adds the bounded route header.
|
||||
export const MAX_HOST_CHANNEL_FRAME_BYTES = Math.floor(
|
||||
(32 * 1024 * 1024 - MAX_HOST_ROUTE_HEADER_BYTES) / 6,
|
||||
);
|
||||
export const MAX_ROUTED_PAYLOAD_BYTES = MAX_WEBSOCKET_MESSAGE_BYTES - MAX_HOST_ROUTE_HEADER_BYTES;
|
||||
export interface HostRouteFrame {
|
||||
routeId: string;
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChunkedTransport } from "./chunked-transport.js";
|
||||
|
||||
describe("chunked transport", () => {
|
||||
it("delivers a large UTF-8 logical message once, preserving order", async () => {
|
||||
const delivered: (string | Uint8Array)[] = [];
|
||||
const receiver = new ChunkedTransport({
|
||||
frameBytes: 17,
|
||||
send: () => {},
|
||||
deliver: (v) => delivered.push(v),
|
||||
});
|
||||
receiver.enabled = true;
|
||||
const frames: (string | Uint8Array)[] = [];
|
||||
const sender = new ChunkedTransport({
|
||||
frameBytes: 17,
|
||||
send: (v) => {
|
||||
frames.push(v);
|
||||
receiver.receive(v);
|
||||
},
|
||||
deliver: () => {},
|
||||
});
|
||||
sender.enabled = true;
|
||||
const text = "🌍".repeat(100);
|
||||
await sender.send(text);
|
||||
expect(delivered).toEqual([text]);
|
||||
expect(frames.length).toBeGreaterThan(1);
|
||||
expect(frames.every((v) => v instanceof Uint8Array && v.byteLength <= 17)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves binary kind and leaves legacy frames unchanged", async () => {
|
||||
const delivered: (string | Uint8Array)[] = [];
|
||||
const receiver = new ChunkedTransport({
|
||||
frameBytes: 8,
|
||||
send: () => {},
|
||||
deliver: (v) => delivered.push(v),
|
||||
});
|
||||
const frames: (string | Uint8Array)[] = [];
|
||||
const sender = new ChunkedTransport({
|
||||
frameBytes: 8,
|
||||
send: (v) => {
|
||||
frames.push(v);
|
||||
receiver.receive(v);
|
||||
},
|
||||
deliver: () => {},
|
||||
});
|
||||
const data = new Uint8Array(40).fill(5);
|
||||
await sender.send(data);
|
||||
expect(frames).toEqual([data]);
|
||||
frames.length = 0;
|
||||
sender.enabled = receiver.enabled = true;
|
||||
await sender.send(data);
|
||||
expect(frames).toHaveLength(10);
|
||||
expect(delivered).toEqual([data, data]);
|
||||
});
|
||||
|
||||
it("rejects malformed sequences and drops incomplete transfers on close", () => {
|
||||
const delivered: unknown[] = [];
|
||||
const receiver = new ChunkedTransport({
|
||||
frameBytes: 8,
|
||||
send: () => {},
|
||||
deliver: (v) => delivered.push(v),
|
||||
});
|
||||
receiver.enabled = true;
|
||||
receiver.receive(new Uint8Array([0x31, 1, 0, 1, 65]));
|
||||
expect(() => receiver.receive("interrupted")).toThrow("Interrupted");
|
||||
receiver.receive(new Uint8Array([0x31, 1, 0, 2, 66]));
|
||||
expect(delivered).toEqual([]);
|
||||
});
|
||||
|
||||
it("waits for drain before the next chunk and preserves logical ordering", async () => {
|
||||
const frames: (string | Uint8Array)[] = [];
|
||||
let drain!: () => void;
|
||||
const sender = new ChunkedTransport({
|
||||
frameBytes: 8,
|
||||
send: (v) => {
|
||||
frames.push(v);
|
||||
if (frames.length === 1)
|
||||
return new Promise<void>((resolve) => {
|
||||
drain = resolve;
|
||||
});
|
||||
},
|
||||
deliver: () => {},
|
||||
});
|
||||
sender.enabled = true;
|
||||
const first = sender.send("abcdefghij");
|
||||
const next = new Uint8Array([1, 2, 3, 4]);
|
||||
const second = sender.send(next);
|
||||
await Promise.resolve();
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(sender.queuedByteLength).toBe(4);
|
||||
drain();
|
||||
await Promise.all([first, second]);
|
||||
expect(frames).toHaveLength(4);
|
||||
expect(frames[3]).toEqual(next);
|
||||
expect(sender.queuedByteLength).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0x31, 2, 0, 3, 65],
|
||||
[0x31, 1, 2, 3, 65],
|
||||
[0x31, 1, 0, 4, 65],
|
||||
[0x31, 1, 0, 2, 65],
|
||||
[0x31, 1, 0, 1],
|
||||
])("rejects malformed chunk metadata %j", (...bytes) => {
|
||||
const receiver = new ChunkedTransport({
|
||||
frameBytes: 8,
|
||||
send: () => {},
|
||||
deliver: () => {
|
||||
throw new Error("Unexpected delivery");
|
||||
},
|
||||
});
|
||||
receiver.enabled = true;
|
||||
expect(() => receiver.receive(new Uint8Array(bytes))).toThrow();
|
||||
});
|
||||
|
||||
it("reconnect discards partial state and requires negotiation again", () => {
|
||||
const delivered: unknown[] = [];
|
||||
const create = () =>
|
||||
new ChunkedTransport({ frameBytes: 8, send: () => {}, deliver: (v) => delivered.push(v) });
|
||||
const old = create();
|
||||
old.enabled = true;
|
||||
old.receive(new Uint8Array([0x31, 1, 0, 1, 65]));
|
||||
old.close();
|
||||
old.receive(new Uint8Array([0x31, 1, 0, 2, 66]));
|
||||
const fresh = create();
|
||||
expect(fresh.enabled).toBe(false);
|
||||
fresh.enabled = true;
|
||||
fresh.receive(new Uint8Array([0x31, 1, 0, 3, 67]));
|
||||
expect(delivered).toEqual(["C"]);
|
||||
});
|
||||
|
||||
it("bounds logical messages waiting behind a blocked writer", async () => {
|
||||
let drain!: () => void;
|
||||
const transport = new ChunkedTransport({
|
||||
frameBytes: 32 * 1024 * 1024,
|
||||
send: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
drain = resolve;
|
||||
}),
|
||||
deliver: () => {},
|
||||
});
|
||||
const first = transport.send(new Uint8Array(100 * 1024 * 1024));
|
||||
const overflow = transport.send("one more byte");
|
||||
try {
|
||||
await expect(overflow).rejects.toThrow("queue");
|
||||
} finally {
|
||||
drain();
|
||||
await first;
|
||||
}
|
||||
});
|
||||
|
||||
it("requests drain for chunks while ordinary frames keep the immediate write path", async () => {
|
||||
const waits: boolean[] = [];
|
||||
const transport = new ChunkedTransport({
|
||||
frameBytes: 8,
|
||||
send: (_frame, wait) => {
|
||||
waits.push(wait);
|
||||
},
|
||||
deliver: () => {},
|
||||
});
|
||||
transport.enabled = true;
|
||||
await transport.send("small");
|
||||
await transport.send("abcdefghij");
|
||||
expect(waits).toEqual([false, true, true, true]);
|
||||
});
|
||||
|
||||
it("charges queued ASCII messages by their UTF-8 bytes", async () => {
|
||||
let drain!: () => void;
|
||||
let blocked = true;
|
||||
const transport = new ChunkedTransport({
|
||||
frameBytes: 32 * 1024 * 1024,
|
||||
send: () => {
|
||||
if (blocked) {
|
||||
blocked = false;
|
||||
return new Promise<void>((resolve) => {
|
||||
drain = resolve;
|
||||
});
|
||||
}
|
||||
},
|
||||
deliver: () => {},
|
||||
});
|
||||
transport.enabled = true;
|
||||
const first = transport.send(new Uint8Array([1]));
|
||||
const text = "x".repeat(10 * 1024 * 1024);
|
||||
const queued = Array.from({ length: 4 }, () => transport.send(text));
|
||||
const settled = Promise.allSettled(queued);
|
||||
expect(transport.queuedByteLength).toBe(40 * 1024 * 1024);
|
||||
drain();
|
||||
await first;
|
||||
expect((await settled).map((result) => result.status)).toEqual([
|
||||
"fulfilled",
|
||||
"fulfilled",
|
||||
"fulfilled",
|
||||
"fulfilled",
|
||||
]);
|
||||
});
|
||||
|
||||
it("notifies the socket owner when an awaited send overflows the queue", async () => {
|
||||
let drain!: () => void;
|
||||
const failures: unknown[] = [];
|
||||
const transport = new ChunkedTransport({
|
||||
frameBytes: 32 * 1024 * 1024,
|
||||
send: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
drain = resolve;
|
||||
}),
|
||||
deliver: () => {},
|
||||
onFailure: (error) => {
|
||||
failures.push(error);
|
||||
},
|
||||
});
|
||||
const first = transport.send(new Uint8Array(100 * 1024 * 1024));
|
||||
const overflow = transport.send("extra");
|
||||
await expect(overflow).rejects.toThrow("queue");
|
||||
drain();
|
||||
await first;
|
||||
expect(failures).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("bounds retained reassembly memory for many one-byte chunks", async () => {
|
||||
const { spawnSync } = await import("node:child_process");
|
||||
// Exercise the built package as a Node consumer; CI builds it before protocol tests.
|
||||
const entry = new URL("../dist/chunked-transport.js", import.meta.url).href;
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--expose-gc",
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`
|
||||
import { ChunkedTransport } from ${JSON.stringify(entry)};
|
||||
let delivered = 0;
|
||||
const receiver = new ChunkedTransport({ frameBytes: 8, send() {}, deliver(frame) { delivered = frame.byteLength; } });
|
||||
receiver.enabled = true;
|
||||
global.gc();
|
||||
const before = process.memoryUsage().heapUsed;
|
||||
receiver.receive(new Uint8Array([0x31, 1, 1, 1, 65]));
|
||||
for (let index = 0; index < 300000; index++) receiver.receive(new Uint8Array([0x31, 1, 1, 0, 65]));
|
||||
global.gc();
|
||||
const growth = process.memoryUsage().heapUsed - before;
|
||||
receiver.receive(new Uint8Array([0x31, 1, 1, 2, 66]));
|
||||
console.log(JSON.stringify({ growth, delivered }));
|
||||
`,
|
||||
],
|
||||
{ encoding: "utf8", timeout: 15000 },
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const observed = JSON.parse(result.stdout);
|
||||
expect(observed.delivered).toBe(300002);
|
||||
expect(observed.growth).toBeLessThan(8 * 1024 * 1024);
|
||||
}, 20000);
|
||||
|
||||
it.each(["ASCII", "中", "🌍", "\ud800", "\udc00", "\ud800x"])(
|
||||
"accounts for queued UTF-8 text %j exactly",
|
||||
async (text) => {
|
||||
let drain!: () => void;
|
||||
let blocked = true;
|
||||
const transport = new ChunkedTransport({
|
||||
frameBytes: 1024,
|
||||
send: () => {
|
||||
if (blocked) {
|
||||
blocked = false;
|
||||
return new Promise<void>((resolve) => {
|
||||
drain = resolve;
|
||||
});
|
||||
}
|
||||
},
|
||||
deliver: () => {},
|
||||
});
|
||||
const first = transport.send(new Uint8Array([1]));
|
||||
const second = transport.send(text);
|
||||
expect(transport.queuedByteLength).toBe(new TextEncoder().encode(text).byteLength);
|
||||
drain();
|
||||
await Promise.all([first, second]);
|
||||
},
|
||||
);
|
||||
|
||||
it("notifies the owner once when an accepted writer fails", async () => {
|
||||
const failures: unknown[] = [];
|
||||
const error = new Error("write failed");
|
||||
const transport = new ChunkedTransport({
|
||||
frameBytes: 1024,
|
||||
deliver: () => {},
|
||||
onFailure: (failure) => {
|
||||
failures.push(failure);
|
||||
},
|
||||
send: () => Promise.reject(error),
|
||||
});
|
||||
await expect(transport.send("hello", { waitForDrain: true })).rejects.toThrow("write failed");
|
||||
expect(failures).toEqual([error]);
|
||||
expect(() => transport.send("again")).toThrow("Transport closed");
|
||||
expect(failures).toEqual([error]);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"a pre-write rejection preserves the sender (chunked=%s)",
|
||||
async (chunked) => {
|
||||
const failures: unknown[] = [];
|
||||
let reject = true;
|
||||
const transport = new ChunkedTransport({
|
||||
frameBytes: 8,
|
||||
deliver: () => {},
|
||||
onFailure: (error) => {
|
||||
failures.push(error);
|
||||
},
|
||||
send: () => {
|
||||
if (reject) throw new Error("capacity");
|
||||
},
|
||||
});
|
||||
transport.enabled = chunked;
|
||||
expect(() => transport.send("abcdefghij")).toThrow("capacity");
|
||||
expect(failures).toEqual([]);
|
||||
reject = false;
|
||||
await transport.send("sibling");
|
||||
},
|
||||
);
|
||||
|
||||
it("a queued pre-write rejection does not poison the next logical send", async () => {
|
||||
let drain!: () => void;
|
||||
const failures: unknown[] = [];
|
||||
const delivered: string[] = [];
|
||||
const transport = new ChunkedTransport({
|
||||
frameBytes: 1024,
|
||||
deliver: () => {},
|
||||
onFailure: (error) => {
|
||||
failures.push(error);
|
||||
},
|
||||
send: (frame) => {
|
||||
if (frame === "blocked")
|
||||
return new Promise<void>((resolve) => {
|
||||
drain = resolve;
|
||||
});
|
||||
if (frame === "reject") throw new Error("capacity");
|
||||
delivered.push(frame as string);
|
||||
},
|
||||
});
|
||||
const first = transport.send("blocked");
|
||||
const rejected = transport.send("reject");
|
||||
const sibling = transport.send("sibling");
|
||||
const results = Promise.allSettled([first, rejected, sibling]);
|
||||
drain();
|
||||
expect((await results).map((result) => result.status)).toEqual([
|
||||
"fulfilled",
|
||||
"rejected",
|
||||
"fulfilled",
|
||||
]);
|
||||
expect(delivered).toEqual(["sibling"]);
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([false, true])("delivery errors leave framing usable (chunked=%s)", async (chunked) => {
|
||||
let fail = true;
|
||||
const delivered: (string | Uint8Array)[] = [];
|
||||
const transport = new ChunkedTransport({
|
||||
frameBytes: 64,
|
||||
send: () => {},
|
||||
deliver: (frame) => {
|
||||
if (fail) throw new Error("listener failed");
|
||||
delivered.push(frame);
|
||||
},
|
||||
});
|
||||
transport.enabled = chunked;
|
||||
const frame = chunked ? new Uint8Array([0x31, 1, 0, 3, 65]) : "A";
|
||||
expect(() => transport.receive(frame)).toThrow("listener failed");
|
||||
fail = false;
|
||||
transport.receive(frame);
|
||||
expect(delivered).toEqual(["A"]);
|
||||
await transport.send("still connected");
|
||||
});
|
||||
|
||||
it("unpaced transports retain ordinary outbound admission and accept inbound chunks", async () => {
|
||||
const frames: unknown[] = [];
|
||||
const delivered: unknown[] = [];
|
||||
const transport = new ChunkedTransport({
|
||||
frameBytes: 8,
|
||||
supportsDrain: false,
|
||||
send: (frame) => {
|
||||
frames.push(frame);
|
||||
if (typeof frame === "string" && frame.length > 8) throw new Error("single frame limit");
|
||||
},
|
||||
deliver: (frame) => {
|
||||
delivered.push(frame);
|
||||
},
|
||||
});
|
||||
transport.enabled = true;
|
||||
expect(() => transport.send("too large for one frame")).toThrow("single frame limit");
|
||||
expect(frames).toEqual(["too large for one frame"]);
|
||||
transport.receive(new Uint8Array([0x31, 1, 0, 3, 65]));
|
||||
expect(delivered).toEqual(["A"]);
|
||||
await transport.send("small");
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { MAX_WEBSOCKET_MESSAGE_BYTES } from "./websocket-message-limit.js";
|
||||
|
||||
type Frame = string | Uint8Array;
|
||||
|
||||
/** A malformed wire frame, distinct from a consumer delivery failure. */
|
||||
export class ChunkedTransportProtocolError extends Error {}
|
||||
// Reserved outside the application binary opcode space. Version, kind, flags follow.
|
||||
const OPCODE = 0x31;
|
||||
const HEADER = 4;
|
||||
|
||||
/** Connection-scoped ordered framing, below serialization and routing, above encryption. */
|
||||
export class ChunkedTransport {
|
||||
enabled = false;
|
||||
private assembly: Uint8Array | undefined;
|
||||
private size = 0;
|
||||
private kind = 0;
|
||||
private closed = false;
|
||||
private pending: Promise<void> | undefined;
|
||||
private queuedBytes = 0;
|
||||
private activeBytes = 0;
|
||||
|
||||
constructor(
|
||||
private readonly io: {
|
||||
frameBytes: number;
|
||||
supportsDrain?: boolean;
|
||||
send: (frame: Frame, waitForDrain: boolean) => void | Promise<void>;
|
||||
deliver: (frame: Frame) => void;
|
||||
onFailure?: (error: unknown) => void;
|
||||
},
|
||||
) {
|
||||
if (io.frameBytes <= HEADER) throw new Error("Invalid transport frame budget");
|
||||
}
|
||||
|
||||
/** Bytes waiting behind the active write; physical buffering owns the active frame. */
|
||||
get queuedByteLength(): number {
|
||||
return this.queuedBytes - this.activeBytes;
|
||||
}
|
||||
get isWriting(): boolean {
|
||||
return this.pending !== undefined;
|
||||
}
|
||||
|
||||
send(frame: Frame, options: { waitForDrain?: boolean; byteLength?: number } = {}): Promise<void> {
|
||||
const byteLength = options.byteLength ?? frameByteLength(frame);
|
||||
if (byteLength > MAX_WEBSOCKET_MESSAGE_BYTES) throw new Error("Logical message too large");
|
||||
const chunk =
|
||||
this.enabled && this.io.supportsDrain !== false && byteLength > this.io.frameBytes;
|
||||
if (this.closed) throw new Error("Transport closed");
|
||||
const write = () => {
|
||||
if (this.closed) throw new Error("Transport closed");
|
||||
this.activeBytes = byteLength;
|
||||
// A synchronous rejection did not admit a frame. Only accepted writes can
|
||||
// fail the connection; queued admission failures still belong to their sender.
|
||||
const result = chunk
|
||||
? this.sendChunks(
|
||||
typeof frame === "string" ? new TextEncoder().encode(frame) : frame,
|
||||
typeof frame === "string",
|
||||
)
|
||||
: this.io.send(frame, options.waitForDrain === true);
|
||||
if (!result) return;
|
||||
return result.catch((error) => {
|
||||
this.fail(error);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
if (this.queuedBytes + byteLength > MAX_WEBSOCKET_MESSAGE_BYTES) {
|
||||
const error = new Error("Logical output queue exceeded its byte limit");
|
||||
this.fail(error);
|
||||
const rejected = Promise.reject<void>(error);
|
||||
void rejected.catch(() => {});
|
||||
return rejected;
|
||||
}
|
||||
this.queuedBytes += byteLength;
|
||||
let result: void | Promise<void>;
|
||||
try {
|
||||
result = this.pending ? this.pending.then(write) : write();
|
||||
} catch (error) {
|
||||
this.queuedBytes -= byteLength;
|
||||
this.activeBytes = 0;
|
||||
throw error;
|
||||
}
|
||||
if (!result) {
|
||||
this.queuedBytes -= byteLength;
|
||||
this.activeBytes = 0;
|
||||
return Promise.resolve();
|
||||
}
|
||||
const completion = Promise.resolve(result).finally(() => {
|
||||
this.queuedBytes -= byteLength;
|
||||
this.activeBytes = 0;
|
||||
});
|
||||
const tail = completion.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.pending = tail;
|
||||
void tail.then(() => {
|
||||
if (this.pending === tail) this.pending = undefined;
|
||||
return undefined;
|
||||
});
|
||||
return completion;
|
||||
}
|
||||
|
||||
private sendChunks(bytes: Uint8Array, text: boolean): Promise<void> {
|
||||
const capacity = this.io.frameBytes - HEADER;
|
||||
const writeChunk = (offset: number) => {
|
||||
if (this.closed) throw new Error("Transport closed");
|
||||
const end = Math.min(bytes.byteLength, offset + capacity);
|
||||
const wire = new Uint8Array(HEADER + end - offset);
|
||||
wire.set([
|
||||
OPCODE,
|
||||
1,
|
||||
text ? 0 : 1,
|
||||
(offset === 0 ? 1 : 0) | (end === bytes.byteLength ? 2 : 0),
|
||||
]);
|
||||
wire.set(bytes.subarray(offset, end), HEADER);
|
||||
return this.io.send(wire, true);
|
||||
};
|
||||
const first = writeChunk(0);
|
||||
return (async () => {
|
||||
await first;
|
||||
for (let offset = capacity; offset < bytes.byteLength; offset += capacity)
|
||||
await writeChunk(offset);
|
||||
})();
|
||||
}
|
||||
|
||||
receive(frame: Frame): void {
|
||||
if (this.closed) return;
|
||||
let message: Frame | undefined;
|
||||
try {
|
||||
message = this.assemble(frame);
|
||||
} catch (error) {
|
||||
this.close();
|
||||
throw new ChunkedTransportProtocolError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
if (message !== undefined) this.io.deliver(message);
|
||||
}
|
||||
|
||||
private assemble(frame: Frame): Frame | undefined {
|
||||
if (typeof frame === "string" || frame[0] !== OPCODE) {
|
||||
if (this.assembly) throw new Error("Interrupted chunked message");
|
||||
return frame;
|
||||
}
|
||||
if (
|
||||
!this.enabled ||
|
||||
frame.length <= HEADER ||
|
||||
frame[1] !== 1 ||
|
||||
frame[2]! > 1 ||
|
||||
(frame[3]! & ~3) !== 0
|
||||
)
|
||||
throw new Error("Invalid chunk header");
|
||||
const start = (frame[3]! & 1) !== 0;
|
||||
const end = (frame[3]! & 2) !== 0;
|
||||
if (start === !!this.assembly) throw new Error("Invalid chunk sequence");
|
||||
if (start) {
|
||||
this.assembly = new Uint8Array(0);
|
||||
this.kind = frame[2]!;
|
||||
this.size = 0;
|
||||
}
|
||||
if (frame[2] !== this.kind) throw new Error("Chunk kind changed");
|
||||
this.size += frame.length - HEADER;
|
||||
if (this.size > MAX_WEBSOCKET_MESSAGE_BYTES) throw new Error("Logical message too large");
|
||||
const payload = frame.subarray(HEADER);
|
||||
if (this.assembly!.byteLength < this.size) {
|
||||
const capacity = Math.min(
|
||||
MAX_WEBSOCKET_MESSAGE_BYTES,
|
||||
Math.max(this.size, this.assembly!.byteLength * 2, 16384),
|
||||
);
|
||||
const grown = new Uint8Array(capacity);
|
||||
grown.set(this.assembly!.subarray(0, this.size - payload.byteLength));
|
||||
this.assembly = grown;
|
||||
}
|
||||
this.assembly!.set(payload, this.size - payload.byteLength);
|
||||
if (!end) return;
|
||||
const data = this.assembly!.subarray(0, this.size);
|
||||
this.assembly = undefined;
|
||||
this.size = 0;
|
||||
return this.kind === 0 ? new TextDecoder("utf-8", { fatal: true }).decode(data) : data.slice();
|
||||
}
|
||||
|
||||
private fail(error: unknown): void {
|
||||
if (this.closed) return;
|
||||
this.close();
|
||||
this.io.onFailure?.(error);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.enabled = false;
|
||||
this.assembly = undefined;
|
||||
this.size = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function frameByteLength(frame: Frame): number {
|
||||
if (typeof frame !== "string") return frame.byteLength;
|
||||
let bytes = 0;
|
||||
for (let index = 0; index < frame.length; index++) {
|
||||
const code = frame.charCodeAt(index);
|
||||
if (code <= 0x7f) bytes++;
|
||||
else if (code <= 0x7ff) bytes += 2;
|
||||
else if (
|
||||
code >= 0xd800 &&
|
||||
code <= 0xdbff &&
|
||||
frame.charCodeAt(index + 1) >= 0xdc00 &&
|
||||
frame.charCodeAt(index + 1) <= 0xdfff
|
||||
) {
|
||||
bytes += 4;
|
||||
index++;
|
||||
} else bytes += 3;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export const CLIENT_CAPS = {
|
||||
binaryChunkingV1: "binary_chunking_v1",
|
||||
helloRejection: "hello_rejection",
|
||||
// COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy ownership after 2027-03-09.
|
||||
ownedSubscriptions: "owned_subscriptions",
|
||||
|
||||
@@ -229,6 +229,8 @@ 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: [] }),
|
||||
@@ -256,6 +258,7 @@ 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())
|
||||
@@ -638,7 +641,9 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail, unknown> = z.discri
|
||||
subAgentType: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
childSessionId: z.string().optional(),
|
||||
log: z.string(),
|
||||
// COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. A sub-agent card is a
|
||||
// launch marker; apps before v0.14.1 required `log`, so daemons still send it empty.
|
||||
log: z.string().optional(),
|
||||
// Compat cruft for clients <= 0.1.65-beta.3 that required this field. Producers still
|
||||
// emit `[]`; nothing reads it. Drop the field (and the `[]` emissions) once those
|
||||
// clients are no longer in the field.
|
||||
@@ -3673,6 +3678,8 @@ 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(),
|
||||
@@ -3827,6 +3834,7 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
selectiveAgentTimeline: z.boolean().optional(),
|
||||
explicitEventSubscriptions: z.boolean().optional(),
|
||||
ownedSubscriptions: z.boolean().optional(),
|
||||
binaryChunkingV1: z.boolean().optional(),
|
||||
workspaceSetupObservation: z.boolean().optional(),
|
||||
workspaceSortTimes: z.boolean().optional(),
|
||||
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30.
|
||||
|
||||
@@ -200,8 +200,8 @@ describe("wire schema compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
|
||||
const parsed = LegacySubAgentToolCallSchema.parse({
|
||||
test("a sub-agent launch marker still parses against the v0.1.65-beta.3 schema", () => {
|
||||
const card = {
|
||||
type: "tool_call",
|
||||
callId: "call-sub-agent-1",
|
||||
name: "Task",
|
||||
@@ -212,12 +212,26 @@ describe("wire schema compatibility", () => {
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect repository structure",
|
||||
childSessionId: "child-session-1",
|
||||
log: "[Read] README.md",
|
||||
log: "",
|
||||
actions: [],
|
||||
},
|
||||
});
|
||||
} as const;
|
||||
|
||||
expect(parsed.detail.actions).toEqual([]);
|
||||
const parsed = LegacySubAgentToolCallSchema.parse(JSON.parse(JSON.stringify(card)));
|
||||
expect(parsed.detail).toMatchObject({ log: "", actions: [] });
|
||||
const { log: _log, ...withoutLog } = card.detail;
|
||||
expect(LegacySubAgentToolCallSchema.safeParse({ ...card, detail: withoutLog }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(AgentTimelineItemPayloadSchema.parse({ ...card, detail: withoutLog })).toMatchObject({
|
||||
detail: { type: "sub_agent", description: "Inspect repository structure" },
|
||||
});
|
||||
expect(
|
||||
AgentTimelineItemPayloadSchema.parse({
|
||||
...card,
|
||||
detail: { ...card.detail, log: "[Read] README.md" },
|
||||
}),
|
||||
).toMatchObject({ detail: { log: "[Read] README.md" } });
|
||||
});
|
||||
|
||||
test("old clients parse agent snapshots with rewind capabilities", () => {
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/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: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,6 +124,7 @@
|
||||
"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",
|
||||
@@ -141,6 +142,7 @@
|
||||
"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",
|
||||
|
||||
@@ -247,7 +247,7 @@ second line'`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps nested sub-agent logs beside the sub-agent that produced them", () => {
|
||||
it("lists each sub-agent as its launch marker", () => {
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
{ type: "assistant_message", text: "Before first child." },
|
||||
toolCallItem({
|
||||
@@ -257,7 +257,7 @@ second line'`,
|
||||
type: "sub_agent",
|
||||
subAgentType: "Child one",
|
||||
description: "First investigation",
|
||||
log: "[Assistant] First child result.",
|
||||
log: "",
|
||||
},
|
||||
}),
|
||||
{ type: "assistant_message", text: "Between children." },
|
||||
@@ -268,7 +268,7 @@ second line'`,
|
||||
type: "sub_agent",
|
||||
subAgentType: "Child two",
|
||||
description: "Second investigation",
|
||||
log: "[Assistant] Second child result.",
|
||||
log: "",
|
||||
},
|
||||
}),
|
||||
{ type: "assistant_message", text: "After second child." },
|
||||
@@ -279,10 +279,8 @@ second line'`,
|
||||
expect(result.split("\n")).toEqual([
|
||||
"[Assistant] Before first child.",
|
||||
"[Child one] First investigation",
|
||||
"[Assistant] First child result.",
|
||||
"[Assistant] Between children.",
|
||||
"[Child two] Second investigation",
|
||||
"[Assistant] Second child result.",
|
||||
"[Assistant] After second child.",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -164,9 +164,6 @@ function curateProjectedActivityEntries(
|
||||
break;
|
||||
}
|
||||
entries.push(formatToolCallEntry(item, options));
|
||||
if (item.detail.type === "sub_agent" && item.detail.log.trim()) {
|
||||
entries.push(activityEntry(item.detail.log.trim()));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "todo":
|
||||
|
||||
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager, type AgentManagerEvent } from "./agent-manager.js";
|
||||
import { AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS } from "./agent-stream-coalescer.js";
|
||||
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
||||
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import type {
|
||||
@@ -31,7 +32,6 @@ import type {
|
||||
|
||||
const COALESCE_WINDOW_MS = AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS;
|
||||
const BEFORE_COALESCE_WINDOW_MS = Math.max(COALESCE_WINDOW_MS - 1, 0);
|
||||
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
|
||||
|
||||
const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: false,
|
||||
@@ -400,10 +400,7 @@ describe("target coalesced behavior", () => {
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
});
|
||||
const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output }));
|
||||
|
||||
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
|
||||
await waitForSessionEventQueue();
|
||||
@@ -425,10 +422,7 @@ describe("target coalesced behavior", () => {
|
||||
try {
|
||||
const { agentId } = await createManagedSession(harness);
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
});
|
||||
const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output }));
|
||||
|
||||
await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output }));
|
||||
|
||||
@@ -449,10 +443,7 @@ describe("target coalesced behavior", () => {
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
});
|
||||
const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output }));
|
||||
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
|
||||
|
||||
await harness.manager.hydrateTimelineFromProvider(agentId, { force: true });
|
||||
@@ -470,10 +461,7 @@ describe("target coalesced behavior", () => {
|
||||
try {
|
||||
const { agentId } = await createManagedSession(harness);
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
});
|
||||
const expectedItem = limitAgentTimelineItemContent(toolCall({ status: "completed", output }));
|
||||
|
||||
await harness.manager.emitLiveTimelineItem(
|
||||
agentId,
|
||||
@@ -495,10 +483,9 @@ describe("target coalesced behavior", () => {
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
const content = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "failed",
|
||||
error: { content: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH) },
|
||||
});
|
||||
const expectedItem = limitAgentTimelineItemContent(
|
||||
toolCall({ status: "failed", error: { content } }),
|
||||
);
|
||||
|
||||
session.pushEvent(timelineEvent(toolCall({ status: "failed", error: { content } })));
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
@@ -4592,7 +4593,7 @@ test("importProviderSession imports the selected session without listing and pub
|
||||
expect(manager.getTimeline(imported.id)).toEqual([
|
||||
{ type: "user_message", text: "Trace provider imports" },
|
||||
{ type: "assistant_message", text: "Done" },
|
||||
{
|
||||
limitAgentTimelineItemContent({
|
||||
type: "tool_call",
|
||||
callId: "large-shell-result",
|
||||
name: "shell",
|
||||
@@ -4601,10 +4602,10 @@ test("importProviderSession imports the selected session without listing and pub
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "print output",
|
||||
output: "x".repeat(64 * 1024),
|
||||
output: "x".repeat(1024 * 1024),
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(manager.listProviderSubagents(imported.id)).toEqual([
|
||||
expect.objectContaining({ id: "thread-child", title: "Imported child", status: "completed" }),
|
||||
@@ -11957,10 +11958,7 @@ test("commits startup notices once on create and after restored history", async
|
||||
exitCode: 0,
|
||||
},
|
||||
};
|
||||
const limitedToolOutput: AgentTimelineItem = {
|
||||
...toolOutput,
|
||||
detail: { type: "shell", command: "print output", output: "x".repeat(64 * 1024), exitCode: 0 },
|
||||
};
|
||||
const limitedToolOutput = limitAgentTimelineItemContent(toolOutput);
|
||||
class NotifyingSession extends TestAgentSession {
|
||||
readonly initialTimeline = [{ item: notice, timestamp: "2026-09-22T00:00:00.000Z" }];
|
||||
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
|
||||
@@ -313,7 +313,10 @@ export type ToolCallDetail =
|
||||
subAgentType?: string;
|
||||
description?: string;
|
||||
childSessionId?: string;
|
||||
log: string;
|
||||
// COMPAT(subagent-log): added in v0.14.1, remove after 2027-04-30. Apps before v0.14.1
|
||||
// require `log`; every producer sends it empty and carries the same tag. Optional here because
|
||||
// protocol timeline items, where `log` is optional, flow into this type.
|
||||
log?: string;
|
||||
actions?: Array<{
|
||||
index: number;
|
||||
toolName: string;
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { AgentTimelineItemPayloadSchema } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
||||
|
||||
function serializedBytes(item: AgentTimelineItem): number {
|
||||
return Buffer.byteLength(JSON.stringify(item), "utf8");
|
||||
}
|
||||
|
||||
describe("agent timeline content", () => {
|
||||
test("returns an item within budget unchanged", () => {
|
||||
const item: AgentTimelineItem = {
|
||||
type: "tool_call",
|
||||
callId: "small",
|
||||
name: "Bash",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "shell", command: "ls", output: "x".repeat(60 * 1024) },
|
||||
};
|
||||
expect(limitAgentTimelineItemContent(item)).toBe(item);
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"preserves the exact UTF-16 prefix for oversized shell output (failed=%s)",
|
||||
"cuts oversized shell output and keeps short fields whole (failed=%s)",
|
||||
(failed) => {
|
||||
const text = "x".repeat(64 * 1024 - 1) + "🙂";
|
||||
const text = "x".repeat(1024 * 1024) + "🙂";
|
||||
const item = limitAgentTimelineItemContent({
|
||||
type: "tool_call",
|
||||
callId: "large-shell",
|
||||
@@ -15,45 +33,116 @@ describe("agent timeline content", () => {
|
||||
detail: { type: "shell", command: "echo large", output: text },
|
||||
error: failed ? { content: text } : null,
|
||||
});
|
||||
expect(item).toEqual({
|
||||
type: "tool_call",
|
||||
expect(item).toMatchObject({
|
||||
callId: "large-shell",
|
||||
name: "Bash",
|
||||
status: failed ? "failed" : "completed",
|
||||
detail: { type: "shell", command: "echo large", output: text.slice(0, 64 * 1024) },
|
||||
error: failed ? { content: text.slice(0, 64 * 1024) } : null,
|
||||
detail: { type: "shell", command: "echo large" },
|
||||
});
|
||||
if (item.type !== "tool_call" || item.detail.type !== "shell") throw new Error("shape");
|
||||
expect(item.detail.output).toMatch(/^x+…\[truncated \d{3}\.\d KB\]$/);
|
||||
expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024);
|
||||
},
|
||||
);
|
||||
|
||||
test("marks truncated plain-text tool content within the content budget", () => {
|
||||
const oversizedInput = "x".repeat(64 * 1024 + 1);
|
||||
|
||||
test("replaces an oversized data URL in an unknown tool result with a marker", () => {
|
||||
const url = `data:image/jpeg;base64,${"A".repeat(14 * 1024 * 1024)}`;
|
||||
const item = limitAgentTimelineItemContent({
|
||||
type: "tool_call",
|
||||
callId: "terminal-session-4242",
|
||||
name: "terminal",
|
||||
callId: "screenshot",
|
||||
name: "node_repl.js",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
text: oversizedInput,
|
||||
icon: "square_terminal",
|
||||
type: "unknown",
|
||||
input: { index: 1 },
|
||||
output: { content: [{ type: "image", url }], note: "captured" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(limitAgentTimelineItemContent(item)).toEqual(item);
|
||||
expect(item).toEqual({
|
||||
type: "tool_call",
|
||||
callId: "terminal-session-4242",
|
||||
name: "terminal",
|
||||
callId: "screenshot",
|
||||
name: "node_repl.js",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
text: "x".repeat(64 * 1024 - "\n\n[Content truncated]".length) + "\n\n[Content truncated]",
|
||||
icon: "square_terminal",
|
||||
type: "unknown",
|
||||
input: { index: 1 },
|
||||
output: { content: [{ type: "image", url: "…[truncated 14.0 MB]" }], note: "captured" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("gives non-tool items a 256 KiB budget", () => {
|
||||
const text = "a".repeat(10 * 1024 * 1024);
|
||||
const item = limitAgentTimelineItemContent({ type: "assistant_message", text });
|
||||
if (item.type !== "assistant_message") throw new Error("shape");
|
||||
expect(item.text).toMatch(/^a+…\[truncated 9\.\d MB\]$/);
|
||||
expect(item.text.length).toBeGreaterThan(200 * 1024);
|
||||
expect(serializedBytes(item)).toBeLessThanOrEqual(256 * 1024);
|
||||
});
|
||||
|
||||
test("bounds an item whose structure alone exceeds the budget", () => {
|
||||
const item = limitAgentTimelineItemContent({
|
||||
type: "tool_call",
|
||||
callId: "many",
|
||||
name: "query",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: Array.from({ length: 1_000_000 }, () => 1) },
|
||||
});
|
||||
if (item.type !== "tool_call" || item.detail.type !== "unknown") throw new Error("shape");
|
||||
const output = item.detail.output as unknown[];
|
||||
expect(output.length).toBeLessThan(1_000_000);
|
||||
expect(output.every((value) => value === 1)).toBe(true);
|
||||
expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024);
|
||||
});
|
||||
|
||||
test("keeps short strings whole and limits idempotently when an item has many strings", () => {
|
||||
const callId = "3f2b8c1e-9a4d-4e6f-8b2a-1c3d5e7f9a0b";
|
||||
const item = limitAgentTimelineItemContent({
|
||||
type: "tool_call",
|
||||
callId,
|
||||
name: "mcp__search__files",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "unknown",
|
||||
input: { query: "useWorkspaceSomethingVeryDescriptive" },
|
||||
output: Array.from(
|
||||
{ length: 10_000 },
|
||||
(_, index) => `${String(index).padStart(5, "0")}${"p".repeat(28)}`,
|
||||
),
|
||||
},
|
||||
});
|
||||
if (item.type !== "tool_call" || item.detail.type !== "unknown") throw new Error("shape");
|
||||
expect(item.callId).toBe(callId);
|
||||
expect(item.detail.input).toEqual({ query: "useWorkspaceSomethingVeryDescriptive" });
|
||||
const output = item.detail.output as string[];
|
||||
expect(output.length).toBeLessThan(10_000);
|
||||
expect(output.every((path) => path.length === 33)).toBe(true);
|
||||
expect(serializedBytes(item)).toBeLessThanOrEqual(64 * 1024);
|
||||
expect(limitAgentTimelineItemContent(item)).toBe(item);
|
||||
});
|
||||
|
||||
test("keeps a long typed array valid against the wire schema", () => {
|
||||
const item = limitAgentTimelineItemContent({
|
||||
type: "todo",
|
||||
items: Array.from({ length: 20_000 }, (_, index) => ({
|
||||
text: `Task ${index}`,
|
||||
completed: false,
|
||||
})),
|
||||
});
|
||||
if (item.type !== "todo") throw new Error("shape");
|
||||
expect(item.items.length).toBeLessThan(20_000);
|
||||
expect(AgentTimelineItemPayloadSchema.safeParse(item).success).toBe(true);
|
||||
expect(serializedBytes(item)).toBeLessThanOrEqual(256 * 1024);
|
||||
});
|
||||
|
||||
test("is stable on an already-limited item", () => {
|
||||
const item = limitAgentTimelineItemContent({
|
||||
type: "reasoning",
|
||||
text: "r".repeat(1024 * 1024),
|
||||
});
|
||||
expect(limitAgentTimelineItemContent(item)).toBe(item);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
|
||||
export const PLUGIN_TIMELINE_DATA_MAX_BYTES = 64 * 1024;
|
||||
|
||||
// One timeline fetch sends its entries in one WebSocket message, so every item must stay small.
|
||||
// Budgets count UTF-16 units plus a fixed cost per node; neither is exact JSON bytes. They bound
|
||||
// long strings and long arrays, not wide objects (every entry is kept) or multi-byte text.
|
||||
const TOOL_CALL_BUDGET = 64 * 1024;
|
||||
const ITEM_BUDGET = 256 * 1024;
|
||||
const NODE_COST = 8;
|
||||
const MARKER_COST = 32;
|
||||
// Ids, names, paths and queries stay whole, so an item keeps its identity across passes.
|
||||
const SHORT_STRING = 1024;
|
||||
const MAX_DEPTH = 64;
|
||||
|
||||
export function assertPluginTimelineDataSize(data: JsonValue): void {
|
||||
const serializedBytes = Buffer.byteLength(JSON.stringify(data), "utf8");
|
||||
if (serializedBytes > PLUGIN_TIMELINE_DATA_MAX_BYTES) {
|
||||
@@ -17,66 +27,119 @@ function retainTextPrefix(text: string, length: number): string {
|
||||
return Buffer.from(text.slice(0, length), "utf16le").toString("utf16le");
|
||||
}
|
||||
|
||||
function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
|
||||
if (
|
||||
item.type !== "tool_call" ||
|
||||
item.detail.type !== "shell" ||
|
||||
item.status !== "failed" ||
|
||||
typeof item.error !== "object" ||
|
||||
item.error === null ||
|
||||
!("content" in item.error) ||
|
||||
typeof item.error.content !== "string" ||
|
||||
item.error.content.length <= TOOL_CALL_CONTENT_MAX_LENGTH
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
error: {
|
||||
...item.error,
|
||||
content: retainTextPrefix(item.error.content, TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
},
|
||||
};
|
||||
function formatSize(units: number): string {
|
||||
if (units < 1024) return `${units} B`;
|
||||
if (units < 1024 * 1024) return `${(units / 1024).toFixed(1)} KB`;
|
||||
return `${(units / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function limitPlainText(item: AgentTimelineItem): AgentTimelineItem {
|
||||
if (
|
||||
item.type !== "tool_call" ||
|
||||
item.detail.type !== "plain_text" ||
|
||||
typeof item.detail.text !== "string" ||
|
||||
item.detail.text.length <= TOOL_CALL_CONTENT_MAX_LENGTH
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
const notice = "\n\n[Content truncated]";
|
||||
return {
|
||||
...item,
|
||||
detail: {
|
||||
...item.detail,
|
||||
text:
|
||||
retainTextPrefix(item.detail.text, TOOL_CALL_CONTENT_MAX_LENGTH - notice.length) + notice,
|
||||
},
|
||||
};
|
||||
function truncatedMarker(units: number): string {
|
||||
return `…[truncated ${formatSize(units)}]`;
|
||||
}
|
||||
|
||||
interface ItemSize {
|
||||
nodeCost: number;
|
||||
stringLengths: number[];
|
||||
tooDeep: boolean;
|
||||
}
|
||||
|
||||
function measure(value: unknown, depth: number, size: ItemSize): void {
|
||||
size.nodeCost += NODE_COST;
|
||||
if (depth > MAX_DEPTH) {
|
||||
size.tooDeep = true;
|
||||
} else if (typeof value === "string") {
|
||||
size.stringLengths.push(value.length);
|
||||
} else if (Array.isArray(value)) {
|
||||
for (const element of value) measure(element, depth + 1, size);
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
size.nodeCost += key.length;
|
||||
measure(child, depth + 1, size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The largest per-string length that keeps all strings, once cut, within `available`. */
|
||||
function findStringCap(lengths: readonly number[], available: number): number {
|
||||
let low = 0;
|
||||
let high = 0;
|
||||
for (const length of lengths) high = Math.max(high, length);
|
||||
while (low < high) {
|
||||
const cap = Math.ceil((low + high) / 2);
|
||||
let total = 0;
|
||||
for (const length of lengths) total += Math.min(length, cap);
|
||||
if (total <= available) low = cap;
|
||||
else high = cap - 1;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
interface CutState {
|
||||
stringCap: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
function cutString(text: string, cap: number): string {
|
||||
if (text.length <= cap) return text;
|
||||
// A partial data URL is useless to every reader, so drop it whole.
|
||||
if (text.startsWith("data:")) return truncatedMarker(text.length);
|
||||
return retainTextPrefix(text, cap) + truncatedMarker(text.length - cap);
|
||||
}
|
||||
|
||||
function cut(value: unknown, depth: number, state: CutState): unknown {
|
||||
state.remaining -= NODE_COST;
|
||||
if (depth > MAX_DEPTH) return "…[truncated]";
|
||||
if (typeof value === "string") {
|
||||
const text = cutString(value, state.stringCap);
|
||||
state.remaining -= text.length;
|
||||
return text;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
let changed = false;
|
||||
const output: unknown[] = [];
|
||||
for (const element of value) {
|
||||
const remainingBefore = state.remaining;
|
||||
const next = cut(element, depth + 1, state);
|
||||
if (state.remaining < 0) {
|
||||
// Drop this and later elements. A marker element would break typed arrays.
|
||||
state.remaining = remainingBefore;
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
changed ||= next !== element;
|
||||
output.push(next);
|
||||
}
|
||||
return changed ? output : value;
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
let changed = false;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
state.remaining -= key.length;
|
||||
const next = cut(child, depth + 1, state);
|
||||
changed ||= next !== child;
|
||||
output[key] = next;
|
||||
}
|
||||
return changed ? output : value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Returns the item itself when it fits its budget; otherwise a copy with its largest strings cut. */
|
||||
export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTimelineItem {
|
||||
item = limitFailedShellError(item);
|
||||
item = limitPlainText(item);
|
||||
if (
|
||||
item.type !== "tool_call" ||
|
||||
item.detail.type !== "shell" ||
|
||||
typeof item.detail.output !== "string"
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
if (item.detail.output.length <= TOOL_CALL_CONTENT_MAX_LENGTH) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
detail: {
|
||||
...item.detail,
|
||||
output: retainTextPrefix(item.detail.output, TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
},
|
||||
};
|
||||
const budget = item.type === "tool_call" ? TOOL_CALL_BUDGET : ITEM_BUDGET;
|
||||
const size: ItemSize = { nodeCost: 0, stringLengths: [], tooDeep: false };
|
||||
measure(item, 0, size);
|
||||
let stringTotal = 0;
|
||||
for (const length of size.stringLengths) stringTotal += length;
|
||||
if (!size.tooDeep && size.nodeCost + stringTotal <= budget) return item;
|
||||
|
||||
// Strings get what structure leaves, but at least half the budget, less a margin for JSON
|
||||
// escaping. Arrays drop the elements that do not fit, which bounds items whose short strings
|
||||
// or structure alone are too big.
|
||||
const markerCost =
|
||||
MARKER_COST * size.stringLengths.filter((length) => length > SHORT_STRING).length;
|
||||
const available = (budget * 15) / 16 - Math.min(size.nodeCost, budget / 2) - markerCost;
|
||||
const stringCap = Math.max(SHORT_STRING, findStringCap(size.stringLengths, available));
|
||||
return cut(item, 0, { stringCap, remaining: budget }) as AgentTimelineItem;
|
||||
}
|
||||
|
||||
@@ -181,6 +181,20 @@ describe("projected timeline retention", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("serves a merged assistant message within the item budget", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("agent", { epoch: "e" });
|
||||
const chunk = "a".repeat(200 * 1024);
|
||||
for (let index = 0; index < 10; index++) {
|
||||
store.append("agent", { type: "assistant_message", messageId: "m", text: chunk });
|
||||
}
|
||||
const [row] = store.fetch("agent", { limit: 1 }).rows;
|
||||
expect(row).toMatchObject({ seqStart: 1, seqEnd: 10 });
|
||||
expect(row.item).toMatchObject({ type: "assistant_message", messageId: "m" });
|
||||
expect(Buffer.byteLength(JSON.stringify(row.item))).toBeLessThanOrEqual(256 * 1024);
|
||||
expect(store.getLastAssistantMessage("agent")).toHaveLength(10 * chunk.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projected sequence ownership", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { TimelineProjection, type ProjectedTimelineRow } from "./timeline-projection.js";
|
||||
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentTimelineFetchOptions,
|
||||
@@ -120,7 +121,12 @@ export class InMemoryAgentTimelineStore {
|
||||
hasNewer: page.hasNewer,
|
||||
startSeq: page.startSeq,
|
||||
endSeq: page.endSeq,
|
||||
rows: page.entries.map((entry) => Object.assign({ seq: entry.seqEnd }, entry)),
|
||||
// Stored rows are already within budget; this bounds assistant and reasoning merges.
|
||||
rows: page.entries.map((entry) =>
|
||||
Object.assign({ seq: entry.seqEnd }, entry, {
|
||||
item: limitAgentTimelineItemContent(entry.item),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("ProviderSubagentStore", () => {
|
||||
const [row] = subagents.fetchTimeline("parent-a", "child-1").rows;
|
||||
expect(row?.item).toMatchObject({
|
||||
type: "tool_call",
|
||||
detail: { type: "shell", output: "x".repeat(64 * 1024) },
|
||||
detail: { type: "shell", output: expect.stringMatching(/^x+…\[truncated \d+\.\d KB\]$/) },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -466,13 +466,13 @@ describe("background Claude subagents", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "general-purpose",
|
||||
description: "Count files here",
|
||||
log: "[Bash] ls -1",
|
||||
log: "",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["before", "after"])(
|
||||
"keeps one logged Task card when child tools start %s the parent finishes",
|
||||
"keeps one Task card when child tools start %s the parent finishes",
|
||||
async (timing) => {
|
||||
// A child can stream across the main session's turn boundary. Claude opens the next
|
||||
// main-session turn with system/init; bare child frames do not open a turn.
|
||||
@@ -605,7 +605,7 @@ describe("background Claude subagents", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "general-purpose",
|
||||
description: "Count files here",
|
||||
log: "[Bash] ls -1",
|
||||
log: "",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -737,7 +737,7 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
|
||||
);
|
||||
}, 120_000);
|
||||
|
||||
test("limits retained child shell output to the timeline's existing content limit", async () => {
|
||||
test("limits retained child shell output to the timeline item budget", async () => {
|
||||
const output = "x".repeat(128 * 1024);
|
||||
writeSession({
|
||||
parentLines: [taskToolUse(), taskToolResult()],
|
||||
@@ -782,7 +782,10 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
|
||||
type: "timeline",
|
||||
id: TOOL_USE_ID,
|
||||
item: expect.objectContaining({
|
||||
detail: expect.objectContaining({ type: "shell", output: output.slice(0, 64 * 1024) }),
|
||||
detail: expect.objectContaining({
|
||||
type: "shell",
|
||||
output: expect.stringMatching(/^x+…\[truncated \d+\.\d KB\]$/),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
+23
-25
@@ -273,7 +273,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
queryFactory.mockReset();
|
||||
});
|
||||
|
||||
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
|
||||
test("labels the Task card from its input and preserves callId lifecycle collapse", async () => {
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
@@ -307,9 +307,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
|
||||
expect(latest.detail.subAgentType).toBe("Explore");
|
||||
expect(latest.detail.description).toBe("Inspect repository structure");
|
||||
expect(latest.detail.log).toContain("[Read] README.md");
|
||||
expect(latest.detail.log).toContain("[Edit] src/index.ts");
|
||||
expect(latest.detail.log).not.toContain("VERY_LARGE_OLD_STRING");
|
||||
expect(latest.detail.log).toBe("");
|
||||
|
||||
const rows: AgentTimelineRow[] = timelineToolCalls.map((item, index) => ({
|
||||
seq: index + 1,
|
||||
@@ -396,10 +394,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
.map((event) => event.item)
|
||||
.at(-1);
|
||||
|
||||
expect(latestSubAgentUpdate?.detail).toMatchObject({
|
||||
type: "sub_agent",
|
||||
log: expect.stringContaining("[Read] README.md"),
|
||||
});
|
||||
expect(latestSubAgentUpdate?.detail).toMatchObject({ type: "sub_agent", log: "" });
|
||||
});
|
||||
|
||||
test("routes a resumed SendMessage sidechain to the original child without a second Task card", async () => {
|
||||
@@ -553,7 +548,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" });
|
||||
});
|
||||
|
||||
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
|
||||
test("keeps the Task card the same size while the child runs", async () => {
|
||||
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
|
||||
|
||||
const session = await new ClaudeAgentClient({
|
||||
@@ -568,24 +563,27 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
|
||||
await session.close();
|
||||
|
||||
const timelineToolCalls = events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "tool_call",
|
||||
const runningCards = events
|
||||
.flatMap((event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.callId === "task-tail-1" &&
|
||||
event.item.status === "running" &&
|
||||
event.item.detail.type === "sub_agent"
|
||||
? [event.item]
|
||||
: [],
|
||||
)
|
||||
.map((event) => event.item)
|
||||
.filter((item) => item.callId === "task-tail-1");
|
||||
const subAgentUpdates = timelineToolCalls.filter((item) => item.detail.type === "sub_agent");
|
||||
const latest = subAgentUpdates[subAgentUpdates.length - 1];
|
||||
expect(latest).toBeDefined();
|
||||
if (!latest || latest.detail.type !== "sub_agent") {
|
||||
throw new Error("expected sub_agent detail");
|
||||
}
|
||||
.map((item) => JSON.stringify(item));
|
||||
expect(runningCards).toHaveLength(1);
|
||||
|
||||
expect(latest.detail.log).not.toContain("[Read] file-1.md");
|
||||
expect(latest.detail.log).not.toContain("[Read] file-5.md");
|
||||
expect(latest.detail.log).toContain("[Read] file-6.md");
|
||||
expect(latest.detail.log).toContain("[Read] file-205.md");
|
||||
const childToolCalls = events.filter(
|
||||
(event) =>
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "timeline" &&
|
||||
event.event.id === "task-tail-1" &&
|
||||
event.event.item.type === "tool_call",
|
||||
);
|
||||
expect(childToolCalls).toHaveLength(205);
|
||||
});
|
||||
|
||||
test("keeps a long main-thread tool call out of the subagents track", async () => {
|
||||
|
||||
@@ -4667,7 +4667,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
/**
|
||||
* The Task card in the parent transcript, built from the declaration.
|
||||
*
|
||||
* The sidechain tracker also emits this card, enriched with the child's action log, and the two
|
||||
* The sidechain tracker also emits this card when it first sees the child, and the two
|
||||
* collapse on the shared tool-call id. Emitting it at declaration matters because a
|
||||
* backgrounded subagent produces no sidechain frames at all — verified on the wire — so the
|
||||
* tracker never runs for one and its card would otherwise stay an unlabeled "Task".
|
||||
@@ -6276,6 +6276,7 @@ function buildClaudeSubagentCardDetail(
|
||||
type: "sub_agent",
|
||||
...(facts.title ? { subAgentType: facts.title } : {}),
|
||||
...(facts.description ? { description: facts.description } : {}),
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
actions: [],
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
|
||||
|
||||
describe("ClaudeSidechainTracker", () => {
|
||||
it("retains a background child's actions across the parent finishing", () => {
|
||||
it("keeps a background child's card across the parent finishing", () => {
|
||||
const tracker = new ClaudeSidechainTracker({
|
||||
getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }),
|
||||
isDescriptorOwnedElsewhere: () => true,
|
||||
@@ -15,27 +15,67 @@ describe("ClaudeSidechainTracker", () => {
|
||||
parent_tool_use_id: "task-1",
|
||||
message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] },
|
||||
}) as unknown as SDKMessage;
|
||||
tracker.handleMessage(action("ls", "ls -1"), "task-1");
|
||||
const parentCards = (events: ReturnType<typeof tracker.handleMessage>) =>
|
||||
events.filter((event) => event.type === "timeline");
|
||||
const childCallIds = (events: ReturnType<typeof tracker.handleMessage>) =>
|
||||
events.flatMap((event) =>
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "timeline" &&
|
||||
event.event.item.type === "tool_call"
|
||||
? [event.event.item.callId]
|
||||
: [],
|
||||
);
|
||||
|
||||
expect(parentCards(tracker.handleMessage(action("ls", "ls -1"), "task-1"))).toHaveLength(1);
|
||||
expect(tracker.finishAll("completed")).toEqual([]);
|
||||
const events = tracker.handleMessage(action("pwd", "pwd"), "task-1");
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: expect.objectContaining({
|
||||
detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const afterFinish = tracker.handleMessage(action("pwd", "pwd"), "task-1");
|
||||
expect(parentCards(afterFinish)).toEqual([]);
|
||||
expect(childCallIds(afterFinish)).toEqual(["pwd"]);
|
||||
|
||||
tracker.delete("task-1");
|
||||
const next = tracker.handleMessage(action("next", "ls"), "task-1");
|
||||
expect(next).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: expect.objectContaining({
|
||||
detail: expect.objectContaining({ log: "[Bash] ls" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(parentCards(next)).toHaveLength(1);
|
||||
expect(childCallIds(next)).toEqual(["next"]);
|
||||
});
|
||||
|
||||
it("shows a streamed child tool call's input once the assistant message brings it", () => {
|
||||
const tracker = new ClaudeSidechainTracker({ getToolInput: () => null });
|
||||
const frames = [
|
||||
{
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: "task-1",
|
||||
event: {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "bash-1", name: "Bash", input: {} },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
parent_tool_use_id: "task-1",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "tool_use", id: "bash-1", name: "Bash", input: { command: "echo child" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
] as unknown as SDKMessage[];
|
||||
|
||||
const childCards = frames
|
||||
.flatMap((frame) => tracker.handleMessage(frame, "task-1"))
|
||||
.flatMap((event) =>
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "timeline" &&
|
||||
event.event.item.type === "tool_call"
|
||||
? [event.event.item]
|
||||
: [],
|
||||
);
|
||||
|
||||
expect(childCards.at(-1)).toMatchObject({
|
||||
callId: "bash-1",
|
||||
status: "running",
|
||||
detail: { type: "shell", command: "echo child" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Claude's native agent name for the provider subagent title", () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
mapClaudeFailedToolCall,
|
||||
mapClaudeRunningToolCall,
|
||||
} from "./tool-call-mapper.js";
|
||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||
import {
|
||||
isClaudeSubagentHandbackToolName,
|
||||
readClaudeSubagentHandback,
|
||||
@@ -20,21 +19,17 @@ interface ClaudeContentChunk {
|
||||
|
||||
type ClaudeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
interface SubAgentActionEntry {
|
||||
index: number;
|
||||
interface SubAgentChildCall {
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
interface SubAgentActivityState {
|
||||
name?: string;
|
||||
subAgentType?: string;
|
||||
description?: string;
|
||||
actions: SubAgentActionEntry[];
|
||||
actionKeys: string[];
|
||||
nextActionIndex: number;
|
||||
actionIndexByKey: Map<string, number>;
|
||||
/** The child's tool calls awaiting results, which carry no input of their own. */
|
||||
runningCalls: Map<string, SubAgentChildCall>;
|
||||
completedActionKeys: Set<string>;
|
||||
}
|
||||
|
||||
@@ -44,7 +39,6 @@ interface SubAgentActionCandidate {
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
const MAX_SUB_AGENT_LOG_ENTRIES = 200;
|
||||
const MAX_SUB_AGENT_SUMMARY_CHARS = 160;
|
||||
|
||||
function readTrimmedString(value: unknown): string | undefined {
|
||||
@@ -79,8 +73,7 @@ export class ClaudeSidechainTracker {
|
||||
needsSyntheticParentToolCard?: (toolUseId: string) => boolean;
|
||||
/**
|
||||
* The parent's tool call once Claude has answered it. A background child streams its frames
|
||||
* after that answer, so its card keeps the settled status and output and gains the child's
|
||||
* action log.
|
||||
* after that answer, so its card keeps the settled status and output.
|
||||
*/
|
||||
getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined;
|
||||
}) {
|
||||
@@ -91,28 +84,27 @@ export class ClaudeSidechainTracker {
|
||||
}
|
||||
|
||||
handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] {
|
||||
const existingState = this.activeSidechains.get(parentToolUseId);
|
||||
const state =
|
||||
this.activeSidechains.get(parentToolUseId) ??
|
||||
existingState ??
|
||||
({
|
||||
actions: [],
|
||||
actionKeys: [],
|
||||
nextActionIndex: 1,
|
||||
actionIndexByKey: new Map<string, number>(),
|
||||
runningCalls: new Map<string, SubAgentChildCall>(),
|
||||
completedActionKeys: new Set<string>(),
|
||||
} satisfies SubAgentActivityState);
|
||||
this.activeSidechains.set(parentToolUseId, state);
|
||||
|
||||
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
|
||||
// The parent card is a launch marker: it changes only when the child is first seen or the
|
||||
// task input names it differently, never with the child's activity.
|
||||
const cardUpdated =
|
||||
this.updateSubAgentContextFromTaskInput(state, parentToolUseId) || !existingState;
|
||||
const actionCandidates = this.extractSubAgentActionCandidates(message);
|
||||
const childTimelineItems = [
|
||||
...this.extractSubAgentTimelineItems(message, state),
|
||||
...this.extractSubAgentToolResults(message, state),
|
||||
];
|
||||
let actionUpdated = false;
|
||||
for (const action of actionCandidates) {
|
||||
if (state.completedActionKeys.has(action.key)) continue;
|
||||
if (this.appendSubAgentAction(state, action)) {
|
||||
actionUpdated = true;
|
||||
if (this.trackChildCall(state, action)) {
|
||||
const toolCall = mapClaudeRunningToolCall({
|
||||
name: action.toolName,
|
||||
callId: action.key,
|
||||
@@ -125,7 +117,7 @@ export class ClaudeSidechainTracker {
|
||||
}
|
||||
}
|
||||
|
||||
if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) {
|
||||
if (!cardUpdated && childTimelineItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -145,11 +137,8 @@ export class ClaudeSidechainTracker {
|
||||
type: "sub_agent",
|
||||
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
|
||||
...(state.description ? { description: state.description } : {}),
|
||||
log: state.actions
|
||||
.map((action) =>
|
||||
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
|
||||
)
|
||||
.join("\n"),
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
actions: [],
|
||||
};
|
||||
|
||||
@@ -170,18 +159,19 @@ export class ClaudeSidechainTracker {
|
||||
},
|
||||
];
|
||||
|
||||
const parentCard: AgentStreamEvent[] = this.needsSyntheticParentToolCard(parentToolUseId)
|
||||
? [
|
||||
{
|
||||
type: "timeline",
|
||||
item: {
|
||||
...toolCall,
|
||||
detail,
|
||||
const parentCard: AgentStreamEvent[] =
|
||||
cardUpdated && this.needsSyntheticParentToolCard(parentToolUseId)
|
||||
? [
|
||||
{
|
||||
type: "timeline",
|
||||
item: {
|
||||
...toolCall,
|
||||
detail,
|
||||
},
|
||||
provider: "claude",
|
||||
},
|
||||
provider: "claude",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
...descriptorEvents,
|
||||
@@ -303,8 +293,7 @@ export class ClaudeSidechainTracker {
|
||||
if (!isClaudeContentChunk(block) || !block.type.endsWith("tool_result")) continue;
|
||||
const callId = readTrimmedString(block.tool_use_id);
|
||||
if (!callId || state.completedActionKeys.has(callId)) continue;
|
||||
const actionIndex = state.actionIndexByKey.get(callId);
|
||||
const action = actionIndex === undefined ? undefined : state.actions[actionIndex];
|
||||
const action = state.runningCalls.get(callId);
|
||||
const toolName = action?.toolName ?? readTrimmedString(block.tool_name);
|
||||
if (!toolName) continue;
|
||||
const params = {
|
||||
@@ -317,6 +306,7 @@ export class ClaudeSidechainTracker {
|
||||
? mapClaudeFailedToolCall({ ...params, error: block })
|
||||
: mapClaudeCompletedToolCall(params);
|
||||
if (toolCall) {
|
||||
state.runningCalls.delete(callId);
|
||||
state.completedActionKeys.add(callId);
|
||||
items.push(toolCall);
|
||||
}
|
||||
@@ -412,13 +402,12 @@ export class ClaudeSidechainTracker {
|
||||
const key =
|
||||
readTrimmedString(block.id) ??
|
||||
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
|
||||
return [
|
||||
{
|
||||
key,
|
||||
toolName: block.name,
|
||||
input: block.input ?? null,
|
||||
},
|
||||
];
|
||||
// A streamed call starts with `{}` until its input arrives, so an empty input is not final.
|
||||
const input =
|
||||
block.input && typeof block.input === "object" && Object.keys(block.input).length > 0
|
||||
? block.input
|
||||
: null;
|
||||
return [{ key, toolName: block.name, input }];
|
||||
}
|
||||
|
||||
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
|
||||
@@ -448,84 +437,20 @@ export class ClaudeSidechainTracker {
|
||||
return [];
|
||||
}
|
||||
|
||||
private appendSubAgentAction(
|
||||
/** Returns whether the call is new or has gained its input, so its running card changes. */
|
||||
private trackChildCall(
|
||||
state: SubAgentActivityState,
|
||||
candidate: SubAgentActionCandidate,
|
||||
): boolean {
|
||||
const normalizedToolName = readTrimmedString(candidate.toolName);
|
||||
if (!normalizedToolName) {
|
||||
const toolName = readTrimmedString(candidate.toolName);
|
||||
if (!toolName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const summary = this.deriveSubAgentActionSummary(normalizedToolName, candidate.input);
|
||||
const existingIndex = state.actionIndexByKey.get(candidate.key);
|
||||
|
||||
if (existingIndex !== undefined) {
|
||||
const existing = state.actions[existingIndex];
|
||||
if (!existing) {
|
||||
return false;
|
||||
}
|
||||
const nextSummary = existing.summary ?? summary;
|
||||
if (existing.toolName === normalizedToolName && existing.summary === nextSummary) {
|
||||
return false;
|
||||
}
|
||||
state.actions[existingIndex] = {
|
||||
...existing,
|
||||
toolName: normalizedToolName,
|
||||
input: existing.input ?? candidate.input,
|
||||
...(nextSummary ? { summary: nextSummary } : {}),
|
||||
};
|
||||
return true;
|
||||
const existing = state.runningCalls.get(candidate.key);
|
||||
if (existing && (existing.input !== null || candidate.input === null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.actions.push({
|
||||
index: state.nextActionIndex,
|
||||
toolName: normalizedToolName,
|
||||
input: candidate.input,
|
||||
...(summary ? { summary } : {}),
|
||||
});
|
||||
state.nextActionIndex += 1;
|
||||
state.actionKeys.push(candidate.key);
|
||||
this.trimSubAgentTail(state);
|
||||
this.rebuildSubAgentActionIndex(state);
|
||||
state.runningCalls.set(candidate.key, { toolName, input: candidate.input });
|
||||
return true;
|
||||
}
|
||||
|
||||
private trimSubAgentTail(state: SubAgentActivityState): void {
|
||||
while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) {
|
||||
state.actions.shift();
|
||||
const removedKey = state.actionKeys.shift();
|
||||
if (removedKey) state.completedActionKeys.delete(removedKey);
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildSubAgentActionIndex(state: SubAgentActivityState): void {
|
||||
state.actionIndexByKey.clear();
|
||||
for (let index = 0; index < state.actionKeys.length; index += 1) {
|
||||
const key = state.actionKeys[index];
|
||||
if (key) {
|
||||
state.actionIndexByKey.set(key, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private deriveSubAgentActionSummary(toolName: string, input: unknown): string | undefined {
|
||||
const runningToolCall = mapClaudeRunningToolCall({
|
||||
name: toolName,
|
||||
callId: `sub-agent-summary-${toolName}`,
|
||||
input,
|
||||
output: null,
|
||||
});
|
||||
if (!runningToolCall) {
|
||||
return undefined;
|
||||
}
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: runningToolCall.name,
|
||||
status: runningToolCall.status,
|
||||
error: runningToolCall.error,
|
||||
detail: runningToolCall.detail,
|
||||
metadata: runningToolCall.metadata,
|
||||
});
|
||||
return this.normalizeSubAgentText(display.summary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,12 +77,19 @@ CHILD_SENTINEL in your own response.
|
||||
|
||||
const completedChildIndex = events.findIndex(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.status === "completed" &&
|
||||
event.item.detail.type === "sub_agent" &&
|
||||
event.item.detail.log.includes("CHILD_SENTINEL"),
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "upsert" &&
|
||||
event.event.status === "completed",
|
||||
);
|
||||
const childAssistantText = events
|
||||
.flatMap((event) =>
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "timeline" &&
|
||||
event.event.item.type === "assistant_message"
|
||||
? [event.event.item.text]
|
||||
: [],
|
||||
)
|
||||
.join("");
|
||||
const rootMessageIndexes = events.flatMap((event, index) =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message" ? [index] : [],
|
||||
);
|
||||
@@ -105,6 +112,7 @@ CHILD_SENTINEL in your own response.
|
||||
.map((item) => item.text)
|
||||
.join("");
|
||||
expect(topLevelAssistantText).not.toContain("CHILD_SENTINEL");
|
||||
expect(childAssistantText).toContain("CHILD_SENTINEL");
|
||||
expect(completedChildIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(rootMessageIndexes[0]).toBeGreaterThan(completedChildIndex);
|
||||
expect(terminalIndexes).toHaveLength(1);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user