Compare commits

...
Author SHA1 Message Date
Mohamed Boudra d889e210e7 test(ci): verify routed skips on hosted Actions 2026-10-18 06:03:40 +02:00
Mohamed Boudra bb24b486a0 test(ci): extend both production bundle warmup deadlines 2026-10-18 05:52:57 +02:00
Mohamed Boudra 506b5f5762 test(ci): select isolated confirmation runs 2026-10-18 05:47:22 +02:00
Mohamed Boudra 866243f1d1 test(ci): measure runner throughput limits 2026-10-18 05:17:33 +02:00
paseo-bot[bot] 1a6a9fa8d0 fix(claude): keep background child action logs after the parent finishes (#7266)
Late child frames were discarded after the launching turn ended, and parent completion cleared accumulated child actions. Keep card updates attached to the launching turn and release child actions on task completion.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 05:02:33 +02:00
paseo-bot[bot] a7a21d91c2 Keep overlapping directory searches from exhausting the heap (#7265)
* fix(server): keep overlapping directory searches within their scan budget

Construct child paths and branch iterators as the round-robin walk consumes them. Eager sibling expansion retained hundreds of thousands of unvisited children per search and exhausted the heap when requests overlapped.

* test(server): stop directory-search children before fixture cleanup

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 03:52:15 +02:00
paseo-bot[bot] b2da58b5bf fix(app): keep chat keyboard scrolling after closing Find (#7263)
Closing Find focused its non-scrolling wrapper, leaving Page Up and Page Down unable to move the feed. Return focus through the viewport handle while preserving the reading position.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 01:51:49 +02:00
paseo-bot[bot] e5d9307cf9 fix(terminal): copy wide characters selected from either half (#7260)
Include the leading cell when a selection starts on a wide character continuation, matching the native renderer highlight. Keep continuation cells out of copied text so real spaces remain unchanged.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 00:55:08 +02:00
paseo-bot[bot] 90c6de0580 fix(app): keep streaming replies complete after reloading (#7256)
A catch-up projection could replace the live reply while leaving its cached prefix in the committed rows. Completion then discarded the complete reply as a duplicate. Reconcile the covered cached prefix together with the live reply.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-18 00:01:33 +02:00
paseo-bot[bot]andhelloimdevman 9bd58f66b2 Copy Korean terminal text without extra spaces (#7254)
* fix(terminal): preserve Korean text when copying native terminal cells

* fix(terminal): omit continuation cells from copied text

Native terminal buffers already retain authoritative cell widths. Skip zero-width continuation cells when serializing a selection so Korean text copies with only its real spaces, while preserving the buffer representation.

Co-authored-by: helloimdevman <333048235+helloimdevman@users.noreply.github.com>

---------

Co-authored-by: helloimdevman <333048235+helloimdevman@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 22:57:04 +02:00
paseo-bot[bot]anda-grasso e2ad26d238 Find source-message text in plugin timeline rows (#7253)
* fix(app): find text in rows a timeline plugin draws

Chat find located a replaced message by its source id but counted matches
only inside elements marked as message text, which a plugin renderer never
sets, so a replaced prompt or reply reported no matches. Mark plugin rows and
search the whole row, with each view bounding a match.

* docs(plugins): qualify search in replacement timeline rows

Chat find discovers source-message matches before inspecting a replacement renderer. Document that renderer-added text is not independently indexed.

Co-authored-by: a-grasso <antonino.grasso@device-insight.com>

* fix(app): find visible text within contiguous plugin blocks

A whole-row walk counted closed popover text and reunited text before and after nested blocks. Keep consecutive block groups and exclude plugin text the browser is not drawing.

---------

Co-authored-by: a-grasso <antonino.grasso@device-insight.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-17 21:58:07 +02:00
paseo-ai[bot] 142cdac8d2 fix: update lockfile signatures and Nix hash [skip ci] 2026-10-17 18:18:08 +00:00
21 changed files with 1398 additions and 200 deletions
+252
View File
@@ -0,0 +1,252 @@
name: CI non-browser routing probe
on:
push:
branches:
- investigate-ci-throughput
paths:
- .github/workflows/ci-routing-probe.yml
workflow_dispatch: {}
permissions:
contents: read
env:
ONNXRUNTIME_NODE_INSTALL: skip
jobs:
changes:
runs-on: ubuntu-latest
outputs:
full: ${{ steps.route.outputs.full }}
browser: ${{ steps.route.outputs.browser }}
format: ${{ steps.route.outputs.format }}
quality: ${{ steps.route.outputs.quality }}
relay: ${{ steps.route.outputs.relay }}
sdk: ${{ steps.route.outputs.sdk }}
steps:
- id: route
run: >-
for contract in full browser quality relay sdk; do echo "$contract=false" >>
"$GITHUB_OUTPUT"; done
echo "format=true" >> "$GITHUB_OUTPUT"
quality:
name: quality
needs: changes
if:
${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format !=
'false' || needs.changes.outputs.quality != 'false' || needs.changes.outputs.relay != 'false'
|| needs.changes.outputs.sdk != 'false') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies
id: install
run: node scripts/npm-retry.mjs ci
- name: Check formatting
if:
${{ !cancelled() && steps.install.outcome == 'success' && (needs.changes.outputs.full != 'false'
|| needs.changes.outputs.format != 'false') }}
run: npm run format:check
- 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
playwright-1:
name: playwright (shard 1/8)
needs: changes
if:
${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser !=
'false') }}
runs-on: ubuntu-latest
steps:
- run: echo "Browser coverage was intentionally unrouted; this must not execute"; exit 99
playwright-2:
name: playwright (shard 2/8)
needs: changes
if:
${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser !=
'false') }}
runs-on: ubuntu-latest
steps:
- run: echo "Browser coverage was intentionally unrouted; this must not execute"; exit 99
playwright-3:
name: playwright (shard 3/8)
needs: changes
if:
${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser !=
'false') }}
runs-on: ubuntu-latest
steps:
- run: echo "Browser coverage was intentionally unrouted; this must not execute"; exit 99
playwright-4:
name: playwright (shard 4/8)
needs: changes
if:
${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser !=
'false') }}
runs-on: ubuntu-latest
steps:
- run: echo "Browser coverage was intentionally unrouted; this must not execute"; exit 99
playwright-5:
name: playwright (shard 5/8)
needs: changes
if:
${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser !=
'false') }}
runs-on: ubuntu-latest
steps:
- run: echo "Browser coverage was intentionally unrouted; this must not execute"; exit 99
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
steps:
- run: echo "Browser coverage was intentionally unrouted; this must not execute"; exit 99
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
steps:
- run: echo "Browser coverage was intentionally unrouted; this must not execute"; exit 99
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
steps:
- run: echo "Browser coverage was intentionally unrouted; this must not execute"; exit 99
playwright:
name: playwright
needs:
- changes
- playwright-1
- playwright-2
- playwright-3
- playwright-4
- playwright-5
- playwright-6
- playwright-7
- playwright-8
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- name: Require all routed Playwright shards
env:
NEEDS_JSON: ${{ toJSON(needs) }}
run: >
node --input-type=module <<'NODE'
const needs = JSON.parse(process.env.NEEDS_JSON);
const routed = needs.changes.outputs.full !== 'false' || needs.changes.outputs.browser !==
'false';
const failures = Object.entries(needs).filter(([name, job]) =>
job.result !== (name === 'changes' || routed ? 'success' : 'skipped'));
for (const [name, job] of failures) console.error(`${name}: ${job.result}`);
process.exitCode = failures.length ? 1 : 0;
NODE
verify:
needs:
- changes
- quality
- playwright
- playwright-1
- playwright-2
- playwright-3
- playwright-4
- playwright-5
- playwright-6
- playwright-7
- playwright-8
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- env:
NEEDS_JSON: ${{ toJSON(needs) }}
run: >-
node --input-type=module <<'NODE'
import assert from 'node:assert/strict';
const needs=JSON.parse(process.env.NEEDS_JSON);
for(const name of ['changes','quality','playwright'])
assert.equal(needs[name].result,'success',name);
for(let i=1;i<=8;i++)
assert.equal(needs['playwright-'+i].result,'skipped','playwright-'+i);
console.log('PASS: real quality ran formatting only; all eight real browser job conditions
skipped; the actual result gate succeeded');
NODE
@@ -0,0 +1,345 @@
name: CI throughput experiment
on:
workflow_dispatch:
inputs:
arm:
description: Run only this comparison arm
type: choice
required: true
default: all
options:
- all
- pw-shard4-w2
- pw-shard4-w3
- pw-shard4-w4
- pw-shard4-w2-prod
- win-unit-mw2
- win-unit-mw3
- win-unit-shard1of3
permissions:
contents: read
concurrency:
group: ci-throughput-experiment-${{ github.run_id }}
cancel-in-progress: false
env:
ONNXRUNTIME_NODE_INSTALL: skip
jobs:
pw-shard4-w2:
name: pw-shard4-w2
runs-on: ubuntu-latest
timeout-minutes: 60
env:
PLAYWRIGHT_SHARD: 4/4
PLAYWRIGHT_ARTIFACT: pw-shard4-w2
E2E_WORKERS: "2"
steps:
- uses: actions/checkout@v4
- name: Record runner capacity
run: node -e "const os=require('node:os');
console.log(JSON.stringify({cpus:os.cpus().length,model:os.cpus()[0].model,memory:os.totalmem(),platform:os.platform()},null,2))"
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
- name: Build server stack
run: npm run build:server
- name: Build native app dependency
run: npm run build --workspace=@getpaseo/expo-two-way-audio
- name: Install agent CLIs for provider tests
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0
opencode-ai
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PWTEST_SHARD_WEIGHTS: 111:172:137:160
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: always()
with:
name: pw-shard4-w2
path: |
packages/app/test-results/
packages/app/playwright-report/
retention-days: 7
if: ${{ inputs.arm == 'all' || inputs.arm == 'pw-shard4-w2' }}
pw-shard4-w3:
name: pw-shard4-w3
runs-on: ubuntu-latest
timeout-minutes: 60
env:
PLAYWRIGHT_SHARD: 4/4
PLAYWRIGHT_ARTIFACT: pw-shard4-w3
E2E_WORKERS: "3"
steps:
- uses: actions/checkout@v4
- name: Record runner capacity
run: node -e "const os=require('node:os');
console.log(JSON.stringify({cpus:os.cpus().length,model:os.cpus()[0].model,memory:os.totalmem(),platform:os.platform()},null,2))"
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
- name: Build server stack
run: npm run build:server
- name: Build native app dependency
run: npm run build --workspace=@getpaseo/expo-two-way-audio
- name: Install agent CLIs for provider tests
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0
opencode-ai
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PWTEST_SHARD_WEIGHTS: 111:172:137:160
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: always()
with:
name: pw-shard4-w3
path: |
packages/app/test-results/
packages/app/playwright-report/
retention-days: 7
if: ${{ inputs.arm == 'all' || inputs.arm == 'pw-shard4-w3' }}
pw-shard4-w4:
name: pw-shard4-w4
runs-on: ubuntu-latest
timeout-minutes: 60
env:
PLAYWRIGHT_SHARD: 4/4
PLAYWRIGHT_ARTIFACT: pw-shard4-w4
E2E_WORKERS: "4"
steps:
- uses: actions/checkout@v4
- name: Record runner capacity
run: node -e "const os=require('node:os');
console.log(JSON.stringify({cpus:os.cpus().length,model:os.cpus()[0].model,memory:os.totalmem(),platform:os.platform()},null,2))"
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
- name: Build server stack
run: npm run build:server
- name: Build native app dependency
run: npm run build --workspace=@getpaseo/expo-two-way-audio
- name: Install agent CLIs for provider tests
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0
opencode-ai
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PWTEST_SHARD_WEIGHTS: 111:172:137:160
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: always()
with:
name: pw-shard4-w4
path: |
packages/app/test-results/
packages/app/playwright-report/
retention-days: 7
if: ${{ inputs.arm == 'all' || inputs.arm == 'pw-shard4-w4' }}
pw-shard4-w2-prod:
name: pw-shard4-w2-prod
runs-on: ubuntu-latest
timeout-minutes: 60
env:
PLAYWRIGHT_SHARD: 4/4
PLAYWRIGHT_ARTIFACT: prod
E2E_WORKERS: "2"
steps:
- uses: actions/checkout@v4
- name: Record runner capacity
run: node -e "const os=require('node:os');
console.log(JSON.stringify({cpus:os.cpus().length,model:os.cpus()[0].model,memory:os.totalmem(),platform:os.platform()},null,2))"
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies with retry
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
- name: Build server stack
run: npm run build:server
- name: Build native app dependency
run: npm run build --workspace=@getpaseo/expo-two-way-audio
- name: Install agent CLIs for provider tests
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0
opencode-ai
- name: Serve the production-mode bundle for this experiment only
run: >-
node <<'NODE'
const fs = require('node:fs');
const file = 'packages/app/e2e/support/global-setup.ts';
const source = fs.readFileSync(file, 'utf8');
const before = '[expoCli, "start", "--web", "--port", String(port)]';
if (source.split(before).length !== 2) throw new Error('Expected one Metro launch');
fs.writeFileSync(file, source.replace(before, '[expoCli, "start", "--web", "--no-dev",
"--minify", "--port", String(port)]'));
const warmup = 'packages/app/e2e/support/metro-warmup.mjs';
const warmupSource = fs.readFileSync(warmup, 'utf8');
if (warmupSource.split('AbortSignal.timeout(120_000)').length !== 3) throw new
Error('Expected one warmup deadline');
fs.writeFileSync(warmup, warmupSource.replaceAll('AbortSignal.timeout(120_000)',
'AbortSignal.timeout(600_000)'));
NODE
- name: Run Playwright E2E tests
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ env.PLAYWRIGHT_SHARD }}
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PWTEST_SHARD_WEIGHTS: 111:172:137:160
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: always()
with:
name: pw-shard4-w2-prod
path: |
packages/app/test-results/
packages/app/playwright-report/
retention-days: 7
if: ${{ inputs.arm == 'all' || inputs.arm == 'pw-shard4-w2-prod' }}
win-unit-mw2:
name: win-unit-mw2
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Record runner capacity
run: node -e "const os=require('node:os');
console.log(JSON.stringify({cpus:os.cpus().length,model:os.cpus()[0].model,memory:os.totalmem(),platform:os.platform()},null,2))"
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Fetch origin/main (worktree tests)
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Build server dependencies
run: npm run build:server-deps
- name: Run Windows unit experiment
run: npm run test:unit --workspace=@getpaseo/server -- --maxWorkers=2 --reporter=default
--reporter=json --outputFile=unit-experiment.json
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: Upload unit results
uses: actions/upload-artifact@v4
if: always()
with:
name: win-unit-mw2
path: packages/server/unit-experiment.json
if-no-files-found: warn
if: ${{ inputs.arm == 'all' || inputs.arm == 'win-unit-mw2' }}
win-unit-mw3:
name: win-unit-mw3
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Record runner capacity
run: node -e "const os=require('node:os');
console.log(JSON.stringify({cpus:os.cpus().length,model:os.cpus()[0].model,memory:os.totalmem(),platform:os.platform()},null,2))"
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Fetch origin/main (worktree tests)
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Build server dependencies
run: npm run build:server-deps
- name: Run Windows unit experiment
run: npm run test:unit --workspace=@getpaseo/server -- --maxWorkers=3 --reporter=default
--reporter=json --outputFile=unit-experiment.json
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: Upload unit results
uses: actions/upload-artifact@v4
if: always()
with:
name: win-unit-mw3
path: packages/server/unit-experiment.json
if-no-files-found: warn
if: ${{ inputs.arm == 'all' || inputs.arm == 'win-unit-mw3' }}
win-unit-shard1of3:
name: win-unit-shard1of3
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Record runner capacity
run: node -e "const os=require('node:os');
console.log(JSON.stringify({cpus:os.cpus().length,model:os.cpus()[0].model,memory:os.totalmem(),platform:os.platform()},null,2))"
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Fetch origin/main (worktree tests)
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Build server dependencies
run: npm run build:server-deps
- name: Run Windows unit experiment
run:
npm run test:unit --workspace=@getpaseo/server -- --shard=1/3 --maxWorkers=1 --reporter=default
--reporter=json --outputFile=unit-experiment.json
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: Upload unit results
uses: actions/upload-artifact@v4
if: always()
with:
name: win-unit-shard1of3
path: packages/server/unit-experiment.json
if-no-files-found: warn
if: ${{ inputs.arm == 'all' || inputs.arm == 'win-unit-shard1of3' }}
+1 -1
View File
@@ -1 +1 @@
sha256-1WmrHvE68Lzyil4On7xtsPMQTODXZ3Jb3/juUg3nHi4=
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
+77 -1
View File
@@ -5,6 +5,7 @@ import {
seedMockAgentWorkspace,
type MockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { waitForTimelineGeometryToSettle } from "../support/helpers/timeline-pagination";
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
import {
expectReconnectingToastGone,
@@ -19,6 +20,10 @@ import {
} from "../support/helpers/composer";
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
import {
expectPromptsDrawnByPlugin,
withPromptTimelinePlugin,
} from "../support/helpers/plugin-timeline";
const MAC_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
@@ -120,6 +125,49 @@ async function closeFind(page: Page) {
.toEqual([]);
}
async function expectChatKeyboardScroll(page: Page, key: "PageUp" | "PageDown") {
// The scrolling surface has no accessible name; use its existing test id.
const viewport = page.getByTestId("agent-chat-scroll");
await waitForTimelineGeometryToSettle(page);
const before = await viewport.evaluate((element) => element.scrollTop);
await page.keyboard.press(key);
const direction = key === "PageUp" ? -1 : 1;
await expect
.poll(
async () => direction * ((await viewport.evaluate((element) => element.scrollTop)) - before),
)
.toBeGreaterThan(100);
}
test("keeps keyboard scrolling the chat after closing Find", async ({ page }, testInfo) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-scroll-focus-",
title: "Chat Find keyboard scrolling",
initialPrompt: "Render the response",
featureValues: { mockAssistantResponse: TALL_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await openAgentRoute(page, agent);
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
await searchChat(page, "Closing paragraph");
await expectHighlight(page, "Closing paragraph");
await waitForTimelineGeometryToSettle(page);
await closeFind(page);
await expectChatKeyboardScroll(page, "PageUp");
await page.screenshot({ path: testInfo.outputPath("chat-after-find-page-up.png") });
await expectChatKeyboardScroll(page, "PageDown");
await searchChat(page, "Closing paragraph");
await expectHighlight(page, "Closing paragraph");
await waitForTimelineGeometryToSettle(page);
await page.getByRole("button", { name: "Close Find", exact: true }).click();
await expect(query(page)).toHaveCount(0);
await expectChatKeyboardScroll(page, "PageUp");
} finally {
await agent.cleanup();
}
});
test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({
page,
}, testInfo) => {
@@ -174,7 +222,7 @@ async function highlightedMessage(page: Page) {
const range = entry && Array.from(entry[1])[0];
if (!(range instanceof Range)) return null;
const message = range.startContainer.parentElement?.closest<HTMLElement>(
'[data-testid="user-message"], [data-testid="assistant-message"]',
'[data-testid="user-message"], [data-testid="plugin-prompt"], [data-testid="assistant-message"]',
);
return message?.dataset.testid ?? null;
});
@@ -219,6 +267,34 @@ test("counts and steps through every match in the chat, not just the selected me
}
});
test("finds text in a prompt a timeline plugin draws", async ({ page }, testInfo) => {
testInfo.setTimeout(120_000);
const agent = await seedMockAgentWorkspace({
repoPrefix: "chat-find-plugin-prompt-",
title: "Chat Find plugin prompt",
initialPrompt: ACROSS_PROMPT,
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
});
try {
await agent.client.waitForFinish(agent.agentId, 15_000);
await withPromptTimelinePlugin(async () => {
await openAgentRoute(page, agent);
await expectPromptsDrawnByPlugin(page);
await openChatFind(page);
await query(page).fill("scope-needle");
await expect(status(page)).toHaveText("1 of 3");
await expectHighlight(page, "scope-needle");
await expectHighlightedMessage(page, "plugin-prompt");
await page.screenshot({ path: testInfo.outputPath("chat-find-plugin-prompt.png") });
await expectNextMatch(page, "2 of 3", "scope-needle");
await expectHighlightedMessage(page, "assistant-message");
await closeFind(page);
});
} finally {
await agent.cleanup();
}
});
test("rejects invisible source matches and ignores a late reply after closing", async ({
page,
}) => {
@@ -118,8 +118,8 @@ export function ChatFind({
}, [active, isInteractive, model]);
const close = useCallback(() => {
model.close();
root.current?.focus({ preventScroll: true });
}, [model]);
viewportRef.current?.focus?.();
}, [model, viewportRef]);
let status = "";
if (state.phase === "searching") status = t("paneFind.searching");
else if (state.phase === "loading") status = t("paneFind.loading");
@@ -76,3 +76,49 @@ it("keeps semantic Markdown paragraphs separate while finding inline matches", (
]);
expect(findRenderedMatches(row, ".another")).toEqual([]);
});
function pluginRow(html: string) {
const row = document.createElement("div");
row.dataset.pluginRow = "true";
row.dataset.messageId = "message-1";
row.innerHTML = html;
document.body.append(row);
return row;
}
it("searches a plugin row that sets no text marker, bounding matches by view", () => {
const row = pluginRow(
"<div><div>Prompt</div><div>find <span>this</span> needle</div><div>needle</div><button>needle</button></div>",
);
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual([
"needle",
"needle",
]);
expect(findRenderedMatches(row, "Promptfind")).toEqual([]);
expect(findRenderedMatches(row, "find this needle")).toHaveLength(1);
expect(findMessageMatches(row.parentElement, "message-1", "needle")).toHaveLength(2);
});
it("leaves rows without the plugin marker limited to marked message text", () => {
const row = document.createElement("div");
row.innerHTML = "<div>needle</div>";
document.body.append(row);
expect(findRenderedMatches(row, "needle")).toEqual([]);
});
it("counts only drawn text in a plugin row, including an opened popover", () => {
const row = pluginRow(
'<div><span>visible needle</span><div hidden>hidden needle</div><div style="display:none">display needle</div><span style="visibility:hidden">visibility needle</span><span style="opacity:0">transparent needle</span><div popover="manual">popover needle</div></div>',
);
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual(["needle"]);
row.querySelector<HTMLElement>("[popover]")!.showPopover();
expect(findRenderedMatches(row, "needle")).toHaveLength(2);
});
it("keeps text around nested plugin blocks in separate occurrences", () => {
const row = pluginRow("<div>alpha <div>details</div>beta</div><div>gamma <div></div>delta</div>");
expect(findRenderedMatches(row, "alpha beta")).toEqual([]);
expect(findRenderedMatches(row, "gamma delta")).toEqual([]);
expect(findRenderedMatches(row, "details").map((range) => range.toString())).toEqual(["details"]);
expect(findRenderedMatches(row, "beta").map((range) => range.toString())).toEqual(["beta"]);
});
@@ -21,28 +21,55 @@ export function findMessageMatches(
return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query));
}
function renderedTextGroups(row: HTMLElement): Text[][] {
const groups: Text[][] = [];
// A plugin renderer owns the DOM of the message it replaced and sets no markers, so the
// whole row is searched and each div (a React Native View or Text) bounds a match.
const pluginRow = row.dataset.pluginRow === "true";
const blocks = pluginRow ? `${BLOCKS},div` : BLOCKS;
const contents = pluginRow
? [row]
: row.querySelectorAll<HTMLElement>('[data-message-text="true"]');
for (const content of contents) {
let group: { block: Element; texts: Text[] } | undefined;
const walker = document.createTreeWalker(
content,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
);
let node: Node | null;
while ((node = walker.nextNode())) {
if (node instanceof Element) {
// Even an empty nested block separates the text before and after it.
if (node.matches(blocks)) group = undefined;
continue;
}
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
if (
pluginRow &&
!node.parentElement?.checkVisibility({ opacityProperty: true, visibilityProperty: true })
)
continue;
const block = node.parentElement?.closest(blocks) ?? content;
if (group?.block !== block) {
group = { block, texts: [] };
groups.push(group.texts);
}
group.texts.push(node);
}
}
return groups;
}
/** Local offsets belong to the DOM that supplied the text, never to a host parser. */
export function findRenderedMatches(row: HTMLElement, query: string): Range[] {
if (!query.trim()) return [];
const groups = new Map<Element, Text[]>();
for (const content of row.querySelectorAll<HTMLElement>('[data-message-text="true"]')) {
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
let node: Node | null;
while ((node = walker.nextNode())) {
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
const block = node.parentElement?.closest(BLOCKS) ?? content;
const texts = groups.get(block) ?? [];
texts.push(node);
groups.set(block, texts);
}
}
const pattern = query
.trim()
.split(/\s+/)
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("\\s+");
const ranges: Range[] = [];
for (const texts of groups.values()) {
for (const texts of renderedTextGroups(row)) {
const points = texts.map((node) => ({ node, start: 0, end: 0 }));
let text = "";
for (const point of points) {
@@ -1162,6 +1162,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
useEffect(() => {
const handle: StreamViewportHandle = {
focus: () => scrollContainerRef.current?.focus({ preventScroll: true }),
scrollToBottom: () => {
setFollowOutput(true);
cancelPendingStickToBottom();
@@ -1250,6 +1251,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
key={item.id}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
style={streamRowStyle}
>
<StreamRowContent
@@ -1267,6 +1269,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
key={item.id}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
style={streamRowStyle}
>
<StreamRowContent
@@ -1329,6 +1332,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
data-index={virtualRow.index}
data-history-row-id={item.id}
data-message-id={getStreamItemMessageId(item)}
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
ref={measureVirtualizedRowElement}
style={renderVirtualRowStyle(virtualRow.start)}
>
@@ -51,6 +51,8 @@ export interface ScrollToMessageOccurrence {
}
export interface StreamViewportHandle {
/** Give keyboard navigation back to the scrolling surface on web. */
focus?: () => void;
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
@@ -41,6 +41,93 @@ function rowWithText(
}
describe("native terminal selection", () => {
it.each([
{ startCol: 1, endCol: 8, expected: "한글 문장" },
{ startCol: 3, endCol: 8, expected: "글 문장" },
{ startCol: 1, endCol: 1, expected: "한" },
{ startCol: 0, endCol: 0, expected: "한" },
{ startCol: 0, endCol: 1, expected: "한" },
{ startCol: 4, endCol: 6, expected: " 문" },
])(
"copies complete wide characters for selected columns $startCol through $endCol",
async ({ startCol, endCol, expected }) => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
const copied: string[] = [];
try {
await terminal.write("한글 문장");
const bounds = terminal.getBufferBounds();
const selection = createTerminalSelectionModel();
selection.begin({
coordinate: { row: bounds.cursorRow, col: endCol },
bounds,
});
selection.update({
coordinate: { row: bounds.cursorRow, col: startCol },
bounds,
});
await copyTerminalSelection({
terminal,
selection: selection.getSnapshot().range,
clipboard: {
writeText: async (text) => {
copied.push(text);
},
},
});
expect(copied).toEqual([expected]);
} finally {
terminal.dispose();
}
},
);
it("copies Korean output without adding spaces for continuation cells", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
const copied: string[] = [];
try {
await terminal.write("한글 문장");
const bounds = terminal.getBufferBounds();
await copyTerminalSelection({
terminal,
selection: {
start: { row: bounds.cursorRow, col: 0 },
end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 },
coordinateEpoch: bounds.coordinateEpoch,
},
clipboard: {
writeText: async (text) => {
copied.push(text);
},
},
});
expect(copied).toEqual(["한글 문장"]);
} finally {
terminal.dispose();
}
});
it("copies Korean text and real spaces without wide-character continuation spaces", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
try {
await terminal.write("한글 문장 | 성공");
const bounds = terminal.getBufferBounds();
expect(
extractTerminalSelectedText({
terminal,
selection: {
start: { row: bounds.cursorRow, col: 0 },
end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 },
coordinateEpoch: bounds.coordinateEpoch,
},
}),
).toBe("한글 문장 | 성공");
} finally {
terminal.dispose();
}
});
it("expands a long-press coordinate to the whole terminal word", async () => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 32, scrollbackLines: 20 });
await terminal.write("before PASEO_TARGET after\r\n");
@@ -324,10 +324,17 @@ export function extractTerminalSelectedText(input: TerminalSelectedTextInput): s
const cells = window.rows[offset] ?? [];
const startsOnThisRow = row === selection.start.row;
const endsOnThisRow = row === selection.end.row;
const startCol = startsOnThisRow ? selection.start.col : 0;
let startCol = Math.max(0, startsOnThisRow ? selection.start.col : 0);
// Selecting either half highlights the whole wide character, so copy its leading cell too.
if (startCol > 0 && cells[startCol]?.width === 0) {
startCol -= 1;
}
const endCol = endsOnThisRow ? selection.end.col : cells.length - 1;
const selectedCells = cells.slice(Math.max(0, startCol), Math.max(0, endCol) + 1);
const line = selectedCells.map((cell) => cell.char || " ").join("");
const selectedCells = cells.slice(startCol, Math.max(0, endCol) + 1);
const line = selectedCells
.filter((cell) => cell.width !== 0)
.map((cell) => cell.char)
.join("");
if (offset > 0 && window.wrappedRows[offset] !== true) {
text += "\n";
}
+84
View File
@@ -77,6 +77,90 @@ function applySynced(agentId: string, seq: number): void {
afterEach(() => useSessionStore.getState().clearSession(SERVER_ID));
describe("viewed timeline persistence", () => {
it("keeps one complete reply when live chunks arrive before a cached chat catches up", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const partial = "Paragraph 1. Paragraph 2.";
const complete = `${partial} Paragraph 3. END`;
const owner = createOwner({
readTimeline: async () => ({
agentId: AGENT_ID,
items: [{ ...item("reply-1", partial, 4), messageId: "reply-1" }],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
hasOlder: false,
}),
commitTimeline: () => undefined,
});
try {
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
await expect
.poll(
() =>
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID)
.status,
)
.toBe("painted");
owner.enqueueStreamEvent(AGENT_ID, {
event: {
type: "timeline",
provider: "codex",
item: { type: "assistant_message", text: " Paragraph 3.", messageId: "reply-1" },
} as AgentStreamEventPayload,
seq: 5,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:01.000Z"),
});
owner.flushStreamAgent(AGENT_ID);
owner.applyTimelineResponse({
requestId: "resume",
agentId: AGENT_ID,
agent: null,
direction: "after",
projection: "projected",
reset: false,
epoch: "epoch-1",
window: { minSeq: 1, maxSeq: 6, nextSeq: 7 },
startCursor: { epoch: "epoch-1", seq: 5 },
endCursor: { epoch: "epoch-1", seq: 6 },
entries: [
{
provider: "codex",
item: { type: "assistant_message", text: complete, messageId: "reply-1" },
timestamp: "2026-08-26T10:00:02.000Z",
seqStart: 2,
seqEnd: 6,
sourceSeqRanges: [{ startSeq: 2, endSeq: 6 }],
collapsed: ["assistant_merge"],
},
],
error: null,
hasNewer: false,
hasOlder: false,
staleCursor: false,
gap: false,
});
const replyTexts = () => {
const session = useSessionStore.getState().sessions[SERVER_ID];
return [
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
]
.filter((entry) => entry.kind === "assistant_message")
.map((entry) => entry.text);
};
expect.soft(replyTexts()).toEqual([complete]);
owner.enqueueStreamEvent(AGENT_ID, {
event: { type: "turn_completed", provider: "codex" } as AgentStreamEventPayload,
seq: 7,
epoch: "epoch-1",
timestamp: new Date("2026-08-26T10:00:03.000Z"),
});
owner.flushStreamAgent(AGENT_ID);
expect.soft(replyTexts()).toEqual([complete]);
} finally {
owner.dispose();
}
});
it("shares an in-flight cache preparation with the viewed owner", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
let release!: (value: CachedTimeline) => void;
@@ -811,7 +811,7 @@ function reconcileOverlappingProjectedAssistant(params: {
};
const headMatch = findMatch(params.head);
const tailMatch = headMatch ? null : findMatch(params.tail);
const tailMatch = findMatch(params.tail);
const match = headMatch ?? tailMatch;
if (!match) {
return { tail: params.tail, head: params.head, reconciled: false };
@@ -833,8 +833,20 @@ function reconcileOverlappingProjectedAssistant(params: {
};
if (headMatch) {
const tailCursor = tailMatch?.current.timelineCursor;
const replacesCachedPrefix =
tailMatch !== null &&
tailCursor?.epoch === params.epoch &&
unit.sourceSeqRanges.some(
(range) => range.startSeq <= tailCursor.seq && tailCursor.seq <= range.endSeq,
);
return {
tail: params.tail,
// Live chunks can arrive before catch-up while their cached prefix is still painted.
// The projected reply owns both copies; keeping the prefix would duplicate it and
// cause completion to discard the updated head as an already-committed item.
tail: replacesCachedPrefix
? [...params.tail.slice(0, tailMatch.index), ...params.tail.slice(tailMatch.index + 1)]
: params.tail,
head: replaceMatch(params.head, headMatch.index),
reconciled: true,
};
@@ -122,7 +122,7 @@ async function collectTimelineAcrossTurns(
seq: rows.length + 1,
timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, rows.length)).toISOString(),
item: event.item,
...(currentTurnId ? { turnId: currentTurnId } : {}),
...((event.turnId ?? currentTurnId) ? { turnId: event.turnId ?? currentTurnId } : {}),
});
}
if (event.type === "turn_completed" && ++completedTurns === turnCount) resolve();
@@ -469,122 +469,145 @@ describe("background Claude subagents", () => {
});
});
test("keeps one logged Task card when the child streams on after the parent's turn ends", async () => {
// A child can stream across the main session's turn boundary. Claude opens the next
// main-session turn with system/init; bare child frames do not open a turn.
queryFactory.mockImplementation(() =>
buildQueryMock([
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
{
type: "assistant",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_background",
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
run_in_background: true,
test.each(["before", "after"])(
"keeps one logged Task card when child tools start %s the parent finishes",
async (timing) => {
// A child can stream across the main session's turn boundary. Claude opens the next
// main-session turn with system/init; bare child frames do not open a turn.
queryFactory.mockImplementation(() =>
buildQueryMock([
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
{
type: "assistant",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_background",
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
run_in_background: true,
},
},
},
],
],
},
},
},
{
type: "system",
subtype: "task_started",
task_id: "bg-task",
tool_use_id: "toolu_background",
task_type: "local_agent",
subagent_type: "general-purpose",
{
type: "system",
subtype: "task_started",
task_id: "bg-task",
tool_use_id: "toolu_background",
task_type: "local_agent",
subagent_type: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
},
{
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_background",
content: "Async agent launched successfully.",
},
],
},
},
...(timing === "after"
? [
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
{
type: "system",
subtype: "init",
session_id: "bg-session",
permissionMode: "default",
},
]
: []),
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_ls",
name: "Bash",
input: { command: "ls -1", description: "List files" },
},
],
},
},
...(timing === "before"
? [
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
{
type: "system",
subtype: "init",
session_id: "bg-session",
permissionMode: "default",
},
]
: []),
{
type: "user",
parent_tool_use_id: "toolu_background",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] },
},
{
type: "system",
subtype: "task_updated",
task_id: "bg-task",
patch: { status: "completed" },
},
{
type: "system",
subtype: "task_notification",
task_id: "bg-task",
tool_use_id: "toolu_background",
status: "completed",
},
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
]),
);
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
const rows = await collectTimelineAcrossTurns(session, "delegate work", 2);
const cards = projectTimelineRows({ rows, mode: "projected" })
.map((entry) => entry.item)
.filter((item) => item.type === "tool_call" && item.callId === "toolu_background");
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
prompt: "Run ls and reply with the number of entries.",
log: "[Bash] ls -1",
},
{
type: "user",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_background",
content: "Async agent launched successfully.",
},
],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: {
model: "claude-haiku-4-5",
content: [
{
type: "tool_use",
id: "toolu_ls",
name: "Bash",
input: { command: "ls -1", description: "List files" },
},
],
},
},
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
{ type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" },
{
type: "user",
parent_tool_use_id: "toolu_background",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }],
},
},
{
type: "assistant",
parent_tool_use_id: "toolu_background",
message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] },
},
{
type: "system",
subtype: "task_updated",
task_id: "bg-task",
patch: { status: "completed" },
},
{
type: "system",
subtype: "task_notification",
task_id: "bg-task",
tool_use_id: "toolu_background",
status: "completed",
},
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
]),
);
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
const rows = await collectTimelineAcrossTurns(session, "delegate work", 2);
const cards = projectTimelineRows({ rows, mode: "projected" })
.map((entry) => entry.item)
.filter((item) => item.type === "tool_call" && item.callId === "toolu_background");
expect(cards).toHaveLength(1);
expect(cards[0]).toMatchObject({
status: "completed",
detail: {
type: "sub_agent",
subAgentType: "general-purpose",
description: "Count files here",
log: "[Bash] ls -1",
},
});
});
});
},
);
test.each([
{
@@ -2250,7 +2250,7 @@ class ClaudeAgentSession implements AgentSession {
private readonly sidechainTracker = new ClaudeSidechainTracker({
getToolInput: (toolUseId) =>
this.toolUseCache.get(toolUseId)?.input ??
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.input ??
this.settledSubagentToolCalls.get(toolUseId)?.input ??
null,
// Releases that predate the task protocol announce nothing, so the tracker keeps deriving
// identity and status from frames for them. Detecting the capability beats comparing version
@@ -2258,13 +2258,11 @@ class ClaudeAgentSession implements AgentSession {
isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive,
// A background child streams its frames after Claude has already answered the call, so its
// card is rebuilt from the settled call: a running one would replace the settled, labeled card.
// A card belongs to the turn that answered the call, so frames from a later turn leave it be.
// Late card updates retain the turn that answered the call.
needsSyntheticParentToolCard: (toolUseId) =>
(this.toolUseCache.has(toolUseId) ||
this.settledSubagentToolCallInCurrentTurn(toolUseId) !== undefined) &&
(this.toolUseCache.has(toolUseId) || this.settledSubagentToolCalls.has(toolUseId)) &&
this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId),
getSettledParentToolCall: (toolUseId) =>
this.settledSubagentToolCallInCurrentTurn(toolUseId)?.item,
getSettledParentToolCall: (toolUseId) => this.settledSubagentToolCalls.get(toolUseId)?.item,
});
private persistedHistory: PersistedTimelineEntry[] = [];
private persistedProviderSubagentEvents: Extract<
@@ -4490,7 +4488,12 @@ class ClaudeAgentSession implements AgentSession {
),
).map((event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event }));
const routedId = canonicalSubagentId ?? parentToolUseId;
return [...runtimeEvents, ...this.sidechainTracker.handleMessage(message, routedId)];
const settled = this.settledSubagentToolCalls.get(routedId);
const sidechainEvents = this.sidechainTracker.handleMessage(message, routedId).map((event) => {
if (event.type === "timeline" && settled?.turnId) event.turnId = settled.turnId;
return event;
});
return [...runtimeEvents, ...sidechainEvents];
}
/**
@@ -5145,7 +5148,8 @@ class ClaudeAgentSession implements AgentSession {
}
private notifySubscribers(event: AgentStreamEvent): void {
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id;
const turnId =
getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? this.autonomousTurn?.id;
const tagged = turnId ? { ...event, turnId } : event;
this.logger.trace(
{
@@ -5621,17 +5625,12 @@ class ClaudeAgentSession implements AgentSession {
});
}
private settledSubagentToolCallInCurrentTurn(subagentId: string) {
const settled = this.settledSubagentToolCalls.get(subagentId);
const currentTurnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
return settled && settled.turnId === currentTurnId ? settled : undefined;
}
/** A finished child streams no more frames, so its settled call has nothing left to update. */
private releaseFinishedSubagentToolCalls(observations: SubagentObservation[]): void {
for (const observation of observations) {
if (observation.kind === "status" && observation.status !== "running") {
this.settledSubagentToolCalls.delete(observation.id);
this.sidechainTracker.delete(observation.id);
}
}
}
@@ -4,6 +4,40 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
describe("ClaudeSidechainTracker", () => {
it("retains a background child's actions across the parent finishing", () => {
const tracker = new ClaudeSidechainTracker({
getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }),
isDescriptorOwnedElsewhere: () => true,
});
const action = (id: string, command: string) =>
({
type: "assistant",
parent_tool_use_id: "task-1",
message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] },
}) as unknown as SDKMessage;
tracker.handleMessage(action("ls", "ls -1"), "task-1");
expect(tracker.finishAll("completed")).toEqual([]);
const events = tracker.handleMessage(action("pwd", "pwd"), "task-1");
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }),
}),
}),
);
tracker.delete("task-1");
const next = tracker.handleMessage(action("next", "ls"), "task-1");
expect(next).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
detail: expect.objectContaining({ log: "[Bash] ls" }),
}),
}),
);
});
it("uses Claude's native agent name for the provider subagent title", () => {
const tracker = new ClaudeSidechainTracker({
getToolInput: () => ({
@@ -199,7 +199,7 @@ export class ClaudeSidechainTracker {
finishAll(status: "completed" | "failed" | "canceled"): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
if (this.isDescriptorOwnedElsewhere()) {
this.activeSidechains.clear();
// The task protocol owns child completion; the parent finishing does not end its children.
return events;
}
for (const [id, state] of this.activeSidechains) {
@@ -0,0 +1,105 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
test("background cards retain child actions after the parent finishes (#6253)", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-background-card-"));
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
logger,
agentClients: { claude: new ClaudeAgentClient({ logger }) },
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
provider: "claude",
model: "haiku",
modeId: "bypassPermissions",
cwd,
title: "Background action log reproduction",
});
await client.sendMessage(
agent.id,
'In one message, call Agent twice with run_in_background true: first with subagent_type "general-purpose", description "List files", prompt "Run ls -1, then run sleep 20, then reply with the number of entries."; second with subagent_type "general-purpose", description "Print cwd", prompt "Run pwd, then run sleep 20, then reply with the path." After launching both, reply "launched" and end your turn immediately without waiting.',
);
await client.waitForFinish(agent.id, 180_000);
const atFinish = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "projected",
});
await expect
.poll(
async () => {
const { subagents } = await client.listProviderSubagents(agent.id);
return subagents.length === 2 && subagents.every((child) => child.status === "completed");
},
{ timeout: 180_000, interval: 1000 },
)
.toBe(true);
const { subagents } = await client.listProviderSubagents(agent.id);
const children = await Promise.all(
subagents.map(async (child) => ({
description: child.description,
timeline: await client.fetchProviderSubagentTimeline(agent.id, child.id, {
direction: "tail",
limit: 0,
}),
})),
);
const final = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "projected",
});
const { stdout } = await promisify(execFile)(
process.execPath,
[
"--conditions=source",
"--import",
"tsx",
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
"--host",
`127.0.0.1:${daemon.port}`,
"logs",
agent.id,
],
{ env: { ...process.env, PASEO_HOME: daemon.paseoHome }, timeout: 30_000 },
);
expect(stdout).toContain("[Bash] ls -1");
expect(stdout).toContain("[Bash] pwd");
expect(
atFinish.entries.filter(
(row) => row.item.type === "tool_call" && row.item.detail?.type === "sub_agent",
),
).toHaveLength(2);
const cards = final.entries
.map((row) => row.item)
.filter((item) => item.type === "tool_call" && item.detail?.type === "sub_agent");
expect(cards).toHaveLength(2);
for (const child of children) {
expect(
child.timeline.rows.some(
(row) => row.item.type === "tool_call" && row.item.name === "Bash",
),
).toBe(true);
}
for (const card of cards) {
expect(card.detail?.type === "sub_agent" ? card.detail.log : "").toContain("[Bash]");
}
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 420_000);
@@ -1,5 +1,5 @@
import { createHook } from "node:async_hooks";
import { execFileSync } from "node:child_process";
import { execFileSync, spawn, type ChildProcess } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
@@ -1066,6 +1066,16 @@ describe("relative typed-entry configuration", () => {
});
});
interface SearchProcessExit {
code: number | null;
signal: string | null;
}
interface SearchProcess {
child: ChildProcess;
exited: Promise<SearchProcessExit>;
}
// Reading a tree is cheap; re-deriving each entry's path state is not. A scan that asks whether
// every ancestor of every entry is inside the root, or is Git-ignored, costs multiples of the
// read and is what made large home directories exceed the client request timeout.
@@ -1073,6 +1083,7 @@ describe("home-tree scan cost", () => {
const DEPTH = 8;
const CHAINS = 40;
let scanRoot: string;
let searchProcess: SearchProcess | null = null;
beforeEach(() => {
scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-cost-")));
@@ -1083,8 +1094,16 @@ describe("home-tree scan cost", () => {
}
});
afterEach(() => {
rmSync(scanRoot, { recursive: true, force: true });
afterEach(async () => {
try {
if (searchProcess) {
searchProcess.child.kill();
await searchProcess.exited;
}
} finally {
searchProcess = null;
rmSync(scanRoot, { recursive: true, force: true });
}
});
// Nothing matches, so the scan walks the whole tree instead of stopping at a confident result.
@@ -1102,6 +1121,78 @@ describe("home-tree scan cost", () => {
return stopPathContainmentMetrics();
}
it("finishes overlapping broad-tree searches within a bounded heap", async () => {
const broadRoot = path.join(scanRoot, "broad");
mkdirSync(broadRoot);
for (let branch = 0; branch < 30; branch += 1) {
const branchRoot = path.join(broadRoot, `branch-${branch}`);
mkdirSync(branchRoot);
for (let group = 0; group < 30; group += 1) {
const groupRoot = path.join(branchRoot, `group-${group}`);
mkdirSync(groupRoot);
for (let leaf = 0; leaf < 30; leaf += 1) {
mkdirSync(path.join(groupRoot, `leaf-${leaf}`));
}
}
}
// A smaller real tree and explicit heap budget keep the million-directory repro cheap in CI.
const moduleUrl = new URL("./directory-suggestions.ts", import.meta.url).href;
const source = `
const { searchDirectoryEntries } = await import(${JSON.stringify(moduleUrl)});
const results = await Promise.all(Array.from({ length: 14 }, (_, index) =>
searchDirectoryEntries({
root: ${JSON.stringify(broadRoot)},
query: "z".repeat(index + 1),
pathFormat: "absolute",
includeFiles: false,
includeDirectories: true,
pathQueryPolicy: "rooted",
rootAliases: ["~"],
blankQueryBehavior: "none",
confidentResultScanThreshold: 5000,
maxEntriesScanned: 2000,
limit: 30,
})
));
process.stdout.write(JSON.stringify(results));
`;
const child = spawn(
process.execPath,
[
"--max-old-space-size=128",
"--conditions=source",
"--import",
"tsx",
"--input-type=module",
"--eval",
source,
],
{ stdio: ["ignore", "pipe", "pipe"], timeout: 20_000 },
);
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
const exited = new Promise<SearchProcessExit>((resolve, reject) => {
child.on("error", reject);
child.on("close", (code, signal) => resolve({ code, signal }));
});
searchProcess = { child, exited };
const exit = await exited;
expect({ ...exit, outOfMemory: stderr.includes("heap out of memory") }).toEqual({
code: 0,
signal: null,
outOfMemory: false,
});
expect(JSON.parse(stdout)).toEqual(Array.from({ length: 14 }, () => []));
});
it("derives no containment for a tree that cannot leave the root", async () => {
const checks = await countContainmentChecks(scanWholeTree);
@@ -63,6 +63,13 @@ interface WalkStart {
resolvedPath: string;
}
interface WalkChildrenOptions {
parent: WalkStart;
depth: number;
input: SearchInput;
visited: Set<string>;
}
interface TraversedEntry extends ChildEntry {
visiblePath: string;
depth: number;
@@ -318,17 +325,18 @@ async function searchChildren(input: SearchInput): Promise<RankedEntry[]> {
const discoveryInput = isInsideExcludedPath(parent, input)
? { ...input, excludedDiscoveryPaths: new Set<string>() }
: input;
const entries = await readChildren(parent);
return entries.flatMap((entry) => {
const ranked: RankedEntry[] = [];
for await (const entry of readChildren(parent)) {
if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, discoveryInput))
return [];
continue;
const candidate: TraversedEntry = {
...entry,
visiblePath: path.join(visibleParent, entry.name),
depth: 1,
};
return shouldSuggest(candidate, input) ? [rank(candidate, input)] : [];
});
if (shouldSuggest(candidate, input)) ranked.push(rank(candidate, input));
}
return ranked;
}
async function searchTree(
@@ -336,21 +344,8 @@ async function searchTree(
start: WalkStart = { visiblePath: input.root, resolvedPath: input.root },
): Promise<RankedEntry[]> {
if (!(input.maxEntriesScanned > 0)) return [];
const roots = (await readChildren(start.resolvedPath)).filter((entry) =>
staysInsideRoot(entry, input.root),
);
const visited = new Set<string>([start.resolvedPath]);
const branches = roots.flatMap((entry) =>
shouldDiscover(entry, input)
? [
walkBranch(
{ ...entry, visiblePath: path.join(start.visiblePath, entry.name), depth: 1 },
input,
visited,
),
]
: [],
);
const branches = walkChildren({ parent: start, depth: 1, input, visited });
const ranked: RankedEntry[] = [];
let scanned = 0;
const threshold = input.confidentResultScanThreshold;
@@ -381,38 +376,39 @@ async function* walkBranch(
)
return;
visited.add(entry.resolvedPath);
const children = (await readChildren(entry.resolvedPath)).filter((child) =>
staysInsideRoot(child, input.root),
);
const branches = children.flatMap((child) =>
shouldDiscover(child, input)
? [
walkBranch(
{
...child,
visiblePath: path.join(entry.visiblePath, child.name),
depth: entry.depth + 1,
},
input,
visited,
),
]
: [],
);
yield* roundRobin(branches);
yield* roundRobin(walkChildren({ parent: entry, depth: entry.depth + 1, input, visited }));
}
async function* roundRobin<T>(branches: Array<AsyncGenerator<T>>): AsyncGenerator<T> {
let active = branches;
while (active.length) {
// Create a child's path and iterator only when its branch gets a turn. Eagerly constructing every
// sibling's subtree retained hundreds of thousands of unvisited children beyond the scan budget.
async function* walkChildren({
parent,
depth,
input,
visited,
}: WalkChildrenOptions): AsyncGenerator<AsyncGenerator<TraversedEntry>> {
for await (const child of readChildren(parent.resolvedPath)) {
if (!staysInsideRoot(child, input.root) || !shouldDiscover(child, input)) continue;
yield walkBranch(
{ ...child, visiblePath: path.join(parent.visiblePath, child.name), depth },
input,
visited,
);
}
}
async function* roundRobin<T>(branches: AsyncIterable<AsyncGenerator<T>>): AsyncGenerator<T> {
let active: AsyncIterable<AsyncGenerator<T>> | Array<AsyncGenerator<T>> = branches;
while (true) {
const nextRound: Array<AsyncGenerator<T>> = [];
for (const branch of active) {
for await (const branch of active) {
const next = await branch.next();
if (!next.done) {
nextRound.push(branch);
yield next.value;
}
}
if (!nextRound.length) return;
active = nextRound;
}
}
@@ -742,9 +738,9 @@ async function resolveDirectory(inputPath: string): Promise<string | null> {
return info?.isDirectory() ? resolved : null;
}
async function readChildren(directory: string): Promise<ChildEntry[]> {
async function* readChildren(directory: string): AsyncGenerator<ChildEntry> {
const directoryInfo = await stat(directory).catch(() => null);
if (!directoryInfo?.isDirectory()) return [];
if (!directoryInfo?.isDirectory()) return;
const cached = CAN_VALIDATE_DIRECTORY_CACHE_FROM_METADATA
? directoryListCache.get(directory)
@@ -774,9 +770,10 @@ async function readChildren(directory: string): Promise<ChildEntry[]> {
}
}
return (await Promise.all(rawEntries.map((entry) => resolveChild(directory, entry))))
.filter((entry): entry is ChildEntry => entry !== null)
.sort((left, right) => left.name.localeCompare(right.name));
for (const rawEntry of rawEntries) {
const entry = await resolveChild(directory, rawEntry);
if (entry) yield entry;
}
}
async function loadGitIgnoredPaths(root: string): Promise<Set<string>> {
+7
View File
@@ -1318,6 +1318,13 @@ the source row, so updates to one streaming item do not remount its renderer. Us
`useRevealedText(text, phase)` hook when a renderer should pace streaming text like Paseo's built-in
assistant rows.
Chat find (Ctrl or Cmd+F) finds and highlights text in replacement rows when the query also
matches the original message. The host searches the original prompt text or the assistant reply
with Markdown syntax removed, then the app counts matches in the text your renderer draws.
Text added only by your renderer is not independently indexed. Matches stay within a rendered
block, and find skips buttons, icons, and anything with `aria-hidden="true"`. Find only runs on
the web and desktop apps.
### Append a timeline row from the daemon
A server handler can add a plugin-owned row to canonical history: