Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 5347d8e1fe test(app): reproduce stale draft error after host reconnects
After an offline draft submit, reconnecting restores model selection but leaves the connection alert visible. Keep the failing browser assertion to guide the submission error ownership decision in #7307.
2026-10-19 00:38:12 +02:00
paseo-bot[bot]andStephen Holiday dc7bed417a Keep late composer pills clear of completed turn actions (#7341)
* fix(app): keep composer tracks clear of timeline footers

* fix(app): preserve detached scroll on overlay mount

* style(app): use object input for overlay anchor policy

---------

Co-authored-by: Stephen Holiday <stephen.holiday@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 00:08:37 +02:00
paseo-bot[bot]andHemanth 08853039fb fix(server): keep resumed goal turns visible and stoppable (#7339)
A Codex goal can start while thread/resume is still connecting, before the manager can subscribe. Retain those startup events until the first subscriber and apply them before registration returns, preserving run state and cancellation ownership.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Hemanth <git@soni.im>
2026-10-18 21:55:29 +02:00
paseo-bot[bot] aef7cc36a0 fix(server): connect to relays over high-latency networks (#7336)
The runtime default cancels a pending IPv4 connect before slow routes complete, then fails immediately on unreachable IPv6. Give each relay address five seconds within the existing ten-second handshake deadline, covering both control and data sockets.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 20:57:22 +02:00
7c7b6555f1 Show provider artwork in the catalog and provider rows (#7330)
* Add missing monochrome provider icons (kiro, devin, hermes, minimax-code, traecli, grok, deepseek-tui)

* fix(app): add Kiro catalog icon and Antigravity builtin icon

The ACP catalog's kiro entry had iconId null and no vendored svg, and
the native Antigravity provider resolved to the Bot fallback before the
daemon snapshot arrived. Vendor the Kiro brand mark, wire iconId, and
register a builtin AntigravityIcon matching the plugin's theme-aware
icon.svg.

* fix(app): keep Kiro eyes in monochrome icon

* test(providers): cover catalog artwork and snapshot-free icons

Lock down the missing provider marks through the catalog and the shared icon hook. Exercise the rendered catalog artwork in Chromium alongside coverage for the pre-snapshot fallback.

Co-authored-by: kevenhu001-cyber <keven.hu001@gmail.com>
Co-authored-by: Lu <jack1125516912@outlook.com>

* fix(website): preserve unrelated agent directory artwork

Drop the unrelated website mark introduced with the provider artwork. Cover the existing directory fallback and retained provider mark in a browser regression.

---------

Co-authored-by: kevenhu001-cyber <keven.hu001@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Lu <jack1125516912@outlook.com>
2026-10-18 20:00:36 +02:00
303cebec06 Keep sidebar setup actions inside the empty-state card (#7312)
* fix(app): wrap sidebar empty-state actions so longer labels stay inside the card

The "Add project" / "Import session" row in the sidebar's empty state is a
non-wrapping flex row. With longer translations (e.g. Russian) the second
button overflows the card at the default 320px sidebar width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): cover sidebar empty-state actions staying inside the card

Renders the empty state in a real browser at the default sidebar width and
checks both actions' bounds against the card, for Russian (fails without the
wrap) and English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(app): keep long sidebar actions inside narrow cards

Wrapping the row still lets a single translated action exceed its available width. Bound each empty-state action to the row and allow its label to shrink and wrap.

Co-authored-by: Vyacheslav Scherbinin <gentslava@mail.ru>

---------

Co-authored-by: Vyacheslav Scherbinin <gentslava@mail.ru>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 17:54:51 +02:00
paseo-bot[bot] fcda2f1cf0 fix(hub): reconnect after silent connection loss (#7315)
A network path can swallow close notifications while the Hub socket remains open locally. Probe it with protocol ping/pong frames and feed stale sockets into the existing reconnect path.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 17:00:13 +02:00
Mohamed Boudra f7a0e2cbea test(server): wait up to ten seconds for the OMP relaunch to register MCP tools (#7304)
The crash-relaunch test waited with vi.waitFor's default one second for a
fresh runtime and MCP child to come up. On the Windows runner with three
workers that took longer once in six runs and failed main. The assertions
are unchanged; only the budget for the relaunch is realistic now.
2026-10-18 21:11:14 +07:00
paseo-ai[bot] 2a9e86b542 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-18 11:09:08 +00:00
paseo-bot[bot]andMichael Whitman 9ed4f1069b fix(app): create project workspaces on the active host (#7308)
Grouped sidebar projects chose the first eligible host even while a workspace on another host was active. Prefer that active host when online and eligible, keeping the existing fallback for other placements.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Michael Whitman <michael.whitman@getsteward.ai>
2026-10-18 12:57:54 +02:00
40 changed files with 1019 additions and 30 deletions
+1 -1
View File
@@ -1 +1 @@
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
sha256-tSE4nb4E06GFZ8I9UEbo93nlPiAKtW00nkywgKQmMhI=
@@ -11,7 +11,7 @@ async function restoreHostConnection(gate: Awaited<ReturnType<typeof installDaem
gate.releaseHeldServerMessage("status");
}
test("an offline workspace draft reports the disconnected host instead of loading models", async ({
test("an offline workspace draft clears its connection error when the host reconnects", async ({
page,
withWorkspace,
}) => {
@@ -28,5 +28,6 @@ test("an offline workspace draft reports the disconnected host instead of loadin
await expect(page.getByText("Loading...", { exact: true })).toHaveCount(0);
await restoreHostConnection(gate);
await expectComposerModel(page, "Ten second stream");
await expect(page.getByRole("alert").filter({ hasText: "Host is not connected" })).toHaveCount(0);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveValue("hello");
});
@@ -0,0 +1,51 @@
import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
import { expect, test, type Page } from "../support/fixtures";
import type { TestInfo } from "@playwright/test";
import { gotoAppShell, openSettings } from "../support/helpers/app";
import { getServerId } from "../support/helpers/server-id";
import {
findAcpCatalogProvider,
openAddProviderArea,
openSettingsHost,
openSettingsHostSection,
} from "../support/helpers/settings";
async function openProviderCatalog(page: Page) {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHost(page, getServerId());
await openSettingsHostSection(page, getServerId(), "providers");
await openAddProviderArea(page);
}
async function expectProviderArtwork(
page: Page,
testInfo: TestInfo,
id: "devin" | "hermes" | "kiro",
name: string,
) {
await findAcpCatalogProvider(page, name);
// Catalog tiles have no semantic role; scope artwork to the named provider's tile.
const icon = page.getByTestId(`provider-catalog-tile-${id}`).locator("svg").first();
await expect(icon).toBeVisible();
// Provider artwork is 24px; the generic PackagePlus fallback is 20px.
await expect.soft(icon).toHaveAttribute("width", "24");
const expectedPaths = await page.evaluate((xml) => {
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
}, ACP_PROVIDER_ICON_SVGS[id]);
await expect(icon.locator("path")).toHaveCount(expectedPaths.length);
for (const [index, path] of expectedPaths.entries()) {
await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path);
}
await page.screenshot({ path: testInfo.outputPath(`${id}-catalog.png`) });
}
test("#7087: the provider catalog shows Devin, Hermes, and Kiro artwork", async ({
page,
}, testInfo) => {
await openProviderCatalog(page);
await expectProviderArtwork(page, testInfo, "devin", "Devin CLI");
await expectProviderArtwork(page, testInfo, "hermes", "Hermes");
await expectProviderArtwork(page, testInfo, "kiro", "Kiro CLI");
});
@@ -9,6 +9,8 @@ import {
expectProjectWorkspaceCountForHost,
expectSeparateProjects,
openGroupedProjectSettings,
openGroupedProjectWorkspace,
expectWorkspaceHost,
openProjectsForSettingsHost,
openProjectDirectory,
openProjectDirectoryWithHosts,
@@ -418,4 +420,29 @@ test.describe("Sidebar project grouping", () => {
count: 2,
});
});
test("creates grouped project workspaces on the active workspace host", async ({
page,
crossHostProject,
}) => {
await openScenario(page, crossHostProject);
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Secondary workspace");
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
await expectWorkspaceHost(page, SECONDARY_HOST_LABEL);
await createWorkspaceWithoutAgent(page);
await expectProjectWorkspaceCountForHost(page, {
projectName: GROUPED_PROJECT_NAME,
hostName: SECONDARY_HOST_LABEL,
count: 2,
});
await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Primary workspace");
await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
await expectWorkspaceHost(page, PRIMARY_HOST_LABEL);
await createWorkspaceWithoutAgent(page);
await expectProjectWorkspaceCountForHost(page, {
projectName: GROUPED_PROJECT_NAME,
hostName: PRIMARY_HOST_LABEL,
count: 2,
});
});
});
@@ -0,0 +1,47 @@
# Late composer pill clearance
This regression opens a completed conversation, relaunches the Android app, and
registers a composer pill five seconds after the client plugin loads. It checks
that the completed turn's Copy and Fork buttons remain fully above the pill.
The daemon, plugin API, native app, layout, and accessibility bounds are real;
no provider calls are needed during the test.
Prerequisites:
- Build the server and app dependencies with `npm run build:server` and
`npm run build --workspace=@getpaseo/expo-two-way-audio`.
- Run an isolated development daemon on a non-default port.
- Run this checkout's Metro server and open the connected Android dev client
with `agent-device`. The app must already know the isolated daemon.
- Open a completed conversation on a compact portrait Android emulator, at the
latest turn, with no other composer tracks visible. Keep the software keyboard
closed. Note the workspace and agent IDs from the daemon.
Set `WORKSPACE_ID` and `AGENT_ID` to that conversation, then run from the
repository root, substituting the session and ports:
```sh
PASEO_OVERLAY_ARTIFACTS_DIR=.dev/composer-overlay-evidence \
node packages/app/e2e/mobile/composer-overlay/android.mjs \
composer-overlay ws://127.0.0.1:6843/ws "$WORKSPACE_ID" "$AGENT_ID" sh.paseo.debug 8193
```
The runner installs and removes its delayed plugin and leaves the conversation
intact. It preserves the daemon's previous plugins-enabled setting. The evidence directory
contains the cold-reopen screenshot and raw accessibility snapshot. Without an
explicit directory, temporary evidence is removed with the fixture.
To check an existing capture independently:
```sh
node packages/app/e2e/mobile/composer-overlay/assert-footer-clear.mjs \
.dev/composer-overlay-evidence/cold-reopen.json 'Overlay regression pill'
```
On the failing baseline, the Copy button extends below the pill's top edge. With
the correction, both actions are clear before the composer receives focus.
Additional checks on the same conversation: opening the keyboard keeps the footer
clear; after scrolling up, a newly registered pill must not jump back to the latest
turn. Native iOS uses a different keyboard implementation and needs its own device
verification.
@@ -0,0 +1,95 @@
// Start on a completed conversation in the connected Android dev client.
// node android.mjs <session> <daemon-ws-url> <workspace-id> <agent-id> [app-id] [metro-port]
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { WebSocket } from "ws";
import { DaemonClient } from "../../../../client/dist/daemon-client.js";
const [, , session, daemonUrl, workspaceId, agentId, appId = "sh.paseo.debug", metroPort = "8081"] =
process.argv;
assert(
session && daemonUrl && workspaceId && agentId,
"Pass a session, isolated daemon URL, workspace ID, and agent ID",
);
const url = new URL(daemonUrl);
assert(["127.0.0.1", "localhost"].includes(url.hostname), "Use an isolated loopback daemon");
assert(!["", "6767", "6768"].includes(url.port), "Do not use the installed or development daemon");
const directory = await mkdtemp(path.join(tmpdir(), "paseo-composer-overlay-"));
const artifacts = process.env.PASEO_OVERLAY_ARTIFACTS_DIR ?? path.join(directory, "evidence");
await mkdir(artifacts, { recursive: true });
const pluginId = "composer-overlay-mobile-repro";
const pillLabel = "Overlay regression pill";
const client = new DaemonClient({
url: daemonUrl,
clientId: "composer-overlay-mobile-regression",
clientType: "cli",
appVersion: JSON.parse(await readFile(new URL("../../../package.json", import.meta.url))).version,
webSocketFactory: (address, options) => new WebSocket(address, options),
});
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8",
timeout: 90_000,
});
}
let installed = false;
let previousConfig;
try {
ad("wait", 'label="Copy turn"', "20000");
await client.connect();
previousConfig = (await client.getDaemonConfig()).config;
await client.patchDaemonConfig({ pluginsEnabled: true });
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({
id: pluginId,
requirements: { paseo: ">=0.11.4" },
}),
);
await writeFile(
path.join(directory, "index.client.tsx"),
`export default function contribute(client) {
let pill;
const timer = setTimeout(() => {
pill = client.addComposerPill({
id: "late-pill", workspaceId: ${JSON.stringify(workspaceId)}, agentId: ${JSON.stringify(agentId)},
button: { title: ${JSON.stringify(pillLabel)}, icon: "Clock", behavior: { kind: "action", onPress() {} } },
});
}, 5000);
return () => { clearTimeout(timer); pill?.remove(); };
}`,
);
await client.installDirectoryPlugin(directory);
installed = true;
ad("wait", `label="${pillLabel}"`, "20000");
// Match the report: reopen a completed conversation, then let the plugin register late.
ad(
"open",
appId,
"--platform",
"android",
"--metro-port",
metroPort,
"--no-test-ime",
"--relaunch",
);
ad("wait", `label="${pillLabel}"`, "20000");
const capture = path.join(artifacts, "cold-reopen.json");
await writeFile(capture, ad("snapshot", "--raw", "--json"));
ad("screenshot", path.join(artifacts, "cold-reopen.png"));
execFileSync(
process.execPath,
[fileURLToPath(new URL("./assert-footer-clear.mjs", import.meta.url)), capture, pillLabel],
{ stdio: "inherit" },
);
} finally {
if (installed) await client.removePlugin(pluginId);
if (previousConfig)
await client.patchDaemonConfig({ pluginsEnabled: previousConfig.pluginsEnabled });
await client.close();
await rm(directory, { recursive: true, force: true });
}
@@ -0,0 +1,21 @@
// Assert geometry from a fresh agent-device snapshot --raw --json capture.
// node assert-footer-clear.mjs <snapshot.json> <composer-pill-label>
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const [, , snapshotPath, pillLabel] = process.argv;
assert(snapshotPath && pillLabel, "Pass a snapshot JSON path and composer pill label");
const { data } = JSON.parse(readFileSync(snapshotPath, "utf8"));
const buttons = data.nodes.filter((node) => node.type === "android.widget.Button");
const pill = buttons.find((node) => node.label === pillLabel);
assert(pill?.rect, `Composer pill '${pillLabel}' must be visible`);
for (const label of ["Copy turn", "Fork chat from here"]) {
const button = buttons.find((node) => node.label === label);
assert(button?.rect, `Completed-turn action '${label}' must be visible`);
const bottom = button.rect.y + button.rect.height;
assert(
bottom <= pill.rect.y,
`${label} ends at ${bottom}, below composer pill top ${pill.rect.y}`,
);
}
console.log("PASS: completed-turn actions remain fully above the composer pill");
@@ -134,3 +134,19 @@ export async function expectProjectSettingsName(page: Page, projectName: string)
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
export async function openGroupedProjectWorkspace(
page: Page,
projectName: string,
workspaceName: string,
): Promise<void> {
await projectGroup(page, projectName)
.getByRole("button")
.filter({ hasText: workspaceName })
.click();
await expect(page).toHaveURL(/\/workspace\//);
}
export async function expectWorkspaceHost(page: Page, hostName: string): Promise<void> {
await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName);
}
+6
View File
@@ -1252,6 +1252,12 @@ function agentStreamViewPropsEqual(
if (left.isAuthoritativeHistoryReady !== right.isAuthoritativeHistoryReady) {
reasons.push("isAuthoritativeHistoryReady");
}
if (left.bottomOverlayTailClearance !== right.bottomOverlayTailClearance) {
reasons.push("bottomOverlayTailClearance");
}
if (left.bottomOverlayControlClearance !== right.bottomOverlayControlClearance) {
reasons.push("bottomOverlayControlClearance");
}
if (left.toast !== right.toast) reasons.push("toast");
if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile");
if (left.readOnly !== right.readOnly) reasons.push("readOnly");
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path d="M2.033 9.867l2.554 1.483a.589.589 0 00.592 0l2.554-1.483.01-.008a.608.608 0 00.11-.084l.013-.015a.631.631 0 00.076-.1c.003-.005.008-.01.01-.016a.558.558 0 00.052-.125l.007-.028a.611.611 0 00.019-.14V7.868c0-.572.307-1.105.8-1.392a1.595 1.595 0 011.598 0l1.277.742a.54.54 0 00.129.053l.028.01c.044.01.088.015.133.016h.006l.013-.002a.587.587 0 00.27-.074l.011-.004 2.554-1.483a.596.596 0 00.297-.516V2.253a.595.595 0 00-.297-.516L12.293.257a.587.587 0 00-.591 0L9.148 1.737l-.01.01a.609.609 0 00-.109.083l-.014.015a.632.632 0 00-.076.1c-.003.005-.008.01-.01.016a.57.57 0 00-.052.124l-.007.028a.612.612 0 00-.018.14v1.483c0 .572-.307 1.105-.8 1.393a1.597 1.597 0 01-1.599 0l-1.276-.742a.603.603 0 00-.13-.053l-.028-.008a.658.658 0 00-.133-.018h-.02a.57.57 0 00-.269.074c-.003.002-.008.002-.012.005L2.033 5.872a.596.596 0 00-.297.515v2.966c0 .213.113.41.297.515z" fill="currentColor"></path><path d="M15.943 10.607a1.596 1.596 0 011.599 0l1.276.74c.041.025.085.04.13.055l.028.008c.043.01.088.016.133.018h.005c.005 0 .01-.002.014-.003a.474.474 0 00.122-.016l.021-.005a.616.616 0 00.126-.052c.004-.002.009-.002.013-.005l2.554-1.482a.597.597 0 00.297-.516V6.383a.596.596 0 00-.297-.515l-2.552-1.483a.587.587 0 00-.592 0l-2.553 1.482-.011.008a.61.61 0 00-.108.084l-.014.016a.637.637 0 00-.076.1c-.003.005-.008.01-.01.016a.57.57 0 00-.052.124l-.007.029a.612.612 0 00-.018.14v1.482c0 .572-.307 1.105-.8 1.393a1.597 1.597 0 01-1.599 0l-1.276-.742a.584.584 0 00-.13-.053l-.028-.008a.62.62 0 00-.133-.018h-.02a.587.587 0 00-.269.074l-.012.004L9.15 10a.596.596 0 00-.296.516v2.966c0 .212.112.409.296.515l2.554 1.483s.008.002.012.005c.04.022.082.04.126.052l.02.004a.57.57 0 00.123.017l.014.002h.006c.054 0 .108-.01.16-.025a.587.587 0 00.13-.054l1.277-.741a1.597 1.597 0 012.398 1.392v1.482c0 .049.007.095.019.14l.007.028a.619.619 0 00.051.125c.004.006.008.01.01.016a.6.6 0 00.076.1l.014.015c.033.032.069.06.108.084.004.002.006.006.011.008l2.554 1.483a.59.59 0 00.593 0l2.554-1.483a.597.597 0 00.296-.516v-2.965a.595.595 0 00-.296-.516l-2.554-1.483s-.008-.002-.012-.005a.54.54 0 00-.126-.051c-.007-.003-.013-.003-.02-.005a.635.635 0 00-.125-.017h-.018a.557.557 0 00-.16.026.588.588 0 00-.13.053l-1.276.742a1.595 1.595 0 01-1.598 0 1.615 1.615 0 010-2.785l-.005-.001z" fill="currentColor"></path><path d="M14.848 18.265l-2.554-1.482-.012-.005a.526.526 0 00-.126-.052c-.007-.002-.014-.002-.02-.005a.64.64 0 00-.124-.017h-.02a.56.56 0 00-.16.026.588.588 0 00-.13.053l-1.276.742a1.594 1.594 0 01-1.598 0c-.493-.286-.8-.82-.8-1.393V14.65a.563.563 0 00-.018-.14l-.008-.028a.604.604 0 00-.051-.124l-.01-.017a.603.603 0 00-.076-.1l-.014-.015a.596.596 0 00-.109-.084c-.003-.002-.005-.006-.01-.008L5.178 12.65a.587.587 0 00-.591 0l-2.554 1.483a.596.596 0 00-.297.516v2.965c0 .213.113.41.297.516l2.554 1.483.012.004a.618.618 0 00.267.074l.016.002h.007a.55.55 0 00.16-.026.584.584 0 00.129-.053l1.277-.742a1.597 1.597 0 012.398 1.393v1.482c0 .05.007.095.019.14l.007.028c.013.044.03.085.051.125l.01.016c.022.036.047.07.076.1l.014.015c.032.032.069.06.109.084l.01.008 2.554 1.483a.587.587 0 00.593 0l2.554-1.483a.596.596 0 00.296-.515v-2.966a.596.596 0 00-.296-.516h-.002z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 1024 1024" fill="none">
<path fill="currentColor" d="M395.479 633.828L735.91 381.105C752.599 368.715 776.454 373.548 784.406 392.792C826.26 494.285 807.561 616.253 724.288 699.996C641.016 783.739 525.151 802.104 419.247 760.277L303.556 814.143C469.49 928.202 670.987 899.995 796.901 773.282C896.776 672.843 927.708 535.937 898.785 412.476L899.047 412.739C857.105 231.37 909.358 158.874 1016.4 10.6326C1018.93 7.11771 1021.47 3.60279 1024 0L883.144 141.651V141.212L395.392 633.916"/>
<path fill="currentColor" d="M325.226 695.251C206.128 580.84 226.662 403.776 328.285 301.668C403.431 226.097 526.549 195.254 634.026 240.596L749.454 186.994C728.657 171.88 702.007 155.623 671.424 144.2C533.19 86.9942 367.693 115.465 255.323 228.382C147.234 337.081 113.244 504.215 171.613 646.833C215.216 753.423 143.739 828.818 71.7385 904.916C46.2237 931.893 20.6216 958.87 0 987.429L325.139 695.339"/>
</svg>

After

Width:  |  Height:  |  Size: 975 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M18.8 0H5.2A5.2 5.2 0 000 5.2v13.6A5.2 5.2 0 005.2 24h13.6a5.2 5.2 0 005.2-5.2V5.2A5.2 5.2 0 0018.8 0z" fill="#9046FF" /> <path d="M7.97 16.376c-1.644 3.642 1.86 4.556 4.443 2.424.76 2.39 3.608.607 4.631-1.247 2.251-4.084 1.342-8.249 1.108-9.108-1.6-5.859-9.6-5.869-10.976.03-.323 1.033-.328 2.206-.507 3.423-.09.617-.16 1.009-.393 1.655-.139.373-.323.7-.62 1.257-.458.865-.264 2.53 2.101 1.665l.224-.1h-.01l-.001.001z" fill="#fff" /> <path d="M12.722 10.985c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.218-.97a.61.61 0 01.537-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005zm2.703 0c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.219-.97a.61.61 0 01.536-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005z" fill="#000" /></svg>

After

Width:  |  Height:  |  Size: 895 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z" fill="currentColor" fill-rule="nonzero"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
<path d="M24 20.541H3.428v-3.426H0V3.4h24V20.54zM3.428 17.115h17.144V6.827H3.428v10.288zm8.573-5.196l-2.425 2.424-2.424-2.424 2.424-2.424 2.425 2.424zm6.857-.001l-2.424 2.423-2.425-2.423 2.425-2.425 2.424 2.425z" fill="currentColor"></path>
</svg>

After

Width:  |  Height:  |  Size: 334 B

@@ -0,0 +1,17 @@
import Svg, { Path } from "react-native-svg";
interface AntigravityIconProps {
size?: number;
color?: string;
}
export function AntigravityIcon({ size = 16, color = "currentColor" }: AntigravityIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path
transform="translate(12 12) scale(0.24) translate(-55.738 -56.945)"
d="M89.6992 93.695C94.3659 97.195 101.366 94.8617 94.9492 88.445C75.6992 69.7783 79.7825 18.445 55.8659 18.445C31.9492 18.445 36.0325 69.7783 16.7825 88.445C9.78251 95.445 17.3658 97.195 22.0325 93.695C40.1159 81.445 38.9492 59.8617 55.8659 59.8617C72.7825 59.8617 71.6159 81.445 89.6992 93.695Z"
/>
</Svg>
);
}
@@ -0,0 +1,18 @@
import Svg, { Path } from "react-native-svg";
interface KiroIconProps {
size?: number;
color?: string;
}
export function KiroIcon({ size = 16, color = "currentColor" }: KiroIconProps) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
<Path
fillRule="evenodd"
clipRule="evenodd"
d="M7.97 16.376c-1.644 3.642 1.86 4.556 4.443 2.424.76 2.39 3.608.607 4.631-1.247 2.251-4.084 1.342-8.249 1.108-9.108-1.6-5.859-9.6-5.869-10.976.03-.323 1.033-.328 2.206-.507 3.423-.09.617-.16 1.009-.393 1.655-.139.373-.323.7-.62 1.257-.458.865-.264 2.53 2.101 1.665l.224-.1h-.01l-.001.001zM12.722 10.985c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.218-.97a.61.61 0 01.537-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005zm2.703 0c-.656 0-.755-.785-.755-1.252 0-.423.074-.756.219-.97a.61.61 0 01.536-.283c.229 0 .428.095.567.289.159.218.243.55.243.964 0 .785-.303 1.252-.805 1.252h-.005z"
/>
</Svg>
);
}
@@ -17,8 +17,10 @@ describe("resolveProviderIconName", () => {
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
expect(resolveProviderIconName("devin")).toEqual({ kind: "catalog", id: "devin" });
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
expect(resolveProviderIconName("gjc")).toEqual({ kind: "catalog", id: "gjc" });
expect(resolveProviderIconName("hermes")).toEqual({ kind: "catalog", id: "hermes" });
expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" });
});
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from "@testing-library/react";
import { Bot } from "lucide-react-native";
import { Bot, PackagePlus } from "lucide-react-native";
import { SvgXml } from "react-native-svg";
import { afterEach, describe, expect, it } from "vitest";
import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
@@ -17,6 +17,16 @@ function renderIcon(Component: ProviderIconComponent) {
}
describe("useProviderIcon", () => {
it.each(["devin", "hermes", "kiro", "antigravity"])(
"#7087: %s has a provider mark before a host snapshot arrives",
(provider) => {
const Icon = renderHook(() => useProviderIcon(provider)).result.current;
expect(Icon).not.toBe(Bot);
expect(Icon).not.toBe(PackagePlus);
expect(typeof Icon).toBe("function");
},
);
it("renders registered snapshot SVG metadata with the requested size and color", () => {
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
replaceProviderSnapshotIcons("server-1", [{ provider: "rendered-provider", iconSvg: svg }]);
@@ -1,12 +1,14 @@
import { useStore } from "zustand";
import { providerSnapshotIcons } from "@/data/provider-icons";
import { Bot, PackagePlus } from "lucide-react-native";
import { Bot } from "lucide-react-native";
import { createElement, useCallback, type ComponentType } from "react";
import { SvgXml } from "react-native-svg";
import { TERMINAL_PROFILE_ICON_NAMES } from "@getpaseo/protocol/provider-icon-names";
import { AntigravityIcon } from "@/components/icons/antigravity-icon";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
import { KiroIcon } from "@/components/icons/kiro-icon";
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { OmpIcon } from "@/components/icons/omp-icon";
@@ -23,10 +25,11 @@ export interface ProviderIconProps {
export type ProviderIconComponent = ComponentType<ProviderIconProps>;
const BUILTIN_PROVIDER_ICONS: Record<string, ProviderIconComponent> = {
antigravity: AntigravityIcon as unknown as ProviderIconComponent,
claude: ClaudeIcon as unknown as ProviderIconComponent,
codex: CodexIcon as unknown as ProviderIconComponent,
copilot: CopilotIcon as unknown as ProviderIconComponent,
kiro: PackagePlus,
kiro: KiroIcon as unknown as ProviderIconComponent,
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
omp: OmpIcon as unknown as ProviderIconComponent,
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
@@ -41,7 +41,11 @@ import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
getHostRuntimeStore,
useHosts,
useHostRuntimeConnectionStatus,
} from "@/runtime/host-runtime";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import {
useSidebarWorkspacePinController,
@@ -1596,14 +1600,18 @@ function ProjectBlock({
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(project.workspaces);
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
const rowModel = useMemo(
() =>
buildSidebarProjectRowModel({
project,
collapsed,
supportsMultiplicityByServerId,
preferredServerId,
}),
[collapsed, project, supportsMultiplicityByServerId],
[collapsed, preferredServerId, project, supportsMultiplicityByServerId],
);
// Collapsed rows hide their workspace rows, so the project row carries the most urgent
@@ -1848,6 +1856,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.useNestable === next.useNestable &&
previous.dragGestureHostActive === next.dragGestureHostActive &&
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId &&
areProjectBlockSelectionsEqual(previous, next)
);
}
@@ -0,0 +1,85 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getAllByRole } from "@testing-library/dom";
import { i18n as testI18n } from "@/i18n/i18next";
import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
import { SidebarProjectEmptyState } from "./empty-states";
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
interface Mounted {
root: Root;
container: HTMLDivElement;
}
const mounted: Mounted[] = [];
afterEach(async () => {
for (const entry of mounted.splice(0)) {
act(() => entry.root.unmount());
entry.container.remove();
}
await testI18n.changeLanguage("en");
});
async function mountEmptyState(language: string, width: number): Promise<HTMLElement> {
await testI18n.changeLanguage(language);
const container = document.createElement("div");
container.style.width = `${width}px`;
// Match the workspace list's horizontal inset around the card.
container.style.paddingInline = "6px";
container.style.boxSizing = "border-box";
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(<SidebarProjectEmptyState />));
mounted.push({ root, container });
const card = container.querySelector('[data-testid="sidebar-project-empty-state"]');
if (!(card instanceof HTMLElement)) {
throw new Error("SidebarProjectEmptyState did not render its card");
}
return card;
}
function expectActionsInsideCard(card: HTMLElement) {
const cardRect = card.getBoundingClientRect();
const buttons = getAllByRole(card, "button");
expect(buttons).toHaveLength(2);
for (const button of buttons) {
const rect = button.getBoundingClientRect();
expect(rect.left).toBeGreaterThanOrEqual(cardRect.left);
expect(rect.right).toBeLessThanOrEqual(cardRect.right);
// Containing the button must also keep the whole label readable, without clipping.
const label = document.createRange();
label.selectNodeContents(button);
for (const fragment of Array.from(label.getClientRects())) {
expect(fragment.left).toBeGreaterThanOrEqual(rect.left);
expect(fragment.right).toBeLessThanOrEqual(rect.right);
expect(fragment.top).toBeGreaterThanOrEqual(rect.top);
expect(fragment.bottom).toBeLessThanOrEqual(rect.bottom);
}
}
}
describe("SidebarProjectEmptyState", () => {
const cases = ["en", "ru", "fr", "ja", "es", "pt-BR", "ar", "ko", "zh-CN"].flatMap((language) =>
[MIN_SIDEBAR_WIDTH, DEFAULT_SIDEBAR_WIDTH].map((width) => ({ language, width })),
);
it.each(cases)(
"keeps every action inside the card ($language, $width px sidebar)",
async ({ language, width }) => {
expectActionsInsideCard(await mountEmptyState(language, width));
},
);
it("keeps English actions on one row when they fit", async () => {
const card = await mountEmptyState("en", DEFAULT_SIDEBAR_WIDTH);
const [addProject, importSession] = getAllByRole(card, "button");
expect(addProject.getBoundingClientRect().top).toBe(importSession.getBoundingClientRect().top);
});
});
@@ -54,7 +54,13 @@ export function SidebarFilterEmptyState() {
title={t("sidebar.filterEmpty.title")}
description={t("sidebar.filterEmpty.description")}
>
<Button variant="secondary" size="xs" onPress={clearFilters}>
<Button
variant="secondary"
size="xs"
style={styles.action}
textStyle={styles.actionText}
onPress={clearFilters}
>
{t("sidebar.filterEmpty.clear")}
</Button>
</SidebarEmptyStateCard>
@@ -76,10 +82,24 @@ export function SidebarProjectEmptyState({
title={t("sidebar.project.empty.title")}
description={t("sidebar.project.empty.description")}
>
<Button variant="secondary" size="xs" leftIcon={Plus} onPress={onAddProject}>
<Button
variant="secondary"
size="xs"
leftIcon={Plus}
style={styles.action}
textStyle={styles.actionText}
onPress={onAddProject}
>
{t("sidebar.actions.addProject")}
</Button>
<Button variant="outline" size="xs" leftIcon={Import} onPress={onImportSession}>
<Button
variant="outline"
size="xs"
leftIcon={Import}
style={styles.action}
textStyle={styles.actionText}
onPress={onImportSession}
>
{t("importSession.title")}
</Button>
</SidebarEmptyStateCard>
@@ -99,9 +119,16 @@ const styles = StyleSheet.create((theme) => ({
},
actions: {
flexDirection: "row",
flexWrap: "wrap",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
action: {
maxWidth: "100%",
},
actionText: {
flexShrink: 1,
},
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
@@ -133,7 +133,7 @@ const CATALOG_DATA = [
title: "Devin CLI",
description: "Cognition's Devin for Terminal via Agent Client Protocol",
version: "manual",
iconId: null,
iconId: "devin",
installLink: "https://cli.devin.ai/docs",
command: ["devin", "acp"],
},
@@ -235,7 +235,7 @@ const CATALOG_DATA = [
title: "Hermes",
description: "Nous Research self-improving AI agent",
version: "manual",
iconId: null,
iconId: "hermes",
installLink: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
command: ["hermes", "acp"],
},
@@ -272,7 +272,7 @@ const CATALOG_DATA = [
title: "Kiro CLI",
description: "Amazon's AI coding agent with native ACP support",
version: "manual",
iconId: null,
iconId: "kiro",
installLink: "https://kiro.dev/docs/cli/acp/",
command: ["kiro-cli", "acp"],
},
@@ -11,6 +11,13 @@ function findProvider(id: string) {
}
describe("ACP provider catalog", () => {
it.each(["devin", "hermes", "kiro"])(
"#7087: %s supplies its provider mark to the catalog picker",
(provider) => {
expect(findProvider(provider).iconSvg).toContain("<svg");
},
);
it("vendors provider entries with unique ids and concrete commands", () => {
const ids = new Set<string>();
@@ -65,6 +65,45 @@ function project(overrides: ProjectOverrides = {}): SidebarProjectEntry {
}
describe("buildSidebarProjectRowModel", () => {
it("prefers the active host when the grouped project can create there", () => {
const result = buildSidebarProjectRowModel({
project: project({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" },
{ serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" },
],
}),
collapsed: false,
preferredServerId: "host-b",
});
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "host-b", projectId: "project-host-b", iconWorkingDir: "/repo/b" },
});
});
it.each(["host-b", "another-host"])(
"keeps the first eligible placement when preferred host %s cannot create here",
(preferredServerId) => {
const result = buildSidebarProjectRowModel({
project: project({
hosts: [
{ serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" },
{ serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "unsupported" },
],
}),
collapsed: false,
preferredServerId,
});
expect(result.trailingAction).toEqual({
kind: "new_workspace",
target: { serverId: "host-a", projectId: "project-host-a", iconWorkingDir: "/repo/a" },
});
},
);
it("renders a non-git single-workspace project as an expandable section", () => {
const result = buildSidebarProjectRowModel({
project: project({
@@ -85,7 +85,9 @@ export function resolveSidebarProjectLocalPath(
function resolveNewWorkspaceTarget(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
preferredServerId: string | null | undefined,
): SidebarProjectHostTarget | null {
let fallbackTarget: SidebarProjectHostTarget | null = null;
for (const host of project.hosts) {
if (
host.worktreeSupport === "unsupported" &&
@@ -94,22 +96,30 @@ function resolveNewWorkspaceTarget(
continue;
}
const target = hostTarget(host);
if (target) return target;
if (!target) continue;
if (host.serverId === preferredServerId) return target;
fallbackTarget ??= target;
}
return null;
return fallbackTarget;
}
function projectTrailingAction(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap<string, boolean>,
preferredServerId: string | null | undefined,
): SidebarProjectTrailingAction {
const target = resolveNewWorkspaceTarget(project, supportsMultiplicityByServerId);
const target = resolveNewWorkspaceTarget(
project,
supportsMultiplicityByServerId,
preferredServerId,
);
return target ? { kind: "new_workspace", target } : { kind: "none" };
}
export function buildSidebarProjectRowModel(input: {
project: SidebarProjectEntry;
collapsed: boolean;
preferredServerId?: string | null;
supportsMultiplicityByServerId?: ReadonlyMap<string, boolean>;
}): SidebarProjectRowModel {
return {
@@ -118,6 +128,7 @@ export function buildSidebarProjectRowModel(input: {
trailingAction: projectTrailingAction(
input.project,
input.supportsMultiplicityByServerId ?? EMPTY_MULTIPLICITY_MAP,
input.preferredServerId,
),
};
}
@@ -1,4 +1,5 @@
export const BUILTIN_PROVIDER_ICON_NAMES = [
"antigravity",
"claude",
"codex",
"copilot",
@@ -22,6 +23,7 @@ export const ACP_PROVIDER_ICON_NAMES = [
"crow-cli",
"cursor",
"deepagents",
"devin",
"dimcode",
"dirac",
"factory-droid",
@@ -31,6 +33,7 @@ export const ACP_PROVIDER_ICON_NAMES = [
"glm-acp-agent",
"goose",
"grok",
"hermes",
"hoplite",
"junie",
"kilo",
@@ -3654,6 +3654,8 @@ export class AgentManager {
this.assertAgentRegistrationActive(managed);
this.emitState(managed, { persist: false });
this.subscribeToSession(managed);
await this.drainSessionEvents(managed.id);
this.assertAgentRegistrationActive(managed);
return { ...managed };
} catch (error) {
if (!registered) {
@@ -9,6 +9,7 @@ import { PassThrough } from "node:stream";
import { fileURLToPath } from "node:url";
import type {
AgentClient,
AgentLaunchContext,
AgentPermissionRequest,
AgentSession,
@@ -94,6 +95,7 @@ import {
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
import { AgentManager } from "../agent-manager.js";
import { buildProviderRegistry } from "../provider-registry.js";
interface CollaborationModeRecord {
@@ -7073,3 +7075,95 @@ describe("Codex denied plan approvals", () => {
});
});
});
describe("Codex interactive resume startup", () => {
test("delivers an autonomous turn that starts during resume to the first subscriber", async () => {
const appServer = createFakeCodexAppServer({
"turn/interrupt": () => ({}),
"thread/resume": () => {
appServer.startsTurn({ threadId: "saved-thread", turnId: "autonomous-turn" });
appServer.says({ threadId: "saved-thread", text: "Working on the goal" });
return { thread: { id: "saved-thread" } };
},
"thread/read": () => ({ thread: { turns: [] } }),
});
const session = new CodexAppServerAgentSession(
createConfig({ modeId: "full-access" }),
{ sessionId: "saved-thread" },
createTestLogger(),
async () => appServer.child,
);
try {
await session.connect();
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
expect(events.some((event) => event.type === "turn_started")).toBe(true);
expect(
events.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text.includes("Working on the goal"),
),
).toBe(true);
await session.interrupt();
expect(
appServer.requests().find((request) => request.method === "turn/interrupt"),
).toMatchObject({ params: { threadId: "saved-thread", turnId: "autonomous-turn" } });
unsubscribe();
const laterEvents: AgentStreamEvent[] = [];
session.subscribe((event) => laterEvents.push(event));
expect(laterEvents).toEqual([]);
} finally {
await session.close();
appServer.assertNoErrors();
}
});
test.each([false, true])(
"applies resumed goal events before returning (completed during resume: %s)",
async (completedDuringResume) => {
const appServer = createFakeCodexAppServer({
"thread/resume": () => {
appServer.startsTurn({ threadId: "saved-thread", turnId: "autonomous-turn" });
if (completedDuringResume) appServer.completeTurn({ threadId: "saved-thread" });
return { thread: { id: "saved-thread" } };
},
"thread/read": () => ({ thread: { turns: [] } }),
});
const session = new CodexAppServerAgentSession(
createConfig({ title: "Resumed goal", cwd: process.cwd() }),
{ sessionId: "saved-thread" },
createTestLogger(),
async () => appServer.child,
);
const client: AgentClient = {
provider: "codex",
capabilities: session.capabilities,
isAvailable: async () => true,
fetchCatalog: async () => ({ models: [], modes: [] }),
createSession: async () => {
throw new Error("Unexpected create");
},
resumeSession: async () => {
await session.connect();
return session;
},
};
const manager = new AgentManager({ clients: { codex: client }, logger: createTestLogger() });
let id: string | undefined;
try {
const resumed = await manager.resumeAgentFromPersistence(
{ provider: "codex", sessionId: "saved-thread" },
createConfig({ title: "Resumed goal", cwd: process.cwd() }),
);
id = resumed.id;
expect(resumed.lifecycle).toBe(completedDuringResume ? "idle" : "running");
expect(manager.getAgent(id)?.lifecycle).toBe(completedDuringResume ? "idle" : "running");
} finally {
if (id) await manager.closeAgent(id);
await session.close();
appServer.assertNoErrors();
}
},
);
});
@@ -3453,6 +3453,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private client: CodexAppServerClient | null = null;
private threadRollbackAvailable = true;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private startupEvents: AgentStreamEvent[] | null = null;
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private activeClientMessageId: string | null = null;
@@ -3571,6 +3572,8 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.resumeHandle?.sessionId) {
this.currentThreadId = this.resumeHandle.sessionId;
this.historyPending = true;
// Resuming can start a goal before the caller receives the session.
if (this.initialResumePurpose === "interactive") this.startupEvents = [];
}
}
@@ -4543,6 +4546,9 @@ export class CodexAppServerAgentSession implements AgentSession {
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
const startupEvents = this.startupEvents;
this.startupEvents = null;
for (const event of startupEvents ?? []) this.notifySubscribers(event);
return () => {
this.subscribers.delete(callback);
};
@@ -5028,6 +5034,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.clearPendingPermissions();
this.pendingSubAgentNotificationsByThreadId.clear();
this.subscribers.clear();
this.startupEvents = null;
this.activeForegroundTurnId = null;
this.activeClientMessageId = null;
this.pendingForegroundTurnIdentification?.resolve(null);
@@ -5375,6 +5382,10 @@ export class CodexAppServerAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event;
if (this.startupEvents) {
this.startupEvents.push(tagged);
return;
}
this.logger.trace(
{
agentId: this.agentId,
@@ -285,10 +285,15 @@ describe("OMP MCP host tools", () => {
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
omp.processExit("crashed");
await omp.startTurn("again");
await vi.waitFor(() => {
expect(omp.runtimeSessions()).toHaveLength(2);
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
});
// The relaunch spawns a new runtime and MCP child; on a loaded Windows
// runner that has taken longer than vi.waitFor's default one second.
await vi.waitFor(
() => {
expect(omp.runtimeSessions()).toHaveLength(2);
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
},
{ timeout: 10_000 },
);
} finally {
await omp.close();
}
@@ -0,0 +1,161 @@
import { createServer as createHttpServer } from "node:http";
import { connect, createServer as createTcpServer, type AddressInfo, type Socket } from "node:net";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { expect, test, vi } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import { HubRelationshipController } from "./relationship-controller.js";
import { DirectHubRelationshipRemote } from "./relationship-remote.js";
test("a silently lost Hub connection leaves connected state and reconnects when the path recovers", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-hub-liveness-"));
const hub = createHttpServer(async (request, response) => {
let body = "";
for await (const chunk of request) body += chunk;
const enrollment = JSON.parse(body) as { daemonId: string };
response.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
daemonId: enrollment.daemonId,
permissions: ["hub.execute"],
webSocketUrl: `ws://127.0.0.1:${proxyPort}/api/daemons/socket`,
}),
);
});
const webSockets = new WebSocketServer({ server: hub });
const peers = new Set<WebSocket>();
let connections = 0;
let pongs = 0;
webSockets.on("connection", (peer) => {
connections += 1;
peers.add(peer);
peer.on("close", () => peers.delete(peer));
});
await listen(hub);
const hubPort = (hub.address() as AddressInfo).port;
const streams = new Set<Socket>();
let dropped = false;
const proxy = createTcpServer((client) => {
const upstream = connect(hubPort, "127.0.0.1");
streams.add(client);
streams.add(upstream);
client.on("data", (data) => {
if (!dropped) upstream.write(data);
});
upstream.on("data", (data) => {
if (!dropped) client.write(data);
});
client.on("end", () => {
if (!dropped) upstream.end();
});
upstream.on("end", () => {
if (!dropped) client.end();
});
for (const socket of [client, upstream]) {
socket.on("error", () => socket.destroy());
socket.on("close", () => streams.delete(socket));
}
});
await listen(proxy);
const proxyPort = (proxy.address() as AddressInfo).port;
let retry: (() => void) | undefined;
const controller = new HubRelationshipController({
paseoHome: home,
hostname: "test-daemon",
serverId: "server-1",
daemonPublicKey: "public-key",
logger: pino({ level: "silent" }),
remote: new DirectHubRelationshipRemote(),
clock: {
now: () => new Date(),
schedule: (_delay, task) => {
retry = task;
return { cancel: () => (retry = undefined) };
},
},
attachSocket: async (socket) => {
if (!(socket instanceof WebSocket)) throw new Error("Expected a real WebSocket");
socket.on("pong", () => (pongs += 1));
},
updateAttachedPermissions: () => undefined,
createExecutionAgents: () => ({
create: async () => {
throw new Error("Unexpected agent creation");
},
subscribe: () => () => undefined,
invalidateAuthority: async () => undefined,
}),
});
try {
// Keep TCP and WebSocket I/O real; accelerate only the keepalive interval and its clock.
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "Date"] });
await controller.connect({
hubUrl: `http://127.0.0.1:${proxyPort}`,
token: "test-token",
permissions: ["hub.execute"],
});
await expect.poll(() => controller.status().state).toBe("connected");
const connectedAt = controller.status().connectedAt;
expect(connections).toBe(1);
// Model the reported proxy request ending without a close reaching the daemon.
dropped = true;
for (const peer of peers) peer.terminate();
await expect.poll(() => peers.size).toBe(0);
await vi.advanceTimersByTimeAsync(60_000);
await expect.poll(() => controller.status().state).toBe("reconnecting");
expect(controller.status().lastError).toBe("Hub WebSocket heartbeat timed out");
expect(retry).toBeTypeOf("function");
dropped = false;
retry?.();
await expect.poll(() => controller.status().state).toBe("connected");
expect(connections).toBe(2);
expect(controller.status().connectedAt).not.toBe(connectedAt);
expect(controller.status().lastError).toBeNull();
// A healthy idle connection survives multiple liveness windows.
for (let tick = 0; tick < 12; tick += 1) {
const before = pongs;
await vi.advanceTimersByTimeAsync(10_000);
await expect.poll(() => pongs).toBeGreaterThan(before);
}
expect(controller.status().state).toBe("connected");
expect(connections).toBe(2);
await controller.stop();
await expect.poll(() => peers.size).toBe(0);
await expect.poll(() => vi.getTimerCount()).toBe(0);
} finally {
await controller.stop();
vi.useRealTimers();
for (const socket of streams) socket.destroy();
for (const peer of peers) peer.terminate();
webSockets.close();
await Promise.all([close(proxy), close(hub)]);
await rm(home, { recursive: true, force: true });
}
});
async function listen(
server: ReturnType<typeof createTcpServer> | ReturnType<typeof createHttpServer>,
) {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
}
async function close(
server: ReturnType<typeof createTcpServer> | ReturnType<typeof createHttpServer>,
) {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
@@ -2,6 +2,9 @@ import { WebSocket } from "ws";
import { z } from "zod";
import type { WebSocketLike } from "../websocket-server.js";
const SOCKET_PING_INTERVAL_MS = 10_000;
const SOCKET_PONG_TIMEOUT_MS = 45_000;
export interface HubEnrollment {
daemonId: string;
idempotencyKey: string;
@@ -171,13 +174,42 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
},
});
let settled = false;
let heartbeat: ReturnType<typeof setInterval> | undefined;
const stopHeartbeat = () => {
clearInterval(heartbeat);
heartbeat = undefined;
};
const fail = (error: Error) => {
if (settled) return;
settled = true;
stopHeartbeat();
socket.terminate();
events.failed(error);
};
let lastPongAt = 0;
socket.on("pong", () => {
lastPongAt = Date.now();
});
socket.once("upgrade", (response) => {
if (response.headers["x-paseo-session-protocol"] === "1") {
sessionProtocol = "session-v1";
}
});
socket.once("open", () => {
if (!settled) events.connected(socket as WebSocketLike, sessionProtocol);
if (settled) return;
lastPongAt = Date.now();
heartbeat = setInterval(() => {
if (socket.readyState !== WebSocket.OPEN) return;
if (Date.now() - lastPongAt >= SOCKET_PONG_TIMEOUT_MS) {
fail(new Error("Hub WebSocket heartbeat timed out"));
return;
}
socket.ping(undefined, undefined, (error) => {
if (error) fail(error);
});
}, SOCKET_PING_INTERVAL_MS);
heartbeat.unref?.();
events.connected(socket as WebSocketLike, sessionProtocol);
});
socket.once("unexpected-response", (_request, response) => {
if (settled) {
@@ -194,16 +226,12 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
events.closed(1006);
});
socket.once("close", (code) => {
stopHeartbeat();
if (settled) return;
settled = true;
events.closed(code);
});
socket.once("error", (error) => {
if (settled) return;
settled = true;
socket.terminate();
events.failed(error);
});
socket.once("error", fail);
return socket;
}
@@ -0,0 +1,61 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { expect, test } from "vitest";
const run = promisify(execFile);
// Opt in on Linux with passwordless sudo, unshare, ip, mount, and tc available:
// RUN_RELAY_LATENCY_E2E=1 npx vitest run src/server/relay-transport.latency.e2e.test.ts
// All networking and the hosts-file mount are private to the child namespaces.
test.runIf(process.platform === "linux" && process.env.RUN_RELAY_LATENCY_E2E === "1")(
"connects relay control and data over a slow dual-stack route with an unreachable address",
async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), "paseo-relay-latency-"));
try {
const hosts = path.join(directory, "hosts");
await writeFile(
hosts,
`${await readFile("/etc/hosts", "utf8")}\n127.0.0.1 relay-latency.test\n2001:db8:1::1 relay-latency.test\n`,
);
const fixture = fileURLToPath(
new URL("./test-utils/relay-latency-fixture.ts", import.meta.url),
);
for (const order of ["ipv4first", "ipv6first"]) {
const { stdout } = await run(
"sudo",
[
"-n",
"unshare",
"--mount",
"--net",
"sh",
"-ec",
`mount --make-rprivate /
mount --bind "$1" /etc/hosts
ip link set lo up
ip link add relay-test type dummy
ip addr add 192.0.2.2/32 dev relay-test
ip -6 addr add 2001:db8::2/128 dev relay-test
ip link set relay-test up
tc qdisc add dev lo root netem delay 350ms
exec "$2" --import tsx "$3" "$4"`,
"sh",
hosts,
process.execPath,
fixture,
order,
],
{ timeout: 20_000 },
);
expect(stdout).toContain("relay data round trip passed");
}
} finally {
await rm(directory, { recursive: true, force: true });
}
},
45_000,
);
@@ -56,7 +56,13 @@ type ControlMessage =
const CONTROL_PING_INTERVAL_MS = 10_000;
const CONTROL_STALE_TIMEOUT_MS = 30_000;
const CONTROL_READY_TIMEOUT_MS = 8_000;
const RELAY_WEBSOCKET_OPTIONS = { handshakeTimeout: 10_000, perMessageDeflate: false } as const;
const RELAY_WEBSOCKET_OPTIONS = {
handshakeTimeout: 10_000,
perMessageDeflate: false,
// Node cancels each non-final address when this expires, even if the next
// address is unreachable. Allow high-latency routes time to connect.
autoSelectFamilyAttemptTimeout: 5_000,
} as const;
function createDefaultRelayWebSocket(url: string): RelayWebSocketLike {
return new WebSocket(url, RELAY_WEBSOCKET_OPTIONS);
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import dns from "node:dns";
import { once } from "node:events";
import net from "node:net";
import { Writable } from "node:stream";
import pino from "pino";
import { WebSocketServer } from "ws";
import { startRelayTransport } from "../relay-transport.js";
// Match the reported runtime even on Node versions with a newer default.
net.setDefaultAutoSelectFamilyAttemptTimeout(250);
const order = process.argv[2];
assert.ok(order === "ipv4first" || order === "ipv6first");
dns.setDefaultResultOrder(order);
const logs: string[] = [];
const logger = pino(
{ level: "debug" },
new Writable({
write(chunk, _encoding, callback) {
logs.push(chunk.toString());
callback();
},
}),
);
const relay = new WebSocketServer({ host: "127.0.0.1", port: 0 });
await once(relay, "listening");
const address = relay.address();
assert.ok(address && typeof address !== "string");
relay.on("connection", (socket, request) => {
const url = new URL(request.url!, "http://relay-latency.test");
if (url.searchParams.has("connectionId")) {
socket.on("message", (data) => socket.send(data));
} else {
socket.send(JSON.stringify({ type: "sync", connectionIds: ["latency-client"] }));
}
});
let complete!: () => void;
let fail!: (error: Error) => void;
const delivered = new Promise<void>((resolve, reject) => {
complete = resolve;
fail = reject;
});
const deadline = setTimeout(() => fail(new Error(logs.join(""))), 8_000);
const transport = startRelayTransport({
logger,
relayEndpoint: `relay-latency.test:${address.port}`,
relayUseTls: false,
serverId: "latency-server",
attachSocket: async (socket) => {
socket.on("message", (data) => {
try {
assert.equal(String(data), "latency round trip");
complete();
} catch (error) {
fail(error as Error);
}
});
socket.send("latency round trip");
},
});
try {
await delivered;
assert.ok(logs.some((line) => JSON.parse(line).msg === "relay_control_connected"));
assert.ok(logs.some((line) => JSON.parse(line).msg === "relay_data_connected"));
assert.ok(!logs.some((line) => JSON.parse(line).msg === "relay_error"));
console.log("relay data round trip passed");
} finally {
clearTimeout(deadline);
await transport.stop();
for (const socket of relay.clients) socket.terminate();
await new Promise<void>((resolve) => relay.close(() => resolve()));
}
+26
View File
@@ -0,0 +1,26 @@
import { expect, test, type Page } from "playwright/test";
test("keeps unrelated agent artwork unchanged while adding the Hermes mark", async ({ page }) => {
await openSupportedAgents(page);
await expectGenericAgentMark(page, "CodeWhale");
await expectVendoredAgentMark(page, "Hermes Agent");
});
async function openSupportedAgents(page: Page) {
await page.goto("/agents");
await expect(page.getByRole("heading", { level: 1, name: "Supported agents" })).toBeVisible();
}
async function expectGenericAgentMark(page: Page, name: string) {
const card = page.getByRole("link", { name, exact: true });
await expect(card).toBeVisible();
// Decorative SVGs have no accessible role; the fallback is Lucide's bot mark.
await expect(card.locator("svg.lucide-bot")).toBeVisible();
}
async function expectVendoredAgentMark(page: Page, name: string) {
const card = page.getByRole("link", { name, exact: true });
await expect(card).toBeVisible();
await expect(card.locator("svg")).toBeVisible();
await expect(card.locator("svg.lucide-bot")).toHaveCount(0);
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB