mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-18 07:12:35 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5c566f807 | ||
|
|
9521ee9955 | ||
|
|
bd5061e7bd | ||
|
|
df7f1f0b75 | ||
|
|
cf62b00911 | ||
|
|
efd6d95f91 |
+158
-95
@@ -8,9 +8,13 @@ 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: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
# CI does not use the CUDA execution provider, and the onnxruntime-node
|
||||
@@ -54,10 +58,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
|
||||
|
||||
format:
|
||||
name: format
|
||||
quality:
|
||||
name: quality
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
|
||||
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') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -68,36 +72,63 @@ 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
|
||||
@@ -143,43 +174,56 @@ 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_test_steps
|
||||
- uses: actions/checkout@v4
|
||||
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
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- &server_node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
- &server_fetch
|
||||
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
|
||||
|
||||
- name: Install dependencies
|
||||
- &server_install
|
||||
name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
- &server_agent_clis
|
||||
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
|
||||
|
||||
- name: Build server dependencies
|
||||
- &server_build
|
||||
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:
|
||||
env: &server_test_env
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
- name: Run built-in plugin tests
|
||||
- &server_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
|
||||
|
||||
- name: Run Hub CLI/daemon contract
|
||||
- &server_hub_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
|
||||
|
||||
@@ -188,7 +232,41 @@ 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_test_steps
|
||||
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
|
||||
|
||||
# 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 —
|
||||
@@ -332,52 +410,19 @@ 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/4)
|
||||
name: playwright (shard 1/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "1/4"
|
||||
PLAYWRIGHT_SHARD: "1/8"
|
||||
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
|
||||
@@ -405,9 +450,9 @@ jobs:
|
||||
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
# 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"
|
||||
E2E_WORKERS: "2"
|
||||
# File groups are weighted by observed reporter durations.
|
||||
PWTEST_SHARD_WEIGHTS: "93:100:101:104:91:94:122:110"
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -420,56 +465,74 @@ jobs:
|
||||
retention-days: 7
|
||||
|
||||
playwright-2:
|
||||
name: playwright (shard 2/4)
|
||||
name: playwright (shard 2/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "2/4"
|
||||
PLAYWRIGHT_SHARD: "2/8"
|
||||
PLAYWRIGHT_ARTIFACT: "2"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
playwright-3:
|
||||
name: playwright (shard 3/4)
|
||||
name: playwright (shard 3/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "3/4"
|
||||
PLAYWRIGHT_SHARD: "3/8"
|
||||
PLAYWRIGHT_ARTIFACT: "3"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
playwright-4:
|
||||
name: playwright (shard 4/4)
|
||||
name: playwright (shard 4/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "4/4"
|
||||
PLAYWRIGHT_SHARD: "4/8"
|
||||
PLAYWRIGHT_ARTIFACT: "4"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
relay-tests:
|
||||
name: relay-tests
|
||||
playwright-5:
|
||||
name: playwright (shard 5/8)
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
env:
|
||||
PLAYWRIGHT_SHARD: "5/8"
|
||||
PLAYWRIGHT_ARTIFACT: "5"
|
||||
steps: *playwright_test_steps
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
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
|
||||
|
||||
- name: Install dependencies
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
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: Build relay
|
||||
run: npm run build:relay
|
||||
|
||||
- name: Run relay tests
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
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
|
||||
|
||||
cli-tests-1:
|
||||
name: cli-tests (shard 1/3)
|
||||
|
||||
@@ -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 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 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -251,7 +251,8 @@ 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/agent-message-display`) owns its heading and expanded details. The daemon
|
||||
presentation (`protocol/paseo-tool-presentation`) owns its heading and expanded details, next to the
|
||||
labels for every Paseo MCP tool. 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 agent prompt",
|
||||
label: "Send message",
|
||||
artifact: "send-prompt-presentation",
|
||||
expectedText: ["Prompt", GREETING, "Agent", "Result"],
|
||||
});
|
||||
|
||||
@@ -259,3 +259,71 @@ 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),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../support/helpers/usage-reports";
|
||||
import {
|
||||
installLoginUsage,
|
||||
expectLoginUsageFreshness,
|
||||
installCodexWindowUsage,
|
||||
expectCodexReportedWindows,
|
||||
refreshLoginUsage,
|
||||
@@ -588,6 +589,69 @@ 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(percentage, { exact: true })).toBeVisible();
|
||||
await expect(card.getByText(new RegExp(`^${percentage}(?:$| ·)`))).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,3 +135,14 @@ 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
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,6 +23,7 @@ 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";
|
||||
@@ -64,6 +65,7 @@ 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;
|
||||
@@ -238,7 +240,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
programmaticScrollEventBudgetRef.current = 3;
|
||||
flatListRef.current?.scrollToOffset({
|
||||
offset: 0,
|
||||
animated,
|
||||
animated:
|
||||
animated &&
|
||||
shouldAnimateNativeScrollToBottom({
|
||||
offsetY: scrollOffsetYRef.current,
|
||||
viewportHeight: streamViewportMetricsRef.current.viewportHeight,
|
||||
windowSize: NATIVE_STREAM_WINDOW_SIZE,
|
||||
}),
|
||||
});
|
||||
scrollOffsetYRef.current = 0;
|
||||
streamViewportMetricsRef.current = {
|
||||
@@ -591,11 +599,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
// A history row can contain an entire long reply. Keep synchronous native
|
||||
// Markdown measurement bounded while FlatList fills the surrounding window.
|
||||
// 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.
|
||||
initialNumToRender={2}
|
||||
maxToRenderPerBatch={2}
|
||||
windowSize={5}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={NATIVE_STREAM_WINDOW_SIZE}
|
||||
removeClippedSubviews={false}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
|
||||
@@ -771,6 +771,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
status={data.status}
|
||||
detail={data.detail}
|
||||
cwd={context.cwd}
|
||||
serverId={resolvedServerId}
|
||||
metadata={data.metadata}
|
||||
agentMessage={data.agentMessage}
|
||||
isLastInSequence={isLastInSequence}
|
||||
@@ -795,7 +796,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
/>
|
||||
);
|
||||
},
|
||||
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
|
||||
[context.cwd, resolvedServerId, 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, useMemo, type ComponentProps } from "react";
|
||||
import { forwardRef, useCallback, useMemo, useState, type ComponentProps } from "react";
|
||||
import type {
|
||||
CodeBlockRendererProps,
|
||||
LinkRendererProps,
|
||||
@@ -27,14 +27,29 @@ 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 }),
|
||||
[anchorProps.style],
|
||||
() => ({
|
||||
...anchorProps.style,
|
||||
overflowWrap: "anywhere" as const,
|
||||
...(hovered ? LINK_HOVER_STYLE : null),
|
||||
}),
|
||||
[anchorProps.style, hovered],
|
||||
);
|
||||
const anchor = (
|
||||
<a {...anchorProps} style={anchorStyle}>
|
||||
<a
|
||||
{...anchorProps}
|
||||
style={anchorStyle}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ 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";
|
||||
@@ -2208,6 +2209,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
||||
|
||||
interface ToolCallProps {
|
||||
agentMessage?: AgentMessage;
|
||||
serverId?: string;
|
||||
toolName: string;
|
||||
args?: unknown;
|
||||
result?: unknown;
|
||||
@@ -2228,6 +2230,7 @@ interface ToolCallProps {
|
||||
|
||||
export const ToolCall = memo(function ToolCall({
|
||||
agentMessage,
|
||||
serverId,
|
||||
toolName,
|
||||
args,
|
||||
result,
|
||||
@@ -2265,6 +2268,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
return undefined;
|
||||
}, [detail, args, result]);
|
||||
|
||||
const targetAgentTitle = useTargetAgentTitle({ serverId, toolName, detail: effectiveDetail });
|
||||
const presentation = useMemo(
|
||||
() =>
|
||||
buildToolCallPresentation({
|
||||
@@ -2275,9 +2279,10 @@ export const ToolCall = memo(function ToolCall({
|
||||
detail: effectiveDetail,
|
||||
metadata,
|
||||
cwd,
|
||||
targetAgentTitle,
|
||||
resolveIcon: resolveToolCallIcon,
|
||||
}),
|
||||
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage],
|
||||
[toolName, status, error, effectiveDetail, metadata, cwd, agentMessage, targetAgentTitle],
|
||||
);
|
||||
const handleOpenFile = useMemo(() => {
|
||||
const openFilePath = presentation.openFilePath;
|
||||
@@ -2393,6 +2398,8 @@ 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-call-detail";
|
||||
} from "@getpaseo/protocol/paseo-tool-presentation/index";
|
||||
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,9 +1,11 @@
|
||||
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,
|
||||
@@ -12,7 +14,11 @@ 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;
|
||||
}
|
||||
@@ -94,32 +100,35 @@ describe("tool-call presentation", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["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.",
|
||||
});
|
||||
});
|
||||
["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.",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("uses an opaque sender ID when no title is available", () => {
|
||||
const presentation = buildToolCallPresentation({
|
||||
@@ -129,5 +138,19 @@ 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 from remote::worker");
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import { buildAgentMessageDisplay } from "@getpaseo/protocol/agent-message-display";
|
||||
import { buildAgentMessageDisplay } from "@getpaseo/protocol/paseo-tool-presentation/index";
|
||||
import type { PlanOutcome } from "@/components/plan-card";
|
||||
import type { ComponentType } from "react";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
@@ -22,6 +22,7 @@ interface BuildToolCallPresentationInput {
|
||||
detail?: ToolCallDetail;
|
||||
cwd?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
targetAgentTitle?: string;
|
||||
resolveIcon: ToolCallIconResolver;
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@ export interface ToolCallPresentation {
|
||||
export type ToolCallIconResolver = (
|
||||
toolName: string,
|
||||
detail: ToolCallDetail | undefined,
|
||||
agentMessage: AgentMessage | undefined,
|
||||
) => ToolCallPresentationIcon;
|
||||
|
||||
function displayStatus(status: ToolCallStatus): ToolCallDisplayInput["status"] {
|
||||
@@ -67,6 +69,7 @@ export function buildToolCallPresentation(
|
||||
detail: detailForDisplay,
|
||||
metadata: input.metadata,
|
||||
cwd: input.cwd,
|
||||
targetAgentTitle: input.targetAgentTitle,
|
||||
});
|
||||
const isLoadingDetails = isPendingToolCallDetail({
|
||||
detail,
|
||||
@@ -80,7 +83,7 @@ export function buildToolCallPresentation(
|
||||
displayName: displayModel.displayName,
|
||||
summary: displayModel.summary,
|
||||
errorText: displayModel.errorText,
|
||||
icon: input.resolveIcon(input.toolName, detail),
|
||||
icon: input.resolveIcon(input.toolName, detail, input.agentMessage),
|
||||
isLoadingDetails,
|
||||
hasDetails,
|
||||
canOpenDetails: hasDetails || isLoadingDetails,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -41,11 +41,17 @@ function fillToneStyle(tone: UsageTone) {
|
||||
}
|
||||
}
|
||||
|
||||
export function UsageBalanceBar({ balance }: { balance: UsageBalance }) {
|
||||
export function UsageBalanceBar({
|
||||
balance,
|
||||
retained = false,
|
||||
}: {
|
||||
balance: UsageBalance;
|
||||
retained?: boolean;
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const { amountText, usedPct } = resolveBalance(balance, i18n.language);
|
||||
const tone = balance.tone ?? "default";
|
||||
const resetLabel = formatResetLabel(balance.resetsAt);
|
||||
const resetLabel = formatResetLabel(balance.resetsAt, retained);
|
||||
|
||||
const fillStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }],
|
||||
|
||||
@@ -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, type UsageRefresh } from "./model";
|
||||
import { formatUsageFreshness, resolveUsageReport, type UsageRefresh } from "./model";
|
||||
import { useReportRefresh } from "./queries";
|
||||
import { UsageSourceIcon } from "./source-icon";
|
||||
import type { UsageReport, UsageReportEntry, UsageWindow } from "./types";
|
||||
@@ -27,31 +27,6 @@ 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);
|
||||
|
||||
@@ -81,8 +56,8 @@ export function UsageCard({
|
||||
const usage = entry.report;
|
||||
const status = statusText(usage);
|
||||
const footer = entry.account.label ?? null;
|
||||
const { windows, balances, details } = reportContent(usage);
|
||||
const messages = reportMessages(entry);
|
||||
const { windows, balances, details, planLabel, successfulAt, retained, messages } =
|
||||
resolveUsageReport(entry);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, compact ? styles.containerCompact : styles.containerPadded],
|
||||
@@ -104,9 +79,7 @@ export function UsageCard({
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{entry.sourceLabel}
|
||||
</Text>
|
||||
{usage.status === "available" && usage.planLabel ? (
|
||||
<StatusBadge label={usage.planLabel} variant="muted" size="xs" />
|
||||
) : null}
|
||||
{planLabel ? <StatusBadge label={planLabel} variant="muted" size="xs" /> : null}
|
||||
<View style={styles.headerSpacer} />
|
||||
{status ? (
|
||||
<View style={styles.statusRow}>
|
||||
@@ -117,7 +90,8 @@ export function UsageCard({
|
||||
{refreshable ? (
|
||||
<UsageRefreshButton
|
||||
sourceLabel={entry.sourceLabel}
|
||||
fetchedAt={entry.fetchedAt}
|
||||
successfulAt={successfulAt}
|
||||
retained={retained}
|
||||
refreshState={refreshState}
|
||||
onRefresh={refresh}
|
||||
compact={isCompact}
|
||||
@@ -138,12 +112,13 @@ 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} />
|
||||
<UsageBalanceBar key={balance.id} balance={balance} retained={retained} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
@@ -170,7 +145,8 @@ export function UsageCard({
|
||||
</Text>
|
||||
{showsFreshnessInline ? (
|
||||
<UsageFreshness
|
||||
fetchedAt={entry.fetchedAt}
|
||||
successfulAt={successfulAt}
|
||||
retained={retained}
|
||||
style={styles.freshness}
|
||||
testID="usage-freshness"
|
||||
/>
|
||||
@@ -192,7 +168,9 @@ function CardWindowBar({
|
||||
window,
|
||||
display,
|
||||
pinnable,
|
||||
retained,
|
||||
}: {
|
||||
retained: boolean;
|
||||
entry: UsageReportEntry;
|
||||
window: UsageWindow;
|
||||
display: UsageDisplay;
|
||||
@@ -207,6 +185,7 @@ function CardWindowBar({
|
||||
return (
|
||||
<UsageWindowBar
|
||||
window={window}
|
||||
retained={retained}
|
||||
displayAs={display.displayAs}
|
||||
pinnable={pinnable}
|
||||
pinned={display.isPinned(pin)}
|
||||
@@ -220,13 +199,15 @@ function CardWindowBar({
|
||||
/** Refreshes this one report. Its tooltip says when the report on screen was fetched. */
|
||||
function UsageRefreshButton({
|
||||
sourceLabel,
|
||||
fetchedAt,
|
||||
successfulAt,
|
||||
retained,
|
||||
refreshState,
|
||||
onRefresh,
|
||||
compact,
|
||||
}: {
|
||||
sourceLabel: string;
|
||||
fetchedAt: string;
|
||||
successfulAt: string | null;
|
||||
retained: boolean;
|
||||
refreshState: UsageRefresh;
|
||||
onRefresh: () => void;
|
||||
compact: boolean;
|
||||
@@ -236,12 +217,13 @@ function UsageRefreshButton({
|
||||
const freshness = useMemo(
|
||||
() => (
|
||||
<UsageFreshness
|
||||
fetchedAt={fetchedAt}
|
||||
successfulAt={successfulAt}
|
||||
retained={retained}
|
||||
style={styles.tooltipText}
|
||||
testID="usage-freshness-tooltip"
|
||||
/>
|
||||
),
|
||||
[fetchedAt],
|
||||
[successfulAt, retained],
|
||||
);
|
||||
return (
|
||||
<ToolbarButton
|
||||
@@ -265,18 +247,20 @@ function UsageRefreshButton({
|
||||
|
||||
/** Its own component so the relative-time clock re-renders one `<Text>`, not the card. */
|
||||
function UsageFreshness({
|
||||
fetchedAt,
|
||||
successfulAt,
|
||||
retained,
|
||||
style,
|
||||
testID,
|
||||
}: {
|
||||
fetchedAt: string;
|
||||
successfulAt: string | null;
|
||||
retained: boolean;
|
||||
style: StyleProp<TextStyle>;
|
||||
testID: string;
|
||||
}) {
|
||||
const elapsed = useCompactTimeAgo(new Date(fetchedAt));
|
||||
const elapsed = useCompactTimeAgo(successfulAt ? new Date(successfulAt) : null);
|
||||
return (
|
||||
<Text style={style} numberOfLines={1} testID={testID}>
|
||||
{formatUsageFreshness(elapsed)}
|
||||
{successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAmount } from "./format";
|
||||
import { formatAmount, formatResetLabel } from "./format";
|
||||
|
||||
describe("formatAmount", () => {
|
||||
it("groups thousands in the app's language", () => {
|
||||
@@ -13,3 +13,10 @@ 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();
|
||||
});
|
||||
|
||||
@@ -27,10 +27,11 @@ function relativeDuration(iso: string): string | null {
|
||||
return `${diffMinutes}m`;
|
||||
}
|
||||
|
||||
export function formatResetLabel(iso: string | null | undefined): string | null {
|
||||
export function formatResetLabel(iso: string | null | undefined, retained = false): 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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveUsageReport,
|
||||
displayPercent,
|
||||
formatUsageFreshness,
|
||||
resolveAgentUsageView,
|
||||
@@ -325,3 +326,72 @@ 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]);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { formatCompactTimeAgoAsProse } from "@/utils/time";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
import type { UsageReportEntry, UsageView, UsageWindow } from "./types";
|
||||
import type { UsageReport, 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): string {
|
||||
return `${usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`;
|
||||
export function formatUsageFreshness(compactTimeAgo: string, retained = false): string {
|
||||
return `${retained ? "Last updated" : usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`;
|
||||
}
|
||||
|
||||
/** A user-requested refresh of one report. The previous report stays on screen throughout. */
|
||||
@@ -192,3 +192,40 @@ 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 "";
|
||||
}
|
||||
|
||||
@@ -257,9 +257,12 @@ describe("resolvePinnedUsage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unavailable and error reports out of the sidebar summary", () => {
|
||||
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");
|
||||
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" },
|
||||
@@ -267,6 +270,7 @@ it("keeps unavailable and error reports out of the sidebar summary", () => {
|
||||
};
|
||||
const failed: UsageReportEntry = {
|
||||
...codex,
|
||||
lastSuccessful: { fetchedAt: codex.fetchedAt, report: codex.report },
|
||||
report: { status: "error", error: "Store deleted" },
|
||||
};
|
||||
expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]);
|
||||
|
||||
@@ -26,7 +26,9 @@ 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. */
|
||||
@@ -40,10 +42,13 @@ export function UsageWindowBar({
|
||||
const shownPct = displayPercent(window, displayAs);
|
||||
const tone = windowTone(window);
|
||||
|
||||
const isAtRisk = window.runsOutAt != null && window.shortfallPct != null;
|
||||
const isAtRisk =
|
||||
window.runsOutAt != null &&
|
||||
window.shortfallPct != null &&
|
||||
(!retained || Date.parse(window.runsOutAt) > Date.now());
|
||||
const trailing = isAtRisk
|
||||
? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim()
|
||||
: formatResetLabel(window.resetsAt);
|
||||
: formatResetLabel(window.resetsAt, retained);
|
||||
|
||||
const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—";
|
||||
const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]);
|
||||
|
||||
@@ -53,4 +53,14 @@ 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,3 +1,4 @@
|
||||
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";
|
||||
|
||||
@@ -17,7 +18,15 @@ const TOOL_DETAIL_ICON_NAMES: Record<ToolCallDetail["type"], ToolCallIcon> = {
|
||||
unknown: "wrench",
|
||||
};
|
||||
|
||||
export function resolveToolCallIconName(toolName: string, detail?: ToolCallDetail): ToolCallIcon {
|
||||
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";
|
||||
}
|
||||
const lowerName = toolName.trim().toLowerCase();
|
||||
|
||||
if (detail?.type === "plain_text" && detail.icon) {
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -36,6 +37,7 @@ export function componentForToolCallIcon(name: ToolCallIcon): ToolCallIconCompon
|
||||
export function resolveToolCallIcon(
|
||||
toolName: string,
|
||||
detail?: ToolCallDetail,
|
||||
agentMessage?: AgentMessage,
|
||||
): ToolCallIconComponent {
|
||||
return componentForToolCallIcon(resolveToolCallIconName(toolName, detail));
|
||||
return componentForToolCallIcon(resolveToolCallIconName(toolName, detail, agentMessage));
|
||||
}
|
||||
|
||||
@@ -277,3 +277,40 @@ 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");
|
||||
});
|
||||
|
||||
@@ -44,8 +44,18 @@ 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 : ["-"];
|
||||
@@ -68,7 +78,7 @@ const summarySchema: OutputSchema<SummaryRow> = {
|
||||
{ header: "ACCOUNT", field: "account" },
|
||||
{ header: "STATUS", field: "status" },
|
||||
{ header: "USAGE", field: "usage" },
|
||||
{ header: "FETCHED", field: "fetched" },
|
||||
{ header: "FETCHED (LATEST ATTEMPT)", field: "fetched" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -79,30 +89,15 @@ function inspect(entry: UsageReportEntry): string {
|
||||
`Source ID: ${entry.sourceId}`,
|
||||
`Account: ${value(entry.account.label)}`,
|
||||
`Status: ${entry.report.status}`,
|
||||
`Fetched: ${value(entry.fetchedAt)}`,
|
||||
`Latest attempt: ${value(entry.fetchedAt)}`,
|
||||
];
|
||||
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)}`);
|
||||
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));
|
||||
}
|
||||
if (entry.loginErrors?.length) {
|
||||
lines.push("Login failures:");
|
||||
@@ -135,3 +130,33 @@ 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;
|
||||
}
|
||||
|
||||
@@ -232,17 +232,18 @@ 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) => ({
|
||||
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" });
|
||||
.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,
|
||||
});
|
||||
await page.screenshot({ path: testInfo.outputPath("scrolled-chat-titlebar.png") });
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6306,14 +6306,15 @@ 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", [
|
||||
z.object({
|
||||
status: z.literal("available"),
|
||||
planLabel: z.string().optional(),
|
||||
windows: z.array(ProviderUsageWindowSchema),
|
||||
balances: z.array(ProviderUsageBalanceSchema).optional(),
|
||||
details: z.array(ProviderUsageDetailSchema).optional(),
|
||||
}),
|
||||
AvailableUsageReportSchema,
|
||||
z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }),
|
||||
z.object({ status: z.literal("error"), error: z.string() }),
|
||||
]);
|
||||
@@ -6325,6 +6326,11 @@ 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,6 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
UsageReportEntrySchema,
|
||||
AgentSnapshotPayloadSchema,
|
||||
AgentTimelineItemPayloadSchema,
|
||||
ServerInfoStatusPayloadSchema,
|
||||
@@ -436,3 +437,40 @@ 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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 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
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildPaseoToolDetailSections } from "./paseo-tool-call-detail.js";
|
||||
import { buildPaseoToolDetailSections } from "./index.js";
|
||||
|
||||
describe("Paseo tool-call detail presentation", () => {
|
||||
it.each(["mcp__paseo__create_agent", "paseo.create_agent", "paseo_remote.create_agent"])(
|
||||
+3
-157
@@ -1,4 +1,5 @@
|
||||
import { getPaseoToolLeafName } from "./tool-name-normalization.js";
|
||||
import { getPaseoToolLeafName } from "../tool-name-normalization.js";
|
||||
import { getPaseoToolEntry, type PaseoToolDetailSpec } from "./catalog.js";
|
||||
|
||||
export interface PaseoToolDetailField {
|
||||
label: string;
|
||||
@@ -17,161 +18,6 @@ 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",
|
||||
@@ -330,7 +176,7 @@ export function buildPaseoToolDetailSections(
|
||||
const leafName = getPaseoToolLeafName(toolName);
|
||||
if (!leafName) return null;
|
||||
|
||||
const spec = TOOL_SPECS[leafName] ?? {};
|
||||
const spec: PaseoToolDetailSpec = getPaseoToolEntry(leafName)?.detail ?? {};
|
||||
const sections: PaseoToolDetailSection[] = [];
|
||||
if (spec.promptField && isRecord(input)) {
|
||||
const prompt = input[spec.promptField];
|
||||
@@ -0,0 +1,141 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
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)}`;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { buildAgentMessageDisplay } from "./agent-message-display.js";
|
||||
import type { ToolCallTimelineItem } from "./agent-types.js";
|
||||
import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js";
|
||||
import {
|
||||
buildAgentMessageDisplay,
|
||||
buildPaseoToolDisplay,
|
||||
} from "./paseo-tool-presentation/index.js";
|
||||
import { stripCwdPrefix } from "./path-utils.js";
|
||||
|
||||
export type ToolCallDisplayInput = Pick<
|
||||
@@ -8,6 +10,8 @@ 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 {
|
||||
@@ -34,12 +38,6 @@ 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;
|
||||
}
|
||||
@@ -152,8 +150,20 @@ function buildUnknownDetailOverride(input: ToolCallDisplayInput): DetailDisplay
|
||||
}
|
||||
|
||||
export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel {
|
||||
if (input.agentMessage) {
|
||||
return { displayName: buildAgentMessageDisplay(input.agentMessage).displayName };
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
const canonicalDisplay = buildCanonicalDetailDisplay(input);
|
||||
const unknownDetailOverride = buildUnknownDetailOverride(input);
|
||||
@@ -162,7 +172,6 @@ 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,
|
||||
|
||||
@@ -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 -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: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: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,6 +2,7 @@ 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;
|
||||
@@ -78,6 +79,28 @@ 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,7 +1,9 @@
|
||||
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";
|
||||
|
||||
@@ -119,6 +121,17 @@ 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,
|
||||
@@ -146,6 +159,10 @@ 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,6 +8,7 @@ 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";
|
||||
|
||||
@@ -342,6 +343,8 @@ 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,6 +66,7 @@ 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";
|
||||
@@ -888,6 +889,40 @@ 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,6 +761,72 @@ 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,6 +1175,9 @@ 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,3 +556,208 @@ 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,6 +56,7 @@ 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(
|
||||
@@ -81,6 +82,8 @@ 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);
|
||||
}
|
||||
|
||||
@@ -96,7 +99,7 @@ export class UsageSourceRegistry {
|
||||
} else if (options.reportIds !== undefined) {
|
||||
ids = options.reportIds;
|
||||
} else {
|
||||
this.defaults = await this.discover({ kind: "global" });
|
||||
this.defaults = await this.discover({ kind: "global" }, this.defaults);
|
||||
this.mergeKnown();
|
||||
ids = [...this.known.keys()];
|
||||
}
|
||||
@@ -124,7 +127,11 @@ 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) return [];
|
||||
if (!session) {
|
||||
this.byAgent.delete(agentId);
|
||||
this.mergeKnown();
|
||||
return [];
|
||||
}
|
||||
const previous = this.byAgent.get(agentId);
|
||||
const sameScope =
|
||||
previous?.sessionKey === session.sessionKey &&
|
||||
@@ -168,9 +175,26 @@ 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 async discover(scope: UsageScope): Promise<Map<string, KnownReport>> {
|
||||
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>> {
|
||||
const discovered = await Promise.all(
|
||||
[...this.sources.values()].map(async (source) => {
|
||||
const reports = new Map<string, KnownReport>();
|
||||
@@ -197,6 +221,11 @@ 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;
|
||||
}),
|
||||
@@ -235,25 +264,42 @@ 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 = `${id}:${createHash("sha256")
|
||||
.update(JSON.stringify([known.label, known.logins.map(loginKey)]))
|
||||
.digest("hex")}`;
|
||||
const cacheKey = cacheIdentity(id, known);
|
||||
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: new Date(this.now()).toISOString(),
|
||||
...(await this.fetchWithFallback(known)),
|
||||
fetchedAt,
|
||||
...outcome,
|
||||
};
|
||||
if (this.sources.get(known.source.id) === known.source) this.writeCache(cacheKey, entry);
|
||||
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);
|
||||
}
|
||||
return entry;
|
||||
})();
|
||||
this.pending.set(cacheKey, request);
|
||||
@@ -346,3 +392,9 @@ 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")}`;
|
||||
}
|
||||
|
||||
@@ -1121,6 +1121,10 @@ 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);
|
||||
@@ -1191,7 +1195,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);
|
||||
|
||||
@@ -19,9 +19,6 @@ export default defineConfig({
|
||||
setupFiles: [path.resolve(__dirname, "./src/test-utils/vitest-setup.ts")],
|
||||
pool: "forks",
|
||||
fileParallelism: false,
|
||||
// Even two concurrent Git-heavy suites can exhaust Windows test deadlines.
|
||||
// Run files serially so their subprocesses do not compete across suites.
|
||||
maxWorkers: process.platform === "win32" ? 1 : undefined,
|
||||
exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**", "**/.dev/**"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,20 @@ function AdmonitionRenderer({
|
||||
@@ -101,12 +101,28 @@ function AdmonitionRenderer({
|
||||
});
|
||||
}
|
||||
function CodeBlockRenderer({
|
||||
@@ -1317,9 +1317,17 @@ index dd70713..ca0385a 100644
|
||||
const language = node.attributes?.language;
|
||||
+ const CustomCodeBlock = capabilities.renderers?.codeBlock;
|
||||
+ if (CustomCodeBlock) {
|
||||
+ return /*#__PURE__*/_jsx(CustomCodeBlock, {
|
||||
+ code: extractNodeText(node),
|
||||
+ language: language ?? ''
|
||||
+ // 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 ?? ''
|
||||
+ })
|
||||
+ });
|
||||
+ }
|
||||
const label = language ? `Code block: ${language}` : 'Code block';
|
||||
|
||||
@@ -2,6 +2,7 @@ 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);
|
||||
@@ -12,21 +13,26 @@ const serverTsconfigPath = new URL("packages/server/tsconfig.server.json", repoR
|
||||
const desktopPackagePath = new URL("packages/desktop/package.json", repoRoot);
|
||||
|
||||
const gatedCiJobs = new Map([
|
||||
["format", { name: "format", contract: "format" }],
|
||||
["lint", { name: "lint", contract: "quality" }],
|
||||
["quality", { name: "quality", contracts: ["format", "quality", "relay", "sdk"] }],
|
||||
["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" }],
|
||||
["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" }],
|
||||
["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" }],
|
||||
["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" }],
|
||||
@@ -36,7 +42,7 @@ function jobBlocks(source) {
|
||||
const jobs = new Map();
|
||||
let currentJob;
|
||||
|
||||
for (const line of source.split("\n")) {
|
||||
for (const line of (source.split("\njobs:\n")[1] ?? source).split("\n")) {
|
||||
const jobMatch = /^ ([a-z0-9_-]+):\s*$/.exec(line);
|
||||
if (jobMatch) {
|
||||
currentJob = jobMatch[1];
|
||||
@@ -100,6 +106,12 @@ 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(
|
||||
@@ -331,3 +343,177 @@ 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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user