Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 0b17d391ef perf(server): use normal worker parallelism on Windows 2026-10-18 05:51:41 +02:00
55 changed files with 453 additions and 2160 deletions
+95 -158
View File
@@ -8,13 +8,9 @@ on:
merge_group:
workflow_dispatch:
# A newer push to the same ref cancels the run still in progress, main included.
# A full run costs about 200 runner-minutes against the org's 20 concurrent
# jobs, and a superseded main run only answers a question the newer commit
# answers again.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# CI does not use the CUDA execution provider, and the onnxruntime-node
@@ -58,10 +54,10 @@ jobs:
- name: Validate CI contracts
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs scripts/upload-release-assets.test.mjs
quality:
name: quality
format:
name: format
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false' || needs.changes.outputs.quality != 'false' || needs.changes.outputs.relay != 'false' || needs.changes.outputs.sdk != 'false') }}
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -72,63 +68,36 @@ jobs:
cache: "npm"
- name: Install dependencies
id: install
run: node scripts/npm-retry.mjs ci
# Explicit status checks keep independent domains running after a failure.
# A failed step still fails quality; no continue-on-error or success shim.
- name: Check formatting
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
run: npm run format:check
lint:
name: lint
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Lint lockfile
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
- name: Verify dependency signatures
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
run: npm audit signatures
- name: Lint
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
run: npm run lint
- name: Build relay
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
run: npm run build:relay
- name: Run relay tests
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
run: npm run test --workspace=@getpaseo/relay
- name: Build client dependencies
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run build:relay && npm run build:client
- name: Build plugin SDK
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run build:plugin
- name: Run plugin SDK tests
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run test --workspace=@getpaseo/plugin
- name: Run protocol tests
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run test --workspace=@getpaseo/protocol
- name: Run client tests
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run test --workspace=@getpaseo/client
- name: Run highlight tests
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run test --workspace=@getpaseo/highlight
- name: Typecheck client examples
if: ${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
run: npm run typecheck:examples --workspace=@getpaseo/client
typecheck:
name: typecheck
needs: changes
@@ -174,56 +143,43 @@ jobs:
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
runs-on: ubuntu-latest
steps:
- &server_capacity
name: Log runner capacity
run: |
node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))"
- &server_checkout
uses: actions/checkout@v4
steps: &server_test_steps
- uses: actions/checkout@v4
with:
fetch-depth: 0
- &server_node
uses: actions/setup-node@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- &server_fetch
name: Fetch origin/main (worktree tests)
- name: Fetch origin/main (worktree tests)
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: git fetch --no-tags origin main:refs/remotes/origin/main
- &server_install
name: Install dependencies
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- &server_agent_clis
name: Install agent CLIs for provider tests
- name: Install agent CLIs for provider tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- &server_build
name: Build server dependencies
- name: Build server dependencies
run: npm run build:server-deps
- name: Run server tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm run test --workspace=@getpaseo/server
env: &server_test_env
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
- &server_plugin_tests
name: Run built-in plugin tests
- name: Run built-in plugin tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm run test --workspace=@getpaseo/builtin-plugins
- &server_hub_contract
name: Run Hub CLI/daemon contract
- name: Run Hub CLI/daemon contract
if: ${{ needs.changes.outputs.full == 'false' && needs.changes.outputs.server == 'false' && needs.changes.outputs.hub != 'false' }}
run: npm run test:hub-cli-contract --workspace=@getpaseo/server
@@ -232,41 +188,7 @@ jobs:
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
runs-on: windows-latest
steps:
- *server_capacity
- *server_checkout
- *server_node
- *server_fetch
- *server_install
- *server_agent_clis
- *server_build
- name: Run server unit tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm run test:unit --workspace=@getpaseo/server
env: *server_test_env
- *server_plugin_tests
- *server_hub_contract
# Keep the serial integration chain off the Windows unit critical path.
server-integration-windows:
name: server-integration (windows-latest)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
runs-on: windows-latest
steps:
- *server_capacity
- *server_checkout
- *server_node
- *server_fetch
- *server_install
- *server_agent_clis
- *server_build
- name: Run server integration tests
run: npm run test:integration --workspace=@getpaseo/server
env: *server_test_env
steps: *server_test_steps
# native-recursive.ts serves darwin and win32, but windows-latest above is the only
# runner that exercises it. macOS — the platform the reported watcher bug was on —
@@ -410,19 +332,52 @@ jobs:
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
sdk-tests:
name: sdk-tests
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Build client dependencies
run: npm run build:relay && npm run build:client
- name: Build plugin SDK
run: npm run build:plugin
- name: Run plugin SDK tests
run: npm run test --workspace=@getpaseo/plugin
- name: Run protocol tests
run: npm run test --workspace=@getpaseo/protocol
- name: Run client tests
run: npm run test --workspace=@getpaseo/client
- name: Run highlight tests
run: npm run test --workspace=@getpaseo/highlight
- name: Typecheck client examples
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright-1:
name: playwright (shard 1/8)
name: playwright (shard 1/4)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "1/8"
PLAYWRIGHT_SHARD: "1/4"
PLAYWRIGHT_ARTIFACT: "1"
steps: &playwright_test_steps
- name: Log runner capacity
run: |
node -e "const os = require('node:os'); console.log(JSON.stringify({cpus: os.cpus().length, model: os.cpus()[0].model, ramGiB: os.totalmem() / 2 ** 30}))"
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -450,9 +405,9 @@ jobs:
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
E2E_WORKERS: "2"
# File groups are weighted by observed reporter durations.
PWTEST_SHARD_WEIGHTS: "93:100:101:104:91:94:122:110"
# Weight file groups by observed duration; raw test counts overload
# early agent/Changes specs. Required check names remain unchanged.
PWTEST_SHARD_WEIGHTS: "111:172:137:160"
- name: Upload test artifacts
uses: actions/upload-artifact@v4
@@ -465,74 +420,56 @@ jobs:
retention-days: 7
playwright-2:
name: playwright (shard 2/8)
name: playwright (shard 2/4)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "2/8"
PLAYWRIGHT_SHARD: "2/4"
PLAYWRIGHT_ARTIFACT: "2"
steps: *playwright_test_steps
playwright-3:
name: playwright (shard 3/8)
name: playwright (shard 3/4)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "3/8"
PLAYWRIGHT_SHARD: "3/4"
PLAYWRIGHT_ARTIFACT: "3"
steps: *playwright_test_steps
playwright-4:
name: playwright (shard 4/8)
name: playwright (shard 4/4)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "4/8"
PLAYWRIGHT_SHARD: "4/4"
PLAYWRIGHT_ARTIFACT: "4"
steps: *playwright_test_steps
playwright-5:
name: playwright (shard 5/8)
relay-tests:
name: relay-tests
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "5/8"
PLAYWRIGHT_ARTIFACT: "5"
steps: *playwright_test_steps
steps:
- uses: actions/checkout@v4
playwright-6:
name: playwright (shard 6/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "6/8"
PLAYWRIGHT_ARTIFACT: "6"
steps: *playwright_test_steps
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
playwright-7:
name: playwright (shard 7/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "7/8"
PLAYWRIGHT_ARTIFACT: "7"
steps: *playwright_test_steps
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
playwright-8:
name: playwright (shard 8/8)
needs: changes
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
runs-on: ubuntu-latest
env:
PLAYWRIGHT_SHARD: "8/8"
PLAYWRIGHT_ARTIFACT: "8"
steps: *playwright_test_steps
- name: Build relay
run: npm run build:relay
- name: Run relay tests
run: npm run test --workspace=@getpaseo/relay
cli-tests-1:
name: cli-tests (shard 1/3)
+1 -1
View File
@@ -18,7 +18,7 @@ Math and video are disabled. Task lists and admonitions are enabled; task checkb
Mermaid fences use the existing isolated diagram runtime and fullscreen viewer. Web dispatches them through the library’s code-block override. Native has no block-renderer hook, so the renderer extracts only Mermaid fences and places the existing WebView host between library-rendered segments. This is the only block exception; see [native selection limitations](native-text-selection.md) for diagram grouping and leading inline images. Streaming keeps the last valid diagram visible while the next revision renders.
Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also keeps the library's block spacing around the web code block override, preserves web list numbering and link titles, marks pending parses with `aria-busy` so Find waits for rendered text, and carries link syntax through native and web tap events. Web inline code inside a link inherits only the link colour; its code font, size and background stay intact. Keep link variants colour-only: native pills replace text runs and prevent paths from wrapping; Android pills also use the surrounding block’s font size. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it.
Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also preserves web list numbering and link titles, marks pending parses with `aria-busy` so Find waits for rendered text, and carries link syntax through native and web tap events. Web inline code inside a link inherits only the link colour; its code font, size and background stay intact. Keep link variants colour-only: native pills replace text runs and prevent paths from wrapping; Android pills also use the surrounding block’s font size. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it.
The package patch also keeps overflowing native horizontal blocks in control of their drags: Android claims parent interception at touch-down and releases it for vertical drags so the chat list can scroll; iOS keeps its scroll recognizer active for outward drags at the leading edge. This prevents compact sidebar pans from taking block scrolling without changing sidebar swipes on prose or non-overflowing blocks. Native changes require a client rebuild.
+1 -2
View File
@@ -251,8 +251,7 @@ do not imply local ownership or authorization. Capture the sender title at deliv
removed senders must remain understandable without a live directory lookup.
The optional `agentMessage` payload carries event, sender, and delivered text. Shared client
presentation (`protocol/paseo-tool-presentation`) owns its heading and expanded details, next to the
labels for every Paseo MCP tool. The daemon
presentation (`protocol/agent-message-display`) owns its heading and expanded details. The daemon
must not construct UI labels or prepend sender labels to the semantic message body. The existing
`tool_call` / `plain_text` envelope is a compatibility adapter: older clients still parse the row and
expand its body. New clients use the typed payload, never infer the event from a tool name or parse
@@ -84,7 +84,7 @@ test.describe("real Codex Paseo tool-call presentation", () => {
expectedText: ["Prompt", GREETING, "Greeting child", "codex/gpt-5.4-mini", "Result"],
});
await captureToolCall(page, testInfo, {
label: "Send message",
label: "Send agent prompt",
artifact: "send-prompt-presentation",
expectedText: ["Prompt", GREETING, "Agent", "Result"],
});
@@ -259,71 +259,3 @@ async function openInlineRegressionFile(page: Page, path: string) {
.getByRole("link", { name: path, exact: true })
.click();
}
// Web links are styled inline by the library, so hover state has to restyle the anchor.
test("markdown links underline on hover", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "markdown-link-hover-",
title: "Markdown link hover",
featureValues: {
mockAssistantResponse: "See [the docs](https://example.com/docs) and [README](README.md).",
},
initialPrompt: "Show links",
});
try {
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
for (const name of ["the docs", "README"]) {
const link = message.getByRole("link", { name, exact: true });
await expect(link).toHaveCSS("text-decoration-line", "none");
await link.hover();
await expect(link).toHaveCSS("text-decoration-line", "underline");
await page.mouse.move(0, 0);
await expect(link).toHaveCSS("text-decoration-line", "none");
}
} finally {
await session.cleanup();
}
});
// The web code block replaces the library's own block, which must keep its spacing.
test("blocks around a web code block keep their spacing", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "markdown-block-spacing-",
title: "Markdown block spacing",
featureValues: {
mockAssistantResponse:
"Intro.\n\n```sh\nnpx playwright test\n```\n\n| Step | Result |\n| --- | --- |\n| hover | none |\n\nMiddle.\n\n```ts\nconst a = 1;\n```\n\nClosing.",
},
initialPrompt: "Show blocks",
});
try {
await session.client.waitForFinish(session.agentId, 30_000);
await openAgentRoute(page, session);
const message = page.getByTestId("assistant-message");
await expect(message.getByRole("table")).toBeVisible();
const gaps = await measureBlockGaps(message.locator(".enrm-text").first());
for (const { between, gap } of gaps) expect(gap, between).toBeGreaterThanOrEqual(8);
} finally {
await session.cleanup();
}
});
async function measureBlockGaps(root: Locator) {
return root.evaluate((element) => {
const blocks = Array.from(element.children).map((block) => {
// Wrappers with display: contents have no box; measure their first rendered child.
let box: Element = block;
while (box.getBoundingClientRect().height === 0 && box.firstElementChild) {
box = box.firstElementChild;
}
const rect = box.getBoundingClientRect();
return { text: (block.textContent ?? "").slice(0, 20), top: rect.top, bottom: rect.bottom };
});
return blocks.slice(1).map((block, index) => ({
between: `${blocks[index].text} -> ${block.text}`,
gap: Math.round(block.top - blocks[index].bottom),
}));
});
}
+1 -65
View File
@@ -16,7 +16,6 @@ import {
} from "../support/helpers/usage-reports";
import {
installLoginUsage,
expectLoginUsageFreshness,
installCodexWindowUsage,
expectCodexReportedWindows,
refreshLoginUsage,
@@ -589,69 +588,6 @@ async function expectLoginErrors(page: Page, lines: string[]) {
async function expectLoginUsageOnly(page: Page, percentage: string) {
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByText(new RegExp(`^${percentage}(?:$| ·)`))).toBeVisible();
await expect(card.getByText(percentage, { exact: true })).toBeVisible();
await expect(card.getByTestId("usage-login-error")).toHaveCount(0);
}
for (const compact of [false, true]) {
test(`failed polls retain usage and successful freshness (${compact ? "footer" : "tooltip"})`, async ({
page,
}) => {
const fixture = await installLoginUsage({ status: "error", error: "Initial failure" });
try {
await page.setViewportSize(
compact ? { width: 390, height: 844 } : { width: 1440, height: 900 },
);
await gotoAppShell(page);
if (compact) await openCompactSidebar(page);
await openUsageFromIcon(page);
await expectLoginErrors(page, ["Claude: Initial failure"]);
await expectLoginUsageFreshness(page, "No successful update", compact);
await fixture.setReport({
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 42, resetsAt: "2000-01-01T00:00:00Z" }],
});
// Move only the browser clock; real daemon fetches continue using the host clock.
await page.clock.setFixedTime(new Date(Date.now() + 3_900_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "42%");
await expectLoginUsageFreshness(page, "Updated 1h ago", compact);
await fixture.setReport({ status: "error", error: "Rate limited" });
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: Rate limited");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: Rate limited");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
await fixture.setReport({
status: "unavailable",
problem: { kind: "no_quota", detail: "No active quota" },
});
await refreshLoginUsage(page);
await expectRetainedLoginUsage(page, "Claude: No active quota");
await expectLoginUsageFreshness(page, "Last updated 1h ago", compact);
await fixture.setReport({
status: "available",
windows: [{ id: "weekly", label: "Weekly", usedPct: 43 }],
});
await page.clock.setFixedTime(new Date(Date.now() + 150_000));
await refreshLoginUsage(page);
await expectLoginUsageOnly(page, "43%");
await expect(page.getByText("42%", { exact: true })).toHaveCount(0);
await expectLoginUsageFreshness(page, "Updated 2m ago", compact);
} finally {
await fixture.cleanup();
}
});
}
async function expectRetainedLoginUsage(page: Page, message: string) {
const card = page.getByTestId("usage-report-login-journey:account");
await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible();
await expect(card.getByTestId("usage-login-error")).toHaveText(message);
await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible();
}
@@ -135,14 +135,3 @@ export async function expectCodexReportedWindows(page: Page, shape: "seven-day-o
);
}
}
export async function expectLoginUsageFreshness(page: Page, text: string, compact: boolean) {
const card = page.getByTestId("usage-report-login-journey:account");
if (compact) {
await expect(card.getByTestId("usage-freshness")).toHaveText(text);
return;
}
await card.getByRole("button", { name: "Refresh Claude", exact: true }).hover();
await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText(text);
await page.mouse.move(0, 0);
}
@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
describe("native scroll-to-bottom animation", () => {
it("jumps across unmounted history instead of animating through blank rows", () => {
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 60_000, viewportHeight: 600, windowSize: 10 }),
).toBe(false);
});
it("keeps near-bottom movement eligible for animation", () => {
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 300, viewportHeight: 600, windowSize: 10 }),
).toBe(true);
});
it("bounds travel by the bottom-side overscan, not the whole window", () => {
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 2700, viewportHeight: 600, windowSize: 10 }),
).toBe(true);
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 2701, viewportHeight: 600, windowSize: 10 }),
).toBe(false);
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 1500, viewportHeight: 600, windowSize: 5 }),
).toBe(false);
});
it("does not animate before the viewport has been measured", () => {
expect(
shouldAnimateNativeScrollToBottom({ offsetY: 0, viewportHeight: 0, windowSize: 10 }),
).toBe(false);
});
});
@@ -1,10 +0,0 @@
export function shouldAnimateNativeScrollToBottom(input: {
offsetY: number;
viewportHeight: number;
windowSize: number;
}): boolean {
// VirtualizedList splits the extra window around the viewport. An animated
// jump farther than the bottom-side overscan crosses unmounted history.
const bottomOverscanHeight = ((input.windowSize - 1) / 2) * input.viewportHeight;
return input.viewportHeight > 0 && input.offsetY <= bottomOverscanHeight;
}
@@ -23,7 +23,6 @@ import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useKeyboardStreamInset } from "@/keyboard/shift";
import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation";
import { useRevisedHistoryRows } from "./history-row-revision";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
@@ -65,7 +64,6 @@ const historyStartSlotStyle: ViewStyle = {
flexShrink: 0,
};
const HISTORY_START_SETTLE_FRAMES = 2;
const NATIVE_STREAM_WINDOW_SIZE = 10;
function keyExtractor(item: { id: string }): string {
return item.id;
@@ -240,13 +238,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
programmaticScrollEventBudgetRef.current = 3;
flatListRef.current?.scrollToOffset({
offset: 0,
animated:
animated &&
shouldAnimateNativeScrollToBottom({
offsetY: scrollOffsetYRef.current,
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
windowSize: NATIVE_STREAM_WINDOW_SIZE,
}),
animated,
});
scrollOffsetYRef.current = 0;
streamViewportMetricsRef.current = {
@@ -599,11 +591,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
maintainVisibleContentPosition={maintainVisibleContentPosition}
// Tall Markdown rows exhaust a shallow window during flings. Keep the initial
// render small, then refill a deeper window in batches large enough to keep up.
// A history row can contain an entire long reply. Keep synchronous native
// Markdown measurement bounded while FlatList fills the surrounding window.
initialNumToRender={2}
maxToRenderPerBatch={10}
windowSize={NATIVE_STREAM_WINDOW_SIZE}
maxToRenderPerBatch={2}
windowSize={5}
removeClippedSubviews={false}
scrollEnabled={scrollEnabled}
showsVerticalScrollIndicator
+1 -2
View File
@@ -771,7 +771,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
status={data.status}
detail={data.detail}
cwd={context.cwd}
serverId={resolvedServerId}
metadata={data.metadata}
agentMessage={data.agentMessage}
isLastInSequence={isLastInSequence}
@@ -796,7 +795,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
/>
);
},
[context.cwd, resolvedServerId, setInlineDetailsExpanded, handleToolCallOpenFile],
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
);
// Read through a stable event so live group updates do not change the renderer identity
@@ -1,5 +1,5 @@
import { Text } from "react-native";
import { forwardRef, useCallback, useMemo, useState, type ComponentProps } from "react";
import { forwardRef, useMemo, type ComponentProps } from "react";
import type {
CodeBlockRendererProps,
LinkRendererProps,
@@ -27,29 +27,14 @@ function WebCodeBlock({ code, language }: CodeBlockRendererProps) {
return <ThemedCodeBlock code={code} language={language} uniProps={codeStyle} />;
}
// The library styles links inline, so a stylesheet :hover rule cannot underline them.
const LINK_HOVER_STYLE = { textDecoration: "underline" } as const;
function WebLink({ link, anchorProps, children }: LinkRendererProps) {
const { tooltipPath, onHoverIn } = useFileLink(markdownLinkSource(link.url, link.sourceType));
const [hovered, setHovered] = useState(false);
const handleMouseEnter = useCallback(() => setHovered(true), []);
const handleMouseLeave = useCallback(() => setHovered(false), []);
const anchorStyle = useMemo(
() => ({
...anchorProps.style,
overflowWrap: "anywhere" as const,
...(hovered ? LINK_HOVER_STYLE : null),
}),
[anchorProps.style, hovered],
() => ({ ...anchorProps.style, overflowWrap: "anywhere" as const }),
[anchorProps.style],
);
const anchor = (
<a
{...anchorProps}
style={anchorStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<a {...anchorProps} style={anchorStyle}>
{children}
</a>
);
+1 -8
View File
@@ -26,7 +26,6 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import { resolveToolCallIcon } from "@/utils/tool-call-icon";
import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentCapabilityFlags, ToolCallDetail } from "@getpaseo/protocol/agent-types";
@@ -2209,7 +2208,6 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
interface ToolCallProps {
agentMessage?: AgentMessage;
serverId?: string;
toolName: string;
args?: unknown;
result?: unknown;
@@ -2230,7 +2228,6 @@ interface ToolCallProps {
export const ToolCall = memo(function ToolCall({
agentMessage,
serverId,
toolName,
args,
result,
@@ -2268,7 +2265,6 @@ export const ToolCall = memo(function ToolCall({
return undefined;
}, [detail, args, result]);
const targetAgentTitle = useTargetAgentTitle({ serverId, toolName, detail: effectiveDetail });
const presentation = useMemo(
() =>
buildToolCallPresentation({
@@ -2279,10 +2275,9 @@ export const ToolCall = memo(function ToolCall({
detail: effectiveDetail,
metadata,
cwd,
targetAgentTitle,
resolveIcon: resolveToolCallIcon,
}),
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage, targetAgentTitle],
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage],
);
const handleOpenFile = useMemo(() => {
const openFilePath = presentation.openFilePath;
@@ -2398,8 +2393,6 @@ export const ToolCall = memo(function ToolCall({
function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) {
if (previous.toolName !== next.toolName) return false;
if (previous.serverId !== next.serverId) return false;
if (previous.agentMessage !== next.agentMessage) return false;
if (previous.args !== next.args) return false;
if (previous.result !== next.result) return false;
if (previous.error !== next.error) return false;
@@ -14,7 +14,7 @@ import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import {
buildPaseoToolDetailSections,
type PaseoToolDetailSection,
} from "@getpaseo/protocol/paseo-tool-presentation/index";
} from "@getpaseo/protocol/paseo-tool-call-detail";
import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers";
import { highlightDiffLines } from "@/utils/diff-highlight";
import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state";
@@ -1,11 +1,9 @@
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { describe, expect, it } from "vitest";
import { buildToolCallPresentation, type ToolCallPresentationIcon } from "./presentation";
const fakeIcons = {
paseo: (() => null) as ToolCallPresentationIcon,
brain: (() => null) as ToolCallPresentationIcon,
eye: (() => null) as ToolCallPresentationIcon,
wrench: (() => null) as ToolCallPresentationIcon,
@@ -14,11 +12,7 @@ const fakeIcons = {
function fakeResolveIcon(
toolName: string,
detail: ToolCallDetail | undefined,
agentMessage: AgentMessage | undefined,
): ToolCallPresentationIcon {
if (agentMessage) {
return fakeIcons.paseo;
}
if (detail?.type === "plan") {
return fakeIcons.brain;
}
@@ -100,35 +94,32 @@ describe("tool-call presentation", () => {
});
it.each([
["message", "Message received"],
["finished", "Agent finished"],
["errored", "Agent errored"],
["permission-required", "Agent needs permission"],
["closed", "Agent closed"],
] as const)(
"presents %s with a fixed label, the sender as context, and the Paseo icon",
(event, heading) => {
const presentation = buildToolCallPresentation({
toolName: "internal_name_should_not_be_visible",
status: "completed",
error: null,
detail: { type: "plain_text", label: "legacy summary", text: "legacy body", icon: "bot" },
agentMessage: {
event,
sender: { id: "server::sender", title: "QA messenger" },
text: "The delivered message.",
},
resolveIcon: fakeResolveIcon,
});
expect(presentation.displayName).toBe(heading);
expect(presentation.summary).toBe("QA messenger");
expect(presentation.icon).toBe(fakeIcons.paseo);
expect(presentation.detail).toEqual({
type: "plain_text",
text: "From agent: server::sender\n\nThe delivered message.",
});
},
);
["message", "Message from QA messenger"],
["finished", "QA messenger finished"],
["errored", "QA messenger errored"],
["permission-required", "QA messenger needs permission"],
["closed", "QA messenger closed"],
] as const)("presents %s from structured provenance without a second label", (event, heading) => {
const presentation = buildToolCallPresentation({
toolName: "internal_name_should_not_be_visible",
status: "completed",
error: null,
detail: { type: "plain_text", label: "legacy summary", text: "legacy body" },
agentMessage: {
event,
sender: { id: "server::sender", title: "QA messenger" },
text: "The delivered message.",
},
resolveIcon: fakeResolveIcon,
});
expect(presentation.displayName).toBe(heading);
expect(presentation.summary).toBeUndefined();
expect(presentation.detail).toEqual({
type: "plain_text",
icon: "bot",
text: "From agent: server::sender\n\nThe delivered message.",
});
});
it("uses an opaque sender ID when no title is available", () => {
const presentation = buildToolCallPresentation({
@@ -138,19 +129,5 @@ it("uses an opaque sender ID when no title is available", () => {
agentMessage: { event: "message", sender: { id: "remote::worker" }, text: "Hello" },
resolveIcon: fakeResolveIcon,
});
expect(presentation.displayName).toBe("Message received");
expect(presentation.summary).toBe("remote::worker");
});
it("shows the target agent title as context for Paseo agent tools", () => {
const presentation = buildToolCallPresentation({
toolName: "mcp__paseo__kill_agent",
status: "completed",
error: null,
detail: { type: "unknown", input: { agentId: "agt_1" }, output: null },
targetAgentTitle: "Reviewer",
resolveIcon: fakeResolveIcon,
});
expect(presentation.displayName).toBe("Kill agent");
expect(presentation.summary).toBe("Reviewer");
expect(presentation.displayName).toBe("Message from remote::worker");
});
+2 -5
View File
@@ -1,5 +1,5 @@
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import { buildAgentMessageDisplay } from "@getpaseo/protocol/paseo-tool-presentation/index";
import { buildAgentMessageDisplay } from "@getpaseo/protocol/agent-message-display";
import type { PlanOutcome } from "@/components/plan-card";
import type { ComponentType } from "react";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
@@ -22,7 +22,6 @@ interface BuildToolCallPresentationInput {
detail?: ToolCallDetail;
cwd?: string;
metadata?: Record<string, unknown>;
targetAgentTitle?: string;
resolveIcon: ToolCallIconResolver;
}
@@ -43,7 +42,6 @@ export interface ToolCallPresentation {
export type ToolCallIconResolver = (
toolName: string,
detail: ToolCallDetail | undefined,
agentMessage: AgentMessage | undefined,
) => ToolCallPresentationIcon;
function displayStatus(status: ToolCallStatus): ToolCallDisplayInput["status"] {
@@ -69,7 +67,6 @@ export function buildToolCallPresentation(
detail: detailForDisplay,
metadata: input.metadata,
cwd: input.cwd,
targetAgentTitle: input.targetAgentTitle,
});
const isLoadingDetails = isPendingToolCallDetail({
detail,
@@ -83,7 +80,7 @@ export function buildToolCallPresentation(
displayName: displayModel.displayName,
summary: displayModel.summary,
errorText: displayModel.errorText,
icon: input.resolveIcon(input.toolName, detail, input.agentMessage),
icon: input.resolveIcon(input.toolName, detail),
isLoadingDetails,
hasDetails,
canOpenDetails: hasDetails || isLoadingDetails,
@@ -1,19 +0,0 @@
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { getPaseoToolTargetAgentId } from "@getpaseo/protocol/paseo-tool-presentation/index";
import { useSessionStore } from "@/stores/session-store";
/** Title of the agent a Paseo tool call acts on, when this host knows it. */
export function useTargetAgentTitle(input: {
serverId: string | undefined;
toolName: string;
detail: ToolCallDetail | undefined;
}): string | undefined {
const agentId = getPaseoToolTargetAgentId(input.toolName, input.detail);
const { serverId } = input;
return useSessionStore((state) => {
if (!serverId || !agentId) return undefined;
const session = state.sessions[serverId];
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
return agent?.title ?? undefined;
});
}
+2 -8
View File
@@ -41,17 +41,11 @@ function fillToneStyle(tone: UsageTone) {
}
}
export function UsageBalanceBar({
balance,
retained = false,
}: {
balance: UsageBalance;
retained?: boolean;
}) {
export function UsageBalanceBar({ balance }: { balance: UsageBalance }) {
const { i18n } = useTranslation();
const { amountText, usedPct } = resolveBalance(balance, i18n.language);
const tone = balance.tone ?? "default";
const resetLabel = formatResetLabel(balance.resetsAt, retained);
const resetLabel = formatResetLabel(balance.resetsAt);
const fillStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
+42 -26
View File
@@ -16,7 +16,7 @@ import { useCompactTimeAgo } from "@/hooks/use-time-ago";
import { UsageBalanceBar } from "./balance-bar";
import { usageCopy } from "./copy";
import type { UsageDisplay } from "./display";
import { formatUsageFreshness, resolveUsageReport, type UsageRefresh } from "./model";
import { formatUsageFreshness, type UsageRefresh } from "./model";
import { useReportRefresh } from "./queries";
import { UsageSourceIcon } from "./source-icon";
import type { UsageReport, UsageReportEntry, UsageWindow } from "./types";
@@ -27,6 +27,31 @@ function statusText(report: UsageReport): string | null {
return report.status === "error" ? "Error" : "Unavailable";
}
function reportContent(report: UsageReport) {
if (report.status === "available")
return {
windows: report.windows,
balances: report.balances ?? [],
details: report.details ?? [],
message: undefined,
};
const message =
report.status === "unavailable" ? usageCopy.problem(report.problem) : report.error;
return { windows: [], balances: [], details: [], message };
}
function reportMessages(entry: UsageReportEntry): string[] {
if (entry.report.status === "available") return [];
if (entry.loginErrors)
return entry.loginErrors.map(
(login) => `${login.harness}: ${reportContent(login.report).message}`,
);
// COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0.
return [
entry.report.status === "error" ? entry.report.error : usageCopy.problem(entry.report.problem),
];
}
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
@@ -56,8 +81,8 @@ export function UsageCard({
const usage = entry.report;
const status = statusText(usage);
const footer = entry.account.label ?? null;
const { windows, balances, details, planLabel, successfulAt, retained, messages } =
resolveUsageReport(entry);
const { windows, balances, details } = reportContent(usage);
const messages = reportMessages(entry);
const containerStyle = useMemo(
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
@@ -79,7 +104,9 @@ export function UsageCard({
<Text style={styles.name} numberOfLines={1}>
{entry.sourceLabel}
</Text>
{planLabel ? <StatusBadge label={planLabel} variant="muted" size="xs" /> : null}
{usage.status === "available" && usage.planLabel ? (
<StatusBadge label={usage.planLabel} variant="muted" size="xs" />
) : null}
<View style={styles.headerSpacer} />
{status ? (
<View style={styles.statusRow}>
@@ -90,8 +117,7 @@ export function UsageCard({
{refreshable ? (
<UsageRefreshButton
sourceLabel={entry.sourceLabel}
successfulAt={successfulAt}
retained={retained}
fetchedAt={entry.fetchedAt}
refreshState={refreshState}
onRefresh={refresh}
compact={isCompact}
@@ -112,13 +138,12 @@ export function UsageCard({
key={window.id}
entry={entry}
window={window}
retained={retained}
display={display}
pinnable={pinnable}
/>
))}
{balances.map((balance) => (
<UsageBalanceBar key={balance.id} balance={balance} retained={retained} />
<UsageBalanceBar key={balance.id} balance={balance} />
))}
</View>
) : null}
@@ -145,8 +170,7 @@ export function UsageCard({
</Text>
{showsFreshnessInline ? (
<UsageFreshness
successfulAt={successfulAt}
retained={retained}
fetchedAt={entry.fetchedAt}
style={styles.freshness}
testID="usage-freshness"
/>
@@ -168,9 +192,7 @@ function CardWindowBar({
window,
display,
pinnable,
retained,
}: {
retained: boolean;
entry: UsageReportEntry;
window: UsageWindow;
display: UsageDisplay;
@@ -185,7 +207,6 @@ function CardWindowBar({
return (
<UsageWindowBar
window={window}
retained={retained}
displayAs={display.displayAs}
pinnable={pinnable}
pinned={display.isPinned(pin)}
@@ -199,15 +220,13 @@ function CardWindowBar({
/** Refreshes this one report. Its tooltip says when the report on screen was fetched. */
function UsageRefreshButton({
sourceLabel,
successfulAt,
retained,
fetchedAt,
refreshState,
onRefresh,
compact,
}: {
sourceLabel: string;
successfulAt: string | null;
retained: boolean;
fetchedAt: string;
refreshState: UsageRefresh;
onRefresh: () => void;
compact: boolean;
@@ -217,13 +236,12 @@ function UsageRefreshButton({
const freshness = useMemo(
() => (
<UsageFreshness
successfulAt={successfulAt}
retained={retained}
fetchedAt={fetchedAt}
style={styles.tooltipText}
testID="usage-freshness-tooltip"
/>
),
[successfulAt, retained],
[fetchedAt],
);
return (
<ToolbarButton
@@ -247,20 +265,18 @@ function UsageRefreshButton({
/** Its own component so the relative-time clock re-renders one `<Text>`, not the card. */
function UsageFreshness({
successfulAt,
retained,
fetchedAt,
style,
testID,
}: {
successfulAt: string | null;
retained: boolean;
fetchedAt: string;
style: StyleProp<TextStyle>;
testID: string;
}) {
const elapsed = useCompactTimeAgo(successfulAt ? new Date(successfulAt) : null);
const elapsed = useCompactTimeAgo(new Date(fetchedAt));
return (
<Text style={style} numberOfLines={1} testID={testID}>
{successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"}
{formatUsageFreshness(elapsed)}
</Text>
);
}
+1 -8
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { formatAmount, formatResetLabel } from "./format";
import { formatAmount } from "./format";
describe("formatAmount", () => {
it("groups thousands in the app's language", () => {
@@ -13,10 +13,3 @@ describe("formatAmount", () => {
expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US");
});
});
it("retained reset times that have passed do not claim the quota is resetting", () => {
const past = "2000-01-01T00:00:00.000Z";
expect(formatResetLabel(past, true)).toBe("Reset time passed");
expect(formatResetLabel(past)).toBe("resetting now");
expect(formatResetLabel(null, true)).toBeNull();
});
+1 -2
View File
@@ -27,11 +27,10 @@ function relativeDuration(iso: string): string | null {
return `${diffMinutes}m`;
}
export function formatResetLabel(iso: string | null | undefined, retained = false): string | null {
export function formatResetLabel(iso: string | null | undefined): string | null {
if (!iso) return null;
const rel = relativeDuration(iso);
if (!rel) return null;
if (rel === "now" && retained) return "Reset time passed";
return rel === "now" ? "resetting now" : `resets ${rel}`;
}
-70
View File
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
resolveUsageReport,
displayPercent,
formatUsageFreshness,
resolveAgentUsageView,
@@ -326,72 +325,3 @@ describe("resolveAgentUsageView", () => {
).toEqual({ kind: "error", message: "Unable to load usage: Unknown agent" });
});
});
it("resolves retained usage and successful time without inventing old-daemon history", () => {
const success = entry({ windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] });
const failure: UsageReportEntry = {
...success,
fetchedAt: "2026-01-01T00:06:00.000Z",
report: { status: "error", error: "Rate limited" },
lastSuccessful: {
fetchedAt: success.fetchedAt,
report: success.report as Extract<UsageReportEntry["report"], { status: "available" }>,
},
};
expect(resolveUsageReport(failure)).toEqual({
windows: success.report.status === "available" ? success.report.windows : [],
balances: [],
details: [],
planLabel: null,
successfulAt: success.fetchedAt,
retained: true,
messages: ["Rate limited"],
});
expect(resolveUsageReport({ ...failure, lastSuccessful: undefined })).toEqual({
windows: [],
balances: [],
details: [],
planLabel: null,
successfulAt: null,
retained: false,
messages: ["Rate limited"],
});
expect(resolveUsageReport(success)).toEqual({
windows: success.report.status === "available" ? success.report.windows : [],
balances: [],
details: [],
planLabel: null,
successfulAt: success.fetchedAt,
retained: false,
messages: [],
});
});
it("orders failed polls by attempt time even when successful data is older", () => {
const success = entry({});
if (success.report.status !== "available") throw new Error("fixture must be available");
const failure: UsageReportEntry = {
...success,
fetchedAt: "2026-01-01T00:06:00.000Z",
report: { status: "error", error: "Rate limited" },
lastSuccessful: { fetchedAt: success.fetchedAt, report: success.report },
};
expect(upsertReport([failure], success)).toEqual([failure]);
expect(settleReports([failure], [success])).toEqual([failure]);
expect(upsertReport([success], failure)).toEqual([failure]);
});
it("available and retained usage show the same successful completion time", () => {
const success: UsageReportEntry = { ...entry({}), successfulAt: "2026-01-01T00:00:15.000Z" };
if (success.report.status !== "available") throw new Error("fixture must be available");
expect(resolveUsageReport(success).successfulAt).toBe("2026-01-01T00:00:15.000Z");
const failure: UsageReportEntry = {
...success,
successfulAt: undefined,
fetchedAt: "2026-01-01T00:06:00.000Z",
report: { status: "error", error: "Rate limited" },
lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report },
};
expect(resolveUsageReport(failure).successfulAt).toBe(resolveUsageReport(success).successfulAt);
expect(upsertReport([failure], success)).toEqual([failure]);
});
+3 -40
View File
@@ -1,7 +1,7 @@
import { formatCompactTimeAgoAsProse } from "@/utils/time";
import { usageCopy } from "./copy";
import type { UsageDisplayAs } from "./preferences";
import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types";
import type { UsageReportEntry, UsageView, UsageWindow } from "./types";
export function usedPercent(window: UsageWindow): number | null {
if (window.usedPct != null) return window.usedPct;
@@ -32,8 +32,8 @@ export function usageWindowRowLabel(input: {
}
/** When a report was fetched, from its compact relative time: "Updated 3m ago". */
export function formatUsageFreshness(compactTimeAgo: string, retained = false): string {
return `${retained ? "Last updated" : usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`;
export function formatUsageFreshness(compactTimeAgo: string): string {
return `${usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`;
}
/** A user-requested refresh of one report. The previous report stays on screen throughout. */
@@ -192,40 +192,3 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null
const picked = connected.find((host) => host.serverId === choice.pickedServerId);
return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null;
}
/** Current outcome owns messages; only a successful poll owns values and freshness. */
export function resolveUsageReport(entry: UsageReportEntry) {
const available = entry.report.status === "available";
// COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
// Older daemons cannot provide history; a failed attempt is never a successful update.
const snapshot = available ? null : (entry.lastSuccessful ?? null);
const content = entry.report.status === "available" ? entry.report : (snapshot?.report ?? null);
const messages: string[] = [];
if (!available) {
if (entry.loginErrors)
messages.push(
...entry.loginErrors.map((login) => `${login.harness}: ${usageProblemText(login.report)}`),
);
// COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0.
else messages.push(usageProblemText(entry.report));
}
return {
windows: content?.windows ?? [],
balances: content?.balances ?? [],
details: content?.details ?? [],
planLabel: content?.planLabel ?? null,
// COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
// Old daemons expose only attempt time for available reports, never for failures.
successfulAt: available
? (entry.successfulAt ?? entry.fetchedAt)
: (snapshot?.fetchedAt ?? null),
retained: snapshot !== null,
messages,
};
}
function usageProblemText(report: UsageReport): string {
if (report.status === "error") return report.error;
if (report.status === "unavailable") return usageCopy.problem(report.problem);
return "";
}
+1 -5
View File
@@ -257,12 +257,9 @@ describe("resolvePinnedUsage", () => {
});
});
it("keeps unavailable and error reports out of the sidebar summary even with retained data", () => {
if (claude.report.status !== "available" || codex.report.status !== "available")
throw new Error("fixtures must be available");
it("keeps unavailable and error reports out of the sidebar summary", () => {
const expired: UsageReportEntry = {
...claude,
lastSuccessful: { fetchedAt: claude.fetchedAt, report: claude.report },
report: {
status: "unavailable",
problem: { kind: "expired", expiresAt: "2026-10-01T00:00:00.000Z", refreshedBy: "claude" },
@@ -270,7 +267,6 @@ it("keeps unavailable and error reports out of the sidebar summary even with ret
};
const failed: UsageReportEntry = {
...codex,
lastSuccessful: { fetchedAt: codex.fetchedAt, report: codex.report },
report: { status: "error", error: "Store deleted" },
};
expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]);
+2 -7
View File
@@ -26,9 +26,7 @@ export function UsageWindowBar({
onTogglePin,
pinLabel,
pinTestID,
retained = false,
}: {
retained?: boolean;
window: UsageWindow;
displayAs: UsageDisplayAs;
/** Whether the row pins the window to the sidebar. When false the row is a plain bar. */
@@ -42,13 +40,10 @@ export function UsageWindowBar({
const shownPct = displayPercent(window, displayAs);
const tone = windowTone(window);
const isAtRisk =
window.runsOutAt != null &&
window.shortfallPct != null &&
(!retained || Date.parse(window.runsOutAt) > Date.now());
const isAtRisk = window.runsOutAt != null && window.shortfallPct != null;
const trailing = isAtRisk
? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim()
: formatResetLabel(window.resetsAt, retained);
: formatResetLabel(window.resetsAt);
const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—";
const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]);
@@ -53,14 +53,4 @@ describe("resolveToolCallIconName", () => {
expect(icon).toBe("wrench");
});
it("uses the Paseo icon for agent messages despite the legacy bot detail icon", () => {
const icon = resolveToolCallIconName(
"agent_message",
{ type: "plain_text", text: "Done.", icon: "bot" },
{ event: "finished", sender: { id: "agt_1" }, text: "Done." },
);
expect(icon).toBe("paseo");
});
});
+1 -10
View File
@@ -1,4 +1,3 @@
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { ToolCallDetail, ToolCallIconName } from "@getpaseo/protocol/agent-types";
import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
@@ -18,15 +17,7 @@ const TOOL_DETAIL_ICON_NAMES: Record<ToolCallDetail["type"], ToolCallIcon> = {
unknown: "wrench",
};
export function resolveToolCallIconName(
toolName: string,
detail?: ToolCallDetail,
agentMessage?: AgentMessage,
): ToolCallIcon {
// Agent messages are Paseo deliveries; the wire detail's legacy "bot" icon is for old clients.
if (agentMessage) {
return "paseo";
}
export function resolveToolCallIconName(toolName: string, detail?: ToolCallDetail): ToolCallIcon {
const lowerName = toolName.trim().toLowerCase();
if (detail?.type === "plain_text" && detail.icon) {
+1 -3
View File
@@ -10,7 +10,6 @@ import {
SquareTerminal,
Wrench,
} from "lucide-react-native";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import { PaseoLogo } from "@/components/icons/paseo-logo";
import { resolveToolCallIconName, type ToolCallIcon } from "./tool-call-icon-name";
@@ -37,7 +36,6 @@ export function componentForToolCallIcon(name: ToolCallIcon): ToolCallIconCompon
export function resolveToolCallIcon(
toolName: string,
detail?: ToolCallDetail,
agentMessage?: AgentMessage,
): ToolCallIconComponent {
return componentForToolCallIcon(resolveToolCallIconName(toolName, detail, agentMessage));
return componentForToolCallIcon(resolveToolCallIconName(toolName, detail));
}
@@ -277,40 +277,3 @@ describe("usage CLI registration", () => {
expect(usage.commands[1]!.helpInformation()).toContain("<report-id>");
});
});
it("shows retained values with current failures and separates successful time from latest attempt", async () => {
const success: UsageReportEntry = { ...reports[0]!, successfulAt: "2026-10-09T12:00:15Z" };
const current = await runUsageCommand(
options,
{ kind: "inspect", id: success.id },
host([success]).connect,
);
expect(render(current)).toContain("Latest attempt: 2026-10-09T12:00:00Z");
expect(render(current)).toContain("Last successful: 2026-10-09T12:00:15Z");
const currentList = await runUsageCommand(options, { kind: "list" }, host([success]).connect);
expect(render(currentList)).toContain("Last successful: 2026-10-09T12:00:15Z");
if (success.report.status !== "available") throw new Error("fixture must be available");
const retained: UsageReportEntry = {
...success,
fetchedAt: "2026-10-09T12:06:00Z",
report: { status: "error", error: "Rate limited" },
successfulAt: undefined,
lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report },
};
const daemon = host([retained]);
const listed = await runUsageCommand(options, { kind: "list" }, daemon.connect);
expect(render(listed)).toContain("Retained: Five hours: 12.5% used");
expect(render(listed)).toContain("Last successful: 2026-10-09T12:00:15Z");
expect(render(listed)).toContain("Rate limited");
expect(JSON.parse(render(listed, { format: "json" }))).toEqual([retained]);
const inspected = await runUsageCommand(
options,
{ kind: "inspect", id: retained.id },
daemon.connect,
);
expect(render(inspected)).toContain("Latest attempt: 2026-10-09T12:06:00Z");
expect(render(inspected)).toContain("Last successful: 2026-10-09T12:00:15Z");
expect(render(inspected)).toContain("Retained usage:");
expect(render(inspected)).toContain("Five hours: 12.5% used");
expect(render(inspected)).toContain("Reason: Rate limited");
});
+24 -49
View File
@@ -44,18 +44,8 @@ function summary(entry: UsageReportEntry): string[] {
const lines = [failureText(report)];
for (const login of entry.loginErrors ?? [])
lines.push(`${login.harness}: ${failureText(login.report)}`);
if (entry.lastSuccessful) {
lines.push(`Last successful: ${entry.lastSuccessful.fetchedAt}`);
lines.push(
...availableSummary(entry.lastSuccessful.report).map((line) => `Retained: ${line}`),
);
} else lines.push("No successful update");
return lines;
}
return [...availableSummary(report), `Last successful: ${successfulTime(entry)}`];
}
function availableSummary(report: Extract<UsageReport, { status: "available" }>): string[] {
const lines = report.windows.map(windowText);
lines.push(...(report.balances ?? []).map(balanceText));
return lines.length ? lines : ["-"];
@@ -78,7 +68,7 @@ const summarySchema: OutputSchema<SummaryRow> = {
{ header: "ACCOUNT", field: "account" },
{ header: "STATUS", field: "status" },
{ header: "USAGE", field: "usage" },
{ header: "FETCHED (LATEST ATTEMPT)", field: "fetched" },
{ header: "FETCHED", field: "fetched" },
],
};
@@ -89,15 +79,30 @@ function inspect(entry: UsageReportEntry): string {
`Source ID: ${entry.sourceId}`,
`Account: ${value(entry.account.label)}`,
`Status: ${entry.report.status}`,
`Latest attempt: ${value(entry.fetchedAt)}`,
`Fetched: ${value(entry.fetchedAt)}`,
];
const report = entry.report.status === "available" ? entry.report : entry.lastSuccessful?.report;
const successfulAt = successfulTime(entry);
lines.push(successfulAt ? `Last successful: ${successfulAt}` : "No successful update");
if (entry.report.status !== "available") lines.push(`Reason: ${failureText(entry.report)}`);
if (report) {
if (entry.report.status !== "available") lines.push("Retained usage:");
lines.push(...availableDetails(report));
const { report } = entry;
if (report.status === "available") {
lines.push(`Plan: ${value(report.planLabel)}`, "Windows:");
if (!report.windows.length) lines.push(" -");
for (const window of report.windows) {
lines.push(` ${windowText(window)} [${window.id}]`, ` Resets: ${value(window.resetsAt)}`);
if (window.runsOutAt != null) lines.push(` Runs out: ${window.runsOutAt}`);
if (window.shortfallPct != null) lines.push(` Shortfall: ${percent(window.shortfallPct)}`);
}
lines.push("Balances:");
if (!report.balances?.length) lines.push(" -");
for (const balance of report.balances ?? []) {
lines.push(
` ${balanceText(balance)} [${balance.id}]`,
` Resets: ${value(balance.resetsAt)}`,
);
}
lines.push("Details:");
if (!report.details?.length) lines.push(" -");
for (const detail of report.details ?? []) lines.push(` ${detail.label}: ${detail.value}`);
} else {
lines.push(`Reason: ${failureText(report)}`);
}
if (entry.loginErrors?.length) {
lines.push("Login failures:");
@@ -130,33 +135,3 @@ export const usageSchema: OutputSchema<UsageReportEntry> = {
return renderTable<SummaryRow>({ type: "list", data: rows, schema: summarySchema }, options);
},
};
function availableDetails(report: Extract<UsageReport, { status: "available" }>): string[] {
const lines: string[] = [];
lines.push(`Plan: ${value(report.planLabel)}`, "Windows:");
if (!report.windows.length) lines.push(" -");
for (const window of report.windows) {
lines.push(` ${windowText(window)} [${window.id}]`, ` Resets: ${value(window.resetsAt)}`);
if (window.runsOutAt != null) lines.push(` Runs out: ${window.runsOutAt}`);
if (window.shortfallPct != null) lines.push(` Shortfall: ${percent(window.shortfallPct)}`);
}
lines.push("Balances:");
if (!report.balances?.length) lines.push(" -");
for (const balance of report.balances ?? []) {
lines.push(
` ${balanceText(balance)} [${balance.id}]`,
` Resets: ${value(balance.resetsAt)}`,
);
}
lines.push("Details:");
if (!report.details?.length) lines.push(" -");
for (const detail of report.details ?? []) lines.push(` ${detail.label}: ${detail.value}`);
return lines;
}
function successfulTime(entry: UsageReportEntry): string | undefined {
// COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0.
// Only available reports from older daemons can use their attempt timestamp as freshness.
if (entry.report.status === "available") return entry.successfulAt ?? entry.fetchedAt;
return entry.lastSuccessful?.fetchedAt;
}
+10 -11
View File
@@ -232,18 +232,17 @@ test("scrolled chat does not exclude the workspace titlebar from dragging", asyn
}
await page.getByRole("button", { name: "Close Find", exact: true }).click();
await expect(find).toBeHidden();
// Closing Find returns keyboard scrolling to the chat viewport, which emits no window region.
await expect
.poll(() => chat.evaluate((scroll) => document.activeElement === scroll))
.toBe(true);
await expect
.poll(() => chat.evaluate(inspectChatDragExclusions))
.toEqual({
contentCrossesHeader: true,
contentCrossesTabRow: true,
focusScopeRegion: "no-drag",
exclusions: 0,
});
.poll(() =>
chat.evaluate((scroll) => ({
focusReturned: document.activeElement?.contains(scroll),
tabindex: document.activeElement?.getAttribute("tabindex"),
appRegion: getComputedStyle(document.activeElement!).getPropertyValue(
"-webkit-app-region",
),
})),
)
.toEqual({ focusReturned: true, tabindex: "-1", appRegion: "no-drag" });
await page.screenshot({ path: testInfo.outputPath("scrolled-chat-titlebar.png") });
} finally {
await agent.cleanup();
@@ -0,0 +1,25 @@
import type { AgentMessage } from "./agent-message.js";
import type { ToolCallDetail } from "./agent-types.js";
/** Presentation shared by clients; never used to encode provider prompts. */
export function buildAgentMessageDisplay(message: AgentMessage): {
displayName: string;
detail: ToolCallDetail;
} {
const sender = message.sender.title ?? message.sender.id;
const headings: Record<AgentMessage["event"], string> = {
message: `Message from ${sender}`,
finished: `${sender} finished`,
errored: `${sender} errored`,
"permission-required": `${sender} needs permission`,
closed: `${sender} closed`,
};
return {
displayName: headings[message.event],
detail: {
type: "plain_text",
icon: "bot",
text: `From agent: ${message.sender.id}\n\n${message.text}`,
},
};
}
+7 -13
View File
@@ -6306,15 +6306,14 @@ export const UsageProblemSchema = z.discriminatedUnion("kind", [
}),
z.object({ kind: z.literal("no_quota"), detail: z.string() }),
]);
const AvailableUsageReportSchema = z.object({
status: z.literal("available"),
planLabel: z.string().optional(),
windows: z.array(ProviderUsageWindowSchema),
balances: z.array(ProviderUsageBalanceSchema).optional(),
details: z.array(ProviderUsageDetailSchema).optional(),
});
export const UsageReportSchema = z.discriminatedUnion("status", [
AvailableUsageReportSchema,
z.object({
status: z.literal("available"),
planLabel: z.string().optional(),
windows: z.array(ProviderUsageWindowSchema),
balances: z.array(ProviderUsageBalanceSchema).optional(),
details: z.array(ProviderUsageDetailSchema).optional(),
}),
z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }),
z.object({ status: z.literal("error"), error: z.string() }),
]);
@@ -6326,11 +6325,6 @@ export const UsageReportEntrySchema = z.object({
sourceLabel: z.string(),
icon: z.string().optional(),
report: UsageReportSchema,
// Successful completion time; fetchedAt remains the attempt timestamp for ordering.
successfulAt: z.string().optional(),
lastSuccessful: z
.object({ fetchedAt: z.string(), report: AvailableUsageReportSchema })
.optional(),
loginErrors: z
.array(
z.object({
@@ -1,7 +1,6 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";
import {
UsageReportEntrySchema,
AgentSnapshotPayloadSchema,
AgentTimelineItemPayloadSchema,
ServerInfoStatusPayloadSchema,
@@ -437,40 +436,3 @@ test("usage login errors are additive and older reports still parse", () => {
expect(SessionOutboundMessageSchema.parse(newMessage)).toEqual(newMessage);
expect(legacy.parse(newMessage)).toEqual(oldMessage);
});
test("usage entries accept older failures and additive successful snapshots", () => {
const oldEntry = {
id: "source:account",
sourceId: "source",
sourceLabel: "Source",
account: {},
fetchedAt: "2026-10-17T10:06:00Z",
report: { status: "error", error: "Rate limited" },
};
expect(UsageReportEntrySchema.parse(oldEntry)).toEqual(oldEntry);
const retained = {
...oldEntry,
lastSuccessful: {
fetchedAt: "2026-10-17T10:00:00Z",
report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] },
},
};
expect(UsageReportEntrySchema.parse(retained)).toEqual(retained);
const available = {
...oldEntry,
report: retained.lastSuccessful.report,
successfulAt: "2026-10-17T10:00:15Z",
};
expect(UsageReportEntrySchema.parse(available)).toEqual(available);
expect(
UsageReportEntrySchema.omit({ lastSuccessful: true, successfulAt: true }).parse(available),
).toEqual({ ...oldEntry, report: available.report });
// An older client's entry shape strips the additive snapshot and still sees the failure.
expect(UsageReportEntrySchema.omit({ lastSuccessful: true }).parse(retained)).toEqual(oldEntry);
expect(
UsageReportEntrySchema.safeParse({
...oldEntry,
lastSuccessful: { fetchedAt: oldEntry.fetchedAt, report: oldEntry.report },
}).success,
).toBe(false);
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildPaseoToolDetailSections } from "./index.js";
import { buildPaseoToolDetailSections } from "./paseo-tool-call-detail.js";
describe("Paseo tool-call detail presentation", () => {
it.each(["mcp__paseo__create_agent", "paseo.create_agent", "paseo_remote.create_agent"])(
@@ -1,5 +1,4 @@
import { getPaseoToolLeafName } from "../tool-name-normalization.js";
import { getPaseoToolEntry, type PaseoToolDetailSpec } from "./catalog.js";
import { getPaseoToolLeafName } from "./tool-name-normalization.js";
export interface PaseoToolDetailField {
label: string;
@@ -18,6 +17,161 @@ export type PaseoToolDetailSection =
fields: PaseoToolDetailField[];
};
interface ToolDetailSpec {
promptField?: string;
inputOrder?: readonly string[];
outputFields?: readonly string[];
}
const WORKSPACE_FIELDS = [
"title",
"workspaceId",
"projectId",
"isolation",
"path",
"mode",
"worktreeSlug",
"branchName",
"baseBranch",
"branch",
"prNumber",
"forge",
] as const;
const AGENT_FIELDS = [
"title",
"agentId",
"provider",
"workspaceId",
"cwd",
"sessionMode",
"modeId",
"background",
"notifyOnFinish",
"internal",
"settings",
"labels",
] as const;
const AUTOMATION_FIELDS = [
"name",
"id",
"cron",
"timezone",
"provider",
"cwd",
"isolation",
"maxRuns",
"expiresIn",
"clearExpires",
] as const;
const BROWSER_FIELDS = [
"browserId",
"url",
"ref",
"sourceRef",
"targetRef",
"value",
"text",
"key",
"button",
"doubleClick",
"modifiers",
"filePaths",
"fullPage",
"maxEntries",
"timeoutMs",
"deltaX",
"deltaY",
"width",
"height",
"function",
] as const;
const TOOL_SPECS: Readonly<Record<string, ToolDetailSpec>> = {
create_workspace: {
inputOrder: WORKSPACE_FIELDS,
outputFields: ["workspaceId", "projectId"],
},
list_workspaces: {},
archive_workspace: {
inputOrder: ["workspaceId"],
outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"],
},
rename_workspace: { inputOrder: ["title", "workspaceId"] },
create_agent: {
promptField: "initialPrompt",
inputOrder: AGENT_FIELDS,
outputFields: ["agentId", "status", "currentModeId", "cwd"],
},
send_agent_prompt: {
promptField: "prompt",
inputOrder: AGENT_FIELDS,
outputFields: ["status", "lastMessage", "permission"],
},
get_agent_status: { inputOrder: ["agentId"] },
list_agents: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] },
cancel_agent: { inputOrder: ["agentId"] },
archive_agent: { inputOrder: ["agentId"] },
kill_agent: { inputOrder: ["agentId"] },
update_agent: { inputOrder: AGENT_FIELDS },
get_agent_activity: { inputOrder: ["agentId", "limit"] },
set_agent_mode: { inputOrder: ["agentId", "modeId"] },
list_workspace_scripts: { inputOrder: ["workspaceId"] },
start_workspace_script: { inputOrder: ["workspaceId", "scriptName"] },
stop_workspace_script: { inputOrder: ["workspaceId", "scriptName"] },
list_terminals: { inputOrder: ["cwd", "all"] },
create_terminal: { inputOrder: ["cwd", "workspaceId", "name", "command"] },
kill_terminal: { inputOrder: ["terminalId"] },
capture_terminal: { inputOrder: ["terminalId", "lines"] },
send_terminal_keys: { inputOrder: ["terminalId", "keys", "literal"] },
create_schedule: {
promptField: "prompt",
inputOrder: AUTOMATION_FIELDS,
outputFields: ["id", "status", "nextRunAt", "expiresAt"],
},
create_heartbeat: {
promptField: "prompt",
inputOrder: AUTOMATION_FIELDS,
outputFields: ["id", "status", "nextRunAt", "expiresAt"],
},
delete_heartbeat: { inputOrder: ["id"] },
list_schedules: {},
inspect_schedule: { inputOrder: ["id"] },
pause_schedule: { inputOrder: ["id"] },
resume_schedule: { inputOrder: ["id"] },
delete_schedule: { inputOrder: ["id"] },
update_schedule: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS },
schedule_logs: { inputOrder: ["id"] },
run_schedule_once: { inputOrder: ["id"] },
list_providers: {},
list_models: { inputOrder: ["provider"] },
list_profiles: {},
inspect_provider: { inputOrder: ["provider", "cwd", "settings"] },
list_pending_permissions: {},
respond_to_permission: { inputOrder: ["agentId", "requestId", "response"] },
browser_list_tabs: {},
browser_new_tab: { inputOrder: BROWSER_FIELDS },
browser_snapshot: { inputOrder: BROWSER_FIELDS },
browser_click: { inputOrder: BROWSER_FIELDS },
browser_fill: { inputOrder: BROWSER_FIELDS },
browser_wait: { inputOrder: BROWSER_FIELDS },
browser_type: { inputOrder: BROWSER_FIELDS },
browser_keypress: { inputOrder: BROWSER_FIELDS },
browser_navigate: { inputOrder: BROWSER_FIELDS },
browser_back: { inputOrder: BROWSER_FIELDS },
browser_forward: { inputOrder: BROWSER_FIELDS },
browser_reload: { inputOrder: BROWSER_FIELDS },
browser_screenshot: { inputOrder: BROWSER_FIELDS },
browser_upload: { inputOrder: BROWSER_FIELDS },
browser_hover: { inputOrder: BROWSER_FIELDS },
browser_select: { inputOrder: BROWSER_FIELDS },
browser_drag: { inputOrder: BROWSER_FIELDS },
browser_logs: { inputOrder: BROWSER_FIELDS },
browser_evaluate: { inputOrder: BROWSER_FIELDS },
browser_scroll: { inputOrder: BROWSER_FIELDS },
browser_resize: { inputOrder: BROWSER_FIELDS },
browser_close_tab: { inputOrder: BROWSER_FIELDS },
};
const FIELD_LABELS: Readonly<Record<string, string>> = {
agentId: "Agent",
archivedAgentIds: "Archived agents",
@@ -176,7 +330,7 @@ export function buildPaseoToolDetailSections(
const leafName = getPaseoToolLeafName(toolName);
if (!leafName) return null;
const spec: PaseoToolDetailSpec = getPaseoToolEntry(leafName)?.detail ?? {};
const spec = TOOL_SPECS[leafName] ?? {};
const sections: PaseoToolDetailSection[] = [];
if (spec.promptField && isRecord(input)) {
const prompt = input[spec.promptField];
@@ -1,31 +0,0 @@
import type { AgentMessage } from "../agent-message.js";
import type { ToolCallDetail } from "../agent-types.js";
import { toSummaryText } from "./summary-text.js";
export interface AgentMessageDisplay {
displayName: string;
summary?: string;
detail: ToolCallDetail;
}
/** Events read noun-first ("Agent finished"); the sender goes in the summary. */
const EVENT_LABELS: Record<AgentMessage["event"], string> = {
message: "Message received",
finished: "Agent finished",
errored: "Agent errored",
"permission-required": "Agent needs permission",
closed: "Agent closed",
};
/** Presentation shared by clients; never used to encode provider prompts. */
export function buildAgentMessageDisplay(message: AgentMessage): AgentMessageDisplay {
const summary = toSummaryText(message.sender.title ?? message.sender.id);
return {
displayName: EVENT_LABELS[message.event],
...(summary ? { summary } : {}),
detail: {
type: "plain_text",
text: `From agent: ${message.sender.id}\n\n${message.text}`,
},
};
}
@@ -1,349 +0,0 @@
/**
* One entry per Paseo MCP tool: the timeline label, the context shown after it,
* and how the expanded details lay out the call.
*
* Labels are fixed text so truncation never hides them. Actions read verb-first
* ("Send message"); the context goes in the summary.
*/
type ToolInput = Readonly<Record<string, unknown>>;
/** `targetAgent` is the target agent's title when known, else its ID. */
export type PaseoToolContext = (input: ToolInput, targetAgent: string | undefined) => unknown;
export interface PaseoToolDetailSpec {
promptField?: string;
inputOrder?: readonly string[];
outputFields?: readonly string[];
}
export interface PaseoToolEntry {
label: string;
context?: PaseoToolContext;
detail?: PaseoToolDetailSpec;
}
const WORKSPACE_FIELDS = [
"title",
"workspaceId",
"projectId",
"isolation",
"path",
"mode",
"worktreeSlug",
"branchName",
"baseBranch",
"branch",
"prNumber",
"forge",
] as const;
const AGENT_FIELDS = [
"title",
"agentId",
"provider",
"workspaceId",
"cwd",
"sessionMode",
"modeId",
"background",
"notifyOnFinish",
"internal",
"settings",
"labels",
] as const;
const AUTOMATION_FIELDS = [
"name",
"id",
"cron",
"timezone",
"provider",
"cwd",
"isolation",
"maxRuns",
"expiresIn",
"clearExpires",
] as const;
const BROWSER_FIELDS = [
"browserId",
"url",
"ref",
"sourceRef",
"targetRef",
"value",
"text",
"key",
"button",
"doubleClick",
"modifiers",
"filePaths",
"fullPage",
"maxEntries",
"timeoutMs",
"deltaX",
"deltaY",
"width",
"height",
"function",
] as const;
const AUTOMATION_OUTPUT = ["id", "status", "nextRunAt", "expiresAt"] as const;
function field(...keys: string[]): PaseoToolContext {
return (input) => keys.map((key) => input[key]).find(isPresent);
}
function targetAgent(): PaseoToolContext {
return (_input, agent) => agent;
}
function fieldOrTargetAgent(key: string): PaseoToolContext {
return (input, agent) => (isPresent(input[key]) ? input[key] : agent);
}
function workspaceContext(): PaseoToolContext {
return (input) =>
[input.title, input.branchName, input.branch, baseName(input.path)].find(isPresent);
}
function viewportContext(): PaseoToolContext {
return (input) =>
typeof input.width === "number" && typeof input.height === "number"
? `${input.width}×${input.height}`
: undefined;
}
function isPresent(value: unknown): boolean {
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0;
return typeof value === "number";
}
function baseName(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
return value.split(/[\\/]/).findLast((segment) => segment.length > 0);
}
function browser(label: string, context?: PaseoToolContext): PaseoToolEntry {
return { label, ...(context ? { context } : {}), detail: { inputOrder: BROWSER_FIELDS } };
}
export const PASEO_TOOL_CATALOG = {
// Agents
create_agent: {
label: "Create agent",
context: field("title", "initialPrompt"),
detail: {
promptField: "initialPrompt",
inputOrder: AGENT_FIELDS,
outputFields: ["agentId", "status", "currentModeId", "cwd"],
},
},
send_agent_prompt: {
label: "Send message",
context: field("prompt"),
detail: {
promptField: "prompt",
inputOrder: AGENT_FIELDS,
outputFields: ["status", "lastMessage", "permission"],
},
},
get_agent_status: {
label: "Check agent",
context: targetAgent(),
detail: { inputOrder: ["agentId"] },
},
get_agent_activity: {
label: "Read agent activity",
context: targetAgent(),
detail: { inputOrder: ["agentId", "limit"] },
},
list_agents: {
label: "List agents",
detail: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] },
},
update_agent: {
label: "Update agent",
context: fieldOrTargetAgent("name"),
detail: { inputOrder: AGENT_FIELDS },
},
set_agent_mode: {
label: "Set agent mode",
context: field("modeId"),
detail: { inputOrder: ["agentId", "modeId"] },
},
cancel_agent: {
label: "Cancel agent",
context: targetAgent(),
detail: { inputOrder: ["agentId"] },
},
archive_agent: {
label: "Archive agent",
context: targetAgent(),
detail: { inputOrder: ["agentId"] },
},
kill_agent: { label: "Kill agent", context: targetAgent(), detail: { inputOrder: ["agentId"] } },
list_pending_permissions: { label: "List permissions" },
respond_to_permission: {
label: "Respond to permission",
context: targetAgent(),
detail: { inputOrder: ["agentId", "requestId", "response"] },
},
// Workspaces and scripts
create_workspace: {
label: "Create workspace",
context: workspaceContext(),
detail: { inputOrder: WORKSPACE_FIELDS, outputFields: ["workspaceId", "projectId"] },
},
list_workspaces: { label: "List workspaces" },
rename_workspace: {
label: "Rename workspace",
context: field("title"),
detail: { inputOrder: ["title", "workspaceId"] },
},
archive_workspace: {
label: "Archive workspace",
context: field("workspaceId"),
detail: {
inputOrder: ["workspaceId"],
outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"],
},
},
list_workspace_scripts: { label: "List scripts", detail: { inputOrder: ["workspaceId"] } },
start_workspace_script: {
label: "Start script",
context: field("scriptName"),
detail: { inputOrder: ["workspaceId", "scriptName"] },
},
stop_workspace_script: {
label: "Stop script",
context: field("scriptName"),
detail: { inputOrder: ["workspaceId", "scriptName"] },
},
// Terminals
create_terminal: {
label: "Create terminal",
context: field("name", "command"),
detail: { inputOrder: ["cwd", "workspaceId", "name", "command"] },
},
list_terminals: { label: "List terminals", detail: { inputOrder: ["cwd", "all"] } },
capture_terminal: {
label: "Read terminal",
context: field("terminalId"),
detail: { inputOrder: ["terminalId", "lines"] },
},
send_terminal_keys: {
label: "Send keys",
context: field("keys"),
detail: { inputOrder: ["terminalId", "keys", "literal"] },
},
kill_terminal: {
label: "Kill terminal",
context: field("terminalId"),
detail: { inputOrder: ["terminalId"] },
},
// Schedules and heartbeats
create_schedule: {
label: "Create schedule",
context: field("name", "cron"),
detail: {
promptField: "prompt",
inputOrder: AUTOMATION_FIELDS,
outputFields: AUTOMATION_OUTPUT,
},
},
create_heartbeat: {
label: "Create heartbeat",
context: field("name", "cron"),
detail: {
promptField: "prompt",
inputOrder: AUTOMATION_FIELDS,
outputFields: AUTOMATION_OUTPUT,
},
},
update_schedule: {
label: "Update schedule",
context: field("name", "id"),
detail: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS },
},
list_schedules: { label: "List schedules" },
inspect_schedule: {
label: "Inspect schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
schedule_logs: {
label: "Read schedule logs",
context: field("id"),
detail: { inputOrder: ["id"] },
},
run_schedule_once: {
label: "Run schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
pause_schedule: { label: "Pause schedule", context: field("id"), detail: { inputOrder: ["id"] } },
resume_schedule: {
label: "Resume schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
delete_schedule: {
label: "Delete schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
delete_heartbeat: {
label: "Delete heartbeat",
context: field("id"),
detail: { inputOrder: ["id"] },
},
// Providers
list_providers: { label: "List providers" },
list_models: {
label: "List models",
context: field("provider"),
detail: { inputOrder: ["provider"] },
},
list_profiles: { label: "List profiles" },
inspect_provider: {
label: "Inspect provider",
context: field("provider"),
detail: { inputOrder: ["provider", "cwd", "settings"] },
},
// Browser
browser_list_tabs: browser("List browser tabs"),
browser_new_tab: browser("Open browser tab", field("url")),
browser_close_tab: browser("Close browser tab", field("browserId")),
browser_navigate: browser("Navigate", field("url")),
browser_back: browser("Go back"),
browser_forward: browser("Go forward"),
browser_reload: browser("Reload page"),
browser_snapshot: browser("Snapshot page"),
browser_screenshot: browser("Take screenshot"),
browser_click: browser("Click element", field("ref")),
browser_hover: browser("Hover element", field("ref")),
browser_drag: browser("Drag element", field("sourceRef")),
browser_fill: browser("Fill field", field("ref")),
browser_select: browser("Select option", field("value", "ref")),
browser_type: browser("Type text", field("text")),
browser_keypress: browser("Press key", field("key")),
browser_upload: browser("Upload files", field("filePaths")),
browser_wait: browser("Wait for page", field("text", "url")),
browser_scroll: browser("Scroll page"),
browser_resize: browser("Resize browser", viewportContext()),
browser_logs: browser("Read browser logs"),
browser_evaluate: browser("Run JavaScript", field("function")),
} satisfies Record<string, PaseoToolEntry>;
export type PaseoToolName = keyof typeof PASEO_TOOL_CATALOG;
export function getPaseoToolEntry(leafName: string): PaseoToolEntry | undefined {
return Object.prototype.hasOwnProperty.call(PASEO_TOOL_CATALOG, leafName)
? PASEO_TOOL_CATALOG[leafName as PaseoToolName]
: undefined;
}
@@ -1,141 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildAgentMessageDisplay,
buildPaseoToolDisplay,
getPaseoToolTargetAgentId,
MAX_SUMMARY_CHARS,
PASEO_TOOL_NAMES,
} from "./index.js";
function display(toolName: string, input: unknown, targetAgentTitle?: string) {
return buildPaseoToolDisplay({
toolName,
detail: { type: "unknown", input, output: null },
...(targetAgentTitle ? { targetAgentTitle } : {}),
});
}
describe("Paseo tool display", () => {
it.each([
[
"send_agent_prompt",
{ agentId: "agt_1", prompt: "Fix the failing test" },
"Send message",
"Fix the failing test",
],
["create_agent", { title: "Reviewer", initialPrompt: "Review it" }, "Create agent", "Reviewer"],
["create_agent", { initialPrompt: "Review it" }, "Create agent", "Review it"],
["get_agent_status", { agentId: "agt_1" }, "Check agent", "agt_1"],
["update_agent", { agentId: "agt_1", name: "Renamed" }, "Update agent", "Renamed"],
["create_workspace", { path: "/tmp/repo/feature-x" }, "Create workspace", "feature-x"],
["create_workspace", { title: "Docs", branchName: "docs" }, "Create workspace", "Docs"],
["start_workspace_script", { scriptName: "web" }, "Start script", "web"],
["send_terminal_keys", { terminalId: "t1", keys: "npm test" }, "Send keys", "npm test"],
["create_schedule", { prompt: "Check CI", cron: "0 9 * * 1" }, "Create schedule", "0 9 * * 1"],
["browser_navigate", { url: "https://paseo.sh" }, "Navigate", "https://paseo.sh"],
[
"browser_upload",
{ ref: "@e1", filePaths: ["/a.png", "/b.png"] },
"Upload files",
"/a.png +1",
],
["browser_resize", { width: 800, height: 600 }, "Resize browser", "800×600"],
])("presents %s with a fixed label and its context", (tool, input, label, summary) => {
expect(display(`mcp__paseo__${tool}`, input)).toEqual({ displayName: label, summary });
});
it("omits the summary when a call has no context", () => {
expect(display("mcp__paseo__list_agents", {})).toEqual({ displayName: "List agents" });
});
it("recognizes every Paseo namespace format", () => {
for (const name of ["mcp__paseo__kill_agent", "paseo.kill_agent", "paseo_remote.kill_agent"]) {
expect(display(name, { agentId: "agt_1" })?.displayName).toBe("Kill agent");
}
});
it("returns null outside the Paseo namespace, including speak", () => {
expect(display("Bash", {})).toBeNull();
expect(display("mcp__paseo__speak", { text: "hi" })).toBeNull();
});
it("humanizes a Paseo tool that has no catalog entry", () => {
expect(display("mcp__paseo__brand_new_tool", {})).toEqual({ displayName: "Brand new tool" });
});
it("prefers the known agent title over the agent ID", () => {
expect(display("mcp__paseo__cancel_agent", { agentId: "agt_1" }, "Reviewer")).toEqual({
displayName: "Cancel agent",
summary: "Reviewer",
});
expect(
getPaseoToolTargetAgentId("mcp__paseo__cancel_agent", {
type: "unknown",
input: { agentId: "agt_1" },
output: null,
}),
).toBe("agt_1");
expect(
getPaseoToolTargetAgentId("Bash", { type: "unknown", input: { agentId: "x" }, output: null }),
).toBeNull();
});
it("truncates a massive single-line prompt and collapses whitespace", () => {
const prompt = `Line one\n\n line two ${"x".repeat(1_000_000)}`;
const summary = display("mcp__paseo__send_agent_prompt", { agentId: "a", prompt })?.summary;
expect(summary).toHaveLength(MAX_SUMMARY_CHARS);
expect(summary?.startsWith("Line one line two xxx")).toBe(true);
expect(summary?.endsWith("…")).toBe(true);
});
it("bounds the scan even when the prompt opens with a wall of whitespace", () => {
const prompt = `${" ".repeat(10_000)}hidden`;
const summary = display("mcp__paseo__send_agent_prompt", { agentId: "a", prompt })?.summary;
expect(summary).toBeUndefined();
});
it("keeps a short prompt intact", () => {
expect(
display("mcp__paseo__send_agent_prompt", { agentId: "a", prompt: " hi\nthere " })?.summary,
).toBe("hi there");
});
it("catalogs tool names as lowercase leaf names", () => {
for (const name of PASEO_TOOL_NAMES) {
expect(name).toMatch(/^[a-z_]+$/);
}
});
});
describe("agent message display", () => {
it.each([
["message", "Message received"],
["finished", "Agent finished"],
["errored", "Agent errored"],
["permission-required", "Agent needs permission"],
["closed", "Agent closed"],
] as const)("labels %s noun-first with the sender as context", (event, label) => {
const result = buildAgentMessageDisplay({
event,
sender: { id: "srv::agt_1", title: "QA messenger" },
text: "Done.",
});
expect(result.displayName).toBe(label);
expect(result.summary).toBe("QA messenger");
expect(result.detail).toEqual({ type: "plain_text", text: "From agent: srv::agt_1\n\nDone." });
});
it("falls back to the sender ID and truncates a long title", () => {
expect(
buildAgentMessageDisplay({ event: "finished", sender: { id: "srv::agt_1" }, text: "" })
.summary,
).toBe("srv::agt_1");
const summary = buildAgentMessageDisplay({
event: "finished",
sender: { id: "a", title: "t".repeat(500) },
text: "",
}).summary;
expect(summary).toHaveLength(MAX_SUMMARY_CHARS);
});
});
@@ -1,85 +0,0 @@
import type { ToolCallDetail } from "../agent-types.js";
import { getPaseoToolLeafName, isPaseoToolName } from "../tool-name-normalization.js";
import { getPaseoToolEntry, PASEO_TOOL_CATALOG, type PaseoToolName } from "./catalog.js";
import { toSummaryText } from "./summary-text.js";
export { buildAgentMessageDisplay, type AgentMessageDisplay } from "./agent-message.js";
export {
buildPaseoToolDetailSections,
type PaseoToolDetailField,
type PaseoToolDetailSection,
} from "./detail-sections.js";
export { MAX_SUMMARY_CHARS } from "./summary-text.js";
export type { PaseoToolName };
export interface PaseoToolDisplayInput {
toolName: string;
detail: ToolCallDetail;
/** Title of the agent named by the call's `agentId`, when the caller knows it. */
targetAgentTitle?: string;
}
export interface PaseoToolDisplay {
displayName: string;
summary?: string;
}
export const PASEO_TOOL_NAMES = Object.keys(PASEO_TOOL_CATALOG) as readonly PaseoToolName[];
/** Returns null for tools outside the Paseo namespace. */
export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDisplay | null {
const leafName = paseoLeafName(input.toolName);
if (!leafName) return null;
const entry = getPaseoToolEntry(leafName);
if (!entry) return { displayName: humanizeLeafName(leafName) };
const toolInput = readToolInput(input.detail);
const targetAgent = input.targetAgentTitle ?? readString(toolInput.agentId);
const summary = formatContext(entry.context?.(toolInput, targetAgent));
return { displayName: entry.label, ...(summary ? { summary } : {}) };
}
/** The agent a Paseo call acts on, so callers can resolve its title. */
export function getPaseoToolTargetAgentId(
toolName: string,
detail: ToolCallDetail | undefined,
): string | null {
if (!detail || !paseoLeafName(toolName)) return null;
return readString(readToolInput(detail).agentId) ?? null;
}
function paseoLeafName(toolName: string): string | null {
return isPaseoToolName(toolName) ? getPaseoToolLeafName(toolName) : null;
}
function readToolInput(detail: ToolCallDetail): Readonly<Record<string, unknown>> {
if (detail.type !== "unknown") return {};
const value = detail.input;
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function formatContext(value: unknown): string | undefined {
if (typeof value === "string") return toSummaryText(value);
if (typeof value === "number") return String(value);
if (Array.isArray(value)) {
const [first, ...rest] = value;
const head = typeof first === "string" ? toSummaryText(first) : undefined;
if (!head) return undefined;
return rest.length > 0 ? `${head} +${rest.length}` : head;
}
return undefined;
}
function humanizeLeafName(leafName: string): string {
const sentence = leafName
.replace(/[._-]+/g, " ")
.trim()
.toLowerCase();
return `${sentence[0]?.toUpperCase() ?? ""}${sentence.slice(1)}`;
}
@@ -1,21 +0,0 @@
/**
* Prompts and results can be one enormous line. Summaries are capped here so no
* consumer relies on its renderer to clip them.
*/
export const MAX_SUMMARY_CHARS = 120;
// Bounds the whitespace scan: inputs can be megabytes.
const SCAN_WINDOW_CHARS = MAX_SUMMARY_CHARS * 4;
export function toSummaryText(value: string): string | undefined {
const isPartial = value.length > SCAN_WINDOW_CHARS;
const collapsed = value.slice(0, SCAN_WINDOW_CHARS).replace(/\s+/g, " ").trim();
if (!collapsed) return undefined;
if (collapsed.length <= MAX_SUMMARY_CHARS && !isPartial) return collapsed;
const kept = dropTrailingHighSurrogate(collapsed.slice(0, MAX_SUMMARY_CHARS - 1)).trimEnd();
return `${kept}…`;
}
function dropTrailingHighSurrogate(text: string): string {
const last = text.charCodeAt(text.length - 1);
return last >= 0xd800 && last <= 0xdbff ? text.slice(0, -1) : text;
}
+11 -20
View File
@@ -1,8 +1,6 @@
import { buildAgentMessageDisplay } from "./agent-message-display.js";
import type { ToolCallTimelineItem } from "./agent-types.js";
import {
buildAgentMessageDisplay,
buildPaseoToolDisplay,
} from "./paseo-tool-presentation/index.js";
import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js";
import { stripCwdPrefix } from "./path-utils.js";
export type ToolCallDisplayInput = Pick<
@@ -10,8 +8,6 @@ export type ToolCallDisplayInput = Pick<
"name" | "status" | "error" | "metadata" | "detail" | "agentMessage"
> & {
cwd?: string;
/** Title of the agent a Paseo call targets, when the caller knows it. */
targetAgentTitle?: string;
};
export interface ToolCallDisplayModel {
@@ -38,6 +34,12 @@ function humanizeToolName(name: string): string {
if (!trimmed) {
return name;
}
if (isPaseoToolName(trimmed)) {
const leaf = getPaseoToolLeafName(trimmed);
if (leaf) {
return humanizeToolName(leaf);
}
}
if (/[:./]/.test(trimmed) || trimmed.includes("__")) {
return trimmed;
}
@@ -150,20 +152,8 @@ function buildUnknownDetailOverride(input: ToolCallDisplayInput): DetailDisplay
}
export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel {
const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined;
const paseoDisplay = input.agentMessage
? buildAgentMessageDisplay(input.agentMessage)
: buildPaseoToolDisplay({
toolName: input.name,
detail: input.detail,
targetAgentTitle: input.targetAgentTitle,
});
if (paseoDisplay) {
return {
displayName: paseoDisplay.displayName,
...(paseoDisplay.summary ? { summary: paseoDisplay.summary } : {}),
...(errorText ? { errorText } : {}),
};
if (input.agentMessage) {
return { displayName: buildAgentMessageDisplay(input.agentMessage).displayName };
}
const canonicalDisplay = buildCanonicalDetailDisplay(input);
const unknownDetailOverride = buildUnknownDetailOverride(input);
@@ -172,6 +162,7 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall
canonicalDisplay.displayName ??
humanizeToolName(input.name);
const summary = unknownDetailOverride.summary ?? canonicalDisplay.summary;
const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined;
return {
displayName,
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/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 && vitest run --maxWorkers=1 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\"",
"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/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 && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
"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",
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import { buildAgentForkContextAttachment, curateAgentActivity } from "./activity-curator.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
import { formatAgentMessage, projectAgentMessage } from "./agent-messages/index.js";
function toolCallItem(params: {
callId: string;
@@ -79,28 +78,6 @@ describe("curateAgentActivity", () => {
);
});
it("renders delivered agent messages with their event, sender, and body", () => {
const delivered = (source: Parameters<typeof formatAgentMessage>[0]["source"], text: string) =>
projectAgentMessage({
type: "user_message",
text: formatAgentMessage({ id: `msg-${text.length}`, source, text }),
})!;
const timeline: AgentTimelineItem[] = [
delivered(
{ kind: "agent-message", agentId: "agt_lead", title: "CI lead" },
"Take ownership.\nRead the handoff.",
),
delivered(
{ kind: "agent-notification", agentId: "agt_child", event: "finished" },
"Child is done.",
),
];
expect(curateAgentActivity(timeline)).toBe(
"[Message received] CI lead\nTake ownership.\nRead the handoff.\n[Agent finished] agt_child\nChild is done.",
);
});
it("renders user/assistant/reasoning entries", () => {
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Hello" },
@@ -1,9 +1,7 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization";
import { buildAgentMessageDisplay } from "@getpaseo/protocol/paseo-tool-presentation/index";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import { projectTimelineRows } from "./timeline-projection.js";
@@ -121,17 +119,6 @@ function formatToolCallEntry(
return activityEntry(summary ? `[${displayName}] ${summary}` : `[${displayName}]`);
}
function appendAgentMessageEntries(entries: ActivityEntry[], message: AgentMessage) {
const display = buildAgentMessageDisplay(message);
entries.push(
activityEntry(
display.summary ? `[${display.displayName}] ${display.summary}` : `[${display.displayName}]`,
),
);
const body = message.text.trim();
if (body) entries.push(activityEntry(body));
}
function curateProjectedActivityEntries(
items: readonly AgentTimelineItem[],
options?: ActivityCuratorOptions,
@@ -159,10 +146,6 @@ function curateProjectedActivityEntries(
appendTextEntry(entries, item, options);
break;
case "tool_call": {
if (item.agentMessage) {
appendAgentMessageEntries(entries, item.agentMessage);
break;
}
entries.push(formatToolCallEntry(item, options));
if (item.detail.type === "sub_agent" && item.detail.log.trim()) {
entries.push(activityEntry(item.detail.log.trim()));
@@ -8,7 +8,6 @@ import type {
ProviderOptions,
ToolPolicy,
} from "@getpaseo/protocol/agent-types";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PaseoToolCatalog } from "./tools/types.js";
@@ -343,8 +342,6 @@ interface ToolCallBase {
name: string;
detail: ToolCallDetail;
metadata?: Record<string, unknown>;
/** Set when the row is a delivered agent message rather than a provider tool call. */
agentMessage?: AgentMessage;
}
type ToolCallRunningTimelineItem = ToolCallBase & {
@@ -66,7 +66,6 @@ import type { ForgeService } from "../../services/forge-service.js";
import { areEquivalentPaths } from "../../utils/path.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index";
import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../daemon-config-store.js";
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
@@ -889,40 +888,6 @@ describe("bundled Paseo skill", () => {
});
});
describe("Paseo tool timeline presentation", () => {
it("catalogs a timeline label for every advertised tool", async () => {
const server = await createAgentMcpServer({
agentManager: new BoundaryAgentManagerFake() as AgentManager,
agentStorage: new BoundaryAgentStorageFake() as AgentStorage,
providerSnapshotManager:
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
browserToolsEnabled: true,
browserToolsBroker: new FakeBrowserToolsBroker({
requestId: "req-catalog",
ok: true,
result: { command: "list_tabs", tabs: [] },
}) as BrowserToolsBroker,
callerAgentId: "agent-1",
logger: createTestLogger(),
});
const client = await connectInMemoryMcpClient(server);
try {
const { tools } = await client.listTools();
const cataloged = new Set<string>(PASEO_TOOL_NAMES);
// speak renders as a voice message, not a tool call row.
const uncataloged = tools
.map((tool) => tool.name)
.filter((name) => name !== "speak" && !cataloged.has(name));
expect(tools.length).toBeGreaterThan(40);
expect(uncataloged).toEqual([]);
} finally {
await client.close();
await server.close();
}
});
});
describe("browser MCP tools", () => {
const logger = createTestLogger();
@@ -761,72 +761,6 @@ describe("PluginAgentClientRegistry", () => {
await expect.poll(harness.closeCount).toBe(1);
expect(harness.inputs.map((input) => input.type)).toContain("session.close");
});
test("delivers a supported steer to the active turn", async () => {
const harness = createProviderHarness({
capabilities: [...CAPABILITIES, "prompt.steer"],
handleInput: async (input, emit) => {
if (input.type !== "session.prompt") return false;
emit({
type: "session.prompt_result",
sessionId: input.sessionId,
clientMessageId: input.prompt.clientMessageId,
result: { type: "steer", turnId: "turn-1" },
});
return true;
},
});
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const client = registry.clients()[harness.registration.id]!;
const session = await client.createSession({ provider: harness.registration.id, cwd: "/tmp" });
try {
await expect(
session.steerActiveTurn!("follow up", {
expectedTurnId: "turn-1",
clientMessageId: "steer-1",
clearPendingPermissions: true,
}),
).resolves.toEqual({ status: "accepted" });
expect(harness.inputs.find((input) => input.type === "session.prompt")).toMatchObject({
prompt: {
delivery: "steer",
clientMessageId: "steer-1",
input: { type: "message", content: [{ type: "text", text: "follow up" }] },
clearPendingPermissions: true,
},
});
} finally {
await session.close();
registry.replace([]);
}
});
test("returns unavailable when steering a provider without prompt.steer capability", async () => {
const harness = createProviderHarness({
capabilities: [
"prompt.message",
"session.configure",
"session.persistence",
"session.subsession",
"permission",
],
});
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: "/workspace",
});
try {
const result = await session.steerActiveTurn!("hello", { expectedTurnId: "turn-1" });
expect(result).toEqual({ status: "unavailable" });
expect(harness.inputs.filter((input) => input.type === "session.prompt")).toHaveLength(0);
} finally {
await session.close();
registry.replace([]);
}
});
});
type RequestKind = "session.open" | "catalog" | "session.configure" | "session.prompt";
@@ -1175,9 +1175,6 @@ class PluginAgentSession implements AgentSession {
prompt: AgentPromptInput,
options: SteerActiveTurnOptions,
): Promise<SteerResult> {
if (!this.bridge.negotiatedCapabilities.includes("prompt.steer")) {
return { status: "unavailable" };
}
const result = await this.bridge.prompt({
clientMessageId: options.clientMessageId ?? randomUUID(),
delivery: "steer",
@@ -556,208 +556,3 @@ test("a hung login records its own timeout and still tries the next login", asyn
{ harness: "OpenCode", report: { status: "error", error: "Login rejected" } },
]);
});
test("retains the last successful poll across failures, cache expiry, and recovery", async () => {
let now = 0;
let report: unknown = {
status: "available",
windows: [{ id: "week", label: "Weekly", usedPct: 42 }],
};
const registry = new UsageSourceRegistry(() => now);
registry.register(
source({
id: "quota",
discover: async () => [{ key: "account", input: {} }],
fetch: async () => report,
}),
);
registry.register(source({ id: "other", discover: async () => [{ key: "account", input: {} }] }));
const [success] = await registry.listReports();
now = 360_000;
// An unrelated write evicts the expired fetch result, but must not discard its snapshot.
await registry.listReports({ reportIds: ["other:account"], forceRefresh: true });
report = { status: "error", error: "Rate limited" };
const [failure] = await registry.listReports({ reportIds: ["quota:account"] });
expect(failure).toMatchObject({
fetchedAt: new Date(now).toISOString(),
report,
lastSuccessful: { fetchedAt: success!.fetchedAt, report: success!.report },
});
now += 360_000;
const [repeated] = await registry.listReports({ reportIds: ["quota:account"] });
expect(repeated?.lastSuccessful).toEqual(failure?.lastSuccessful);
report = { status: "unavailable", problem: { kind: "no_quota", detail: "No quota" } };
const [unavailable] = await registry.listReports({
reportIds: ["quota:account"],
forceRefresh: true,
});
expect(unavailable?.lastSuccessful).toEqual(failure?.lastSuccessful);
now += 1000;
report = { status: "available", windows: [{ id: "week", label: "Weekly", usedPct: 43 }] };
const [recovered] = await registry.listReports({
reportIds: ["quota:account"],
forceRefresh: true,
});
expect(recovered?.report).toEqual(report);
expect(recovered?.lastSuccessful).toBeUndefined();
expect(recovered?.fetchedAt).toBe(new Date(now).toISOString());
});
test("successful history is isolated by login set and pruned when identities retire", async () => {
let login = "old";
let present = true;
let available = true;
const registry = new UsageSourceRegistry();
const fixture = source({
id: "source",
discover: async () => (present ? [{ key: "same", input: login }] : []),
fetch: async () =>
available ? { status: "available", windows: [] } : { status: "error", error: "failed" },
});
registry.register(fixture);
await registry.listReports();
available = false;
login = "new";
expect((await registry.listReports({ forceRefresh: true }))[0]?.lastSuccessful).toBeUndefined();
login = "old";
expect((await registry.listReports({ forceRefresh: true }))[0]?.lastSuccessful).toBeUndefined();
available = true;
await registry.listReports({ forceRefresh: true });
present = false;
expect(await registry.listReports()).toEqual([]);
present = true;
available = false;
expect((await registry.listReports())[0]?.lastSuccessful).toBeUndefined();
available = true;
await registry.listReports({ forceRefresh: true });
registry.unregister("source");
registry.register(fixture);
available = false;
expect((await registry.listReports())[0]?.lastSuccessful).toBeUndefined();
});
test("host fallback history never supplies an agent's failed login, and removed agents lose history", async () => {
const sessions = new Map([["agent", { provider: "codex", env: {}, sessionKey: "one" }]]);
let available = true;
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
hasAgent: (id) => sessions.has(id),
usageSession: (id) => sessions.get(id) ?? null,
});
registry.register({
id: "source",
label: "Source",
discover: async (scope) =>
(scope.kind === "global" ? ["host", "agent"] : ["agent"]).map((input) => ({
key: "same",
input,
})),
fetch: async () =>
available ? { status: "available", windows: [] } : { status: "error", error: "failed" },
});
await registry.listReports();
available = false;
expect((await registry.listReports({ agentId: "agent" }))[0]?.lastSuccessful).toBeUndefined();
available = true;
const [success] = await registry.listReports({ agentId: "agent", forceRefresh: true });
available = false;
expect(
(await registry.listReports({ agentId: "agent", forceRefresh: true }))[0]?.lastSuccessful
?.report,
).toEqual(success?.report);
sessions.delete("agent");
await registry.listReports();
sessions.set("agent", { provider: "codex", env: {}, sessionKey: "two" });
expect((await registry.listReports({ agentId: "agent" }))[0]?.lastSuccessful).toBeUndefined();
});
test("a timed out poll retains successful values and late completion cannot replace the failure", async () => {
let now = 1000;
let finish!: (value: unknown) => void;
let slow = false;
const registry = new UsageSourceRegistry(() => now, 300_000, undefined, undefined, 10);
registry.register(
source({
id: "source",
discover: async () => [{ key: "same", input: {} }],
fetch: async () =>
slow
? new Promise((resolve) => {
finish = resolve;
})
: { status: "available", windows: [] },
}),
);
const [success] = await registry.listReports();
now = 2000;
slow = true;
const [failure] = await registry.listReports({ forceRefresh: true });
expect(failure?.report).toEqual({ status: "error", error: "Usage fetch timed out" });
expect(failure?.lastSuccessful).toEqual({
fetchedAt: success?.fetchedAt,
report: success?.report,
});
finish({ status: "available", windows: [{ id: "late", label: "Late" }] });
expect((await registry.listReports())[0]).toEqual(failure);
});
test("attempt ordering keeps its timestamp while retained freshness records successful completion", async () => {
let now = 1000;
let available = true;
const registry = new UsageSourceRegistry(() => now);
registry.register(
source({
id: "source",
discover: async () => [{ key: "same", input: {} }],
fetch: async () => {
if (available) now = 2000;
return available
? { status: "available", windows: [] }
: { status: "error", error: "failed" };
},
}),
);
const [success] = await registry.listReports();
expect(success?.fetchedAt).toBe(new Date(1000).toISOString());
expect(success?.successfulAt).toBe(new Date(2000).toISOString());
available = false;
now = 3000;
const [failure] = await registry.listReports({ forceRefresh: true });
expect(failure?.fetchedAt).toBe(new Date(3000).toISOString());
expect(failure?.lastSuccessful?.fetchedAt).toBe(success?.successfulAt);
});
test("failed discovery preserves history until discovery confirms account removal", async () => {
let discoveryFails = false;
let present = true;
let available = true;
const registry = new UsageSourceRegistry(() => 1000, 300_000, { warn: () => {} });
registry.register(
source({
id: "source",
discover: async () => {
if (discoveryFails) throw new Error("Login store temporarily unavailable");
return present ? [{ key: "account", input: {} }] : [];
},
fetch: async () =>
available
? { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] }
: { status: "error", error: "Rate limited" },
}),
);
const [success] = await registry.listReports();
available = false;
discoveryFails = true;
const [duringDiscoveryFailure] = await registry.listReports({ forceRefresh: true });
expect(duringDiscoveryFailure?.lastSuccessful).toEqual({
fetchedAt: success?.successfulAt,
report: success?.report,
});
discoveryFails = false;
const [afterDiscoveryRecovery] = await registry.listReports({ forceRefresh: true });
expect(afterDiscoveryRecovery?.lastSuccessful).toEqual(duringDiscoveryFailure?.lastSuccessful);
expect(afterDiscoveryRecovery?.report).toEqual({ status: "error", error: "Rate limited" });
present = false;
expect(await registry.listReports()).toEqual([]);
present = true;
expect((await registry.listReports())[0]?.lastSuccessful).toBeUndefined();
});
@@ -56,7 +56,6 @@ export class UsageSourceRegistry {
private defaults = new Map<string, KnownReport>();
private readonly byAgent = new Map<string, AgentReports>();
private readonly cache = new Map<string, { at: number; entry: UsageReportEntry }>();
private readonly successful = new Map<string, NonNullable<UsageReportEntry["lastSuccessful"]>>();
private readonly pending = new Map<string, Promise<UsageReportEntry>>();
constructor(
@@ -82,8 +81,6 @@ export class UsageSourceRegistry {
}
for (const key of this.known.keys()) if (key.startsWith(`${id}:`)) this.known.delete(key);
for (const key of this.cache.keys()) if (key.startsWith(`${id}:`)) this.cache.delete(key);
for (const key of this.successful.keys())
if (key.startsWith(`${id}:`)) this.successful.delete(key);
for (const key of this.pending.keys()) if (key.startsWith(`${id}:`)) this.pending.delete(key);
}
@@ -99,7 +96,7 @@ export class UsageSourceRegistry {
} else if (options.reportIds !== undefined) {
ids = options.reportIds;
} else {
this.defaults = await this.discover({ kind: "global" }, this.defaults);
this.defaults = await this.discover({ kind: "global" });
this.mergeKnown();
ids = [...this.known.keys()];
}
@@ -127,11 +124,7 @@ export class UsageSourceRegistry {
private async discoverAgent(agentId: string): Promise<string[]> {
if (!this.agents.hasAgent(agentId)) throw new Error(`Unknown agent: ${agentId}`);
const session = this.agents.usageSession(agentId);
if (!session) {
this.byAgent.delete(agentId);
this.mergeKnown();
return [];
}
if (!session) return [];
const previous = this.byAgent.get(agentId);
const sameScope =
previous?.sessionKey === session.sessionKey &&
@@ -175,26 +168,9 @@ export class UsageSourceRegistry {
}
}
}
// Fetch freshness expires independently of successful snapshots. Retain only live identities.
const active = this.activeCacheKeys();
for (const key of this.successful.keys()) if (!active.has(key)) this.successful.delete(key);
for (const key of this.cache.keys()) if (!active.has(key)) this.cache.delete(key);
}
private activeCacheKeys(): Set<string> {
return new Set(
[
this.known,
this.defaults,
...[...this.byAgent.values()].map((agent) => agent.reports),
].flatMap((reports) => [...reports].map(([id, report]) => cacheIdentity(id, report))),
);
}
private async discover(
scope: UsageScope,
previous: ReadonlyMap<string, KnownReport> = new Map(),
): Promise<Map<string, KnownReport>> {
private async discover(scope: UsageScope): Promise<Map<string, KnownReport>> {
const discovered = await Promise.all(
[...this.sources.values()].map(async (source) => {
const reports = new Map<string, KnownReport>();
@@ -221,11 +197,6 @@ export class UsageSourceRegistry {
}
} catch (error) {
this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed");
// A failed store read does not confirm removal. Session discovery has no previous
// mapping because a resumed agent may use a different launch environment.
for (const [id, report] of previous) {
if (report.source === source) reports.set(id, report);
}
}
return reports;
}),
@@ -264,42 +235,25 @@ export class UsageSourceRegistry {
private fetchId(id: string, known: KnownReport, forceRefresh = false): Promise<UsageReportEntry> {
// Account identity groups cards; the ordered login set identifies a fetch result.
const cacheKey = cacheIdentity(id, known);
const cacheKey = `${id}:${createHash("sha256")
.update(JSON.stringify([known.label, known.logins.map(loginKey)]))
.digest("hex")}`;
const cached = this.cache.get(cacheKey);
if (!forceRefresh && cached && this.now() - cached.at < this.ttlMs)
return Promise.resolve(cached.entry);
const pending = this.pending.get(cacheKey);
if (pending) return pending;
const request = (async () => {
const fetchedAt = new Date(this.now()).toISOString();
const outcome = await this.fetchWithFallback(known);
const completedAt = new Date(this.now()).toISOString();
const entry: UsageReportEntry = {
id,
sourceId: known.source.id,
sourceLabel: known.source.label,
icon: known.source.icon,
account: { label: known.label },
fetchedAt,
...outcome,
fetchedAt: new Date(this.now()).toISOString(),
...(await this.fetchWithFallback(known)),
};
if (entry.report.status === "available") {
entry.successfulAt = completedAt;
} else {
entry.lastSuccessful = this.successful.get(cacheKey);
}
if (
this.sources.get(known.source.id) === known.source &&
this.activeCacheKeys().has(cacheKey)
) {
if (entry.report.status === "available") {
this.successful.set(cacheKey, {
fetchedAt: completedAt,
report: entry.report,
});
}
this.writeCache(cacheKey, entry);
}
if (this.sources.get(known.source.id) === known.source) this.writeCache(cacheKey, entry);
return entry;
})();
this.pending.set(cacheKey, request);
@@ -392,9 +346,3 @@ function loginKey(login: Login): string {
: value,
);
}
function cacheIdentity(id: string, known: KnownReport): string {
return `${id}:${createHash("sha256")
.update(JSON.stringify([known.label, known.logins.map(loginKey)]))
.digest("hex")}`;
}
@@ -6,13 +6,6 @@ import { homedir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, onTestFinished, test } from "vitest";
import pino from "pino";
import {
LocalSpeechWorkerClient,
WorkerBackedSpeechToTextProvider,
WorkerBackedTurnDetectionProvider,
} from "./worker-client.js";
import { createVoiceTurnController } from "../../../session/voice/voice-turn-controller.js";
import { parsePcm16MonoWav, wordSimilarity } from "../../../test-utils/dictation-e2e.js";
import { ensureSherpaOnnxModels } from "./sherpa/model-downloader.js";
@@ -230,78 +223,6 @@ test("delivers audio and serves control requests while later TTS is synthesizing
expect(completed).toEqual(["first", "second"]);
}, 120_000);
workerSpeechTest(
"voice input resumes after its local speech worker is replaced",
async () => {
const workers: ChildProcess[] = [];
const logger = pino({ level: "silent" });
const client = new LocalSpeechWorkerClient({
logger,
config: {
modelsDir,
voiceSttModel: "parakeet-tdt-0.6b-v2-int8",
dictationSttModel: "parakeet-tdt-0.6b-v2-int8",
voiceTtsModel: "kokoro-en-v0_19",
},
forkWorker: () => {
const worker = forkWorker();
workers.push(worker);
return worker;
},
});
const transcripts: string[] = [];
const errors: Error[] = [];
const controller = createVoiceTurnController({
logger,
stt: new WorkerBackedSpeechToTextProvider(client, "voiceStt"),
turnDetection: new WorkerBackedTurnDetectionProvider(client),
callbacks: {
async onSpeechStarted() {},
async onSpeechStopped() {},
async onPartialTranscript() {},
async onFinalTranscript(input) {
transcripts.push(input.transcript);
},
onError(error) {
errors.push(error);
},
},
});
const { pcm16, sampleRate } = parsePcm16MonoWav(await readFile(fixturePath("recording.wav")));
const input = Buffer.concat([pcm16, Buffer.alloc(sampleRate * 4)]);
async function sendUtterance() {
for (let offset = 0; offset < input.length; offset += 3200) {
await controller.appendClientChunk({
audioBase64: input.subarray(offset, offset + 3200).toString("base64"),
format: `audio/pcm;rate=${sampleRate}`,
});
// Pace IPC so speech events are consumed before the next audio boundary.
await new Promise((resolve) => setTimeout(resolve, 5));
}
await client.transcribeVoice(Buffer.alloc(0), `audio/pcm;rate=${sampleRate}`);
}
try {
await controller.start();
await sendUtterance();
await expect.poll(() => transcripts.length).toBe(1);
const firstWorker = workers[0];
const exited = new Promise<void>((resolve) => firstWorker.once("close", () => resolve()));
firstWorker.kill("SIGKILL");
await exited;
await expect.poll(() => errors.length).toBeGreaterThan(0);
// A successful transcription request proves that the replacement is serving requests.
await client.transcribeVoice(Buffer.alloc(0), `audio/pcm;rate=${sampleRate}`);
await sendUtterance();
await expect.poll(() => transcripts.length, { timeout: 3000 }).toBe(2);
expect(transcripts.every((text) => text.toLowerCase().includes("voice note"))).toBe(true);
} finally {
await controller.stop();
client.shutdown();
}
},
120_000,
);
type RequestInput = LocalSpeechWorkerRequest extends infer Request
? Request extends LocalSpeechWorkerRequest
? Omit<Request, "requestId">
@@ -1121,10 +1121,6 @@ describe("home-tree scan cost", () => {
return stopPathContainmentMetrics();
}
// Creating the 27,000-directory fixture alone takes about 20 s on Windows
// runners under parallel load, and it has crossed the default 30 s deadline.
// The heap bound is enforced by the child's --max-old-space-size and its own
// 20 s timeout, so the test deadline only has to cover fixture setup.
it("finishes overlapping broad-tree searches within a bounded heap", async () => {
const broadRoot = path.join(scanRoot, "broad");
mkdirSync(broadRoot);
@@ -1195,7 +1191,7 @@ describe("home-tree scan cost", () => {
outOfMemory: false,
});
expect(JSON.parse(stdout)).toEqual(Array.from({ length: 14 }, () => []));
}, 120_000);
});
it("derives no containment for a tree that cannot leave the root", async () => {
const checks = await countContainmentChecks(scanWholeTree);
@@ -1304,7 +1304,7 @@ diff --git a/node_modules/react-native-enriched-markdown/lib/module/web/renderer
index dd70713..ca0385a 100644
--- a/node_modules/react-native-enriched-markdown/lib/module/web/renderers/BlockRenderers.js
+++ b/node_modules/react-native-enriched-markdown/lib/module/web/renderers/BlockRenderers.js
@@ -101,12 +101,28 @@ function AdmonitionRenderer({
@@ -101,12 +101,20 @@ function AdmonitionRenderer({
});
}
function CodeBlockRenderer({
@@ -1317,17 +1317,9 @@ index dd70713..ca0385a 100644
const language = node.attributes?.language;
+ const CustomCodeBlock = capabilities.renderers?.codeBlock;
+ if (CustomCodeBlock) {
+ // The custom renderer owns the block's look; the library keeps its spacing,
+ // including the zeroed trailing margin of the last block.
+ return /*#__PURE__*/_jsx("div", {
+ style: {
+ marginTop: styles.codeBlock.marginTop,
+ marginBottom: styles.codeBlock.marginBottom
+ },
+ children: /*#__PURE__*/_jsx(CustomCodeBlock, {
+ code: extractNodeText(node),
+ language: language ?? ''
+ })
+ return /*#__PURE__*/_jsx(CustomCodeBlock, {
+ code: extractNodeText(node),
+ language: language ?? ''
+ });
+ }
const label = language ? `Code block: ${language}` : 'Code block';
+9 -195
View File
@@ -2,7 +2,6 @@ import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
import { relative as relativePath } from "node:path";
import test from "node:test";
import { runInNewContext } from "node:vm";
const repoRoot = new URL("../", import.meta.url);
const ciWorkflowPath = new URL(".github/workflows/ci.yml", repoRoot);
@@ -13,26 +12,21 @@ const serverTsconfigPath = new URL("packages/server/tsconfig.server.json", repoR
const desktopPackagePath = new URL("packages/desktop/package.json", repoRoot);
const gatedCiJobs = new Map([
["quality", { name: "quality", contracts: ["format", "quality", "relay", "sdk"] }],
["format", { name: "format", contract: "format" }],
["lint", { name: "lint", contract: "quality" }],
["typecheck", { name: "typecheck", contract: "quality" }],
["server-tests-ubuntu", { name: "server-tests (ubuntu-latest)", contracts: ["server", "hub"] }],
["server-tests-windows", { name: "server-tests (windows-latest)", contracts: ["server", "hub"] }],
[
"server-integration-windows",
{ name: "server-integration (windows-latest)", contract: "server" },
],
["server-tests-macos", { name: "server-tests (macos-14, file observation)", contract: "server" }],
["desktop-tests-ubuntu", { name: "desktop-tests (ubuntu-latest)", contract: "desktop" }],
["desktop-tests-windows", { name: "desktop-tests (windows-latest)", contract: "desktop" }],
["app-tests", { name: "app-tests", contract: "app" }],
["playwright-1", { name: "playwright (shard 1/8)", contract: "browser" }],
["playwright-2", { name: "playwright (shard 2/8)", contract: "browser" }],
["playwright-3", { name: "playwright (shard 3/8)", contract: "browser" }],
["playwright-4", { name: "playwright (shard 4/8)", contract: "browser" }],
["playwright-5", { name: "playwright (shard 5/8)", contract: "browser" }],
["playwright-6", { name: "playwright (shard 6/8)", contract: "browser" }],
["playwright-7", { name: "playwright (shard 7/8)", contract: "browser" }],
["playwright-8", { name: "playwright (shard 8/8)", contract: "browser" }],
["sdk-tests", { name: "sdk-tests", contract: "sdk" }],
["playwright-1", { name: "playwright (shard 1/4)", contract: "browser" }],
["playwright-2", { name: "playwright (shard 2/4)", contract: "browser" }],
["playwright-3", { name: "playwright (shard 3/4)", contract: "browser" }],
["playwright-4", { name: "playwright (shard 4/4)", contract: "browser" }],
["relay-tests", { name: "relay-tests", contract: "relay" }],
["cli-tests-1", { name: "cli-tests (shard 1/3)", contract: "cli" }],
["cli-tests-2", { name: "cli-tests (shard 2/3)", contract: "cli" }],
["cli-tests-3", { name: "cli-tests (shard 3/3)", contract: "cli" }],
@@ -42,7 +36,7 @@ function jobBlocks(source) {
const jobs = new Map();
let currentJob;
for (const line of (source.split("\njobs:\n")[1] ?? source).split("\n")) {
for (const line of source.split("\n")) {
const jobMatch = /^ ([a-z0-9_-]+):\s*$/.exec(line);
if (jobMatch) {
currentJob = jobMatch[1];
@@ -106,12 +100,6 @@ test("gated checks are statically named jobs with real job-level gating", () =>
});
test("change gating allows superseded workflow runs to cancel", () => {
const ci = readFileSync(ciWorkflowPath, "utf8");
assert.match(
ci,
/^concurrency:\n group: ci-\$\{\{ github\.workflow \}\}-\$\{\{ github\.ref \}\}\n cancel-in-progress: true$/m,
"a newer push to any ref, main included, cancels the superseded run",
);
for (const workflowPath of [ciWorkflowPath, dockerWorkflowPath, nixWorkflowPath]) {
const source = readFileSync(workflowPath, "utf8");
assert.doesNotMatch(
@@ -343,177 +331,3 @@ test("stable iOS releases upload once and distribute to external testers before
);
assert.match(review, /bundle exec fastlane ios submit_review/);
});
test("eight Playwright shards share two-worker coverage", () => {
const source = readFileSync(ciWorkflowPath, "utf8");
const jobs = jobBlocks(source);
assert.equal(jobs.size, 21);
for (let shard = 1; shard <= 8; shard++) {
const job = jobs.get(`playwright-${shard}`).join("\n");
assert.match(job, new RegExp(`PLAYWRIGHT_SHARD: "${shard}/8"`));
assert.match(job, new RegExp(`PLAYWRIGHT_ARTIFACT: "${shard}"`));
assert.match(
job,
shard === 1 ? /steps: &playwright_test_steps/ : /steps: \*playwright_test_steps/,
);
}
const shared = jobs.get("playwright-1").join("\n");
assert.match(shared, /E2E_WORKERS: "2"/);
const weights = /PWTEST_SHARD_WEIGHTS: "([\d:]+)"/.exec(shared)[1].split(":").map(Number);
assert.equal(weights.length, 8);
assert.ok(weights.every((weight) => weight > 0));
assert.match(shared, /npm run test:e2e --workspace=@getpaseo\/app -- --shard=/);
// No fan-in result job: a gate needs its own runner and under contention it
// queued 8m08s behind the last shard. Required checks name the shards directly.
assert.equal(jobs.has("playwright"), false);
});
test("quality preserves commands and routes each domain independently after failures", () => {
const jobs = jobBlocks(readFileSync(ciWorkflowPath, "utf8"));
const quality = jobs.get("quality").join("\n");
assert.doesNotMatch(quality, /^\s+continue-on-error:/m);
const commands = {
format: ["npm run format:check"],
quality: [
"npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity",
"npm audit signatures",
"npm run lint",
],
relay: ["npm run build:relay", "npm run test --workspace=@getpaseo/relay"],
sdk: [
"npm run build:relay && npm run build:client",
"npm run build:plugin",
"npm run test --workspace=@getpaseo/plugin",
"npm run test --workspace=@getpaseo/protocol",
"npm run test --workspace=@getpaseo/client",
"npm run test --workspace=@getpaseo/highlight",
"npm run typecheck:examples --workspace=@getpaseo/client",
],
};
const checks = [...quality.matchAll(/ if: \$\{\{ (.+) \}\}\n run: (.+)/g)];
assert.deepEqual(
checks.map(([, , command]) => command),
Object.values(commands).flat(),
);
const domains = Object.keys(commands);
function accepts(expression, outputs, { cancelled = false, installed = true } = {}) {
// Prior checks have failed: success() is false. Explicit !cancelled() must
// override Actions' default success condition so later domains still run.
assert.match(expression, /!cancelled\(\)/);
return runInNewContext(expression, {
needs: { changes: { outputs } },
steps: { install: { outcome: installed ? "success" : "failure" } },
cancelled: () => cancelled,
success: () => false,
});
}
const disabled = { full: "false", ...Object.fromEntries(domains.map((key) => [key, "false"])) };
const jobCondition = / if: \$\{\{ (.+) \}\}/.exec(quality)[1];
assert.equal(accepts(jobCondition, disabled), false);
for (const selected of domains) {
const outputs = { ...disabled, [selected]: "true" };
assert.equal(accepts(jobCondition, outputs), true);
for (const [, expression, command] of checks) {
assert.equal(accepts(expression, outputs), commands[selected].includes(command), command);
}
}
for (const [, expression] of checks) {
assert.equal(accepts(expression, { ...disabled, full: "true" }), true);
assert.equal(accepts(expression, {}), true, "missing routing outputs fail open");
assert.equal(accepts(expression, {}, { cancelled: true }), false);
assert.equal(accepts(expression, {}, { installed: false }), false);
}
assert.equal(accepts(jobCondition, {}, { cancelled: true }), false);
});
test("Windows units and integration run independently with complete routed coverage", () => {
const source = readFileSync(ciWorkflowPath, "utf8");
const jobs = jobBlocks(source);
const ubuntu = jobs.get("server-tests-ubuntu").join("\n");
const windows = jobs.get("server-tests-windows").join("\n");
const integration = jobs.get("server-integration-windows").join("\n");
assert.match(ubuntu, /run: npm run test --workspace=@getpaseo\/server/);
assert.match(windows, /run: npm run test:unit --workspace=@getpaseo\/server/);
assert.doesNotMatch(windows, /run: npm run test(?: |:integration)/);
assert.match(integration, /run: npm run test:integration --workspace=@getpaseo\/server/);
assert.doesNotMatch(integration, /test:unit|server_plugin_tests|server_hub_contract/);
for (const job of [windows, integration]) {
assert.match(job, /^ needs: changes$/m, "both phases become eligible independently");
assert.match(job, /^ runs-on: windows-latest$/m);
assert.doesNotMatch(job, /continue-on-error|maxWorkers|--exclude/);
for (const step of [
"server_capacity",
"server_checkout",
"server_node",
"server_fetch",
"server_install",
"server_agent_clis",
"server_build",
]) {
assert.match(ubuntu, new RegExp(`&${step}\\b`));
assert.match(job, new RegExp(`- \\*${step}\\b`));
}
assert.match(job, /env: \*server_test_env/);
}
assert.match(windows, /- \*server_plugin_tests/);
assert.match(windows, /- \*server_hub_contract/);
assert.match(ubuntu, /run: npm run test --workspace=@getpaseo\/builtin-plugins/);
assert.match(ubuntu, /run: npm run test:hub-cli-contract --workspace=@getpaseo\/server/);
const scripts = JSON.parse(
readFileSync(new URL("packages/server/package.json", repoRoot), "utf8"),
).scripts;
assert.equal(scripts.test, "npm run test:unit && npm run test:integration");
assert.equal(scripts["test:unit"], 'vitest run --fileParallelism --exclude "**/*.e2e.test.ts"');
const invocations = scripts["test:integration"].split(" && ");
// Each vitest start costs about half a minute on Windows, so the four
// name-filtered files share one invocation and one combined filter.
assert.equal(invocations.length, 2);
assert.ok(invocations.every((command) => command.startsWith("vitest run --maxWorkers=1 ")));
assert.doesNotMatch(invocations[0], / -t /);
assert.equal(
invocations[1],
'vitest run --maxWorkers=1 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"',
);
const serverConfig = readFileSync(new URL("packages/server/vitest.config.ts", repoRoot), "utf8");
assert.doesNotMatch(serverConfig, /maxWorkers/);
function condition(job) {
return / if: \$\{\{ (.+) \}\}/.exec(job)[1];
}
function selected(expression, outputs, cancelled = false) {
return runInNewContext(expression, {
needs: { changes: { outputs } },
cancelled: () => cancelled,
});
}
const unitCondition = condition(windows);
const integrationCondition = condition(integration);
const unitStep = / - name: Run server unit tests\n if: \$\{\{ (.+) \}\}/.exec(
windows,
)[1];
const hubStep =
/ - &server_hub_contract\n name: Run Hub CLI\/daemon contract\n if: \$\{\{ (.+) \}\}/.exec(
ubuntu,
)[1];
const pluginStep =
/ - &server_plugin_tests\n name: Run built-in plugin tests\n if: \$\{\{ (.+) \}\}/.exec(
ubuntu,
)[1];
for (const [outputs, units, integrations, hub] of [
[{ full: "true", server: "false", hub: "false" }, true, true, false],
[{ full: "false", server: "true", hub: "false" }, true, true, false],
[{ full: "false", server: "false", hub: "true" }, true, false, true],
[{ full: "false", server: "false", hub: "false" }, false, false, false],
[{}, true, true, false],
]) {
assert.equal(selected(unitCondition, outputs), units);
assert.equal(selected(integrationCondition, outputs), integrations);
assert.equal(selected(unitStep, outputs), integrations);
assert.equal(selected(pluginStep, outputs), integrations);
assert.equal(selected(hubStep, outputs), hub);
assert.equal(selected(unitCondition, outputs, true), false);
assert.equal(selected(integrationCondition, outputs, true), false);
}
});