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
9 changed files with 886 additions and 210 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' }}
-43
View File
@@ -1246,49 +1246,6 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => {
messageId?: string;
}
it("keeps reasoning separate from a reply sharing its native message ID", async () => {
const harness = connectorHarness({
promptUpdates: () => [
textChunk("private reasoning", "agent_thought_chunk", "shared-id"),
textChunk("public reply", "agent_message_chunk", "shared-id"),
],
});
const registration = runAcpProvider({
id: "chunk-acp",
label: "Chunk ACP",
connector: harness.connector,
});
const connection = await registration.connect({
versions: [1],
capabilities: ["prompt.message"],
});
const events: ProviderEvent[] = [];
connection.onEvent((event) => events.push(event));
try {
await connection.send(openInput());
await waitForEvent(events, (event) => event.type === "session.ready");
await connection.send({
type: "session.prompt",
sessionId: "session-1",
prompt: {
clientMessageId: "same-id",
delivery: "auto",
input: { type: "message", content: [{ type: "text", text: "hello" }] },
},
});
await waitForEvent(
events,
(event) => event.type === "session.turn" && event.state === "completed",
);
const reply = events.findLast(
(event) => event.type === "timeline.item" && event.item.type === "assistant_message",
);
expect(reply).toMatchObject({ item: { text: "public reply" } });
} finally {
await connection.close();
}
});
it("continues one timeline item per turn instead of one per chunk", async () => {
const chunksByPrompt: Record<string, Chunk[]> = {
first: [
@@ -287,45 +287,6 @@ function expectNestedChildren(events: AgentStreamEvent[]) {
}
describe("PluginAgentClientRegistry", () => {
test("returns unavailable when steering a provider without prompt.steer capability", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: "/tmp",
});
try {
await expect(
session.steerActiveTurn!("follow up", { expectedTurnId: "turn-1" }),
).resolves.toEqual({ status: "unavailable" });
expect(harness.inputs.filter((input) => input.type === "session.prompt")).toHaveLength(0);
} finally {
await session.close();
registry.replace([]);
}
});
test("resumes a native ACP handle after the same provider ID moves to a plugin", async () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
try {
const session = await registry.clients()[harness.registration.id]!.resumeSession(
{
provider: harness.registration.id,
sessionId: "native-session-id",
nativeHandle: "native-session-id",
metadata: { provider: "acp", cwd: "/tmp" },
},
{ provider: harness.registration.id, cwd: "/tmp" },
);
await session.close();
} finally {
registry.replace([]);
}
});
test("stores only agent options while the plugin receives merged defaults", async () => {
const logger = createTestLogger();
const harness = createProviderHarness();
@@ -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);