mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
306b9bfb7b | ||
|
|
14329d1b98 | ||
|
|
fbe24fcdb6 | ||
|
|
787edb1bf9 | ||
|
|
385c29b655 | ||
|
|
9477fe0c00 | ||
|
|
531370c4ce | ||
|
|
e299b21e94 | ||
|
|
1f79efcd62 | ||
|
|
e3682ba18c |
@@ -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
|
||||
|
||||
|
||||
+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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -64,7 +64,7 @@ function resolveShouldFill(
|
||||
): boolean {
|
||||
if (!fillAvailableHeight) return false;
|
||||
const t = detail?.type;
|
||||
return t === "shell" || t === "edit" || t === "write" || t === "read";
|
||||
return t === "shell" || t === "edit" || t === "write" || t === "read" || t === "sub_agent";
|
||||
}
|
||||
|
||||
function useDetailStyles(
|
||||
@@ -186,6 +186,184 @@ 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;
|
||||
@@ -458,7 +636,19 @@ function buildDetailSections(
|
||||
<ShellDetailSection key="shell" command={detail.command} output={detail.output} ds={ds} />,
|
||||
];
|
||||
}
|
||||
if (detail.type === "worktree_setup" || detail.type === "sub_agent") return [];
|
||||
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 === "edit") {
|
||||
return [<EditDetailSection key="edit" diffLines={diffLines} ds={ds} />];
|
||||
}
|
||||
@@ -710,6 +900,34 @@ 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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -832,6 +832,7 @@ 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,6 +2013,7 @@ export const ar: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "خطأ",
|
||||
empty: "لا توجد تفاصيل إضافية متاحة",
|
||||
subAgentActivity: "نشاط الوكيل الفرعي",
|
||||
input: "مدخل",
|
||||
output: "الإخراج",
|
||||
},
|
||||
@@ -2091,6 +2092,11 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "التحقق من الاستخدام",
|
||||
checkUsageHint: "ينطبق على {{host}} على جميع الأجهزة",
|
||||
off: "التحقق من الاستخدام متوقف على {{host}}.",
|
||||
turnOn: "تشغيل",
|
||||
|
||||
options: "الإعدادات",
|
||||
refreshFailed: "تعذّر تحديث الاستخدام",
|
||||
noHosts: "لا توجد مضيفات متصلة",
|
||||
|
||||
@@ -2038,6 +2038,7 @@ export const en = {
|
||||
toolCallDetails: {
|
||||
error: "Error",
|
||||
empty: "No additional details available",
|
||||
subAgentActivity: "Sub-agent activity",
|
||||
input: "Input",
|
||||
output: "Output",
|
||||
},
|
||||
@@ -2116,6 +2117,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,6 +2061,7 @@ export const es: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "Error",
|
||||
empty: "No hay detalles adicionales disponibles",
|
||||
subAgentActivity: "Actividad de subagente",
|
||||
input: "Aporte",
|
||||
output: "Producción",
|
||||
},
|
||||
@@ -2139,6 +2140,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,6 +2060,7 @@ export const fr: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "Erreur",
|
||||
empty: "Aucun détail supplémentaire disponible",
|
||||
subAgentActivity: "Activité du sous-agent",
|
||||
input: "Entrée",
|
||||
output: "Sortie",
|
||||
},
|
||||
@@ -2139,6 +2140,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,6 +2033,7 @@ export const ja: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "エラー",
|
||||
empty: "追加の詳細はありません",
|
||||
subAgentActivity: "サブエージェントアクティビティ",
|
||||
input: "入力",
|
||||
output: "出力",
|
||||
},
|
||||
@@ -2112,6 +2113,11 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "使用状況を確認",
|
||||
checkUsageHint: "すべてのデバイスで {{host}} に適用",
|
||||
off: "{{host}} の使用状況確認はオフです。",
|
||||
turnOn: "オンにする",
|
||||
|
||||
options: "設定",
|
||||
refreshFailed: "使用状況を更新できません",
|
||||
noHosts: "接続中のホストがありません",
|
||||
|
||||
@@ -2023,6 +2023,7 @@ export const ko: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "오류",
|
||||
empty: "추가 세부 정보가 없습니다",
|
||||
subAgentActivity: "서브에이전트 활동",
|
||||
input: "입력",
|
||||
output: "출력",
|
||||
},
|
||||
@@ -2101,6 +2102,11 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "사용량 확인",
|
||||
checkUsageHint: "모든 기기에서 {{host}}에 적용",
|
||||
off: "{{host}}의 사용량 확인이 꺼져 있습니다.",
|
||||
turnOn: "켜기",
|
||||
|
||||
options: "설정",
|
||||
refreshFailed: "사용량을 새로 고칠 수 없습니다",
|
||||
noHosts: "연결된 호스트가 없습니다",
|
||||
|
||||
@@ -2046,6 +2046,7 @@ export const ptBR: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "Erro",
|
||||
empty: "Nenhum detalhe adicional disponível",
|
||||
subAgentActivity: "Atividade do subagente",
|
||||
input: "Entrada",
|
||||
output: "Saída",
|
||||
},
|
||||
@@ -2124,6 +2125,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,6 +2046,7 @@ export const ru: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "Ошибка",
|
||||
empty: "Дополнительные сведения отсутствуют",
|
||||
subAgentActivity: "Активность субагента",
|
||||
input: "Входные данные",
|
||||
output: "Выходные данные",
|
||||
},
|
||||
@@ -2124,6 +2125,11 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
},
|
||||
providerUsage: {
|
||||
checkUsage: "Проверять использование",
|
||||
checkUsageHint: "Применяется к {{host}} на всех устройствах",
|
||||
off: "Проверка использования отключена на {{host}}.",
|
||||
turnOn: "Включить",
|
||||
|
||||
options: "Настройки",
|
||||
refreshFailed: "Не удалось обновить использование",
|
||||
noHosts: "Нет подключённых хостов",
|
||||
|
||||
@@ -1991,6 +1991,7 @@ export const zhCN: TranslationResources = {
|
||||
toolCallDetails: {
|
||||
error: "错误",
|
||||
empty: "没有可用的更多详情",
|
||||
subAgentActivity: "Sub-agent 活动",
|
||||
input: "输入",
|
||||
output: "输出",
|
||||
},
|
||||
@@ -2069,6 +2070,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} />;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ 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 {
|
||||
@@ -69,9 +75,9 @@ export function hasMeaningfulToolCallDetail(detail: ToolCallDetail | undefined):
|
||||
case "fetch":
|
||||
return hasMeaningfulFetchDetail(detail);
|
||||
case "worktree_setup":
|
||||
// A sub-agent card is a launch marker; the child's activity lives in the sub-agent panel.
|
||||
case "sub_agent":
|
||||
return false;
|
||||
case "sub_agent":
|
||||
return hasMeaningfulSubAgentDetail(detail);
|
||||
case "plain_text":
|
||||
return hasMeaningfulPlainTextDetail(detail);
|
||||
case "plan":
|
||||
@@ -86,7 +92,7 @@ export function isPendingToolCallDetail(params: {
|
||||
status: "executing" | "running" | "completed" | "failed" | "canceled";
|
||||
error: unknown;
|
||||
}): boolean {
|
||||
if (params.detail?.type === "worktree_setup" || params.detail?.type === "sub_agent") return false;
|
||||
if (params.detail?.type === "worktree_setup") return false;
|
||||
const isRunning = params.status === "running" || params.status === "executing";
|
||||
return isRunning && params.error == null && !hasMeaningfulToolCallDetail(params.detail);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
@@ -191,14 +198,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;
|
||||
|
||||
@@ -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,9 +641,7 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail, unknown> = z.discri
|
||||
subAgentType: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
childSessionId: z.string().optional(),
|
||||
// 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(),
|
||||
log: z.string(),
|
||||
// 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.
|
||||
@@ -3675,6 +3676,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(),
|
||||
|
||||
@@ -200,8 +200,8 @@ describe("wire schema compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("a sub-agent launch marker still parses against the v0.1.65-beta.3 schema", () => {
|
||||
const card = {
|
||||
test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
|
||||
const parsed = LegacySubAgentToolCallSchema.parse({
|
||||
type: "tool_call",
|
||||
callId: "call-sub-agent-1",
|
||||
name: "Task",
|
||||
@@ -212,26 +212,12 @@ describe("wire schema compatibility", () => {
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect repository structure",
|
||||
childSessionId: "child-session-1",
|
||||
log: "",
|
||||
log: "[Read] README.md",
|
||||
actions: [],
|
||||
},
|
||||
} as const;
|
||||
|
||||
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" } });
|
||||
|
||||
expect(parsed.detail.actions).toEqual([]);
|
||||
});
|
||||
|
||||
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("lists each sub-agent as its launch marker", () => {
|
||||
it("keeps nested sub-agent logs beside the sub-agent that produced them", () => {
|
||||
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: "",
|
||||
log: "[Assistant] First child result.",
|
||||
},
|
||||
}),
|
||||
{ type: "assistant_message", text: "Between children." },
|
||||
@@ -268,7 +268,7 @@ second line'`,
|
||||
type: "sub_agent",
|
||||
subAgentType: "Child two",
|
||||
description: "Second investigation",
|
||||
log: "",
|
||||
log: "[Assistant] Second child result.",
|
||||
},
|
||||
}),
|
||||
{ type: "assistant_message", text: "After second child." },
|
||||
@@ -279,8 +279,10 @@ 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,6 +164,9 @@ 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":
|
||||
|
||||
@@ -313,10 +313,7 @@ export type ToolCallDetail =
|
||||
subAgentType?: string;
|
||||
description?: string;
|
||||
childSessionId?: 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;
|
||||
log: string;
|
||||
actions?: Array<{
|
||||
index: number;
|
||||
toolName: string;
|
||||
|
||||
@@ -466,13 +466,13 @@ describe("background Claude subagents", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "general-purpose",
|
||||
description: "Count files here",
|
||||
log: "",
|
||||
log: "[Bash] ls -1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["before", "after"])(
|
||||
"keeps one Task card when child tools start %s the parent finishes",
|
||||
"keeps one logged 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: "",
|
||||
log: "[Bash] ls -1",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
+25
-23
@@ -273,7 +273,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
queryFactory.mockReset();
|
||||
});
|
||||
|
||||
test("labels the Task card from its input and preserves callId lifecycle collapse", async () => {
|
||||
test("accumulates lightweight sub_agent detail and preserves callId lifecycle collapse", async () => {
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
@@ -307,7 +307,9 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
|
||||
expect(latest.detail.subAgentType).toBe("Explore");
|
||||
expect(latest.detail.description).toBe("Inspect repository structure");
|
||||
expect(latest.detail.log).toBe("");
|
||||
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");
|
||||
|
||||
const rows: AgentTimelineRow[] = timelineToolCalls.map((item, index) => ({
|
||||
seq: index + 1,
|
||||
@@ -394,7 +396,10 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
.map((event) => event.item)
|
||||
.at(-1);
|
||||
|
||||
expect(latestSubAgentUpdate?.detail).toMatchObject({ type: "sub_agent", log: "" });
|
||||
expect(latestSubAgentUpdate?.detail).toMatchObject({
|
||||
type: "sub_agent",
|
||||
log: expect.stringContaining("[Read] README.md"),
|
||||
});
|
||||
});
|
||||
|
||||
test("routes a resumed SendMessage sidechain to the original child without a second Task card", async () => {
|
||||
@@ -548,7 +553,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" });
|
||||
});
|
||||
|
||||
test("keeps the Task card the same size while the child runs", async () => {
|
||||
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
|
||||
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
|
||||
|
||||
const session = await new ClaudeAgentClient({
|
||||
@@ -563,27 +568,24 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
|
||||
await session.close();
|
||||
|
||||
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]
|
||||
: [],
|
||||
const timelineToolCalls = events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline" && event.item.type === "tool_call",
|
||||
)
|
||||
.map((item) => JSON.stringify(item));
|
||||
expect(runningCards).toHaveLength(1);
|
||||
.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");
|
||||
}
|
||||
|
||||
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);
|
||||
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");
|
||||
});
|
||||
|
||||
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 when it first sees the child, and the two
|
||||
* The sidechain tracker also emits this card, enriched with the child's action log, 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,7 +6276,6 @@ 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("keeps a background child's card across the parent finishing", () => {
|
||||
it("retains a background child's actions across the parent finishing", () => {
|
||||
const tracker = new ClaudeSidechainTracker({
|
||||
getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }),
|
||||
isDescriptorOwnedElsewhere: () => true,
|
||||
@@ -15,67 +15,27 @@ describe("ClaudeSidechainTracker", () => {
|
||||
parent_tool_use_id: "task-1",
|
||||
message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] },
|
||||
}) as unknown as SDKMessage;
|
||||
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);
|
||||
tracker.handleMessage(action("ls", "ls -1"), "task-1");
|
||||
expect(tracker.finishAll("completed")).toEqual([]);
|
||||
const afterFinish = tracker.handleMessage(action("pwd", "pwd"), "task-1");
|
||||
expect(parentCards(afterFinish)).toEqual([]);
|
||||
expect(childCallIds(afterFinish)).toEqual(["pwd"]);
|
||||
|
||||
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" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
tracker.delete("task-1");
|
||||
const next = tracker.handleMessage(action("next", "ls"), "task-1");
|
||||
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" },
|
||||
});
|
||||
expect(next).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: expect.objectContaining({
|
||||
detail: expect.objectContaining({ log: "[Bash] ls" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Claude's native agent name for the provider subagent title", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
mapClaudeFailedToolCall,
|
||||
mapClaudeRunningToolCall,
|
||||
} from "./tool-call-mapper.js";
|
||||
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
|
||||
import {
|
||||
isClaudeSubagentHandbackToolName,
|
||||
readClaudeSubagentHandback,
|
||||
@@ -19,17 +20,21 @@ interface ClaudeContentChunk {
|
||||
|
||||
type ClaudeToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||
|
||||
interface SubAgentChildCall {
|
||||
interface SubAgentActionEntry {
|
||||
index: number;
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
interface SubAgentActivityState {
|
||||
name?: string;
|
||||
subAgentType?: string;
|
||||
description?: string;
|
||||
/** The child's tool calls awaiting results, which carry no input of their own. */
|
||||
runningCalls: Map<string, SubAgentChildCall>;
|
||||
actions: SubAgentActionEntry[];
|
||||
actionKeys: string[];
|
||||
nextActionIndex: number;
|
||||
actionIndexByKey: Map<string, number>;
|
||||
completedActionKeys: Set<string>;
|
||||
}
|
||||
|
||||
@@ -39,6 +44,7 @@ interface SubAgentActionCandidate {
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
const MAX_SUB_AGENT_LOG_ENTRIES = 200;
|
||||
const MAX_SUB_AGENT_SUMMARY_CHARS = 160;
|
||||
|
||||
function readTrimmedString(value: unknown): string | undefined {
|
||||
@@ -73,7 +79,8 @@ 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.
|
||||
* after that answer, so its card keeps the settled status and output and gains the child's
|
||||
* action log.
|
||||
*/
|
||||
getSettledParentToolCall?: (toolUseId: string) => ClaudeToolCallItem | undefined;
|
||||
}) {
|
||||
@@ -84,27 +91,28 @@ export class ClaudeSidechainTracker {
|
||||
}
|
||||
|
||||
handleMessage(message: SDKMessage, parentToolUseId: string): AgentStreamEvent[] {
|
||||
const existingState = this.activeSidechains.get(parentToolUseId);
|
||||
const state =
|
||||
existingState ??
|
||||
this.activeSidechains.get(parentToolUseId) ??
|
||||
({
|
||||
runningCalls: new Map<string, SubAgentChildCall>(),
|
||||
actions: [],
|
||||
actionKeys: [],
|
||||
nextActionIndex: 1,
|
||||
actionIndexByKey: new Map<string, number>(),
|
||||
completedActionKeys: new Set<string>(),
|
||||
} satisfies SubAgentActivityState);
|
||||
this.activeSidechains.set(parentToolUseId, state);
|
||||
|
||||
// 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 contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
|
||||
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.trackChildCall(state, action)) {
|
||||
if (this.appendSubAgentAction(state, action)) {
|
||||
actionUpdated = true;
|
||||
const toolCall = mapClaudeRunningToolCall({
|
||||
name: action.toolName,
|
||||
callId: action.key,
|
||||
@@ -117,7 +125,7 @@ export class ClaudeSidechainTracker {
|
||||
}
|
||||
}
|
||||
|
||||
if (!cardUpdated && childTimelineItems.length === 0) {
|
||||
if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -137,8 +145,11 @@ export class ClaudeSidechainTracker {
|
||||
type: "sub_agent",
|
||||
...(state.subAgentType ? { subAgentType: state.subAgentType } : {}),
|
||||
...(state.description ? { description: state.description } : {}),
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: state.actions
|
||||
.map((action) =>
|
||||
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
|
||||
)
|
||||
.join("\n"),
|
||||
actions: [],
|
||||
};
|
||||
|
||||
@@ -159,19 +170,18 @@ export class ClaudeSidechainTracker {
|
||||
},
|
||||
];
|
||||
|
||||
const parentCard: AgentStreamEvent[] =
|
||||
cardUpdated && this.needsSyntheticParentToolCard(parentToolUseId)
|
||||
? [
|
||||
{
|
||||
type: "timeline",
|
||||
item: {
|
||||
...toolCall,
|
||||
detail,
|
||||
},
|
||||
provider: "claude",
|
||||
const parentCard: AgentStreamEvent[] = this.needsSyntheticParentToolCard(parentToolUseId)
|
||||
? [
|
||||
{
|
||||
type: "timeline",
|
||||
item: {
|
||||
...toolCall,
|
||||
detail,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
provider: "claude",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
...descriptorEvents,
|
||||
@@ -293,7 +303,8 @@ 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 action = state.runningCalls.get(callId);
|
||||
const actionIndex = state.actionIndexByKey.get(callId);
|
||||
const action = actionIndex === undefined ? undefined : state.actions[actionIndex];
|
||||
const toolName = action?.toolName ?? readTrimmedString(block.tool_name);
|
||||
if (!toolName) continue;
|
||||
const params = {
|
||||
@@ -306,7 +317,6 @@ export class ClaudeSidechainTracker {
|
||||
? mapClaudeFailedToolCall({ ...params, error: block })
|
||||
: mapClaudeCompletedToolCall(params);
|
||||
if (toolCall) {
|
||||
state.runningCalls.delete(callId);
|
||||
state.completedActionKeys.add(callId);
|
||||
items.push(toolCall);
|
||||
}
|
||||
@@ -402,12 +412,13 @@ export class ClaudeSidechainTracker {
|
||||
const key =
|
||||
readTrimmedString(block.id) ??
|
||||
`stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`;
|
||||
// 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 }];
|
||||
return [
|
||||
{
|
||||
key,
|
||||
toolName: block.name,
|
||||
input: block.input ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] {
|
||||
@@ -437,20 +448,84 @@ export class ClaudeSidechainTracker {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Returns whether the call is new or has gained its input, so its running card changes. */
|
||||
private trackChildCall(
|
||||
private appendSubAgentAction(
|
||||
state: SubAgentActivityState,
|
||||
candidate: SubAgentActionCandidate,
|
||||
): boolean {
|
||||
const toolName = readTrimmedString(candidate.toolName);
|
||||
if (!toolName) {
|
||||
const normalizedToolName = readTrimmedString(candidate.toolName);
|
||||
if (!normalizedToolName) {
|
||||
return false;
|
||||
}
|
||||
const existing = state.runningCalls.get(candidate.key);
|
||||
if (existing && (existing.input !== null || candidate.input === null)) {
|
||||
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;
|
||||
}
|
||||
state.runningCalls.set(candidate.key, { toolName, input: candidate.input });
|
||||
|
||||
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);
|
||||
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,19 +77,12 @@ CHILD_SENTINEL in your own response.
|
||||
|
||||
const completedChildIndex = events.findIndex(
|
||||
(event) =>
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "upsert" &&
|
||||
event.event.status === "completed",
|
||||
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"),
|
||||
);
|
||||
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] : [],
|
||||
);
|
||||
@@ -112,7 +105,6 @@ 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);
|
||||
|
||||
@@ -3524,7 +3524,7 @@ describe("Codex app-server provider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps child-thread Codex activity out of the parent sub-agent card", async () => {
|
||||
test("folds child-thread Codex activity into the parent sub-agent tool call", async () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -3562,6 +3562,7 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
const timelineEvents = events.filter((event) => event.type === "timeline");
|
||||
expect(timelineEvents).toHaveLength(4);
|
||||
expect(timelineEvents.every((event) => event.item.type === "tool_call")).toBe(true);
|
||||
const finalItem = timelineEvents.at(-1)?.item;
|
||||
expect(finalItem).toMatchObject({
|
||||
@@ -3573,7 +3574,7 @@ describe("Codex app-server provider", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Sub-agent",
|
||||
description: "Report findings.",
|
||||
log: "",
|
||||
log: "[Assistant] Found the path.",
|
||||
actions: [],
|
||||
},
|
||||
});
|
||||
@@ -3650,9 +3651,13 @@ describe("Codex app-server provider", () => {
|
||||
id: "settled-child-thread",
|
||||
status: "completed",
|
||||
});
|
||||
expect(events.findLast((event) => event.type === "timeline")).toMatchObject({
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: "timeline",
|
||||
item: { callId: "call-settled-child", status: "completed" },
|
||||
item: {
|
||||
callId: "call-settled-child",
|
||||
status: "completed",
|
||||
detail: { type: "sub_agent", log: "[Assistant] Late trailing output." },
|
||||
},
|
||||
});
|
||||
|
||||
appServer.completesSubAgentActivity({
|
||||
@@ -3957,8 +3962,6 @@ describe("Codex app-server provider", () => {
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
try {
|
||||
const resultPromise = session.run("Delegate the investigation, then report the result.");
|
||||
@@ -4002,18 +4005,11 @@ describe("Codex app-server provider", () => {
|
||||
type: "tool_call",
|
||||
callId: "spawn-child-early",
|
||||
status: "completed",
|
||||
detail: { type: "sub_agent", log: "" },
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
log: "[Assistant] Early child findings.",
|
||||
},
|
||||
});
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "provider_subagent",
|
||||
event: expect.objectContaining({
|
||||
type: "timeline",
|
||||
id: "child-thread-early",
|
||||
item: expect.objectContaining({ type: "assistant_message" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
@@ -4084,7 +4080,10 @@ describe("Codex app-server provider", () => {
|
||||
expect(toolCalls.at(-1)).toMatchObject({
|
||||
callId: "spawn-child-stable",
|
||||
status: "canceled",
|
||||
detail: { type: "sub_agent", log: "" },
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
log: "[Assistant] Still on the same card.",
|
||||
},
|
||||
});
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
@@ -4164,10 +4163,11 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
const result = await resultPromise;
|
||||
const toolCalls = result.timeline.filter((item) => item.type === "tool_call");
|
||||
expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed"]);
|
||||
expect(toolCalls.map((item) => item.status)).toEqual(["running", "completed", "completed"]);
|
||||
expect(toolCalls.at(-1)).toMatchObject({
|
||||
callId: "spawn-child-late-compaction",
|
||||
status: "completed",
|
||||
detail: { type: "sub_agent", log: "[Compacted]" },
|
||||
});
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
@@ -4175,7 +4175,7 @@ describe("Codex app-server provider", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("routes legacy child tools to the child timeline", async () => {
|
||||
test("projects legacy child tools into one stable sub-agent log", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
@@ -4183,8 +4183,6 @@ describe("Codex app-server provider", () => {
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
try {
|
||||
const resultPromise = session.run("Delegate the implementation.");
|
||||
@@ -4227,26 +4225,19 @@ describe("Codex app-server provider", () => {
|
||||
expect(finalToolCall).toMatchObject({
|
||||
callId: "spawn-legacy-tool-child",
|
||||
status: "completed",
|
||||
detail: { type: "sub_agent", log: "" },
|
||||
detail: { type: "sub_agent" },
|
||||
});
|
||||
const childToolCalls = events.flatMap((event) =>
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "timeline" &&
|
||||
event.event.id === "legacy-tool-child" &&
|
||||
event.event.item.type === "tool_call"
|
||||
? [event.event.item]
|
||||
: [],
|
||||
);
|
||||
expect(new Set(childToolCalls.map((item) => item.detail.type))).toEqual(
|
||||
new Set(["shell", "edit"]),
|
||||
);
|
||||
if (finalToolCall?.detail.type === "sub_agent") {
|
||||
expect(finalToolCall.detail.log.match(/\[Shell\]/g)).toHaveLength(1);
|
||||
expect(finalToolCall.detail.log).toContain("[Edit]");
|
||||
}
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps nested MultiAgentV2 output out of the root sub-agent card", async () => {
|
||||
test("keeps nested MultiAgentV2 output inside the root sub-agent card", async () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -4306,22 +4297,8 @@ describe("Codex app-server provider", () => {
|
||||
expect(beforeParentCompletes.at(-1)).toMatchObject({
|
||||
callId: "spawn-child-root",
|
||||
status: "running",
|
||||
detail: { type: "sub_agent", log: "" },
|
||||
detail: { type: "sub_agent", log: expect.stringContaining("Grandchild findings.") },
|
||||
});
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "provider_subagent",
|
||||
event: {
|
||||
type: "timeline",
|
||||
id: "grandchild-thread",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
messageId: "grandchild-message",
|
||||
text: "Grandchild findings.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await asInternals(session).handleNotification("turn/completed", {
|
||||
threadId: "child-thread-root",
|
||||
@@ -4388,20 +4365,13 @@ describe("Codex app-server provider", () => {
|
||||
"child-thread-root",
|
||||
"child-thread-root",
|
||||
]);
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "provider_subagent",
|
||||
event: {
|
||||
type: "timeline",
|
||||
id: "grandchild-thread",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
messageId: "grandchild-after-wait",
|
||||
text: "Still nested.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: "timeline",
|
||||
item: {
|
||||
callId: "spawn-child-root",
|
||||
detail: { type: "sub_agent", log: expect.stringContaining("Still nested.") },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("never treats an unmapped foreign terminal as the root terminal", async () => {
|
||||
@@ -4821,7 +4791,7 @@ describe("Codex app-server provider", () => {
|
||||
expect(events).toHaveLength(eventCountAfterSpawn);
|
||||
});
|
||||
|
||||
test("keeps child context compaction on the child timeline", async () => {
|
||||
test("keeps child context compaction inside the child card", async () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -4854,70 +4824,10 @@ describe("Codex app-server provider", () => {
|
||||
(item) => item.type === "tool_call" && item.callId === "spawn-child-compaction",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
events.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event] : [],
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
id: "child-thread-compaction",
|
||||
item: { type: "compaction", status: "loading" },
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
id: "child-thread-compaction",
|
||||
item: { type: "compaction", status: "completed" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("settles a child compaction once when the item completion is missing or duplicated", async () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
for (const child of ["compacted-child", "turn-ended-child"]) {
|
||||
await asInternals(session).handleNotification("item/completed", {
|
||||
threadId: "test-thread",
|
||||
item: {
|
||||
type: "subAgentActivity",
|
||||
id: `spawn-${child}`,
|
||||
kind: "started",
|
||||
agentThreadId: child,
|
||||
agentPath: `/root/${child}`,
|
||||
},
|
||||
});
|
||||
await asInternals(session).handleNotification("item/started", {
|
||||
threadId: child,
|
||||
item: { type: "contextCompaction", id: `${child}-compaction` },
|
||||
});
|
||||
}
|
||||
await asInternals(session).handleNotification("thread/compacted", {
|
||||
threadId: "compacted-child",
|
||||
expect(timelineItems.at(-1)).toMatchObject({
|
||||
type: "tool_call",
|
||||
detail: { type: "sub_agent", log: "[Compacted]" },
|
||||
});
|
||||
await asInternals(session).handleNotification("item/completed", {
|
||||
threadId: "compacted-child",
|
||||
item: { type: "contextCompaction", id: "compacted-child-compaction" },
|
||||
});
|
||||
await asInternals(session).handleNotification("turn/completed", {
|
||||
threadId: "turn-ended-child",
|
||||
turn: { status: "completed" },
|
||||
});
|
||||
|
||||
const compactions = events.flatMap((event) =>
|
||||
event.type === "provider_subagent" && event.event.type === "timeline"
|
||||
? [
|
||||
`${event.event.id} ${event.event.item.type === "compaction" ? event.event.item.status : ""}`,
|
||||
]
|
||||
: [],
|
||||
);
|
||||
expect(compactions).toEqual([
|
||||
"compacted-child loading",
|
||||
"turn-ended-child loading",
|
||||
"compacted-child completed",
|
||||
"turn-ended-child completed",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps the parent sub-agent running when a child command fails during the child turn", async () => {
|
||||
@@ -4952,7 +4862,7 @@ describe("Codex app-server provider", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(events.findLast((event) => event.type === "timeline")?.item).toMatchObject({
|
||||
expect(events.at(-1)?.item).toMatchObject({
|
||||
type: "tool_call",
|
||||
callId: "call-sub-agent-child-command-failure",
|
||||
name: "Sub-agent",
|
||||
@@ -5247,7 +5157,7 @@ describe("Codex app-server provider", () => {
|
||||
);
|
||||
expect(liveToolCalls.at(-1)).toMatchObject({
|
||||
status: "running",
|
||||
detail: { type: "sub_agent", log: "" },
|
||||
detail: { type: "sub_agent", log: "[Assistant] More findings after resume." },
|
||||
});
|
||||
|
||||
liveEvents.length = 0;
|
||||
@@ -5260,12 +5170,12 @@ describe("Codex app-server provider", () => {
|
||||
itemId: "legacy-child-message-after-resume",
|
||||
delta: "Legacy findings after resume.",
|
||||
});
|
||||
expect(liveEvents.findLast((event) => event.type === "timeline")).toMatchObject({
|
||||
expect(liveEvents.at(-1)).toMatchObject({
|
||||
type: "timeline",
|
||||
item: {
|
||||
callId: "legacy-spawn-history",
|
||||
status: "running",
|
||||
detail: { type: "sub_agent", log: "" },
|
||||
detail: { type: "sub_agent", log: "[Assistant] Legacy findings after resume." },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,10 +49,10 @@ import { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { z } from "zod";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||
import { curateAgentActivity } from "../activity-curator.js";
|
||||
import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js";
|
||||
import {
|
||||
mapCodexToolCallEnvelope,
|
||||
@@ -3384,6 +3384,8 @@ interface CodexSubAgentCallState {
|
||||
activityItemIds: Set<string>;
|
||||
pendingCommandOutputDeltas: Map<string, string[]>;
|
||||
pendingFileChangeOutputDeltas: Map<string, string[]>;
|
||||
childItemOrder: string[];
|
||||
childItems: Map<string, AgentTimelineItem>;
|
||||
childThreadIds: Set<string>;
|
||||
}
|
||||
|
||||
@@ -3485,9 +3487,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
|
||||
private pendingRootCompactionItemIds = new Set<string>();
|
||||
private pendingAnonymousRootCompactions = 0;
|
||||
// Child threads with a compaction shown as loading. Some Codex builds settle it only through
|
||||
// `thread/compacted` or the end of the child's turn.
|
||||
private pendingChildCompactionThreadIds = new Set<string>();
|
||||
// Codex can report one completed compaction through both channels:
|
||||
// `thread/compacted` and a completed `contextCompaction` item.
|
||||
private unpairedCompactionNotificationCompletions = 0;
|
||||
@@ -5517,9 +5516,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
case "patch_apply_completed":
|
||||
this.handlePatchApplyCompletedNotification(parsed, callId);
|
||||
return;
|
||||
case "context_compacted":
|
||||
this.settleChildCompaction(parsed.threadId);
|
||||
return;
|
||||
default:
|
||||
// Aggregate child telemetry is redundant and must not leak into the
|
||||
// root timeline. Concrete legacy tools are projected above for Codex
|
||||
@@ -5696,10 +5692,20 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
activityItemIds: new Set<string>(),
|
||||
pendingCommandOutputDeltas: new Map<string, string[]>(),
|
||||
pendingFileChangeOutputDeltas: new Map<string, string[]>(),
|
||||
childItemOrder: [],
|
||||
childItems: new Map<string, AgentTimelineItem>(),
|
||||
childThreadIds: new Set<string>(),
|
||||
} satisfies CodexSubAgentCallState);
|
||||
|
||||
state.toolCall = timelineItem;
|
||||
state.toolCall = {
|
||||
...timelineItem,
|
||||
detail: {
|
||||
...timelineItem.detail,
|
||||
log:
|
||||
timelineItem.detail.log ||
|
||||
(state.toolCall.detail.type === "sub_agent" ? state.toolCall.detail.log : ""),
|
||||
},
|
||||
};
|
||||
state.parentCallId ??= parentCallId;
|
||||
state.parentSubagentId ??= parentSubagentId;
|
||||
const activity = readCodexSubAgentActivity(rawItem);
|
||||
@@ -5752,17 +5758,25 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, {
|
||||
cwd: this.config.cwd ?? null,
|
||||
});
|
||||
const subAgentType =
|
||||
activityToolCall?.detail.type === "sub_agent"
|
||||
? activityToolCall.detail.subAgentType
|
||||
: undefined;
|
||||
if (
|
||||
activityToolCall?.detail.type === "sub_agent" &&
|
||||
state.toolCall.detail.type === "sub_agent"
|
||||
) {
|
||||
state.toolCall = {
|
||||
...state.toolCall,
|
||||
detail: {
|
||||
...state.toolCall.detail,
|
||||
subAgentType: activityToolCall.detail.subAgentType,
|
||||
},
|
||||
};
|
||||
}
|
||||
let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
|
||||
if (activity.kind === "interrupted") {
|
||||
nextStatus = "canceled";
|
||||
} else if (isTerminalSubAgentStatus(state.toolCall.status)) {
|
||||
nextStatus = undefined;
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(callId, nextStatus, { subAgentType });
|
||||
this.emitSubAgentActivityUpdate(callId, nextStatus);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -5803,13 +5817,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
): boolean {
|
||||
if (
|
||||
childSubAgentCallId &&
|
||||
parsed.threadId &&
|
||||
this.handleSubAgentContextCompactionItem(
|
||||
childSubAgentCallId,
|
||||
parsed.threadId,
|
||||
parsed.item,
|
||||
"completed",
|
||||
)
|
||||
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "completed")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -5819,6 +5827,17 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private upsertSubAgentChildItem(callId: string, itemId: string, item: AgentTimelineItem): void {
|
||||
const state = this.subAgentCallsByCallId.get(callId);
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
if (!state.childItems.has(itemId)) {
|
||||
state.childItemOrder.push(itemId);
|
||||
}
|
||||
state.childItems.set(itemId, item);
|
||||
}
|
||||
|
||||
private emitCodexToolTimelineItem(
|
||||
timelineItem: ToolCallTimelineItem,
|
||||
subAgentCallId: string | null,
|
||||
@@ -5828,6 +5847,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
return;
|
||||
}
|
||||
this.upsertSubAgentChildItem(subAgentCallId, timelineItem.callId, timelineItem);
|
||||
const state = this.subAgentCallsByCallId.get(subAgentCallId);
|
||||
if (state) {
|
||||
const targetThreadIds = childThreadId ? [childThreadId] : state.childThreadIds;
|
||||
@@ -5841,15 +5861,26 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private getSubAgentChildTimeline(state: CodexSubAgentCallState): AgentTimelineItem[] {
|
||||
return state.childItemOrder
|
||||
.map((itemId) => state.childItems.get(itemId))
|
||||
.filter((item): item is AgentTimelineItem => Boolean(item));
|
||||
}
|
||||
|
||||
private emitSubAgentActivityUpdate(
|
||||
callId: string,
|
||||
status?: ToolCallTimelineItem["status"],
|
||||
options?: { reopen?: boolean; subAgentType?: string },
|
||||
options?: { reopen?: boolean },
|
||||
): void {
|
||||
const state = this.subAgentCallsByCallId.get(callId);
|
||||
if (!state || state.toolCall.detail.type !== "sub_agent") {
|
||||
return;
|
||||
}
|
||||
const childTimeline = this.getSubAgentChildTimeline(state);
|
||||
const log =
|
||||
childTimeline.length > 0
|
||||
? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
|
||||
: "";
|
||||
let resolvedStatus = status ?? state.toolCall.status;
|
||||
if (
|
||||
status === "running" &&
|
||||
@@ -5858,12 +5889,16 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
) {
|
||||
resolvedStatus = state.toolCall.status;
|
||||
}
|
||||
const baseToolCall = options?.subAgentType
|
||||
? {
|
||||
...state.toolCall,
|
||||
detail: { ...state.toolCall.detail, subAgentType: options.subAgentType },
|
||||
}
|
||||
: state.toolCall;
|
||||
for (const childThreadId of state.childThreadIds) {
|
||||
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
|
||||
}
|
||||
const baseToolCall = {
|
||||
...state.toolCall,
|
||||
detail: {
|
||||
...state.toolCall.detail,
|
||||
log,
|
||||
},
|
||||
};
|
||||
const nextToolCall: ToolCallTimelineItem =
|
||||
resolvedStatus === "failed"
|
||||
? {
|
||||
@@ -5876,14 +5911,10 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
status: resolvedStatus,
|
||||
error: null,
|
||||
};
|
||||
// The card is a launch marker: the child's activity streams to its own timeline, so the card
|
||||
// is re-sent only when its status or label changes.
|
||||
const cardChanged = !isDeepStrictEqual(nextToolCall, state.toolCall);
|
||||
state.toolCall = nextToolCall;
|
||||
for (const childThreadId of state.childThreadIds) {
|
||||
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
|
||||
}
|
||||
if (!cardChanged || (state.parentCallId && state.parentCallId !== callId)) {
|
||||
if (state.parentCallId && state.parentCallId !== callId) {
|
||||
this.upsertSubAgentChildItem(state.parentCallId, state.callId, nextToolCall);
|
||||
this.emitSubAgentActivityUpdate(state.parentCallId);
|
||||
return;
|
||||
}
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall });
|
||||
@@ -5991,6 +6022,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
): void {
|
||||
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
|
||||
if (itemId) {
|
||||
this.upsertSubAgentChildItem(callId, itemId, timelineItem);
|
||||
this.pendingAgentMessages.delete(itemId);
|
||||
this.pendingReasoning.delete(itemId);
|
||||
this.pendingCommandOutputDeltas.delete(itemId);
|
||||
@@ -6001,29 +6033,19 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
private handleSubAgentContextCompactionItem(
|
||||
callId: string,
|
||||
childThreadId: string,
|
||||
item: { id?: string; type?: string; [key: string]: unknown },
|
||||
status: "loading" | "completed",
|
||||
): boolean {
|
||||
if (!this.isContextCompactionItem(item)) {
|
||||
return false;
|
||||
}
|
||||
if (status === "loading") {
|
||||
this.pendingChildCompactionThreadIds.add(childThreadId);
|
||||
this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status });
|
||||
} else {
|
||||
this.settleChildCompaction(childThreadId);
|
||||
if (item.id) {
|
||||
this.upsertSubAgentChildItem(callId, item.id, { type: "compaction", status });
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(callId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private settleChildCompaction(childThreadId: string): void {
|
||||
if (this.pendingChildCompactionThreadIds.delete(childThreadId)) {
|
||||
this.emitProviderSubagentTimeline(childThreadId, { type: "compaction", status: "completed" });
|
||||
}
|
||||
}
|
||||
|
||||
private shouldSkipCompletedThreadItem(
|
||||
timelineItem: AgentTimelineItem,
|
||||
normalizedItemType: string | undefined,
|
||||
@@ -6054,6 +6076,11 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
text: parsed.delta,
|
||||
});
|
||||
}
|
||||
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
|
||||
type: "assistant_message",
|
||||
messageId: parsed.itemId,
|
||||
text,
|
||||
});
|
||||
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
|
||||
return;
|
||||
}
|
||||
@@ -6087,6 +6114,10 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
text: parsed.delta,
|
||||
});
|
||||
}
|
||||
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
|
||||
type: "reasoning",
|
||||
text: prev.join(""),
|
||||
});
|
||||
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
|
||||
return;
|
||||
}
|
||||
@@ -6154,7 +6185,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
): void {
|
||||
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||
if (subAgentCallId) {
|
||||
if (parsed.threadId) this.settleChildCompaction(parsed.threadId);
|
||||
let status: ToolCallTimelineItem["status"] = "completed";
|
||||
if (parsed.status === "failed") {
|
||||
status = "failed";
|
||||
@@ -6741,13 +6771,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||
if (
|
||||
childSubAgentCallId &&
|
||||
parsed.threadId &&
|
||||
this.handleSubAgentContextCompactionItem(
|
||||
childSubAgentCallId,
|
||||
parsed.threadId,
|
||||
parsed.item,
|
||||
"loading",
|
||||
)
|
||||
this.handleSubAgentContextCompactionItem(childSubAgentCallId, parsed.item, "loading")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -6778,6 +6802,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
if (childSubAgentCallId) {
|
||||
this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem);
|
||||
if (parsed.item.id) {
|
||||
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
return;
|
||||
@@ -6831,6 +6858,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (parsed.threadId) {
|
||||
this.emitProviderSubagentTimeline(parsed.threadId, timelineItem);
|
||||
}
|
||||
if (itemId) {
|
||||
this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem);
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -950,7 +950,6 @@ function mapCollabAgentToolCallItem(
|
||||
type: "sub_agent",
|
||||
subAgentType: "Sub-agent",
|
||||
...(item.prompt ? { description: item.prompt } : {}),
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
actions: [],
|
||||
};
|
||||
@@ -1007,7 +1006,6 @@ function mapSubAgentActivityItem(
|
||||
type: "sub_agent",
|
||||
subAgentType: nativeName,
|
||||
description,
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
actions: [],
|
||||
},
|
||||
|
||||
@@ -69,7 +69,7 @@ import {
|
||||
type OmpRuntimeOptions,
|
||||
} from "./provider-config.js";
|
||||
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
|
||||
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
|
||||
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
|
||||
import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js";
|
||||
import { getUserMessageText } from "./message-history.js";
|
||||
import { mapOmpSystemNoticeToNotification } from "./system-notice.js";
|
||||
@@ -130,6 +130,7 @@ export interface OmpAgentClientOptions {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
runtime?: OmpRuntime;
|
||||
subagentCardScheduler?: OmpSubagentCardScheduler;
|
||||
providerIdleScheduler?: OmpProviderIdleScheduler;
|
||||
noTurnScheduler?: OmpNoTurnScheduler;
|
||||
usagePollScheduler?: OmpUsagePollScheduler;
|
||||
@@ -204,6 +205,7 @@ interface OmpAgentSessionOptions {
|
||||
initialState: OmpSessionState;
|
||||
currentModeId?: string | null;
|
||||
logger: Logger;
|
||||
subagentCardScheduler?: OmpSubagentCardScheduler;
|
||||
providerIdleScheduler?: OmpProviderIdleScheduler;
|
||||
noTurnScheduler?: OmpNoTurnScheduler;
|
||||
usagePollScheduler?: OmpUsagePollScheduler;
|
||||
@@ -771,7 +773,9 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.logger.debug({ err: error }, "OMP context usage poll failed");
|
||||
},
|
||||
});
|
||||
this.subagentCardTracker = new OmpSubagentCardTracker();
|
||||
this.subagentCardTracker = new OmpSubagentCardTracker({
|
||||
scheduler: options.subagentCardScheduler,
|
||||
});
|
||||
this.lastKnownThinkingOptionId =
|
||||
normalizeOmpThinkingOption(options.config.thinkingOptionId) ??
|
||||
this.state.thinkingLevel ??
|
||||
@@ -2151,7 +2155,8 @@ export class OmpAgentSession implements AgentSession {
|
||||
return mapOmpToolDetail(toolCall, result, {
|
||||
toolCallId,
|
||||
bridgedTool: this.hostTools?.bridge?.tool(toolCall.toolName),
|
||||
mapSubagentDetail: (detail) => this.subagentCardTracker.detailFor(toolCallId, detail),
|
||||
mapSubagentDetail: (detail) =>
|
||||
this.subagentCardTracker.detailFor(toolCallId, detail) ?? detail,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2241,6 +2246,7 @@ export class OmpAgentClient implements AgentClient {
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly subagentCardScheduler?: OmpSubagentCardScheduler;
|
||||
private readonly providerIdleScheduler?: OmpProviderIdleScheduler;
|
||||
private readonly noTurnScheduler?: OmpNoTurnScheduler;
|
||||
private readonly usagePollScheduler?: OmpUsagePollScheduler;
|
||||
@@ -2259,6 +2265,7 @@ export class OmpAgentClient implements AgentClient {
|
||||
);
|
||||
this.logger = options.logger;
|
||||
this.runtimeSettings = runtimeSettings;
|
||||
this.subagentCardScheduler = options.subagentCardScheduler;
|
||||
this.providerIdleScheduler = options.providerIdleScheduler;
|
||||
this.noTurnScheduler = options.noTurnScheduler;
|
||||
this.usagePollScheduler = options.usagePollScheduler;
|
||||
@@ -2342,6 +2349,7 @@ export class OmpAgentClient implements AgentClient {
|
||||
initialState,
|
||||
currentModeId: launchMode.modeId,
|
||||
logger: this.logger,
|
||||
subagentCardScheduler: this.subagentCardScheduler,
|
||||
providerIdleScheduler: this.providerIdleScheduler,
|
||||
noTurnScheduler: this.noTurnScheduler,
|
||||
usagePollScheduler: this.usagePollScheduler,
|
||||
@@ -2413,6 +2421,7 @@ export class OmpAgentClient implements AgentClient {
|
||||
initialState,
|
||||
currentModeId: launchMode.modeId,
|
||||
logger: this.logger,
|
||||
subagentCardScheduler: this.subagentCardScheduler,
|
||||
providerIdleScheduler: this.providerIdleScheduler,
|
||||
noTurnScheduler: this.noTurnScheduler,
|
||||
usagePollScheduler: this.usagePollScheduler,
|
||||
|
||||
@@ -637,7 +637,7 @@ describe("OMP history mapper", () => {
|
||||
subAgentType: "explore",
|
||||
description: "Inspect files",
|
||||
childSessionId: "/tmp/omp-task/Explore.jsonl",
|
||||
log: "",
|
||||
log: "done\ntranscript: /tmp/omp-task/Explore.jsonl",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
|
||||
import { OmpSubagentCardTracker, type OmpSubagentCardScheduler } from "./subagent-card-tracker.js";
|
||||
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
|
||||
|
||||
const PARENT_TOOL_CALL_ID = "task-1";
|
||||
@@ -59,84 +59,184 @@ const PROGRESS: OmpSubagentProgressPayload[] = [
|
||||
},
|
||||
];
|
||||
|
||||
class ManualScheduler implements OmpSubagentCardScheduler {
|
||||
private currentMs = 0;
|
||||
private nextId = 1;
|
||||
private readonly timers = new Map<number, { dueMs: number; callback: () => void }>();
|
||||
|
||||
now(): number {
|
||||
return this.currentMs;
|
||||
}
|
||||
|
||||
setTimeout(callback: () => void, delayMs: number) {
|
||||
const id = this.nextId;
|
||||
this.nextId += 1;
|
||||
this.timers.set(id, { dueMs: this.currentMs + delayMs, callback });
|
||||
return { token: id };
|
||||
}
|
||||
|
||||
clearTimeout(timer: { token: unknown }): void {
|
||||
if (typeof timer.token === "number") {
|
||||
this.timers.delete(timer.token);
|
||||
}
|
||||
}
|
||||
|
||||
advance(ms: number): void {
|
||||
this.currentMs += ms;
|
||||
const dueTimers = [...this.timers.entries()]
|
||||
.filter(([, timer]) => timer.dueMs <= this.currentMs)
|
||||
.sort((left, right) => left[1].dueMs - right[1].dueMs);
|
||||
for (const [id, timer] of dueTimers) {
|
||||
if (this.timers.delete(id)) {
|
||||
timer.callback();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("OmpSubagentCardTracker", () => {
|
||||
test("keeps the card a launch marker while the child runs", () => {
|
||||
test("folds lifecycle and progress into one throttled sub-agent detail", () => {
|
||||
const scheduler = new ManualScheduler();
|
||||
const emitted: string[] = [];
|
||||
const emit = (toolCallId: string) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
};
|
||||
const tracker = new OmpSubagentCardTracker();
|
||||
tracker.handleLifecycle(LIFECYCLE, emit);
|
||||
const tracker = new OmpSubagentCardTracker({
|
||||
scheduler,
|
||||
emitToolCall: (toolCallId) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
tracker.handleLifecycle(LIFECYCLE);
|
||||
for (const payload of PROGRESS) {
|
||||
tracker.handleProgress(payload, emit);
|
||||
tracker.handleProgress(payload);
|
||||
}
|
||||
|
||||
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
|
||||
expect(
|
||||
tracker.detailFor(PARENT_TOOL_CALL_ID, {
|
||||
type: "sub_agent",
|
||||
subAgentType: "task",
|
||||
description: "Task arg description wins",
|
||||
log: "",
|
||||
}),
|
||||
).toEqual({
|
||||
const detailBeforeTrailing = tracker.detailFor(PARENT_TOOL_CALL_ID, {
|
||||
type: "sub_agent",
|
||||
subAgentType: "task",
|
||||
description: "Task arg description wins",
|
||||
log: "",
|
||||
});
|
||||
expect(detailBeforeTrailing).toEqual({
|
||||
type: "sub_agent",
|
||||
subAgentType: "task",
|
||||
description: "Task arg description wins",
|
||||
childSessionId: SESSION_FILE,
|
||||
log: "",
|
||||
log: [
|
||||
"EchoSubagent started",
|
||||
"[bash] echo subagent-hi",
|
||||
"[yield]",
|
||||
"EchoSubagent completed",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
test("re-emits the card when a later event names the child", () => {
|
||||
const emitted: string[] = [];
|
||||
const emit = (toolCallId: string) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
};
|
||||
const tracker = new OmpSubagentCardTracker();
|
||||
tracker.handleLifecycle({ ...LIFECYCLE, description: undefined, sessionFile: undefined }, emit);
|
||||
tracker.handleProgress(PROGRESS[0], emit);
|
||||
scheduler.advance(500);
|
||||
|
||||
expect(emitted).toEqual([PARENT_TOOL_CALL_ID, PARENT_TOOL_CALL_ID]);
|
||||
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
|
||||
type: "sub_agent",
|
||||
childSessionId: SESSION_FILE,
|
||||
log: "",
|
||||
});
|
||||
|
||||
test("aggregates batch progress streams into one index-prefixed log", () => {
|
||||
const scheduler = new ManualScheduler();
|
||||
const emitted: string[] = [];
|
||||
const tracker = new OmpSubagentCardTracker({
|
||||
scheduler,
|
||||
emitToolCall: (toolCallId) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
tracker.delete(PARENT_TOOL_CALL_ID);
|
||||
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
|
||||
tracker.handleLifecycle({
|
||||
id: "Explore",
|
||||
agent: "task",
|
||||
description: "Inspect files",
|
||||
status: "started",
|
||||
sessionFile: "/tmp/one.jsonl",
|
||||
parentToolCallId: "task.batch-1",
|
||||
index: 0,
|
||||
});
|
||||
scheduler.advance(600);
|
||||
tracker.handleProgress({
|
||||
index: 5,
|
||||
agent: "task",
|
||||
task: "Run tests",
|
||||
parentToolCallId: "task.batch-1",
|
||||
progress: {
|
||||
id: "Test",
|
||||
status: "running",
|
||||
description: "Run tests",
|
||||
recentTools: [{ tool: "bash", args: "npm test", endMs: 10 }],
|
||||
},
|
||||
sessionFile: "/tmp/two.jsonl",
|
||||
});
|
||||
|
||||
expect(emitted).toEqual(["task.batch-1", "task.batch-1"]);
|
||||
expect(
|
||||
tracker.detailFor("task.batch-1", {
|
||||
type: "sub_agent",
|
||||
subAgentType: "batch",
|
||||
actions: [],
|
||||
log: "",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "sub_agent",
|
||||
log: "",
|
||||
subAgentType: "batch",
|
||||
description: "Inspect files",
|
||||
childSessionId: "/tmp/one.jsonl",
|
||||
log: "[1/6] Explore started\n[6/6] [bash] npm test",
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
test("leaves the card alone when a later child of the same task starts", () => {
|
||||
const emitted: string[] = [];
|
||||
const emit = (toolCallId: string) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
};
|
||||
const tracker = new OmpSubagentCardTracker();
|
||||
tracker.handleLifecycle(LIFECYCLE, emit);
|
||||
tracker.handleLifecycle(
|
||||
{
|
||||
...LIFECYCLE,
|
||||
id: "Second",
|
||||
index: 1,
|
||||
description: "Second child",
|
||||
sessionFile: "/s.jsonl",
|
||||
},
|
||||
emit,
|
||||
);
|
||||
|
||||
expect(emitted).toEqual([PARENT_TOOL_CALL_ID]);
|
||||
expect(tracker.detailFor(PARENT_TOOL_CALL_ID, { type: "sub_agent", log: "" })).toEqual({
|
||||
type: "sub_agent",
|
||||
description: "Run echo in subagent",
|
||||
childSessionId: SESSION_FILE,
|
||||
log: "",
|
||||
test("keeps dirty trailing state available for final detail and cancels it on cleanup", () => {
|
||||
const scheduler = new ManualScheduler();
|
||||
const emitted: string[] = [];
|
||||
const tracker = new OmpSubagentCardTracker({
|
||||
scheduler,
|
||||
emitToolCall: (toolCallId) => {
|
||||
emitted.push(toolCallId);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
tracker.handleLifecycle({
|
||||
id: "Explore",
|
||||
agent: "task",
|
||||
description: "Inspect files",
|
||||
status: "started",
|
||||
parentToolCallId: "task-1",
|
||||
index: 0,
|
||||
});
|
||||
scheduler.advance(100);
|
||||
tracker.handleProgress({
|
||||
index: 0,
|
||||
agent: "task",
|
||||
task: "Inspect files",
|
||||
parentToolCallId: "task-1",
|
||||
progress: {
|
||||
id: "Explore",
|
||||
status: "running",
|
||||
recentOutput: ["found target file"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(emitted).toEqual(["task-1"]);
|
||||
expect(
|
||||
tracker.detailFor("task-1", {
|
||||
type: "sub_agent",
|
||||
log: "",
|
||||
}).log,
|
||||
).toBe("Explore started\nfound target file");
|
||||
|
||||
tracker.delete("task-1");
|
||||
scheduler.advance(500);
|
||||
|
||||
expect(emitted).toEqual(["task-1"]);
|
||||
expect(
|
||||
tracker.detailFor("task-1", {
|
||||
type: "sub_agent",
|
||||
log: "static",
|
||||
}).log,
|
||||
).toBe("static");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,106 +1,361 @@
|
||||
import type { ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
import type { OmpSubagentLifecyclePayload, OmpSubagentProgressPayload } from "./rpc-types.js";
|
||||
|
||||
interface OmpSubagentCardChild {
|
||||
export interface OmpSubagentCardTimer {
|
||||
readonly token: unknown;
|
||||
}
|
||||
|
||||
export interface OmpSubagentCardScheduler {
|
||||
now(): number;
|
||||
setTimeout(callback: () => void, delayMs: number): OmpSubagentCardTimer;
|
||||
clearTimeout(timer: OmpSubagentCardTimer): void;
|
||||
}
|
||||
|
||||
export interface OmpSubagentCardTrackerOptions {
|
||||
scheduler?: OmpSubagentCardScheduler;
|
||||
emitToolCall?: (toolCallId: string) => boolean;
|
||||
}
|
||||
|
||||
interface OmpSubagentLogLine {
|
||||
key: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface OmpSubagentCardItem {
|
||||
index: number;
|
||||
description?: string;
|
||||
agent?: string;
|
||||
status?: "pending" | "running" | "completed" | "failed" | "aborted";
|
||||
childSessionId?: string;
|
||||
lines: OmpSubagentLogLine[];
|
||||
lineKeys: Set<string>;
|
||||
}
|
||||
|
||||
interface OmpSubagentCardState {
|
||||
items: Map<number, OmpSubagentCardItem>;
|
||||
emitToolCall: (toolCallId: string) => boolean;
|
||||
lastEmitMs: number | null;
|
||||
trailingTimer: OmpSubagentCardTimer | null;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES_PER_ITEM = 200;
|
||||
const MAX_LOG_LINE_CHARS = 240;
|
||||
const THROTTLE_INTERVAL_MS = 500;
|
||||
|
||||
export function createOmpSubagentCardScheduler(): OmpSubagentCardScheduler {
|
||||
return {
|
||||
now: () => Date.now(),
|
||||
setTimeout: (callback, delayMs) => ({ token: setTimeout(callback, delayMs) }),
|
||||
clearTimeout: (timer) => {
|
||||
clearTimeout(timer.token as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills a task card's description and child session from OMP's sub-agent events. The card is a
|
||||
* launch marker that names the task's first child; the children's activity belongs to their own
|
||||
* timelines.
|
||||
*/
|
||||
export class OmpSubagentCardTracker {
|
||||
private readonly firstChildren = new Map<string, OmpSubagentCardChild>();
|
||||
private readonly scheduler: OmpSubagentCardScheduler;
|
||||
private readonly emitToolCall: (toolCallId: string) => boolean;
|
||||
private readonly states = new Map<string, OmpSubagentCardState>();
|
||||
|
||||
constructor(options: OmpSubagentCardTrackerOptions) {
|
||||
this.scheduler = options.scheduler ?? createOmpSubagentCardScheduler();
|
||||
this.emitToolCall = options.emitToolCall ?? (() => false);
|
||||
}
|
||||
|
||||
handleLifecycle(
|
||||
payload: OmpSubagentLifecyclePayload,
|
||||
emitToolCall: (toolCallId: string) => boolean,
|
||||
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
|
||||
): void {
|
||||
this.update(
|
||||
payload.parentToolCallId,
|
||||
{
|
||||
index: payload.index,
|
||||
description: payload.description,
|
||||
childSessionId: payload.sessionFile,
|
||||
},
|
||||
emitToolCall,
|
||||
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
|
||||
if (!parentToolCallId) {
|
||||
return;
|
||||
}
|
||||
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
|
||||
const item = this.upsertItem(parentToolCallId, {
|
||||
index: payload.index,
|
||||
agent: payload.agent,
|
||||
description: payload.description,
|
||||
status: payload.status === "started" ? "running" : payload.status,
|
||||
childSessionId: payload.sessionFile,
|
||||
});
|
||||
this.appendLine(
|
||||
item,
|
||||
`lifecycle:${payload.status}:${payload.id}`,
|
||||
`${payload.id} ${payload.status}`,
|
||||
);
|
||||
this.requestEmit(parentToolCallId);
|
||||
}
|
||||
|
||||
handleProgress(
|
||||
payload: OmpSubagentProgressPayload,
|
||||
emitToolCall: (toolCallId: string) => boolean,
|
||||
emitToolCall: (toolCallId: string) => boolean = this.emitToolCall,
|
||||
): void {
|
||||
this.update(
|
||||
payload.parentToolCallId,
|
||||
{
|
||||
index: payload.index,
|
||||
description: payload.progress.description,
|
||||
childSessionId: payload.sessionFile,
|
||||
},
|
||||
emitToolCall,
|
||||
);
|
||||
const parentToolCallId = readTrimmedString(payload.parentToolCallId);
|
||||
if (!parentToolCallId) {
|
||||
return;
|
||||
}
|
||||
this.stateFor(parentToolCallId).emitToolCall = emitToolCall;
|
||||
const item = this.upsertItem(parentToolCallId, {
|
||||
index: payload.index,
|
||||
agent: payload.agent,
|
||||
description: payload.progress.description,
|
||||
status: payload.progress.status,
|
||||
childSessionId: payload.sessionFile,
|
||||
});
|
||||
|
||||
const currentToolLine = summarizeTool(readRecord(payload.progress.currentTool));
|
||||
if (currentToolLine) {
|
||||
this.appendLine(item, `current:${currentToolLine.key}`, currentToolLine.text);
|
||||
}
|
||||
|
||||
for (const tool of readRecordArray(payload.progress.recentTools)) {
|
||||
const line = summarizeTool(tool);
|
||||
if (line) {
|
||||
this.appendLine(item, `tool:${line.key}`, line.text);
|
||||
}
|
||||
}
|
||||
|
||||
for (const output of readOutputLines(payload.progress.recentOutput)) {
|
||||
this.appendLine(item, `output:${output}`, output);
|
||||
}
|
||||
|
||||
if (payload.progress.status !== "running") {
|
||||
this.appendLine(
|
||||
item,
|
||||
`status:${payload.progress.status}:${payload.progress.id}`,
|
||||
`${payload.progress.id} ${payload.progress.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.requestEmit(parentToolCallId);
|
||||
}
|
||||
|
||||
detailFor(toolCallId: string, baseDetail: ToolCallDetail): ToolCallDetail {
|
||||
if (baseDetail.type !== "sub_agent") {
|
||||
return baseDetail;
|
||||
}
|
||||
const first = this.firstChildren.get(toolCallId);
|
||||
if (!first) {
|
||||
const state = this.states.get(toolCallId);
|
||||
if (!state) {
|
||||
return baseDetail;
|
||||
}
|
||||
const description = baseDetail.description ?? first.description;
|
||||
const childSessionId = first.childSessionId ?? baseDetail.childSessionId;
|
||||
return {
|
||||
...baseDetail,
|
||||
...(description ? { description } : {}),
|
||||
...(childSessionId ? { childSessionId } : {}),
|
||||
const items = [...state.items.values()].sort((left, right) => left.index - right.index);
|
||||
const firstItem = items[0];
|
||||
if (!firstItem) {
|
||||
return baseDetail;
|
||||
}
|
||||
|
||||
const detail: ToolCallDetail = {
|
||||
type: "sub_agent",
|
||||
...(baseDetail.subAgentType ? { subAgentType: baseDetail.subAgentType } : {}),
|
||||
...((baseDetail.description ?? firstItem.description)
|
||||
? { description: baseDetail.description ?? firstItem.description }
|
||||
: {}),
|
||||
...((firstItem.childSessionId ?? baseDetail.childSessionId)
|
||||
? { childSessionId: firstItem.childSessionId ?? baseDetail.childSessionId }
|
||||
: {}),
|
||||
log: buildLog(items, baseDetail.log),
|
||||
};
|
||||
return baseDetail.actions ? { ...detail, actions: baseDetail.actions } : detail;
|
||||
}
|
||||
|
||||
flush(toolCallId: string): void {
|
||||
const state = this.states.get(toolCallId);
|
||||
if (!state || !state.dirty) {
|
||||
return;
|
||||
}
|
||||
this.emitNow(toolCallId, state);
|
||||
}
|
||||
|
||||
delete(toolCallId: string): void {
|
||||
this.firstChildren.delete(toolCallId);
|
||||
const state = this.states.get(toolCallId);
|
||||
if (state?.trailingTimer) {
|
||||
this.scheduler.clearTimeout(state.trailingTimer);
|
||||
}
|
||||
this.states.delete(toolCallId);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.firstChildren.clear();
|
||||
for (const toolCallId of this.states.keys()) {
|
||||
this.delete(toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
private update(
|
||||
parentToolCallId: string | undefined,
|
||||
input: { index: number; description?: string; childSessionId?: string },
|
||||
emitToolCall: (toolCallId: string) => boolean,
|
||||
): void {
|
||||
const toolCallId = readTrimmedString(parentToolCallId);
|
||||
if (!toolCallId) {
|
||||
return;
|
||||
private upsertItem(
|
||||
parentToolCallId: string,
|
||||
input: {
|
||||
index: number;
|
||||
agent?: string;
|
||||
status?: "pending" | "running" | "completed" | "failed" | "aborted";
|
||||
description?: string;
|
||||
childSessionId?: string;
|
||||
},
|
||||
): OmpSubagentCardItem {
|
||||
const state = this.stateFor(parentToolCallId);
|
||||
const existing = state.items.get(input.index);
|
||||
if (existing) {
|
||||
existing.agent = readTrimmedString(input.agent) ?? existing.agent;
|
||||
existing.status = input.status ?? existing.status;
|
||||
existing.description = readTrimmedString(input.description) ?? existing.description;
|
||||
existing.childSessionId = readTrimmedString(input.childSessionId) ?? existing.childSessionId;
|
||||
return existing;
|
||||
}
|
||||
const first = this.firstChildren.get(toolCallId);
|
||||
if (first && input.index > first.index) {
|
||||
return;
|
||||
}
|
||||
const existing = first?.index === input.index ? first : undefined;
|
||||
const next: OmpSubagentCardChild = {
|
||||
|
||||
const item: OmpSubagentCardItem = {
|
||||
index: input.index,
|
||||
description: readTrimmedString(input.description) ?? existing?.description,
|
||||
childSessionId: readTrimmedString(input.childSessionId) ?? existing?.childSessionId,
|
||||
lines: [],
|
||||
lineKeys: new Set<string>(),
|
||||
...(readTrimmedString(input.agent) ? { agent: readTrimmedString(input.agent) } : {}),
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
...(readTrimmedString(input.description)
|
||||
? { description: readTrimmedString(input.description) }
|
||||
: {}),
|
||||
...(readTrimmedString(input.childSessionId)
|
||||
? { childSessionId: readTrimmedString(input.childSessionId) }
|
||||
: {}),
|
||||
};
|
||||
if (
|
||||
existing &&
|
||||
existing.description === next.description &&
|
||||
existing.childSessionId === next.childSessionId
|
||||
) {
|
||||
state.items.set(input.index, item);
|
||||
return item;
|
||||
}
|
||||
|
||||
private stateFor(parentToolCallId: string): OmpSubagentCardState {
|
||||
const existing = this.states.get(parentToolCallId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const state: OmpSubagentCardState = {
|
||||
items: new Map(),
|
||||
emitToolCall: this.emitToolCall,
|
||||
lastEmitMs: null,
|
||||
trailingTimer: null,
|
||||
dirty: false,
|
||||
};
|
||||
this.states.set(parentToolCallId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
private appendLine(item: OmpSubagentCardItem, key: string, text: string): void {
|
||||
const normalizedText = normalizeLine(text);
|
||||
if (!normalizedText || item.lineKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
this.firstChildren.set(toolCallId, next);
|
||||
emitToolCall(toolCallId);
|
||||
item.lines.push({ key, text: normalizedText });
|
||||
item.lineKeys.add(key);
|
||||
while (item.lines.length > MAX_LOG_LINES_PER_ITEM) {
|
||||
const removed = item.lines.shift();
|
||||
if (removed) {
|
||||
item.lineKeys.delete(removed.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requestEmit(toolCallId: string): void {
|
||||
const state = this.stateFor(toolCallId);
|
||||
const now = this.scheduler.now();
|
||||
const elapsedMs = state.lastEmitMs === null ? THROTTLE_INTERVAL_MS : now - state.lastEmitMs;
|
||||
if (elapsedMs >= THROTTLE_INTERVAL_MS) {
|
||||
this.emitNow(toolCallId, state);
|
||||
return;
|
||||
}
|
||||
|
||||
state.dirty = true;
|
||||
if (state.trailingTimer) {
|
||||
return;
|
||||
}
|
||||
state.trailingTimer = this.scheduler.setTimeout(() => {
|
||||
state.trailingTimer = null;
|
||||
if (state.dirty) {
|
||||
this.emitNow(toolCallId, state);
|
||||
}
|
||||
}, THROTTLE_INTERVAL_MS - elapsedMs);
|
||||
}
|
||||
|
||||
private emitNow(toolCallId: string, state: OmpSubagentCardState): void {
|
||||
if (state.trailingTimer) {
|
||||
this.scheduler.clearTimeout(state.trailingTimer);
|
||||
state.trailingTimer = null;
|
||||
}
|
||||
state.dirty = false;
|
||||
state.lastEmitMs = this.scheduler.now();
|
||||
state.emitToolCall(toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
function buildLog(items: OmpSubagentCardItem[], fallback: string): string {
|
||||
const itemCount = Math.max(...items.map((item) => item.index)) + 1;
|
||||
const lines = items.flatMap((item) => {
|
||||
const prefix = itemCount > 1 ? `[${item.index + 1}/${itemCount}] ` : "";
|
||||
return item.lines.map((line) => `${prefix}${line.text}`);
|
||||
});
|
||||
return lines.length > 0 ? lines.join("\n") : fallback;
|
||||
}
|
||||
|
||||
function summarizeTool(tool: Record<string, unknown> | null): OmpSubagentLogLine | null {
|
||||
if (!tool) {
|
||||
return null;
|
||||
}
|
||||
const toolName = readTrimmedString(tool.tool) ?? readTrimmedString(tool.name);
|
||||
if (!toolName) {
|
||||
return null;
|
||||
}
|
||||
const args = readTrimmedString(tool.args) ?? summarizeUnknown(tool.input);
|
||||
const endMs = typeof tool.endMs === "number" ? `:${tool.endMs}` : "";
|
||||
return {
|
||||
key: `${toolName}:${args ?? ""}${endMs}`,
|
||||
text: args ? `[${toolName}] ${args}` : `[${toolName}]`,
|
||||
};
|
||||
}
|
||||
|
||||
function readOutputLines(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.flatMap((item) => {
|
||||
const text =
|
||||
readTrimmedString(item) ??
|
||||
(isRecord(item)
|
||||
? (readTrimmedString(item.text) ??
|
||||
readTrimmedString(item.output) ??
|
||||
readTrimmedString(item.content))
|
||||
: undefined);
|
||||
return text ? [text] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
function readRecordArray(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(isRecord) : [];
|
||||
}
|
||||
|
||||
function summarizeUnknown(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return readTrimmedString(value);
|
||||
}
|
||||
const json = JSON.stringify(value);
|
||||
return typeof json === "string" ? normalizeLine(json) : undefined;
|
||||
}
|
||||
|
||||
function normalizeLine(value: string): string | undefined {
|
||||
const normalized = value.trim().replace(/\s+/g, " ");
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized.length <= MAX_LOG_LINE_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, MAX_LOG_LINE_CHARS)}...`;
|
||||
}
|
||||
|
||||
function readTrimmedString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("OMP tool call mapper", () => {
|
||||
expect(mapOmpToolDetail(parseToolArgs("todo", { op: "view" }), null)).toBeNull();
|
||||
});
|
||||
|
||||
test("takes the child session from the task transcript path", () => {
|
||||
test("uses task result text and transcript path as the best static replay detail", () => {
|
||||
expect(
|
||||
mapOmpToolDetail(
|
||||
parseToolArgs("task", {
|
||||
@@ -106,7 +106,7 @@ describe("OMP tool call mapper", () => {
|
||||
subAgentType: "explore",
|
||||
description: "Inspect the target files",
|
||||
childSessionId: "/tmp/omp-task-static/Explore.jsonl",
|
||||
log: "",
|
||||
log: "done\ntranscript: /tmp/omp-task-static/Explore.jsonl",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@ const OPAQUE_TEXT_TOOLS = new Set([
|
||||
function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail {
|
||||
const argRecord = isRecord(args) ? args : {};
|
||||
const firstTask = Array.isArray(argRecord.tasks) ? argRecord.tasks.find(isRecord) : undefined;
|
||||
const resultText = extractTextFromToolResult(result);
|
||||
const childSessionId = readChildSessionId(result);
|
||||
return {
|
||||
type: "sub_agent",
|
||||
@@ -199,8 +200,7 @@ function mapOmpTaskDetail(args: unknown, result: OmpToolResult): ToolCallDetail
|
||||
argRecord.assignment,
|
||||
),
|
||||
...(childSessionId ? { childSessionId } : {}),
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: resultText?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4846,6 +4846,15 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
...item,
|
||||
status: "failed",
|
||||
error,
|
||||
detail:
|
||||
item.detail.type === "sub_agent"
|
||||
? {
|
||||
...item.detail,
|
||||
log: [item.detail.log, error.message]
|
||||
.filter((entry) => entry.trim().length > 0)
|
||||
.join("\n"),
|
||||
}
|
||||
: item.detail,
|
||||
},
|
||||
},
|
||||
turnId,
|
||||
|
||||
@@ -153,6 +153,21 @@ const OpencodeGrepOutputSchema = z
|
||||
])
|
||||
.nullable();
|
||||
|
||||
function formatLogEntry(value: unknown): string | undefined {
|
||||
const outputText = readOutputText(value);
|
||||
if (outputText) {
|
||||
return outputText;
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function extractOpenCodeTaskSessionId(value: unknown): string | undefined {
|
||||
const text = readOutputText(value);
|
||||
if (text) {
|
||||
@@ -180,6 +195,7 @@ function extractOpenCodeTaskSessionId(value: unknown): string | undefined {
|
||||
function deriveOpencodeTaskDetail(
|
||||
input: unknown,
|
||||
output: unknown,
|
||||
error: unknown,
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): ToolCallDetail | null {
|
||||
if (!isRecord(input)) {
|
||||
@@ -192,6 +208,7 @@ function deriveOpencodeTaskDetail(
|
||||
return null;
|
||||
}
|
||||
|
||||
const log = [formatLogEntry(output), formatLogEntry(error)].filter((entry) => entry).join("\n");
|
||||
const childSessionId =
|
||||
normalizeSubAgentText(metadata?.sessionId) ?? extractOpenCodeTaskSessionId(output);
|
||||
return {
|
||||
@@ -199,8 +216,7 @@ function deriveOpencodeTaskDetail(
|
||||
...(subAgentType ? { subAgentType } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(childSessionId ? { childSessionId } : {}),
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log,
|
||||
actions: [],
|
||||
};
|
||||
}
|
||||
@@ -357,10 +373,11 @@ export function deriveOpencodeToolDetail(
|
||||
toolName: string,
|
||||
input: unknown,
|
||||
output: unknown,
|
||||
error: unknown = null,
|
||||
metadata?: Record<string, unknown>,
|
||||
): ToolCallDetail {
|
||||
if (toolName.trim().toLowerCase() === "task") {
|
||||
const taskDetail = deriveOpencodeTaskDetail(input, output, metadata);
|
||||
const taskDetail = deriveOpencodeTaskDetail(input, output, error, metadata);
|
||||
if (taskDetail) {
|
||||
return taskDetail;
|
||||
}
|
||||
|
||||
@@ -460,7 +460,7 @@ describe("opencode tool-call mapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps completed task calls to sub_agent detail", () => {
|
||||
it("maps completed task calls with final output log to sub_agent detail", () => {
|
||||
const item = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "task",
|
||||
@@ -480,7 +480,7 @@ describe("opencode tool-call mapper", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "explore",
|
||||
description: "Explore agent-tools codebase",
|
||||
log: "",
|
||||
log: "Found the CLI entrypoint and provider registry.",
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
@@ -505,12 +505,12 @@ describe("opencode tool-call mapper", () => {
|
||||
subAgentType: "explore",
|
||||
description: "Explore current directory",
|
||||
childSessionId: "ses_2268db431ffe299vL1bbot8R7Z",
|
||||
log: "",
|
||||
log: '<task id="ses_2268db431ffe299vL1bbot8R7Z">done</task>',
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps aborted task calls to sub_agent detail", () => {
|
||||
it("maps aborted task calls with preserved error log to sub_agent detail", () => {
|
||||
const item = expectMapped(
|
||||
mapOpencodeToolCall({
|
||||
toolName: "task",
|
||||
@@ -531,7 +531,7 @@ describe("opencode tool-call mapper", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "explore",
|
||||
description: "Explore agent-tools codebase",
|
||||
log: "",
|
||||
log: "Tool execution aborted",
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim
|
||||
const error = raw.error ?? null;
|
||||
const rawStatus = typeof raw.status === "string" ? raw.status : undefined;
|
||||
const status = normalizeToolCallStatus(rawStatus, error, output);
|
||||
const detail = deriveOpencodeToolDetail(name, input, output, raw.metadata);
|
||||
const detail = deriveOpencodeToolDetail(name, input, output, error, raw.metadata);
|
||||
|
||||
if (status === "failed") {
|
||||
return {
|
||||
|
||||
@@ -3643,3 +3643,114 @@ test("closing Pi during image conversion rejects its pending start", async () =>
|
||||
await session.close();
|
||||
await rejected;
|
||||
});
|
||||
|
||||
describe("Pi tool-result images", () => {
|
||||
test.each([false, true])(
|
||||
"tool-result images precede the reply and completion (isError=%s)",
|
||||
async (isError) => {
|
||||
const { pi, session, events } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
const fakeSession = pi.latestSession();
|
||||
await session.startTurn("run");
|
||||
fakeSession.emit({
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "shot-1",
|
||||
toolName: "screenshot",
|
||||
args: {},
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "shot-1",
|
||||
toolName: "screenshot",
|
||||
result: {
|
||||
content: [
|
||||
{ type: "text", text: "captured" },
|
||||
{ type: "image", data: Buffer.from("hello").toString("base64"), mimeType: "image/png" },
|
||||
],
|
||||
structuredContent: {
|
||||
type: "image",
|
||||
data: Buffer.from("hello").toString("base64"),
|
||||
mimeType: "image/png",
|
||||
note: "captured",
|
||||
},
|
||||
},
|
||||
isError,
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "message_update",
|
||||
assistantMessageEvent: { type: "text_delta", delta: "Image read complete." },
|
||||
});
|
||||
fakeSession.finishTurn();
|
||||
await events.nextTurnCompletion();
|
||||
|
||||
const items = events.timelineItems();
|
||||
const toolIndex = items.findIndex(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
item.callId === "shot-1" &&
|
||||
item.status === (isError ? "failed" : "completed"),
|
||||
);
|
||||
expect(toolIndex).toBeGreaterThanOrEqual(0);
|
||||
// Base64 must never reach the tool output stringifier.
|
||||
expect(JSON.stringify(items[toolIndex])).not.toContain(
|
||||
Buffer.from("hello").toString("base64"),
|
||||
);
|
||||
expect(items.slice(toolIndex + 1)).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
text: expect.stringContaining(",
|
||||
}),
|
||||
expect.objectContaining({ type: "assistant_message", text: "Image read complete." }),
|
||||
]);
|
||||
expect(events.timelineAndCompletionEvents().at(-1)).toEqual({ type: "turn_completed" });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Pi image rendering preserves turn ownership across an interrupt and follow-up", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
const emitted: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => emitted.push(event));
|
||||
const native = pi.latestSession();
|
||||
const first = await session.startTurn("read image");
|
||||
native.emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "large-image",
|
||||
toolName: "screenshot",
|
||||
result: {
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
data: Buffer.alloc(8 * 1024 * 1024, 73).toString("base64"),
|
||||
mimeType: "image/png",
|
||||
},
|
||||
],
|
||||
},
|
||||
isError: false,
|
||||
});
|
||||
native.finishTurn();
|
||||
await session.interrupt();
|
||||
const followup = await session.startTurn("follow-up after image");
|
||||
await waitForImmediate();
|
||||
await waitForImmediate();
|
||||
await expect
|
||||
.poll(() =>
|
||||
events
|
||||
.timelineItems()
|
||||
.some((item) => item.type === "assistant_message" && item.text.includes("),
|
||||
)
|
||||
.toBe(true);
|
||||
expect(events.turnCompletedEvents().some((event) => event.turnId === followup.turnId)).toBe(
|
||||
false,
|
||||
);
|
||||
const imageEvent = emitted.find(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
event.item.text.includes(",
|
||||
);
|
||||
expect(imageEvent).toMatchObject({ turnId: first.turnId });
|
||||
native.finishTurn();
|
||||
await events.nextTurnCompletion();
|
||||
});
|
||||
|
||||
@@ -64,7 +64,11 @@ import {
|
||||
streamPiHistory,
|
||||
type PiCapturedUserMessageEntry,
|
||||
} from "./history-mapper.js";
|
||||
import { materializeProviderImage } from "../provider-image-output.js";
|
||||
import {
|
||||
materializeProviderImage,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
type ProviderImageOutput,
|
||||
} from "../provider-image-output.js";
|
||||
import { PiCliRuntime } from "./cli-runtime.js";
|
||||
import {
|
||||
createPiExtensionHost,
|
||||
@@ -89,6 +93,7 @@ import {
|
||||
mapToolDetail,
|
||||
parseToolArgs,
|
||||
parseToolResult,
|
||||
splitPiToolResultImages,
|
||||
type PiToolResult,
|
||||
type PiTrackedToolCall,
|
||||
} from "./tool-call-mapper.js";
|
||||
@@ -1201,6 +1206,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
private closed = false;
|
||||
private readonly closeController = new AbortController();
|
||||
private readonly pendingExtensionHydrations = new Set<Promise<void>>();
|
||||
private pendingOutput: Promise<void> | null = null;
|
||||
// Pi publishes the terminal before acknowledging abort. Autonomous runs have no
|
||||
// turn ID; retain their errors too until the cancellation request settles.
|
||||
private interruptingTurn: { turnId: string | undefined; error: string | null } | null = null;
|
||||
@@ -1602,6 +1608,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.pendingExtensionUiRequests.size ||
|
||||
this.pendingExtensionResults.size ||
|
||||
this.pendingExtensionHydrations.size ||
|
||||
this.pendingOutput ||
|
||||
this.pendingSteerSubmissions.length ||
|
||||
this.pendingNoTurnOutputs.length ||
|
||||
this.interruptingTurn
|
||||
@@ -1622,7 +1629,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
try {
|
||||
await this.runtimeSession.close();
|
||||
} finally {
|
||||
await Promise.all(this.pendingExtensionHydrations);
|
||||
await Promise.all([this.pendingOutput, ...this.pendingExtensionHydrations]);
|
||||
this.rejectAllExtensionResults(new Error("Pi session closed"));
|
||||
this.cleanup?.();
|
||||
}
|
||||
@@ -1686,9 +1693,31 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private emit(event: AgentStreamEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
if (this.pendingOutput) {
|
||||
this.emitAsync(() => Promise.resolve([event]));
|
||||
return;
|
||||
}
|
||||
this.publish(event);
|
||||
}
|
||||
|
||||
private publish(event: AgentStreamEvent): void {
|
||||
for (const subscriber of this.subscribers) subscriber(event);
|
||||
}
|
||||
|
||||
// Keep timeline order while images are materialized, without delaying provider state changes.
|
||||
private emitAsync(render: () => Promise<AgentStreamEvent[]>): void {
|
||||
const pending = (this.pendingOutput ?? Promise.resolve())
|
||||
.then(render)
|
||||
.then((events) => {
|
||||
if (!this.closed) for (const event of events) this.publish(event);
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => this.logger.error({ err: error }, "Pi output rendering failed"));
|
||||
this.pendingOutput = pending;
|
||||
void pending.then(() => {
|
||||
if (this.pendingOutput === pending) this.pendingOutput = null;
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private emitExtensionOutput(output: PiExtensionEventOutput | undefined, turnId?: string): void {
|
||||
@@ -2315,8 +2344,11 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null);
|
||||
this.activeToolCalls.delete(event.toolCallId);
|
||||
|
||||
const result = parseToolResult(event.result);
|
||||
const error = event.isError ? event.result : null;
|
||||
// Pull image blocks out of the result so base64 never reaches the tool output, then render
|
||||
// each one as an assistant_message markdown image after the tool_call (mirroring the Claude
|
||||
// provider, upstream #1717).
|
||||
const { images, result } = splitPiToolResultImages(parseToolResult(event.result));
|
||||
const error = event.isError ? result : null;
|
||||
const status = event.isError ? "failed" : "completed";
|
||||
this.extensionHost.onToolEnd({
|
||||
callId: event.toolCallId,
|
||||
@@ -2326,6 +2358,30 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
result,
|
||||
});
|
||||
this.emitToolCallEvent(event.toolCallId, toolCall, status, result, error);
|
||||
if (images.length === 0) return;
|
||||
const turnId = this.currentTurnIdForEvent();
|
||||
this.emitAsync(() => this.renderToolResultImages(images, turnId));
|
||||
}
|
||||
|
||||
private async renderToolResultImages(
|
||||
images: ProviderImageOutput[],
|
||||
turnId: string | undefined,
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for (const image of images) {
|
||||
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
|
||||
materialize: materializeProviderImage,
|
||||
});
|
||||
if (imageItem && !this.closed) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
turnId,
|
||||
item: imageItem,
|
||||
});
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private emitCompactionTimeline(input: {
|
||||
|
||||
@@ -21,11 +21,11 @@ export function mapPiChildSession(
|
||||
return new PiChildSessionFollower(id, file, maxBytes).readNew();
|
||||
}
|
||||
|
||||
function mapChildLine(
|
||||
async function mapChildLine(
|
||||
mapper: PiHistoryMapper,
|
||||
id: string,
|
||||
line: string,
|
||||
): ProviderSubagentInputEvent[] {
|
||||
): Promise<ProviderSubagentInputEvent[]> {
|
||||
let entry: { message?: PiAgentMessage; timestamp?: string };
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
@@ -33,7 +33,7 @@ function mapChildLine(
|
||||
return [];
|
||||
}
|
||||
if (!entry.message || typeof entry.message !== "object" || !("role" in entry.message)) return [];
|
||||
return mapper.mapMessages([entry.message]).flatMap((mapped) =>
|
||||
return (await mapper.mapMessages([entry.message])).flatMap((mapped) =>
|
||||
mapped.type === "timeline"
|
||||
? [
|
||||
{
|
||||
@@ -83,7 +83,10 @@ export class PiChildSessionFollower {
|
||||
const events: ProviderSubagentInputEvent[] = [];
|
||||
for (const line of data.subarray(0, lastNewline).toString("utf8").split("\n")) {
|
||||
if (this.items >= MAX_ITEMS) break;
|
||||
const mapped = mapChildLine(this.mapper, this.id, line).slice(0, MAX_ITEMS - this.items);
|
||||
const mapped = (await mapChildLine(this.mapper, this.id, line)).slice(
|
||||
0,
|
||||
MAX_ITEMS - this.items,
|
||||
);
|
||||
this.items += mapped.length;
|
||||
events.push(...mapped);
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { extractTextFromToolResult } from "../../tool-call-mapper.js";
|
||||
import type { PiChildSessionFile, PiExtension } from "../contract.js";
|
||||
import type { ProviderSubagentInputEvent } from "../../../../provider-subagents/store.js";
|
||||
|
||||
@@ -98,8 +99,7 @@ export const edxethPiSubagents: PiExtension = {
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
description,
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
||||
},
|
||||
subagents,
|
||||
childSessions,
|
||||
|
||||
-12
@@ -130,18 +130,6 @@ describe("@gotgenes/pi-subagents adapter", () => {
|
||||
.filter((event) => event.event.type === "upsert")
|
||||
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
|
||||
).toEqual(["running", "completed"]);
|
||||
expect(
|
||||
events.flatMap((event) => (event.event.type === "timeline" ? [event.event] : [])),
|
||||
).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
id: "call_502323",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "Agent completed in 4.7s (1 tool uses, 9.0k token).\nAgent ID: 848583f5-6749-4a1\n\nThe workspace is empty.",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
test("correlates a background notification and result collection", async () => {
|
||||
const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
|
||||
|
||||
+3
-18
@@ -64,8 +64,7 @@ export const gotgenesPiSubagents: PiExtension = {
|
||||
type: "sub_agent" as const,
|
||||
subAgentType: title,
|
||||
description,
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
||||
};
|
||||
if (call.status === "running")
|
||||
return {
|
||||
@@ -93,10 +92,6 @@ export const gotgenesPiSubagents: PiExtension = {
|
||||
outputFileFromToolResult(call.result) ?? pendingFilesByAgent.get(details.data.agentId);
|
||||
pendingFilesByAgent.delete(details.data.agentId);
|
||||
const childSessions = childSession(call.callId, file);
|
||||
const childStatus = status(details.data.status);
|
||||
// Without a session file, as on replay, the child's answer is the tool result.
|
||||
const answer =
|
||||
!file && childStatus !== "running" ? extractTextFromToolResult(call.result)?.trim() : "";
|
||||
return {
|
||||
detail,
|
||||
subagents: [
|
||||
@@ -106,17 +101,8 @@ export const gotgenesPiSubagents: PiExtension = {
|
||||
title,
|
||||
description,
|
||||
toolCallId: call.callId,
|
||||
status: childStatus,
|
||||
status: status(details.data.status),
|
||||
},
|
||||
...(answer
|
||||
? [
|
||||
{
|
||||
type: "timeline" as const,
|
||||
id: call.callId,
|
||||
item: { type: "assistant_message" as const, text: answer },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
childSessions,
|
||||
};
|
||||
@@ -132,8 +118,7 @@ export const gotgenesPiSubagents: PiExtension = {
|
||||
const detail = {
|
||||
type: "sub_agent" as const,
|
||||
description: details.success ? details.data.description : undefined,
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
||||
};
|
||||
if (call.status === "running" || !details.success) return { detail };
|
||||
const file = details.data.transcriptPath;
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("Pi extension host", () => {
|
||||
test("declines a throwing adapter and preserves generic history", async () => {
|
||||
const host = createPiExtensionHost(undefined, [throwingAdapter]);
|
||||
const mapper = new PiHistoryMapper("pi", [], {}, host);
|
||||
const events = mapper.mapMessages([
|
||||
const events = await mapper.mapMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call-1", name: "other", arguments: {} }],
|
||||
@@ -193,7 +193,7 @@ describe("Pi extension host", () => {
|
||||
await output?.hydration;
|
||||
expect(reads).toHaveLength(8);
|
||||
});
|
||||
test("claims a tool, declines an unrelated one, and maps history like live events", () => {
|
||||
test("claims a tool, declines an unrelated one, and maps history like live events", async () => {
|
||||
const host = createPiExtensionHost();
|
||||
const args = { agent: "scout", task: "Inspect files" };
|
||||
const result = parseToolResult({ content: [{ type: "text", text: "Found two files" }] });
|
||||
@@ -210,7 +210,7 @@ describe("Pi extension host", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "scout",
|
||||
description: "Inspect files",
|
||||
log: "",
|
||||
log: "Found two files",
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -225,7 +225,7 @@ describe("Pi extension host", () => {
|
||||
).toBeUndefined();
|
||||
|
||||
const mapper = new PiHistoryMapper("pi");
|
||||
const events = mapper.mapMessages([
|
||||
const events = await mapper.mapMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call-1", name: "subagent", arguments: args }],
|
||||
|
||||
@@ -137,7 +137,7 @@ describe("pi-subagents adapter", () => {
|
||||
type: "sub_agent",
|
||||
subAgentType: "reviewer",
|
||||
description: "Review the Pi mapper change",
|
||||
log: "",
|
||||
log: "The mapper change preserves provider status.",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -131,8 +131,7 @@ export const piSubagents: PiExtension = {
|
||||
type: "sub_agent" as const,
|
||||
subAgentType: args.data.agent,
|
||||
description: args.data.task,
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
||||
};
|
||||
const base = {
|
||||
type: "upsert" as const,
|
||||
|
||||
+2
-2
@@ -79,7 +79,7 @@ test("cancellation declines each remaining real RPC dialog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("captured result renders in live and history tool rows, while foreign shapes decline", () => {
|
||||
test("captured result renders in live and history tool rows, while foreign shapes decline", async () => {
|
||||
const call = {
|
||||
callId: end.toolCallId,
|
||||
toolName: end.toolName,
|
||||
@@ -93,7 +93,7 @@ test("captured result renders in live and history tool rows, while foreign shape
|
||||
label: "Questions",
|
||||
text: "Which snack?: Cookie\nWhich colors?: Red, Blue\nWhat project name?: Nebula",
|
||||
});
|
||||
const history = new PiHistoryMapper("pi").mapMessages([
|
||||
const history = await new PiHistoryMapper("pi").mapMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
|
||||
@@ -54,6 +54,8 @@ export async function verifySubagentFixture(
|
||||
try {
|
||||
await session.startTurn("Delegate work");
|
||||
for (const event of fixture.events) pi.latestSession().emit(event);
|
||||
const immediate = live.filter((event) => event.type === "provider_subagent");
|
||||
expect(immediate.some((event) => event.event.type === "timeline")).toBe(false);
|
||||
pi.latestSession().finishTurn();
|
||||
const replay: AgentStreamEvent[] = [];
|
||||
for await (const event of streamPiHistory("pi", fixture.messages)) replay.push(event);
|
||||
|
||||
@@ -89,7 +89,7 @@ export async function verifyTaskFixture(path: URL, expectedStatuses: string[]):
|
||||
for (const event of fixture.events) pi.latestSession().emit(event);
|
||||
pi.latestSession().finishTurn();
|
||||
expect(todos(live)).toEqual(mapped);
|
||||
const replay = new PiHistoryMapper("pi").mapMessages(fixture.messages);
|
||||
const replay = await new PiHistoryMapper("pi").mapMessages(fixture.messages);
|
||||
expect(todos(replay)).toEqual(mapped);
|
||||
expect(
|
||||
replay.filter(
|
||||
|
||||
+2
-19
@@ -36,17 +36,12 @@ describe("@tintinweb/pi-subagents adapter", () => {
|
||||
});
|
||||
expect(mapping?.subagents).toEqual([
|
||||
expect.objectContaining({ id: "call-1", status: "completed" }),
|
||||
{
|
||||
type: "timeline",
|
||||
id: "call-1",
|
||||
item: { type: "assistant_message", text: "Wrapped up (turn limit)" },
|
||||
},
|
||||
]);
|
||||
expect(mapping?.detail).toEqual({
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect",
|
||||
log: "",
|
||||
log: "Wrapped up (turn limit)",
|
||||
});
|
||||
});
|
||||
test("maps captured foreground lifecycle live and on replay", async () => {
|
||||
@@ -58,18 +53,6 @@ describe("@tintinweb/pi-subagents adapter", () => {
|
||||
.filter((event) => event.event.type === "upsert")
|
||||
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
|
||||
).toEqual(["running", "completed"]);
|
||||
expect(
|
||||
events.flatMap((event) => (event.event.type === "timeline" ? [event.event] : [])),
|
||||
).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
id: "call_458926",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "Agent completed in 6.6s (1 tool uses, 18.8k token).\n\nThe workspace directory `/tmp/paseo-pi-extension-phase3/workspace` is empty (no files or hidden dotfiles).",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
test("uses a structured notification to complete a background child", async () => {
|
||||
const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
|
||||
@@ -164,7 +147,7 @@ describe("@tintinweb/pi-subagents adapter", () => {
|
||||
expect(mapping?.detail).toEqual({
|
||||
type: "sub_agent",
|
||||
description: undefined,
|
||||
log: "",
|
||||
log: "Wrapped up (turn limit)",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
+3
-18
@@ -57,8 +57,7 @@ export const tintinwebPiSubagents: PiExtension = {
|
||||
type: "sub_agent" as const,
|
||||
subAgentType: title,
|
||||
description,
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
||||
};
|
||||
if (call.status === "running")
|
||||
return {
|
||||
@@ -85,10 +84,6 @@ export const tintinwebPiSubagents: PiExtension = {
|
||||
const file = outputFileFromToolResult(call.result);
|
||||
const childSessions = file && !readSessions.has(file) ? [{ id: call.callId, file }] : [];
|
||||
if (file) readSessions.add(file);
|
||||
const childStatus = status(details.data.status);
|
||||
// A foreground child has no session file; its answer is the tool result.
|
||||
const answer =
|
||||
!file && childStatus !== "running" ? extractTextFromToolResult(call.result)?.trim() : "";
|
||||
return {
|
||||
detail,
|
||||
subagents: [
|
||||
@@ -98,17 +93,8 @@ export const tintinwebPiSubagents: PiExtension = {
|
||||
title,
|
||||
description,
|
||||
toolCallId: call.callId,
|
||||
status: childStatus,
|
||||
status: status(details.data.status),
|
||||
},
|
||||
...(answer
|
||||
? [
|
||||
{
|
||||
type: "timeline" as const,
|
||||
id: call.callId,
|
||||
item: { type: "assistant_message" as const, text: answer },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
childSessions,
|
||||
};
|
||||
@@ -124,8 +110,7 @@ export const tintinwebPiSubagents: PiExtension = {
|
||||
const detail = {
|
||||
type: "sub_agent" as const,
|
||||
description: details.success ? details.data.description : undefined,
|
||||
// COMPAT(subagent-log): remove after 2027-04-30.
|
||||
log: "",
|
||||
log: extractTextFromToolResult(call.result)?.trim() ?? "",
|
||||
};
|
||||
if (call.status === "running" || !details.success) return { detail };
|
||||
return {
|
||||
|
||||
@@ -221,3 +221,75 @@ describe("Pi history mapper", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pi tool-result image replay", () => {
|
||||
test("recreates an image from native tool-result history without its source file", async () => {
|
||||
const events = await collectHistory([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "image-read",
|
||||
name: "read",
|
||||
arguments: { path: "missing-preview.png" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "image-read",
|
||||
toolName: "read",
|
||||
content: [
|
||||
{ type: "text", text: "Read image file [image/png]" },
|
||||
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(events.map((event) => event.item?.type)).toEqual([
|
||||
"tool_call",
|
||||
"tool_call",
|
||||
"assistant_message",
|
||||
]);
|
||||
const image = events.find(
|
||||
(event) => event.type === "timeline" && event.item.type === "assistant_message",
|
||||
);
|
||||
expect(image).toMatchObject({
|
||||
type: "timeline",
|
||||
item: { type: "assistant_message", text: expect.stringContaining(" },
|
||||
});
|
||||
if (image?.type !== "timeline" || image.item.type !== "assistant_message")
|
||||
throw new Error("Missing image");
|
||||
const url = image.item.text.match(/\((file:[^)]+)\)/)![1];
|
||||
const { readFile, unlink } = await import("node:fs/promises");
|
||||
expect(await readFile(new URL(url))).toEqual(Buffer.from("hello"));
|
||||
await unlink(new URL(url));
|
||||
const replay = await collectHistory([
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "image-read",
|
||||
toolName: "read",
|
||||
content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }],
|
||||
},
|
||||
]);
|
||||
expect(replay).toContainEqual(image);
|
||||
expect(await readFile(new URL(url))).toEqual(Buffer.from("hello"));
|
||||
});
|
||||
});
|
||||
|
||||
test("replayed Pi images keep their position before the following reply", async () => {
|
||||
const events = await collectHistory([
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "image-position",
|
||||
toolName: "read",
|
||||
content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/png" }],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "text", text: "Image read complete." }] },
|
||||
]);
|
||||
expect(
|
||||
events.map((event) =>
|
||||
event.item?.type === "assistant_message" ? event.item.text : event.item?.type,
|
||||
),
|
||||
).toEqual(["tool_call", expect.stringContaining(", "Image read complete."]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { mapCustomMessageToToolCall } from "../custom-message.js";
|
||||
import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
import {
|
||||
materializeProviderImage,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
} from "../provider-image-output.js";
|
||||
import {
|
||||
createPiExtensionHost,
|
||||
type PiExtensionEventOutput,
|
||||
@@ -11,6 +15,7 @@ import {
|
||||
mapToolDetail,
|
||||
parseToolArgs,
|
||||
parseToolResult,
|
||||
splitPiToolResultImages,
|
||||
type PiToolResult,
|
||||
type PiTrackedToolCall,
|
||||
} from "./tool-call-mapper.js";
|
||||
@@ -71,7 +76,7 @@ export class PiHistoryMapper {
|
||||
private readonly extensionHost: PiExtensionHost = createPiExtensionHost(),
|
||||
) {}
|
||||
|
||||
mapMessages(messages: readonly PiAgentMessage[]): AgentStreamEvent[] {
|
||||
async mapMessages(messages: readonly PiAgentMessage[]): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
@@ -86,7 +91,7 @@ export class PiHistoryMapper {
|
||||
events.push(...this.mapAssistantMessage(message));
|
||||
break;
|
||||
case "toolResult": {
|
||||
events.push(...this.mapToolResultMessage(message));
|
||||
events.push(...(await this.mapToolResultMessage(message)));
|
||||
break;
|
||||
}
|
||||
case "bashExecution":
|
||||
@@ -207,24 +212,35 @@ export class PiHistoryMapper {
|
||||
return events;
|
||||
}
|
||||
|
||||
private mapToolResultMessage(
|
||||
private async mapToolResultMessage(
|
||||
message: Extract<PiAgentMessage, { role: "toolResult" }>,
|
||||
): AgentStreamEvent[] {
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const tracked =
|
||||
this.pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
|
||||
this.pendingToolCalls.delete(message.toolCallId);
|
||||
const result = parseToolResult({ content: message.content, details: message.details });
|
||||
// Pull image blocks out of the result so base64 never reaches the tool output, then render
|
||||
// each one as an assistant_message markdown image after the tool_call (mirroring the live
|
||||
// path and the Claude provider, upstream #1717).
|
||||
const { images, result: splitResult } = splitPiToolResultImages(result);
|
||||
const mapping = this.extensionHost.mapToolCall({
|
||||
callId: message.toolCallId,
|
||||
toolName: tracked.toolName,
|
||||
args: tracked.args,
|
||||
status: message.isError ? "failed" : "completed",
|
||||
result,
|
||||
result: splitResult,
|
||||
});
|
||||
const detail = this.mapToolDetail(message.toolCallId, tracked, result, mapping?.detail);
|
||||
const detail = this.mapToolDetail(message.toolCallId, tracked, splitResult, mapping?.detail);
|
||||
if (!detail) {
|
||||
return [];
|
||||
}
|
||||
const imageEvents: AgentStreamEvent[] = [];
|
||||
for (const image of images) {
|
||||
const item = await renderProviderImageOutputAsAssistantMarkdown(image, {
|
||||
materialize: materializeProviderImage,
|
||||
});
|
||||
if (item) imageEvents.push({ type: "timeline", provider: this.provider, item });
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: "timeline",
|
||||
@@ -234,9 +250,10 @@ export class PiHistoryMapper {
|
||||
name: mapping?.name ?? tracked.toolName,
|
||||
isError: Boolean(message.isError),
|
||||
detail,
|
||||
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
|
||||
errorText: extractTextFromToolResult(splitResult) ?? "Tool call failed",
|
||||
}),
|
||||
},
|
||||
...imageEvents,
|
||||
...this.extensionEvents(mapping),
|
||||
];
|
||||
}
|
||||
@@ -297,9 +314,10 @@ export async function* streamPiHistory(
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
const mapper = new PiHistoryMapper(provider, userEntries, hooks, extensionHost);
|
||||
for (const event of mapper.mapMessages(messages)) {
|
||||
for (const message of messages) {
|
||||
if (signal?.aborted) return;
|
||||
if (event) {
|
||||
for (const event of await mapper.mapMessages([message])) {
|
||||
if (signal?.aborted) return;
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { mapToolDetail, parseToolArgs, parseToolResult } from "./tool-call-mapper.js";
|
||||
import {
|
||||
extractTextFromToolResult,
|
||||
mapToolDetail,
|
||||
parseToolArgs,
|
||||
parseToolResult,
|
||||
splitPiToolResultImages,
|
||||
} from "./tool-call-mapper.js";
|
||||
|
||||
describe("Pi tool call mapper", () => {
|
||||
test("maps bash args and result to shell detail", () => {
|
||||
@@ -56,3 +62,77 @@ describe("Pi tool call mapper", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitPiToolResultImages", () => {
|
||||
test("extracts image blocks and replaces them with [image] placeholders", () => {
|
||||
const parsed = parseToolResult({
|
||||
content: [
|
||||
{ type: "text", text: "shot taken" },
|
||||
{ type: "image", data: "aGVsbG8=", mimeType: "image/png" },
|
||||
],
|
||||
});
|
||||
const { images, result } = splitPiToolResultImages(parsed);
|
||||
|
||||
expect(images).toEqual([{ data: "aGVsbG8=", mimeType: "image/png" }]);
|
||||
expect(result).toEqual({
|
||||
content: [
|
||||
{ type: "text", text: "shot taken" },
|
||||
{ type: "text", text: "[image]" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("replaces image-only results with a non-empty placeholder", () => {
|
||||
const parsed = parseToolResult({
|
||||
content: [{ type: "image", data: "aGVsbG8=", mimeType: "image/jpeg" }],
|
||||
});
|
||||
const { images, result } = splitPiToolResultImages(parsed);
|
||||
|
||||
expect(images).toHaveLength(1);
|
||||
expect(extractTextFromToolResult(result)).toBe("[image]");
|
||||
});
|
||||
|
||||
test("passes through string results and results without images", () => {
|
||||
expect(splitPiToolResultImages("plain output")).toEqual({
|
||||
images: [],
|
||||
result: "plain output",
|
||||
});
|
||||
const textOnly = parseToolResult({ content: [{ type: "text", text: "no images" }] });
|
||||
expect(splitPiToolResultImages(textOnly)).toEqual({ images: [], result: textOnly });
|
||||
expect(splitPiToolResultImages(null)).toEqual({ images: [], result: null });
|
||||
});
|
||||
|
||||
test("keeps other fields on the result object", () => {
|
||||
const parsed = parseToolResult({
|
||||
exitCode: 0,
|
||||
content: [{ type: "image", data: "aGVsbG8=" }],
|
||||
});
|
||||
const { result } = splitPiToolResultImages(parsed);
|
||||
expect(result).toMatchObject({ exitCode: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
test("tool-result images remove nested structured image data while preserving metadata", () => {
|
||||
const data = Buffer.from("nested screenshot bytes").toString("base64");
|
||||
const result = parseToolResult({
|
||||
content: [{ type: "image", data, mimeType: "image/png" }],
|
||||
structuredContent: {
|
||||
content: [{ type: "image", data, mimeType: "image/png", width: 64 }],
|
||||
captureId: "capture-1",
|
||||
read: { type: "image", data, mimeType: "image/png", note: "Read image file" },
|
||||
extra: { data: "keep ordinary data" },
|
||||
},
|
||||
});
|
||||
const split = splitPiToolResultImages(result);
|
||||
const detail = mapToolDetail(parseToolArgs("screenshot", {}), split.result);
|
||||
expect(JSON.stringify(detail)).not.toContain(data);
|
||||
expect(split.images).toEqual([{ data, mimeType: "image/png" }]);
|
||||
expect(split.result).toMatchObject({
|
||||
structuredContent: {
|
||||
content: [{ type: "image", data: "[image]", mimeType: "image/png", width: 64 }],
|
||||
captureId: "capture-1",
|
||||
read: { type: "image", data: "[image]", mimeType: "image/png", note: "Read image file" },
|
||||
extra: { data: "keep ordinary data" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
import type { ProviderImageOutput } from "../provider-image-output.js";
|
||||
|
||||
interface BashToolInput {
|
||||
command: string;
|
||||
@@ -52,6 +53,7 @@ interface PiToolResultObject {
|
||||
stdout?: string;
|
||||
text?: string;
|
||||
content?: PiToolResultContent[];
|
||||
structuredContent?: unknown;
|
||||
exitCode?: number;
|
||||
code?: number;
|
||||
details?: PiToolResultDetails;
|
||||
@@ -67,6 +69,12 @@ interface PiToolResultTextContent {
|
||||
text: string;
|
||||
}
|
||||
|
||||
const PiToolResultImageContentSchema = z.object({
|
||||
type: z.literal("image"),
|
||||
data: z.string().min(1),
|
||||
mimeType: z.string().optional(),
|
||||
});
|
||||
|
||||
interface PiToolResultUnknownContent {
|
||||
type: string;
|
||||
}
|
||||
@@ -236,6 +244,61 @@ export function parseToolResult(rawResult: unknown): PiToolResult {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pi returns images inside tool-result content as base64 blocks. Left in place they reach the
|
||||
// tool-output stringifier, which dumps base64 into the tool output. We pull those blocks out to
|
||||
// render them as image markdown and leave a "[image]" placeholder so image-only results still
|
||||
// produce non-empty output (mirroring splitClaudeToolResultImages).
|
||||
export function splitPiToolResultImages(result: PiToolResult): {
|
||||
images: ProviderImageOutput[];
|
||||
result: PiToolResult;
|
||||
} {
|
||||
if (!result || typeof result === "string" || !result.content) {
|
||||
return { images: [], result };
|
||||
}
|
||||
const images: ProviderImageOutput[] = [];
|
||||
let replaced = false;
|
||||
const content = result.content.map((block) => {
|
||||
const image = toPiImageOutput(block);
|
||||
if (image) {
|
||||
images.push(image);
|
||||
replaced = true;
|
||||
return { type: "text", text: "[image]" } satisfies PiToolResultTextContent;
|
||||
}
|
||||
return block;
|
||||
});
|
||||
if (!replaced) {
|
||||
return { images: [], result };
|
||||
}
|
||||
return {
|
||||
images,
|
||||
result: {
|
||||
...result,
|
||||
content,
|
||||
...(result.structuredContent === undefined
|
||||
? {}
|
||||
: { structuredContent: withoutImageData(result.structuredContent) }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withoutImageData(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(withoutImageData);
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
const image = toPiImageOutput(value);
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
image && key === "data" ? "[image]" : withoutImageData(child),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function toPiImageOutput(block: unknown): ProviderImageOutput | null {
|
||||
const parsed = PiToolResultImageContentSchema.safeParse(block);
|
||||
if (!parsed.success) return null;
|
||||
return { data: parsed.data.data, mimeType: parsed.data.mimeType ?? null };
|
||||
}
|
||||
|
||||
export function extractTextFromToolResult(result: PiToolResult): string | undefined {
|
||||
if (typeof result === "string") {
|
||||
return result;
|
||||
|
||||
@@ -420,6 +420,7 @@ export interface PaseoDaemonConfig {
|
||||
trustedProxies?: true | string[];
|
||||
mcpEnabled?: boolean;
|
||||
mcpInjectIntoAgents?: boolean;
|
||||
usageEnabled?: boolean;
|
||||
browserToolsEnabled?: boolean;
|
||||
git?: {
|
||||
maxProcessesPerSecond: number;
|
||||
@@ -590,6 +591,7 @@ function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): Mutab
|
||||
...(config.providerCatalogRefreshTimeoutMs !== undefined
|
||||
? { catalogRefreshTimeoutMs: config.providerCatalogRefreshTimeoutMs }
|
||||
: {}),
|
||||
usage: { enabled: config.usageEnabled !== false },
|
||||
browserTools: { enabled: config.browserToolsEnabled ?? false },
|
||||
providers,
|
||||
metadataGeneration: {
|
||||
|
||||
@@ -523,6 +523,10 @@ function resolveAppendSystemPrompt(persisted: ReturnType<typeof loadPersistedCon
|
||||
return persisted.daemon?.appendSystemPrompt ?? "";
|
||||
}
|
||||
|
||||
function resolveUsageEnabled(persisted: ReturnType<typeof loadPersistedConfig>): boolean {
|
||||
return persisted.daemon?.usage?.enabled !== false;
|
||||
}
|
||||
|
||||
function resolveBrowserToolsEnabled(persisted: ReturnType<typeof loadPersistedConfig>): boolean {
|
||||
return persisted.daemon?.browserTools?.enabled ?? false;
|
||||
}
|
||||
@@ -548,6 +552,7 @@ function resolveStaticLoadConfigSettings(
|
||||
mcpEnabled: cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true,
|
||||
mcpInjectIntoAgents:
|
||||
cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false,
|
||||
usageEnabled: resolveUsageEnabled(persisted),
|
||||
browserToolsEnabled: resolveBrowserToolsEnabled(persisted),
|
||||
autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false,
|
||||
appendSystemPrompt: resolveAppendSystemPrompt(persisted),
|
||||
@@ -576,6 +581,7 @@ export type ResolvedDaemonSettings = Pick<
|
||||
| "trustedProxies"
|
||||
| "mcpEnabled"
|
||||
| "mcpInjectIntoAgents"
|
||||
| "usageEnabled"
|
||||
| "browserToolsEnabled"
|
||||
| "git"
|
||||
| "autoArchiveAfterMerge"
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type HostUpdate,
|
||||
} from "@getpaseo/protocol/hosts";
|
||||
import type { DaemonPermission, WSHelloMessage } from "@getpaseo/protocol/messages";
|
||||
import { webSocketProxyAgent } from "../network/index.js";
|
||||
|
||||
export class HostError extends Error {
|
||||
constructor(
|
||||
@@ -242,6 +243,7 @@ export class ConnectedHosts {
|
||||
{
|
||||
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
|
||||
connectTimeoutMs: 10000,
|
||||
webSocketAgent: webSocketProxyAgent,
|
||||
},
|
||||
this.budget,
|
||||
);
|
||||
|
||||
@@ -10,6 +10,10 @@ import { readSpeechSettings } from "./speech/speech-settings.js";
|
||||
import type { PersistedConfig } from "./persisted-config.js";
|
||||
import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
|
||||
|
||||
function usageEnabled(persisted: PersistedConfig): boolean {
|
||||
return persisted.daemon?.usage?.enabled !== false;
|
||||
}
|
||||
|
||||
function reloadableConfig(
|
||||
persisted: PersistedConfig,
|
||||
options: { relayEnabledFallback?: boolean } = {},
|
||||
@@ -23,6 +27,7 @@ function reloadableConfig(
|
||||
enabled: relay.enabled ?? options.relayEnabledFallback ?? true,
|
||||
},
|
||||
mcp: { enabled: true, injectIntoAgents: false },
|
||||
usage: { enabled: usageEnabled(persisted) },
|
||||
browserTools: { enabled: daemon.browserTools?.enabled ?? false },
|
||||
providers: (agents.providers ?? {}) as MutableDaemonConfig["providers"],
|
||||
metadataGeneration: { providers: agents.metadataGeneration?.providers ?? [] },
|
||||
@@ -1572,3 +1577,31 @@ describe("DaemonConfigStore speech settings", () => {
|
||||
expect(JSON.stringify(other.get())).not.toContain("secret");
|
||||
});
|
||||
});
|
||||
|
||||
test("usage policy persists across restart and live reload", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "usage-policy-"));
|
||||
try {
|
||||
const initial = reloadableConfig({});
|
||||
const store = new DaemonConfigStore(paseoHome, initial, undefined, {
|
||||
reloadSource: {
|
||||
resolve: (persisted) => ({
|
||||
mutable: reloadableConfig(persisted),
|
||||
overrideControlledPaths: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
await store.patch({ usage: { enabled: false } });
|
||||
expect(loadConfig(paseoHome, { env: {} }).usageEnabled).toBe(false);
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
const restarted = new DaemonConfigStore(paseoHome, reloadableConfig(persisted));
|
||||
expect(restarted.get().usage).toEqual({ enabled: false });
|
||||
writeFileSync(
|
||||
path.join(paseoHome, "config.json"),
|
||||
JSON.stringify({ daemon: { usage: { enabled: true } } }),
|
||||
);
|
||||
await store.reload();
|
||||
expect(store.get().usage).toEqual({ enabled: true });
|
||||
} finally {
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ type ProviderOverride = import("./agent/provider-launch-config.js").ProviderOver
|
||||
interface SupportedMutableConfigPatch {
|
||||
relay?: { enabled?: boolean; requireProtectedSecurity?: boolean };
|
||||
mcp?: { injectIntoAgents?: boolean };
|
||||
usage?: { enabled: boolean };
|
||||
browserTools?: { enabled?: boolean };
|
||||
providers?: MutableDaemonConfig["providers"];
|
||||
removeProviders?: string[];
|
||||
@@ -182,6 +183,7 @@ const RELOADABLE_PATHS = [
|
||||
"daemon.relay.requireProtectedSecurity",
|
||||
"daemon.mcp.enabled",
|
||||
"daemon.mcp.injectIntoAgents",
|
||||
"daemon.usage.enabled",
|
||||
"daemon.browserTools.enabled",
|
||||
"daemon.hostnames",
|
||||
"daemon.cors.allowedOrigins",
|
||||
@@ -209,6 +211,7 @@ const PERSISTED_TO_MUTABLE_PATH = new Map<string, string>([
|
||||
["daemon.relay.requireProtectedSecurity", "relay.requireProtectedSecurity"],
|
||||
["daemon.mcp.enabled", "mcp.enabled"],
|
||||
["daemon.mcp.injectIntoAgents", "mcp.injectIntoAgents"],
|
||||
["daemon.usage.enabled", "usage.enabled"],
|
||||
["daemon.browserTools.enabled", "browserTools.enabled"],
|
||||
["daemon.hostnames", "hostnames"],
|
||||
["daemon.cors.allowedOrigins", "cors.allowedOrigins"],
|
||||
@@ -281,6 +284,7 @@ function pickRelayPatch(
|
||||
function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMutableConfigPatch {
|
||||
return {
|
||||
...pickRelayPatch(patch.relay),
|
||||
usage: patch.usage,
|
||||
...(patch.mcp?.injectIntoAgents !== undefined
|
||||
? { mcp: { injectIntoAgents: patch.mcp.injectIntoAgents } }
|
||||
: {}),
|
||||
@@ -767,6 +771,7 @@ function mergeMutableDaemonPatch(
|
||||
if (patch.mcp?.injectIntoAgents !== undefined) {
|
||||
next.mcp = { ...next.mcp, injectIntoAgents: patch.mcp.injectIntoAgents };
|
||||
}
|
||||
if (patch.usage) next.usage = patch.usage;
|
||||
if (patch.browserTools?.enabled !== undefined) {
|
||||
next.browserTools = { ...next.browserTools, enabled: patch.browserTools.enabled };
|
||||
}
|
||||
|
||||
+23
-1
@@ -1,3 +1,6 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -7,7 +10,7 @@ import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
|
||||
test("background children keep their actions after the parent finishes (#6253)", async () => {
|
||||
test("background cards retain child actions after the parent finishes (#6253)", async () => {
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-background-card-"));
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
@@ -59,6 +62,22 @@ test("background children keep their actions after the parent finishes (#6253)",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
});
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
"--conditions=source",
|
||||
"--import",
|
||||
"tsx",
|
||||
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
"logs",
|
||||
agent.id,
|
||||
],
|
||||
{ env: { ...process.env, PASEO_HOME: daemon.paseoHome }, timeout: 30_000 },
|
||||
);
|
||||
expect(stdout).toContain("[Bash] ls -1");
|
||||
expect(stdout).toContain("[Bash] pwd");
|
||||
expect(
|
||||
atFinish.entries.filter(
|
||||
(row) => row.item.type === "tool_call" && row.item.detail?.type === "sub_agent",
|
||||
@@ -75,6 +94,9 @@ test("background children keep their actions after the parent finishes (#6253)",
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
for (const card of cards) {
|
||||
expect(card.detail?.type === "sub_agent" ? card.detail.log : "").toContain("[Bash]");
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { initializeEnvironmentProxy } from "./network/index.js";
|
||||
import { createPaseoDaemon, formatListenTarget } from "./bootstrap.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { resolvePaseoHome } from "./paseo-home.js";
|
||||
@@ -132,6 +133,11 @@ function applyCliFlagOverrides(config: ReturnType<typeof loadConfig>): void {
|
||||
|
||||
async function main() {
|
||||
const { paseoHome, logger, config } = bootstrapFromEnvironment();
|
||||
for (const variable of initializeEnvironmentProxy()) {
|
||||
writeWorkerLifecycleLog(paseoHome, "Ignoring invalid or unsupported HTTP proxy variable", {
|
||||
variable,
|
||||
});
|
||||
}
|
||||
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
|
||||
let shutdownPromise: Promise<number> | null = null;
|
||||
let exitHookInstalled = false;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WebSocket } from "ws";
|
||||
import { z } from "zod";
|
||||
import { webSocketProxyAgent } from "../network/index.js";
|
||||
import type { WebSocketLike } from "../websocket-server.js";
|
||||
|
||||
const SOCKET_PING_INTERVAL_MS = 10_000;
|
||||
@@ -166,6 +167,7 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
|
||||
openSocket(input: HubSocketCredentials, events: HubSocketEvents): HubSocketConnection {
|
||||
let sessionProtocol: "legacy" | "session-v1" = "legacy";
|
||||
const socket = new WebSocket(input.webSocketUrl, {
|
||||
agent: webSocketProxyAgent(input.webSocketUrl),
|
||||
handshakeTimeout: this.requestTimeoutMs,
|
||||
headers: {
|
||||
authorization: `Bearer ${input.credential}`,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Agent } from "node:http";
|
||||
import { EnvHttpProxyAgent, Pool, setGlobalDispatcher } from "undici";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
|
||||
interface ProxyEnvironment {
|
||||
httpProxy: string;
|
||||
httpsProxy: string;
|
||||
noProxy: string;
|
||||
}
|
||||
|
||||
let environment = readEnvironment(process.env, process.platform).environment;
|
||||
|
||||
function readEnvironment(
|
||||
env: NodeJS.ProcessEnv,
|
||||
platform: NodeJS.Platform,
|
||||
): {
|
||||
environment: ProxyEnvironment;
|
||||
ignored: string[];
|
||||
} {
|
||||
// process.env lookups are case-insensitive on Windows. Read each actual entry once.
|
||||
const values = new Map(
|
||||
Object.entries(env).map(([name, value]) => [
|
||||
platform === "win32" ? name.toLowerCase() : name,
|
||||
value,
|
||||
]),
|
||||
);
|
||||
const ignored: string[] = [];
|
||||
function proxy(variable: string): string | undefined {
|
||||
const value = values.get(variable);
|
||||
if (value === undefined) return undefined;
|
||||
if (!value.trim()) return undefined;
|
||||
try {
|
||||
const url = new URL(value.includes("://") ? value : `http://${value}`);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:")
|
||||
throw new Error("Unsupported proxy protocol");
|
||||
decodeURIComponent(url.username);
|
||||
decodeURIComponent(url.password);
|
||||
return url.href;
|
||||
} catch {
|
||||
ignored.push(variable);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const httpProxy = proxy("http_proxy") ?? proxy("HTTP_PROXY") ?? "";
|
||||
return {
|
||||
environment: {
|
||||
httpProxy,
|
||||
httpsProxy: proxy("https_proxy") ?? proxy("HTTPS_PROXY") ?? httpProxy,
|
||||
noProxy: values.get("no_proxy") ?? values.get("NO_PROXY") ?? "",
|
||||
},
|
||||
ignored,
|
||||
};
|
||||
}
|
||||
|
||||
function bypassList(noProxy: string): string {
|
||||
return noProxy.split(/[,\s]+/).includes("*") ? "*" : `${noProxy},localhost,127.0.0.1,[::1],::1`;
|
||||
}
|
||||
|
||||
function proxyClient(origin: URL, options: object) {
|
||||
const pool = new Pool(origin, options);
|
||||
if (!origin.username && !origin.password) return pool;
|
||||
const credentials = `${decodeURIComponent(origin.username)}:${decodeURIComponent(origin.password)}`;
|
||||
const authorization = `Basic ${Buffer.from(credentials).toString("base64")}`;
|
||||
// This pool only sends CONNECT requests to this proxy, never requests to the origin.
|
||||
// undici's URL auth requires both fields; HTTP Basic also permits either field to be empty.
|
||||
return pool.compose(
|
||||
(dispatch) => (request, handler) =>
|
||||
dispatch(
|
||||
{
|
||||
...request,
|
||||
headers: { ...request.headers, "proxy-authorization": authorization },
|
||||
},
|
||||
handler,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Install once at startup; return ignored variable names for logging without exposing credentials. */
|
||||
export function initializeEnvironmentProxy(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string[] {
|
||||
const resolved = readEnvironment(env, platform);
|
||||
environment = resolved.environment;
|
||||
setGlobalDispatcher(
|
||||
new EnvHttpProxyAgent({
|
||||
httpProxy: environment.httpProxy,
|
||||
httpsProxy: environment.httpsProxy,
|
||||
noProxy: bypassList(environment.noProxy),
|
||||
clientFactory: proxyClient,
|
||||
}),
|
||||
);
|
||||
return resolved.ignored;
|
||||
}
|
||||
|
||||
/** ws uses Node HTTP agents rather than the fetch dispatcher. */
|
||||
export function webSocketProxyAgent(address: string): Agent | undefined {
|
||||
const url = new URL(address);
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const port = url.port || (url.protocol === "wss:" ? "443" : "80");
|
||||
const entries = bypassList(environment.noProxy)
|
||||
.toLowerCase()
|
||||
.split(/[,\s]+/);
|
||||
if (
|
||||
entries.some((entry) => {
|
||||
if (entry === "*") return true;
|
||||
const match = entry.match(/^(.+):(\d+)$/);
|
||||
if (match && Number(match[2]) !== Number(port)) return false;
|
||||
const host = (match ? match[1]! : entry).replace(/^\*?\./, "");
|
||||
return host !== "" && (hostname === host || hostname.endsWith(`.${host}`));
|
||||
})
|
||||
)
|
||||
return undefined;
|
||||
const proxy = url.protocol === "wss:" ? environment.httpsProxy : environment.httpProxy;
|
||||
return proxy ? new HttpsProxyAgent(proxy) : undefined;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user