Compare commits

...

22 Commits

Author SHA1 Message Date
Mohamed Boudra
8bddfd1629 feat(providers): expose agent cwd to provider processes 2026-07-28 22:17:26 +02:00
Mohamed Boudra
bbf3d0f9cc Keep plan approval focused on the latest proposal (#2534)
* fix(server): replace stale plan approvals

Synthetic plan approvals accumulated across planning turns because each proposal used a new permission ID. Dismiss the current proposal only after a follow-up prompt is accepted, and enforce one pending proposal when a newer plan arrives.

* fix(server): close plan approval submission race

Deactivate the existing proposal before starting a revision so it cannot be implemented concurrently. Restore it when submission fails, while preserving any newer approval emitted during the request.

* fix(server): deactivate plans before prompt setup

Start the plan-dismissal transaction before any asynchronous prompt setup so a stale proposal cannot be implemented from another client while the revision is preparing.

* fix(server): make prompt dismissal final

Treat prompt submission as the dismissal event instead of compensating after failures. This avoids resurrecting stale plans across ambiguous provider outcomes and manager failure cleanup.
2026-07-28 17:10:32 +02:00
Mohamed Boudra
963d4f9240 Configure agent thinking from the CLI (#2533)
* feat(cli): update agent thinking from the CLI

* fix(cli): harden agent thinking updates

* feat(cli): configure thinking for schedules

* fix(cli): report applied thinking updates

* fix(cli): report current agent thinking state
2026-07-28 22:30:50 +08:00
nllptrx
e241e02afb feat(app): dismiss the chat keyboard on a fast upward flick (#2417)
* feat(app): dismiss the chat keyboard on a fast upward flick

Scrolling the history to read earlier messages left the keyboard up, so the
visible transcript stayed cramped and the keyboard had to be closed by hand
first — two steps for what should be one.

React Native's keyboardDismissMode cannot express the wanted behaviour:
"on-drag" fires on the first pixel and kills the keyboard on any peek-scroll,
and "interactive" is broken on inverted lists. So the gesture is measured
here: samples are taken from the scroll events and, at release, the speed over
the drag's final stretch decides. Measuring at release rather than averaging
the whole drag is what keeps a fast but controlled read-scroll from counting
as a flick, since such a gesture decelerates before the finger lifts.

Timestamps and offsets come from the events, never from a clock: with a busy
JS thread the callbacks arrive in a burst long after the gesture, and
wall-clock spacing then reads a calm scroll as a flick. The release offset
comes from the end-drag event for the same reason — the last onScroll can be
stale by the time a short flick lands.

Android needs both the blur and the dismiss. Dismissing alone leaves the
input focused and the keyboard inset applied, so the layout stays shifted
with an empty gap where the keyboard was; blurring alone releases focus but
leaves the IME on screen.

Verified on an Android device with a Release build: a slow drag and a
0.6 dp/ms scroll keep the keyboard, a 2.9 dp/ms flick dismisses it and the
composer settles back with no leftover gap. iOS was exercised by hand on
device only, without automated coverage of the gesture itself.

* refactor(app): isolate keyboard flick dismissal

* fix(app): isolate keyboard shift context

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-28 22:29:33 +08:00
Mohamed Boudra
fdee3236f7 Speed up server CI tests (#2537)
* perf(ci): reduce server test latency

Server test files use isolated resources, so they do not require suite-wide serialization.

* fix(ci): scope server test parallelism to unit suite

Keep real-provider and local-resource suites serialized because they may share user configuration and account limits.

* fix(terminal): isolate zsh runtimes by process

Prevent concurrent daemon and test processes from deleting or replacing shell integration files used by another process.

* fix(ci): preserve required matrix check names

GitHub evaluates job conditions before expanding a matrix. Expand active matrices first and gate their expensive steps so required check contexts are always reported. Allow superseded runs to cancel while retaining fail-open path detection.
2026-07-28 21:51:12 +08:00
stonegray
fd7061a8b2 Fix AppImage launches from Linux desktops (#2439) 2026-07-28 13:59:26 +02:00
Mohamed Boudra
76e336a1be Run only relevant CI checks for each pull request (#2500)
* perf(ci): skip unaffected test jobs

Keep required checks present as skipped jobs and run the full matrix whenever change detection cannot produce a trustworthy result.

* fix(ci): harden change-based job gating

Include shared build inputs in packaged desktop smoke selection and pin the path filter action that controls job execution.

* fix(ci): run CLI checks for Nix packaging changes

The CLI supervision regression suite reads the Nix package definition directly, so include that external dependency in its path filter.

* fix(ci): track external test inputs

Select server and CLI suites when their narrowly scoped cross-package fixtures and source assertions change.

* fix(ci): track server test CLI imports

Run server tests for CLI source changes because the Hub relationship harness executes the CLI command graph directly.

* fix(ci): pin gating checkout action

Keep every third-party action that controls change detection pinned to a verified commit SHA.
2026-07-28 19:18:56 +08:00
Jason@HND
f0d7eeb98c fix(quota): restore Grok Settings usage for current CLI auth/billing (#2353)
* fix(quota): restore Grok Settings usage for current CLI auth/billing

Grok CLI no longer stores a top-level access_token or usage.creditUsage.
Read nested auth key tokens and config.used.val so Settings → Usage
shows monthly credits again. Keep legacy shapes and env tokens working.

Fixes #2352

* fix(quota): make Grok auth-file tests work on Windows

Inject homeDir into GrokQuotaProvider like Kimi so nested
~/.grok/auth.json tests do not depend on os.homedir() which
ignores $HOME on Windows (USERPROFILE).
2026-07-28 18:49:21 +08:00
Mohamed Boudra
f91a984348 fix(claude): show a single 1M-context Opus 5 model (#2497) 2026-07-28 18:18:21 +08:00
Michael Wu
cbbf6c1684 Carry local files and shared state into new worktrees (#2419)
* feat(server): support symlink worktree includes

* refactor(server): simplify worktree include handling

* fix(server): constrain worktree include traversal

* fix(server): clean up failed worktree branches

* fix(server): skip missing worktree include entries

* fix(server): make worktree includes best effort

* fix(server): skip failed worktree includes

* fix(server): harden worktree include planning

* fix(server): preserve reused worktree result shape

Materialization reports describe one creation attempt, not durable worktree identity. Carry them beside the worktree so newly created and reused results keep the same stable shape.

* fix(server): harden worktree include boundaries

Replace directory snapshots exactly, keep canonical Git metadata protected, report recovery skips, and only roll back branches owned before worktree creation.

* fix(server): follow safe include aliases

Traverse canonical in-checkout directory links during glob planning and treat coded revalidation failures as per-entry skips.

* fix(server): make include preflight race safe

Create fetched checkout refs atomically, retain overlapping copy entries for independent fallback, and protect the full managed-worktree base.

* fix(server): preserve staged recovery state

Validate completed directory snapshots, retain backups after failed restoration, and derive atomic ref guards from the repository object ID width.

* fix(server): preserve partial include progress

Keep safe glob matches, enforce recursive-directory types, validate staged roots, and retain OID guards through rollback.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-28 03:18:40 +02:00
Kamil
89c2fac3c6 Open project and workspace folders from the sidebar (#2491)
* feat(app): open project folder from project context menu (#2487)

* refactor(app): give file manager action a home

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-28 00:48:29 +02:00
Saravjeet 'Aman' Singh
f8dd0fc2e0 Switch projects from New Workspace with ⌘P/Ctrl+P (#2110)
* feat(app): add ⌘P shortcut to switch project on New Workspace screen

Opens the existing project picker with its search focused so the project
can be switched from the keyboard (type + Enter) instead of clicking the
badge and then the project.

Wires a new "workspace.project.pick" action through the standard keyboard
pipeline (binding -> route passthrough -> dispatcher), handled by a
screen-scoped handler on the New Workspace screen that is only registered
while the screen is mounted and there are projects to pick. Because
preventDefault only fires when a handler handles the key, ⌘P/Ctrl+P still
triggers native print everywhere else. Adds a Settings -> Shortcuts help
row (rebindable) and the "Switch project" label across all locales.

* test(app): cover project picker shortcut

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-28 00:32:41 +02:00
Dmitry Sinev
b55acfc60f fix(omp): accept nullable model context windows (#2406) 2026-07-28 00:28:30 +02:00
黄黄汪
a6fdcb469c docs: list paseo-skins as a community project (#2343) 2026-07-28 00:08:25 +02:00
Derek Perez
5bd317205c fix(omp): expose injected Paseo tools directly (#2418) 2026-07-27 23:44:25 +02:00
Matt Cowger
e59c94812b perf(build): parallelize server dependencies (#2434) 2026-07-28 04:12:39 +08:00
维她命@
1c8fabd293 fix(server): discover Codex project skills from cwd (#2423) 2026-07-27 22:12:22 +02:00
Matt Cowger
717f195f0c fix(app): render HTML in PR comments (#2432) 2026-07-28 04:01:43 +08:00
Li Mu Zhi
869edcbf11 fix(forge): preserve non-default port in forge web URLs (#2478)
"Open in browser" links for a self-hosted forge served on a non-standard
port (e.g. Forgejo/Gitea on :60443) dropped the port, producing
https://host/owner/repo/... instead of https://host:60443/owner/repo/...,
which 404s or hits the wrong service.

parseGitRemoteLocation discarded parsed.port (GitRemoteLocation had no
port field), and buildForgeBranchTreeUrl / buildForgeBlobUrl rebuilt the
origin from the portless host. Preserve the port on GitRemoteLocation and
reattach it in the web-URL builders, only for self-hosted http(s) origins
(an SSH port isn't the web port; a canonicalized cloud host uses the
default port). Host-identity matching (forge detection, cloud-host checks)
stays port-agnostic.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 04:00:46 +08:00
Aditya Borakati
c596e058cd fix(dev): make worktree setup run on Windows (#2431)
`worktree.setup` ran two POSIX-only command strings, but lifecycle commands
go through PowerShell on Windows, so worktree creation failed at:

    PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_SEED_HOME=... ./scripts/dev-home.sh
    -> PASEO_DEV_MANAGED_HOME=1 : The term ... is not recognized

PowerShell has no `VAR=value cmd` prefix syntax. The `cp` entry was broken the
same way: `$PASEO_SOURCE_CHECKOUT_PATH` is an undefined *PowerShell* variable,
not an env var, so it expanded to empty and the copy resolved to
`/packages/server/.env`.

Neither entry can be expressed portably in a single shell string, and `bash` is
not guaranteed on Windows, so move both steps into a Node script that reads its
inputs from `process.env` — matching the existing
`node ./scripts/seed-ios-native-cache.mjs` entry. One code path, no platform
branching.

`scripts/dev-home.sh` is unchanged and still sourced by the bash service
scripts; only the setup-time seeding is ported.

One behavior change: a missing `packages/server/.env` in the source checkout is
now skipped with a log instead of aborting setup. It is untracked local config,
and the old `cp` hard-failed worktree creation for anyone without one.

Co-authored-by: ABorakati <ABorakati@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 03:52:26 +08:00
Mohamed Boudra
fa1198c2be Stop completed turns from appearing stuck (#2484)
* fix(app): stop completed turns from appearing stuck

Turn activity was inferred from a user-message row flag, so a stale duplicate row could keep the working footer active after the turn ended. Track submission lifecycle separately and route every send path through the shared submission flow.

* fix(app): guard pending message identity

* test(app): measure submission layout independent of scroll

* fix(app): keep submitted messages consistent through reconnects

Track each in-flight send until its own RPC establishes acceptance, and keep canonical timeline placement authoritative. Restore legacy cached rewind IDs during cache deserialization.

* fix(app): close canonical submission races

* test(app): cover canonical submission races in browser

* fix(app): keep replacement submissions authoritative

Keep current pending rows out of legacy cache migration and leave ambiguous terminal lifecycle events to the daemon snapshot. This prevents fabricated rewind identities and stale completion events from marking replacement turns idle.

* fix(app): keep submitted messages stable across sync

Give submission transport and canonical timeline ingestion separate authority. Preserve every unresolved local send across replacement, reconcile provider identity once, and bridge RPC acceptance to authoritative running state without deriving lifecycle from timeline rows.

* fix(app): settle submissions in either acknowledgement order

Complete submission transactions when RPC and provider acknowledgement arrive in either order. Cache only transaction-owned local rows as transient data, preserve canonical ID-less prompts, and invalidate ambiguous legacy display caches instead of inventing provider identity.

* fix(app): keep agent visible during history handoff

Running and terminal updates could clear create continuity before the initial authoritative timeline arrived, leaving a streaming agent behind the loading screen. End the handoff only when authoritative history is applied.

* fix(app): settle attachment-only submissions

Canonical providers can acknowledge image-only prompts with empty text. Reconcile those events by client identity without rendering a blank canonical row.

* fix(agent): settle out-of-band message submissions

Accepted commands that do not allocate a foreground turn previously had no canonical user acknowledgement. Record the command before its handler runs so submission state and reconnect history converge through the normal timeline producer.

* fix(app): settle out-of-band submissions compatibly

* fix(app): preserve canonical prompt order

* test(app): keep workspace status check timing-independent

The workspace-status scenario asserted footer settlement before initial agent creation had necessarily completed. The dedicated draft-handoff coverage owns that lifecycle contract.
2026-07-27 21:27:10 +02:00
paseo-ai[bot]
1f253d92e2 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-27 18:00:50 +00:00
126 changed files with 8245 additions and 1420 deletions

View File

@@ -18,6 +18,106 @@ env:
ONNXRUNTIME_NODE_INSTALL: skip
jobs:
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
quality: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.quality != 'false' }}
server: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.server != 'false' }}
desktop: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop != 'false' }}
desktop_package: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.desktop_package != 'false' }}
app: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.app != 'false' }}
sdk: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.sdk != 'false' }}
playwright: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.playwright != 'false' }}
relay: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.relay != 'false' }}
cli: ${{ steps.filter.outputs.shared != 'false' || steps.filter.outputs.cli != 'false' }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 0
- name: Detect affected CI jobs
id: filter
uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3.0.3
with:
filters: |
shared:
- '.github/workflows/ci.yml'
- '.github/actions/**'
- '.mise.toml'
- '.tool-versions'
- 'package.json'
- 'package-lock.json'
- 'patches/**'
- 'scripts/**'
- 'tsconfig.json'
- 'tsconfig.base.json'
- 'vitest.config.ts'
quality:
- 'packages/**'
- '*.cjs'
- '*.js'
- '*.json'
- '*.mjs'
- '*.ts'
server:
- 'packages/app/e2e/fixtures/recording.*'
- 'packages/client/**'
- 'packages/cli/src/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
desktop:
- 'packages/app/**'
- 'packages/cli/**'
- 'packages/client/**'
- 'packages/desktop/**'
- 'packages/expo-two-way-audio/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
desktop_package:
- '.github/workflows/ci.yml'
- 'packages/desktop/**'
app:
- 'packages/app/**'
- 'packages/client/**'
- 'packages/expo-two-way-audio/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
sdk:
- 'packages/client/**'
- 'packages/protocol/**'
- 'packages/relay/**'
playwright:
- 'packages/app/**'
- 'packages/client/**'
- 'packages/expo-two-way-audio/**'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
relay:
- 'packages/relay/**'
cli:
- 'nix/**'
- 'packages/app/e2e/global-setup.ts'
- 'packages/cli/**'
- 'packages/client/**'
- 'packages/desktop/src/daemon/runtime-paths.ts'
- 'packages/highlight/**'
- 'packages/protocol/**'
- 'packages/relay/**'
- 'packages/server/**'
- name: Validate CI workflow
run: node --test scripts/ci-workflow.test.mjs
format:
runs-on: ubuntu-latest
env:
@@ -39,6 +139,12 @@ jobs:
run: npx oxfmt --check .
lint:
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -63,6 +169,12 @@ jobs:
run: npm run lint
typecheck:
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.quality != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -89,6 +201,8 @@ jobs:
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
server-tests:
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -97,28 +211,43 @@ jobs:
name: server-tests (${{ matrix.os }})
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.server != 'false' }}
steps:
- name: Skip unaffected server tests
if: env.RUN_TESTS != 'true'
run: echo "No server changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
with:
fetch-depth: 0
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Fetch origin/main (worktree tests)
if: env.RUN_TESTS == 'true'
run: git fetch --no-tags origin main:refs/remotes/origin/main
- name: Install dependencies
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code opencode-ai
- name: Build server dependencies
if: env.RUN_TESTS == 'true'
run: npm run build:server-deps
- name: Run server tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/server
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -126,6 +255,8 @@ jobs:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
desktop-tests:
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -134,49 +265,53 @@ jobs:
timeout-minutes: 30
permissions:
contents: read
pull-requests: read
env:
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop != 'false' }}
steps:
- uses: actions/checkout@v4
- name: Skip unaffected desktop tests
if: env.RUN_TESTS != 'true'
run: echo "No desktop changes detected."
- name: Detect desktop changes
if: matrix.os == 'ubuntu-latest'
id: desktop_changes
uses: dorny/paths-filter@v3
with:
filters: |
desktop:
- 'packages/desktop/**'
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Run desktop tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/desktop
- name: Build app dependencies for desktop E2E
if: matrix.os == 'ubuntu-latest'
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
run: npm run build:app-deps
- name: Install virtual display
if: matrix.os == 'ubuntu-latest'
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
- name: Run real Electron browser tab bridge E2E
if: matrix.os == 'ubuntu-latest'
if: env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest'
run: npm run test:e2e:browser-tab-bridge --workspace=@getpaseo/desktop
env:
PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR: ${{ runner.temp }}/browser-tab-bridge-e2e
- name: Upload browser tab bridge diagnostics
uses: actions/upload-artifact@v4
if: failure() && matrix.os == 'ubuntu-latest'
if: env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest'
with:
name: browser-tab-bridge-e2e
path: ${{ runner.temp }}/browser-tab-bridge-e2e
@@ -184,7 +319,11 @@ jobs:
retention-days: 7
- name: Build and smoke unpacked desktop app
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
if: >-
env.RUN_TESTS == 'true' && matrix.os == 'ubuntu-latest' &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop_package != 'false')
run: npm run build:desktop -- --publish never --linux --x64 --dir
env:
EP_GH_IGNORE_TIME: true
@@ -192,7 +331,11 @@ jobs:
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
- name: Upload packaged smoke diagnostics
if: failure() && matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
if: >-
env.RUN_TESTS == 'true' && failure() && matrix.os == 'ubuntu-latest' &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.desktop_package != 'false')
uses: actions/upload-artifact@v4
with:
name: desktop-packaged-smoke-linux-x64
@@ -201,6 +344,12 @@ jobs:
retention-days: 7
app-tests:
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.app != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -225,6 +374,12 @@ jobs:
run: npm run test --workspace=@getpaseo/app
sdk-tests:
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.sdk != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -251,6 +406,8 @@ jobs:
run: npm run typecheck:examples --workspace=@getpaseo/client
playwright:
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -264,43 +421,57 @@ jobs:
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.playwright != 'false' }}
steps:
- name: Skip unaffected Playwright tests
if: env.RUN_TESTS != 'true'
run: echo "No Playwright changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies with retry
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Install Playwright browsers
if: env.RUN_TESTS == 'true'
timeout-minutes: 10
run: npx playwright install chromium
- name: Build app dependencies
if: env.RUN_TESTS == 'true'
run: npm run build:app-deps
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Install agent CLIs for provider tests
if: ${{ !matrix.desktop }}
if: env.RUN_TESTS == 'true' && !matrix.desktop
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run Playwright E2E tests
if: ${{ !matrix.desktop }}
if: env.RUN_TESTS == 'true' && !matrix.desktop
run: npm run test:e2e --workspace=@getpaseo/app -- --shard=${{ matrix.shard }}/4
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Run desktop-overlay Playwright tests
if: ${{ matrix.desktop }}
if: env.RUN_TESTS == 'true' && matrix.desktop
run: npm run test:e2e:desktop --workspace=@getpaseo/app
- name: Upload test artifacts
uses: actions/upload-artifact@v4
if: failure()
if: env.RUN_TESTS == 'true' && failure()
with:
name: playwright-results-${{ matrix.shard }}
path: |
@@ -309,6 +480,12 @@ jobs:
retention-days: 7
relay-tests:
needs: changes
if: >-
${{ !cancelled() &&
(github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.relay != 'false') }}
runs-on: ubuntu-latest
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
@@ -330,6 +507,8 @@ jobs:
run: npm run test --workspace=@getpaseo/relay
cli-tests:
needs: changes
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
@@ -338,24 +517,38 @@ jobs:
name: cli-tests (shard ${{ matrix.shard }}/3)
env:
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
RUN_TESTS: >-
${{ github.event_name == 'workflow_dispatch' ||
needs.changes.result != 'success' ||
needs.changes.outputs.cli != 'false' }}
steps:
- name: Skip unaffected CLI tests
if: env.RUN_TESTS != 'true'
run: echo "No CLI changes detected."
- uses: actions/checkout@v4
if: env.RUN_TESTS == 'true'
- uses: actions/setup-node@v4
if: env.RUN_TESTS == 'true'
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs ci
- name: Build server stack
if: env.RUN_TESTS == 'true'
run: npm run build:server
- name: Install agent CLIs for provider tests
if: env.RUN_TESTS == 'true'
run: node scripts/npm-retry.mjs install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
- name: Run CLI tests
if: env.RUN_TESTS == 'true'
run: npm run test --workspace=@getpaseo/cli
env:
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"

View File

@@ -154,6 +154,7 @@ npm run typecheck
## 関連プロジェクト
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — Elixir 製の公式分散リレー
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo デスクトップ向けコミュニティテーマと、Agent Skill 対応のゼロパッチテーマローダー
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 拡張機能
## ライセンス

View File

@@ -163,6 +163,7 @@ npm run typecheck
## Related projects
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — official distributed relay, written in Elixir
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — community themes and a zero-patch desktop theme loader with an Agent Skill
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code extension
## License

View File

@@ -154,6 +154,7 @@ npm run typecheck
## 相关项目
- [getpaseo/paseo-relay](https://github.com/getpaseo/paseo-relay) — 官方分布式 relay使用 Elixir 编写
- [paseo-skins](https://github.com/huangguang1999/paseo-skins) — Paseo 桌面端社区主题与零 patch 换肤工具,支持 Agent Skill
- [paseo-vscode](https://marketplace.visualstudio.com/items?itemName=hinnes.paseo-vscode) — VS Code 扩展
### 自托管 relay TLS

View File

@@ -50,6 +50,12 @@ The daemon also supports an optional shared-secret password (set via `auth.passw
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
An explicit `symlink <path>` entry in a repository's .worktreeinclude intentionally gives a
Paseo-created worktree live access to that source-checkout file or directory. It is useful for
local dependencies and caches, but it weakens the usual worktree isolation: agents and lifecycle
scripts can modify the source through the link. Paseo validates entries and refuses traversal or
destination-link escapes, but the linked source is a deliberate shared-data boundary.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
In Docker, the official image runs the daemon and agents as the non-root

View File

@@ -29,6 +29,7 @@ Root checkout dev is intentionally split across terminals:
- **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app.
- **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint.
- **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied.
- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy <path>` copy a snapshot into the new worktree; `symlink <path>` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the active source checkout. If that checkout is itself Paseo-managed, its own paths remain eligible while other managed worktree paths stay protected; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs.
- **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so.
Override knobs:
@@ -242,6 +243,14 @@ commands use the same non-login Bash behavior on macOS/Linux, but preserve their
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
they launch in a terminal and receive the service environment described below.
Because the shell differs per platform, a lifecycle command that must run
everywhere cannot use POSIX-only syntax — `VAR=1 cmd` env prefixes, `$VAR`
expansion, `cp`/`rm`, or a `./scripts/*.sh` entrypoint all fail under PowerShell,
and `bash` is not guaranteed to exist on Windows. Put that logic in a Node script
that reads what it needs from `process.env` and invoke it as
`node ./scripts/<name>.mjs`. This repo's own setup does exactly that in
`scripts/seed-worktree-dev-state.mjs` and `scripts/seed-ios-native-cache.mjs`.
```json
{
"worktree": {

View File

@@ -34,7 +34,7 @@ Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
OMP supports native Paseo host tools. The adapter registers the caller-scoped Paseo tool catalog directly with OMP, so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools do not need the internal MCP fallback. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
OMP supports native Paseo host tools. The adapter registers the full caller-scoped Paseo tool catalog directly with OMP, matching providers such as Claude that expose the full catalog through MCP. Serialize every OMP host definition with `loadMode: "essential"` so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools remain direct calls; omitting the field makes OMP mount non-built-in names under `xd://` instead. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.

View File

@@ -87,14 +87,42 @@ its completion advances `seqEnd`, followed by a merged assistant message. The ap
remaining page through the existing stream reducer. It must not append full projected text to a
live prefix.
Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts
them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly
where the user submitted it. Other canonical rows are applied after the already-present timeline
instead of relocating visible user messages around newly fetched history.
Every path that sends a message to an agent — composer send, dictation accept-and-send, queued
send-now, and the automatic queue drain in `HostRuntime` — goes through
`dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same
product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending
footer, and permanently drops attachments because the daemon does not echo them back.
A submitted prompt is one `UserMessageItem` row. That row is the authoritative local presentation:
its stable identity, text, timestamp, images, and attachments do not change when the provider
acknowledges it. Submission lifecycle is a separate record keyed by agent, not another row shape or
a property inferred from message identity. The transaction registry holds every unresolved send and
records RPC acceptance and provider acknowledgement independently. Provider acknowledgement exists
solely so a later transport error cannot roll back a prompt already observed canonically.
The daemon's accepted response already waits for the correlated run start, but its response and the
directory update reach client state separately. An accepted transaction remains active until the
directory observes that run or canonical ingestion acknowledges the prompt, bridging those ordered
authorities without inspecting timeline snapshots. Either signal clears only an RPC-accepted
transaction, regardless of which arrived first; it cannot settle a fresh send.
Overlapping sends settle independently rather than collapsing to one newest pending message.
Canonical submitted user rows carry the provider's `messageId` and Paseo's optional
`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is
limited to the dated compatibility path for daemon timelines created before that field existed.
`clientMessageId`. The user-message producer reconciles them by `clientMessageId`, adds provider
identity to the existing row, and keeps the local presentation in its original timeline slot.
Content matching is limited to the dated compatibility path for daemon timelines created before
that field existed. Canonical ingestion may match only an explicit unreconciled local candidate;
the draft-create handoff is the one boundary that also permits the legacy canonical twin to have
arrived first. Generic reducers and consumers do not reimplement message identity matching.
Ordinary bootstrap, same-epoch reset, and catch-up replacement preserve unmatched locally submitted
rows because a provider may never echo them. A known epoch change or rewind replaces history and
drops acknowledged local rows omitted by the new canonical epoch; every transaction not yet
acknowledged by the provider, and no other local row, crosses that destructive boundary.
Canonical replacement owns both timeline lanes. A matching local row keeps its presentation ID and
payload while taking the canonical row's ordered position. If a live assistant head is the
canonical assistant prefix, it stays in the head lane. No row may be returned in both lanes.
## Relevant code

View File

@@ -1 +1 @@
sha256-DaVBk1PxsNr1AQu/mTG1Inp67pLLDmc+Ed0IZ5GyzgY=
sha256-n7k3zQ1NOm7dGmpqKE6RaEkl50/M2eFek6XIQJbYCEc=

View File

@@ -49,9 +49,9 @@
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
"build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol",
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
"build:client": "npm run build --workspace=@getpaseo/client",
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
"build:server-deps": "concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",

View File

@@ -0,0 +1,685 @@
import type { Locator, Page } from "@playwright/test";
import { expect, test as baseTest } from "./fixtures";
import { awaitToolCall, expectAgentIdle } from "./helpers/agent-stream";
import { gateNextAgentMessage } from "./helpers/agent-message-gate";
import {
attachImageFromMenu,
expectComposerDraft,
expectComposerEditable,
expectAttachmentPill,
expectComposerVisible,
fillComposerDraft,
sendDraftToQueue,
startRunningMockAgent,
} from "./helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { readScrollMetrics } from "./helpers/agent-bottom-anchor";
import { seedWorkspace } from "./helpers/seed-client";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { getServerId } from "./helpers/server-id";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import { selectModel } from "./helpers/app";
const IMAGE = {
name: "message-submission.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
),
};
interface MessageGeometry {
x: number;
y: number;
width: number;
height: number;
}
interface SubmissionScenario {
gate: Awaited<ReturnType<typeof gateNextAgentMessage>>;
}
interface DraftCreateScenario {
workspaceId: string;
agentCreatedDelay: Awaited<ReturnType<typeof delayBrowserAgentCreatedStatus>>;
}
interface RejectionScenario {
errorMessage: string;
}
interface UnrelatedRunningScenario {
gate: Awaited<ReturnType<typeof gateNextAgentMessage>>;
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>;
}
const test = baseTest.extend<{
submissionScenario: SubmissionScenario;
draftCreateScenario: DraftCreateScenario;
rejectionScenario: RejectionScenario;
unrelatedRunningScenario: UnrelatedRunningScenario;
}>({
submissionScenario: async ({ page }, provide, testInfo) => {
const gate = await gateNextAgentMessage(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `message-submission-${testInfo.workerIndex}-`,
title: "Message submission regression",
model: "ten-second-stream",
});
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
await provide({ gate });
await agent.cleanup();
},
draftCreateScenario: async ({ page }, provide, testInfo) => {
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
const workspace = await seedWorkspace({
repoPrefix: `message-create-handoff-${testInfo.workerIndex}-`,
});
await provide({ workspaceId: workspace.workspaceId, agentCreatedDelay });
agentCreatedDelay.release();
await workspace.cleanup();
},
rejectionScenario: async ({ page }, provide, testInfo) => {
const errorMessage = "Requested mock prompt rejection";
const agent = await seedMockAgentWorkspace({
repoPrefix: `message-rejection-${testInfo.workerIndex}-`,
title: "Message rejection regression",
model: "ten-second-stream",
featureValues: { mockPromptRejections: 1 },
});
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
await provide({ errorMessage });
await agent.cleanup();
},
unrelatedRunningScenario: async ({ page }, provide, testInfo) => {
const gate = await gateNextAgentMessage(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `unrelated-running-${testInfo.workerIndex}-`,
title: "Unrelated running transition",
model: "one-minute-stream",
});
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
await provide({ gate, agent });
await agent.cleanup();
},
});
async function submitMessageWithImage(page: Page, prompt: string): Promise<Locator> {
await attachImageFromMenu(page, IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await composer.press("Enter");
const nextFrame = await composer.evaluate(
(composerElement, submittedPrompt) =>
new Promise<{
rowPresent: boolean;
workingPresent: boolean;
composerValue: string | null;
attachmentPresent: boolean;
}>((resolve) => {
requestAnimationFrame(() => {
const rows = Array.from(document.querySelectorAll('[data-testid="user-message"]'));
const composerInput = composerElement as HTMLInputElement | HTMLTextAreaElement;
resolve({
rowPresent: rows.some((row) => row.textContent?.includes(submittedPrompt)),
workingPresent: Boolean(
document.querySelector('[data-testid="turn-working-indicator"]'),
),
composerValue: composerInput.value,
attachmentPresent: Boolean(
document.querySelector('[data-testid="composer-image-attachment-pill"]'),
),
});
});
}),
prompt,
);
expect(nextFrame).toEqual({
rowPresent: true,
workingPresent: true,
composerValue: "",
attachmentPresent: false,
});
return page.getByTestId("user-message").filter({ hasText: prompt }).last();
}
async function submitImageOnlyMessage(page: Page): Promise<Locator> {
await attachImageFromMenu(page, IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
const userMessage = page.getByTestId("user-message").last();
await expect(userMessage).toBeVisible();
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
return userMessage;
}
async function expectPendingSubmission(page: Page, userMessage: Locator): Promise<void> {
await expect(userMessage).toBeVisible();
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toHaveValue("");
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await expect(userMessage.getByTestId("user-message-timestamp")).toBeAttached();
await expect(userMessage.getByTestId("user-message-trailing-row")).toHaveCSS("opacity", "0");
await expect(userMessage).toHaveAttribute("aria-busy", "true");
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
}
async function readMessageGeometry(page: Page, userMessage: Locator): Promise<MessageGeometry> {
const box = await userMessage.boundingBox();
if (!box) throw new Error("Submitted user message has no browser geometry");
const { offsetY } = await readScrollMetrics(page);
return { x: box.x, y: box.y + offsetY, width: box.width, height: box.height };
}
async function beginWorkingFooterContinuityCheck(page: Page): Promise<() => Promise<void>> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await page.evaluate(() => {
const state = { active: true, sawMissing: false };
const windowState = window as unknown as Record<string, unknown>;
windowState.__messageSubmissionFooterContinuity = state;
const checkFrame = () => {
if (!state.active) return;
if (!document.querySelector('[data-testid="turn-working-indicator"]')) {
state.sawMissing = true;
}
requestAnimationFrame(checkFrame);
};
requestAnimationFrame(checkFrame);
});
return async () => {
const sawMissing = await page.evaluate(() => {
const windowState = window as unknown as Record<string, unknown>;
const state = windowState.__messageSubmissionFooterContinuity as
| { active: boolean; sawMissing: boolean }
| undefined;
if (!state) throw new Error("Working-footer continuity check was not started");
state.active = false;
delete windowState.__messageSubmissionFooterContinuity;
return state.sawMissing;
});
expect(sawMissing).toBe(false);
};
}
async function expectAcceptedSubmission(
page: Page,
userMessage: Locator,
submittedGeometry: MessageGeometry,
): Promise<void> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
expect(await readMessageGeometry(page, userMessage)).toEqual(submittedGeometry);
}
async function submitMessageThatWillBeRejected(page: Page, prompt: string): Promise<void> {
await attachImageFromMenu(page, IMAGE);
await expectAttachmentPill(page, "composer-image-attachment-pill");
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
await composer.fill(prompt);
await composer.press("Enter");
}
async function expectRejectedSubmissionRestored(
page: Page,
input: { prompt: string; errorMessage: string },
): Promise<void> {
await expect(page.getByText(input.errorMessage)).toBeVisible({ timeout: 30_000 });
await expectComposerDraft(page, input.prompt);
await expectComposerEditable(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled();
await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0);
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
}
async function retryRestoredSubmission(page: Page, prompt: string): Promise<void> {
await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter");
const userMessage = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(userMessage).toHaveCount(1);
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
}
async function queueMessage(page: Page, prompt: string): Promise<void> {
await fillComposerDraft(page, prompt);
await sendDraftToQueue(page);
}
async function expectQueuedSendFailuresRestored(page: Page, prompts: string[]): Promise<void> {
await expect(page.getByRole("button", { name: "Send queued message now" })).toHaveCount(
prompts.length,
);
for (const prompt of prompts) {
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
}
}
async function expectFailedSubmissionRestored(page: Page, prompt: string): Promise<void> {
await expectComposerDraft(page, prompt);
await expectComposerEditable(page);
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0);
}
async function expectInterruptedTurnOrderAfterReconnect(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-reconnect-${testInfo.workerIndex}-`,
title: "Submission reconnect ordering",
model: "ten-second-stream",
});
const prompt = "Keep this prompt before its response.";
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await agent.client.sendAgentMessage(agent.agentId, "Start the turn that will be interrupted.");
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await queueMessage(page, prompt);
gate.setAgentStreamSuppressed(true);
await page.getByRole("button", { name: "Send queued message now" }).click();
const promptRow = page.getByTestId("user-message").filter({ hasText: prompt });
await expect(promptRow).toBeVisible();
await gate.waitForServerMessage("send_agent_message_response");
await gate.drop();
await agent.client.waitForFinish(agent.agentId, 30_000);
gate.setAgentStreamSuppressed(false);
gate.forceNextTimelineEpochReset();
gate.restoreFresh();
await gate.waitForServerMessage("fetch_agent_timeline_response", 2);
const response = page.getByText("(end of synthetic stream)", { exact: true }).last();
await expect(promptRow).toBeVisible();
await expect(response).toBeVisible();
await expectRenderedBefore(promptRow, response);
} finally {
gate.restore();
await agent.cleanup();
}
}
async function expectCompletedSubmissionClearsAfterMissedRunningTransition(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-missed-running-${testInfo.workerIndex}-`,
title: "Submission missed running transition",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
gate.holdNextClientRequest("send_agent_message_request");
const userMessage = await submitImageOnlyMessage(page);
await gate.waitForHeldClientRequest();
gate.setServerMessageSuppressed("agent_status", true);
gate.setServerMessageSuppressed("agent_update", true);
gate.releaseHeldClientRequest();
await gate.waitForServerMessage("send_agent_message_response");
await expect(userMessage).toHaveAttribute("aria-busy", "false");
await gate.drop();
await agent.client.waitForFinish(agent.agentId, 30_000);
gate.setServerMessageSuppressed("agent_status", false);
gate.setServerMessageSuppressed("agent_update", false);
gate.restoreFresh();
await gate.waitForServerMessage("fetch_agent_timeline_response", 2);
await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible();
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
await expect(userMessage).toHaveAttribute("aria-busy", "false");
} finally {
gate.restore();
await agent.cleanup();
}
}
async function expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-ack-before-rpc-${testInfo.workerIndex}-`,
title: "Submission acknowledgement before RPC",
model: "ten-second-stream",
});
const prompt = "Settle this provider-acknowledged submission.";
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
gate.setServerMessageSuppressed("agent_status", true);
gate.setServerMessageSuppressed("agent_update", true);
gate.holdNextServerMessage("send_agent_message_response");
const userMessage = await submitMessageWithImage(page, prompt);
await gate.waitForHeldServerMessage();
await gate.waitForAgentStreamItem("user_message");
gate.releaseHeldServerMessage();
await gate.drop();
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
await expect(userMessage).toHaveAttribute("aria-busy", "false");
} finally {
gate.restore();
await agent.cleanup();
}
}
async function expectLegacyAssistantStartsAfterInterruptedPrompt(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-legacy-assistant-${testInfo.workerIndex}-`,
title: "Legacy assistant interrupt boundary",
model: "ten-second-stream",
});
const prompt = "Start the replacement answer after this prompt.";
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await agent.client.sendAgentMessage(agent.agentId, "Start the interrupted answer.");
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await queueMessage(page, prompt);
gate.setAssistantMessageIdsStripped(true);
gate.setAgentStreamEventSuppressed("turn_canceled", true);
await page.getByRole("button", { name: "Send queued message now" }).click();
const promptRow = page.getByTestId("user-message").filter({ hasText: prompt });
const replacementAnswer = page.getByText("(end of synthetic stream)", { exact: true }).last();
await expect(promptRow).toBeVisible();
await expect(replacementAnswer).toBeVisible({ timeout: 30_000 });
await expectRenderedBefore(promptRow, replacementAnswer);
} finally {
gate.setAssistantMessageIdsStripped(false);
gate.setAgentStreamEventSuppressed("turn_canceled", false);
await agent.cleanup();
}
}
async function expectStaleCanonicalPagePreservesNewerLiveOutput(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-stale-canonical-${testInfo.workerIndex}-`,
title: "Stale canonical page race",
model: "one-minute-stream",
});
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await agent.client.sendAgentMessage(agent.agentId, "End the snapshot at a tool call.");
await awaitToolCall(page, "read");
await page
.getByRole("button", { name: /stop|cancel/i })
.first()
.click();
await expectAgentIdle(page);
gate.holdNextServerMessage("fetch_agent_timeline_response");
gate.requestTimelineTail(agent.agentId);
await gate.waitForHeldServerMessage();
gate.truncateHeldTimelineAfterLast("tool_call");
expect(gate.getHeldTimelineLastItemType()).toBe("tool_call");
const nextPrompt = "Stream after the stale snapshot.";
await agent.client.sendAgentMessage(agent.agentId, nextPrompt);
const nextPromptRow = page.getByTestId("user-message").filter({ hasText: nextPrompt });
const liveAssistant = nextPromptRow.locator(
'xpath=following::*[@data-testid="assistant-message"][1]',
);
await expect(nextPromptRow).toBeVisible();
await expect(liveAssistant).toContainText("Cycle 1");
gate.releaseHeldServerMessage();
await expect(liveAssistant).toContainText("Cycle 1");
} finally {
await agent.cleanup();
}
}
async function expectCanonicalOrderWinsAcrossOverlappingClients(
page: Page,
testInfo: { workerIndex: number },
): Promise<void> {
const gate = await installDaemonWebSocketGate(page);
const agent = await seedMockAgentWorkspace({
repoPrefix: `submission-cross-client-order-${testInfo.workerIndex}-`,
title: "Cross-client submission order",
model: "ten-second-stream",
});
const localPrompt = "Send this after the other client turn.";
const remotePrompt = "Commit this other client turn first.";
try {
await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId });
await expectComposerVisible(page);
await expectAgentIdle(page);
gate.holdNextClientRequest("send_agent_message_request");
const localRow = await submitMessageWithImage(page, localPrompt);
await gate.waitForHeldClientRequest();
await agent.client.sendAgentMessage(agent.agentId, remotePrompt);
await agent.client.waitForFinish(agent.agentId, 30_000);
const remoteRow = page.getByTestId("user-message").filter({ hasText: remotePrompt });
await expect(remoteRow).toBeVisible();
const userMessageCount = gate.getAgentStreamItemCount("user_message");
gate.releaseHeldClientRequest();
await gate.waitForAgentStreamItem("user_message", userMessageCount + 1);
await expect(localRow).toHaveAttribute("aria-busy", "false");
await expect(localRow.getByRole("button", { name: "Open image attachment" })).toBeVisible();
await expect
.poll(async () => {
const localElement = await localRow.elementHandle();
if (!localElement) return false;
return remoteRow.evaluate(
(remoteElement, localNode) =>
Boolean(
remoteElement.compareDocumentPosition(localNode) & Node.DOCUMENT_POSITION_FOLLOWING,
),
localElement,
);
})
.toBe(true);
} finally {
gate.restore();
await agent.cleanup();
}
}
async function expectRenderedBefore(first: Locator, second: Locator): Promise<void> {
const secondElement = await second.elementHandle();
if (!secondElement) throw new Error("Expected the second timeline item to be rendered");
expect(
await first.evaluate(
(firstElement, secondNode) =>
Boolean(
firstElement.compareDocumentPosition(secondNode) & Node.DOCUMENT_POSITION_FOLLOWING,
),
secondElement,
),
).toBe(true);
}
async function openWorkspaceDraft(page: Page, workspaceId: string): Promise<void> {
await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab-inline").click();
await expectComposerVisible(page);
}
async function expectCreatedAgentHandoff(
page: Page,
prompt: string,
userMessage: Locator,
): Promise<void> {
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expect(page.getByTestId(/^workspace-tab-agent_/).first()).toBeVisible({ timeout: 30_000 });
await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1);
await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible();
}
interface DraftCreatePendingSubmission {
prompt: string;
userMessage: Locator;
}
async function beginDraftCreateSubmission(
page: Page,
scenario: DraftCreateScenario,
): Promise<DraftCreatePendingSubmission> {
await openWorkspaceDraft(page, scenario.workspaceId);
await selectModel(page, "one-minute-stream");
const prompt = "Keep this row through create handoff.";
const userMessage = await submitMessageWithImage(page, prompt);
await scenario.agentCreatedDelay.waitForCreateRequest();
await scenario.agentCreatedDelay.waitForDelayedCreatedStatus();
await expectPendingSubmission(page, userMessage);
return { prompt, userMessage };
}
async function completeDraftCreateSubmission(
page: Page,
scenario: DraftCreateScenario,
pending: DraftCreatePendingSubmission,
): Promise<void> {
scenario.agentCreatedDelay.release();
await expectCreatedAgentHandoff(page, pending.prompt, pending.userMessage);
}
test.describe("Agent message submission", () => {
test("keeps the submitted row stable when the host accepts", async ({
page,
submissionScenario,
}) => {
const userMessage = await submitMessageWithImage(page, "Hold this submission.");
await expectPendingSubmission(page, userMessage);
await submissionScenario.gate.waitForRequest();
const submittedGeometry = await readMessageGeometry(page, userMessage);
const finishFooterContinuityCheck = await beginWorkingFooterContinuityCheck(page);
submissionScenario.gate.accept();
await expectAcceptedSubmission(page, userMessage, submittedGeometry);
await finishFooterContinuityCheck();
});
test("keeps the submitted row stable through draft create handoff", async ({
page,
draftCreateScenario,
}) => {
test.setTimeout(120_000);
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
});
test("restores a rejected submission and accepts its retry", async ({
page,
rejectionScenario,
}) => {
const prompt = "Restore this rejected submission.";
await submitMessageThatWillBeRejected(page, prompt);
await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario });
await retryRestoredSubmission(page, prompt);
});
test("restores overlapping queued sends when their connection fails", async ({
page,
}, testInfo) => {
test.setTimeout(120_000);
const gate = await gateNextAgentMessage(page);
const agent = await startRunningMockAgent(page, {
prefix: `overlapping-queued-send-${testInfo.workerIndex}-`,
model: "one-minute-stream",
prompt: "Keep the agent running while messages queue.",
});
const prompts = ["Restore the first queued send.", "Restore the second queued send."];
try {
await queueMessage(page, prompts[0]);
await queueMessage(page, prompts[1]);
await page.getByRole("button", { name: "Send queued message now" }).first().click();
await gate.waitForRequest(1);
await page.getByRole("button", { name: "Send queued message now" }).first().click();
await gate.waitForRequest(2);
await gate.disconnect();
await expectQueuedSendFailuresRestored(page, prompts);
} finally {
await agent.cleanup();
}
});
test("does not accept a failed submission from an unrelated running turn", async ({
page,
unrelatedRunningScenario,
}) => {
const prompt = "Restore this unsent prompt.";
await submitMessageThatWillBeRejected(page, prompt);
await unrelatedRunningScenario.gate.waitForRequest();
await unrelatedRunningScenario.agent.client.sendAgentMessage(
unrelatedRunningScenario.agent.agentId,
"Start an unrelated turn.",
);
await expect(
page.getByTestId("user-message").filter({ hasText: "Start an unrelated turn." }),
).toBeVisible();
await unrelatedRunningScenario.gate.disconnect();
await expectFailedSubmissionRestored(page, prompt);
});
test("keeps a submitted prompt before its response when canonical history arrives", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
await expectInterruptedTurnOrderAfterReconnect(page, testInfo);
});
test("clears an attachment-only submission when canonical history arrives after a missed running transition", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
await expectCompletedSubmissionClearsAfterMissedRunningTransition(page, testInfo);
});
test("clears a provider acknowledgement that arrives before RPC acceptance", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
await expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission(page, testInfo);
});
test("keeps an old-daemon replacement answer after its interrupted prompt", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
await expectLegacyAssistantStartsAfterInterruptedPrompt(page, testInfo);
});
test("preserves newer live output when a stale canonical page arrives", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
await expectStaleCanonicalPagePreservesNewerLiveOutput(page, testInfo);
});
test("uses canonical order when another client turn overtakes a held submission", async ({
page,
}, testInfo) => {
await expectCanonicalOrderWinsAcrossOverlappingClients(page, testInfo);
});
});

View File

@@ -0,0 +1,86 @@
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
interface SendAgentMessageRequest {
type: "send_agent_message_request";
requestId: string;
agentId: string;
}
function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null {
if (typeof message !== "string") return null;
try {
const envelope = JSON.parse(message) as {
type?: unknown;
message?: Record<string, unknown>;
};
const request = envelope.type === "session" ? envelope.message : null;
if (
request?.type !== "send_agent_message_request" ||
typeof request.requestId !== "string" ||
typeof request.agentId !== "string"
) {
return null;
}
return {
type: "send_agent_message_request",
requestId: request.requestId,
agentId: request.agentId,
};
} catch {
return null;
}
}
export async function gateNextAgentMessage(page: Page) {
let serverSocket: WebSocketRoute | null = null;
let browserSocket: WebSocketRoute | null = null;
const heldMessages: Array<WebSocketMessage | null> = [];
const requests: SendAgentMessageRequest[] = [];
const requestWaiters = new Set<() => void>();
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
browserSocket = ws;
const server = ws.connectToServer();
serverSocket = server;
ws.onMessage((message) => {
const request = readSendRequest(message);
if (request) {
heldMessages.push(message);
requests.push(request);
for (const resolve of requestWaiters) resolve();
requestWaiters.clear();
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});
const waitForRequest = async (count = 1): Promise<SendAgentMessageRequest> => {
while (requests.length < count) {
await new Promise<void>((resolve) => requestWaiters.add(resolve));
}
return requests[count - 1];
};
return {
waitForRequest,
accept(index = 0) {
const heldMessage = heldMessages[index];
if (!serverSocket || !heldMessage) {
throw new Error("No held send-agent-message request to accept");
}
serverSocket.send(heldMessage);
heldMessages[index] = null;
},
async disconnect(): Promise<void> {
if (!browserSocket) throw new Error("No browser daemon socket to disconnect");
await browserSocket.close({ code: 1008, reason: "Dropped by submission test." });
},
};
}

View File

@@ -16,6 +16,20 @@ interface ClientRequest {
type?: unknown;
subscribe?: unknown;
page?: { cursor?: unknown };
payload?: unknown;
}
function readSessionMessage(message: string | Buffer): ClientRequest | null {
if (typeof message !== "string") return null;
try {
const envelope = JSON.parse(message) as {
type?: unknown;
message?: ClientRequest;
};
return envelope.message ?? envelope;
} catch {
return null;
}
}
function readClientRequest(message: string | Buffer): ClientRequest | null {
@@ -38,15 +52,111 @@ function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCo
return null;
}
function stripAssistantMessageId(
message: string | Buffer,
enabled: boolean,
messageType: unknown,
): string | Buffer {
if (!enabled || messageType !== "agent_stream" || typeof message !== "string") return message;
const envelope = JSON.parse(message) as {
message?: { payload?: { event?: { type?: unknown; item?: Record<string, unknown> } } };
payload?: { event?: { type?: unknown; item?: Record<string, unknown> } };
};
const event = (envelope.message?.payload ?? envelope.payload)?.event;
if (event?.type !== "timeline" || event.item?.type !== "assistant_message") return message;
delete event.item.messageId;
return JSON.stringify(envelope);
}
function stripMessageSubmissionDisposition(
message: string | Buffer,
enabled: boolean,
messageType: unknown,
): string | Buffer {
if (!enabled || messageType !== "send_agent_message_response" || typeof message !== "string") {
return message;
}
const envelope = JSON.parse(message) as {
message?: { payload?: Record<string, unknown> };
payload?: Record<string, unknown>;
};
const payload = envelope.message?.payload ?? envelope.payload;
if (!payload) return message;
delete payload.outOfBand;
return JSON.stringify(envelope);
}
function forceTimelineReset(message: string | Buffer, enabled: boolean): string | Buffer {
if (!enabled || typeof message !== "string") return message;
const envelope = JSON.parse(message) as {
message?: { payload?: Record<string, unknown> };
payload?: Record<string, unknown>;
};
const payload = envelope.message?.payload ?? envelope.payload;
if (!payload) return message;
payload.epoch = `playwright-reset-${Date.now()}`;
payload.reset = true;
return JSON.stringify(envelope);
}
function readAgentStreamEventType(message: ClientRequest | null): string | null {
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
return null;
}
const event = (message.payload as { event?: { type?: unknown } }).event;
return typeof event?.type === "string" ? event.type : null;
}
function readAgentStreamItemType(message: ClientRequest | null): string | null {
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
return null;
}
const event = (message.payload as { event?: { type?: unknown; item?: { type?: unknown } } })
.event;
return event?.type === "timeline" && typeof event.item?.type === "string"
? event.item.type
: null;
}
function shouldSuppressServerMessage(input: {
message: ClientRequest | null;
messageTypes: ReadonlySet<string>;
agentStreamEventTypes: ReadonlySet<string>;
suppressAgentStream: boolean;
}): boolean {
const messageType = typeof input.message?.type === "string" ? input.message.type : null;
if (messageType && input.messageTypes.has(messageType)) return true;
if (input.suppressAgentStream && messageType === "agent_stream") return true;
const eventType = readAgentStreamEventType(input.message);
return Boolean(eventType && input.agentStreamEventTypes.has(eventType));
}
export async function installDaemonWebSocketGate(page: Page) {
let acceptingConnections = true;
let reconnectWithFreshClient = false;
let suppressAgentStream = false;
let forceTimelineEpochReset = false;
let stripAssistantMessageIds = false;
let stripSubmissionDisposition = false;
let heldClientRequestType: string | null = null;
let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null;
let resolveHeldClientRequest: (() => void) | null = null;
let heldServerMessageType: string | null = null;
let heldServerMessage: { browser: WebSocketRoute; message: string | Buffer } | null = null;
let resolveHeldServerMessage: (() => void) | null = null;
const suppressedServerMessageTypes = new Set<string>();
const suppressedAgentStreamEventTypes = new Set<string>();
const activeSockets = new Set<WebSocketRoute>();
let latestServer: WebSocketRoute | null = null;
const directoryStarts: DirectoryRequestStartCounts = {
subscribed: { agents: 0, workspaces: 0 },
unsubscribed: { agents: 0, workspaces: 0 },
total: { agents: 0, workspaces: 0 },
};
const clientRequestCounts = new Map<string, number>();
const serverMessageCounts = new Map<string, number>();
const agentStreamItemCounts = new Map<string, number>();
const serverMessageWaiters = new Set<() => void>();
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
if (!acceptingConnections) {
@@ -56,9 +166,20 @@ export async function installDaemonWebSocketGate(page: Page) {
activeSockets.add(ws);
const server = ws.connectToServer();
latestServer = server;
ws.onMessage((message) => {
if (!acceptingConnections) return;
if (reconnectWithFreshClient && typeof message === "string") {
const hello = readClientRequest(message);
if (hello?.type === "hello") {
const parsed = JSON.parse(message) as { clientId?: string };
parsed.clientId = `${parsed.clientId ?? "playwright"}-fresh-${Date.now()}`;
reconnectWithFreshClient = false;
server.send(JSON.stringify(parsed));
return;
}
}
const request = readClientRequest(message);
if (typeof request?.type === "string") {
clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1);
@@ -69,6 +190,12 @@ export async function installDaemonWebSocketGate(page: Page) {
directoryStarts.total[directory] += 1;
}
}
if (request?.type === heldClientRequestType) {
heldClientRequest = { server, message };
resolveHeldClientRequest?.();
resolveHeldClientRequest = null;
return;
}
try {
server.send(message);
} catch {
@@ -78,8 +205,55 @@ export async function installDaemonWebSocketGate(page: Page) {
server.onMessage((message) => {
if (!acceptingConnections) return;
const serverMessage = readSessionMessage(message);
let outboundMessage = stripAssistantMessageId(
message,
stripAssistantMessageIds,
serverMessage?.type,
);
outboundMessage = stripMessageSubmissionDisposition(
outboundMessage,
stripSubmissionDisposition,
serverMessage?.type,
);
const shouldForceTimelineReset =
forceTimelineEpochReset && serverMessage?.type === "fetch_agent_timeline_response";
outboundMessage = forceTimelineReset(outboundMessage, shouldForceTimelineReset);
if (shouldForceTimelineReset) forceTimelineEpochReset = false;
if (typeof serverMessage?.type === "string") {
serverMessageCounts.set(
serverMessage.type,
(serverMessageCounts.get(serverMessage.type) ?? 0) + 1,
);
for (const resolve of serverMessageWaiters) resolve();
serverMessageWaiters.clear();
}
const agentStreamItemType = readAgentStreamItemType(serverMessage);
if (agentStreamItemType) {
agentStreamItemCounts.set(
agentStreamItemType,
(agentStreamItemCounts.get(agentStreamItemType) ?? 0) + 1,
);
for (const resolve of serverMessageWaiters) resolve();
serverMessageWaiters.clear();
}
if (serverMessage?.type === heldServerMessageType) {
heldServerMessage = { browser: ws, message: outboundMessage };
resolveHeldServerMessage?.();
resolveHeldServerMessage = null;
return;
}
if (
shouldSuppressServerMessage({
message: serverMessage,
messageTypes: suppressedServerMessageTypes,
agentStreamEventTypes: suppressedAgentStreamEventTypes,
suppressAgentStream,
})
)
return;
try {
ws.send(message);
ws.send(outboundMessage);
} catch {
activeSockets.delete(ws);
}
@@ -100,6 +274,125 @@ export async function installDaemonWebSocketGate(page: Page) {
restore(): void {
acceptingConnections = true;
},
restoreFresh(): void {
reconnectWithFreshClient = true;
acceptingConnections = true;
},
holdNextClientRequest(type: string): void {
heldClientRequestType = type;
heldClientRequest = null;
},
waitForHeldClientRequest(): Promise<void> {
if (heldClientRequest) return Promise.resolve();
return new Promise<void>((resolve) => {
resolveHeldClientRequest = resolve;
});
},
releaseHeldClientRequest(): void {
if (!heldClientRequest) throw new Error("No held client request to release");
heldClientRequest.server.send(heldClientRequest.message);
heldClientRequest = null;
heldClientRequestType = null;
},
holdNextServerMessage(type: string): void {
heldServerMessageType = type;
heldServerMessage = null;
},
waitForHeldServerMessage(): Promise<void> {
if (heldServerMessage) return Promise.resolve();
return new Promise<void>((resolve) => {
resolveHeldServerMessage = resolve;
});
},
releaseHeldServerMessage(): void {
if (!heldServerMessage) throw new Error("No held server message to release");
heldServerMessage.browser.send(heldServerMessage.message);
heldServerMessage = null;
heldServerMessageType = null;
},
requestTimelineTail(agentId: string): void {
if (!latestServer) throw new Error("No daemon WebSocket is connected");
latestServer.send(
JSON.stringify({
type: "session",
message: {
type: "fetch_agent_timeline_request",
agentId,
requestId: `playwright-timeline-${Date.now()}`,
direction: "tail",
limit: 0,
projection: "projected",
},
}),
);
},
getHeldTimelineLastItemType(): string | null {
if (!heldServerMessage) throw new Error("No held server message to inspect");
const response = readSessionMessage(heldServerMessage.message);
const payload = response?.payload;
if (!payload || typeof payload !== "object") return null;
const entries = (payload as { entries?: unknown }).entries;
if (!Array.isArray(entries)) return null;
const last = entries.at(-1) as { item?: { type?: unknown } } | undefined;
return typeof last?.item?.type === "string" ? last.item.type : null;
},
truncateHeldTimelineAfterLast(itemType: string): void {
if (!heldServerMessage || typeof heldServerMessage.message !== "string") {
throw new Error("No held text server message to truncate");
}
const envelope = JSON.parse(heldServerMessage.message) as {
message?: { payload?: Record<string, unknown> };
payload?: Record<string, unknown>;
};
const payload = envelope.message?.payload ?? envelope.payload;
if (!payload) throw new Error("Held message has no payload");
const entries = payload.entries;
if (!Array.isArray(entries)) throw new Error("Held message is not a timeline response");
const index = entries.findLastIndex(
(entry) =>
typeof entry === "object" &&
entry !== null &&
(entry as { item?: { type?: unknown } }).item?.type === itemType,
);
if (index < 0) throw new Error(`Timeline response has no ${itemType} item`);
const retained = entries.slice(0, index + 1) as Array<{ seqEnd?: unknown }>;
const lastSeq = retained.at(-1)?.seqEnd;
if (typeof lastSeq !== "number") throw new Error("Timeline entry has no sequence end");
payload.entries = retained;
payload.endCursor = { epoch: payload.epoch, seq: lastSeq };
payload.hasNewer = false;
if (payload.window && typeof payload.window === "object") {
(payload.window as Record<string, unknown>).maxSeq = lastSeq;
(payload.window as Record<string, unknown>).nextSeq = lastSeq + 1;
}
heldServerMessage.message = JSON.stringify(envelope);
},
setServerMessageSuppressed(type: string, suppressed: boolean): void {
if (suppressed) {
suppressedServerMessageTypes.add(type);
} else {
suppressedServerMessageTypes.delete(type);
}
},
setAgentStreamEventSuppressed(type: string, suppressed: boolean): void {
if (suppressed) {
suppressedAgentStreamEventTypes.add(type);
} else {
suppressedAgentStreamEventTypes.delete(type);
}
},
setAssistantMessageIdsStripped(stripped: boolean): void {
stripAssistantMessageIds = stripped;
},
setMessageSubmissionDispositionStripped(stripped: boolean): void {
stripSubmissionDisposition = stripped;
},
setAgentStreamSuppressed(suppressed: boolean): void {
suppressAgentStream = suppressed;
},
forceNextTimelineEpochReset(): void {
forceTimelineEpochReset = true;
},
getDirectoryRequestStartCounts(): DirectoryRequestStartCounts {
return {
subscribed: { ...directoryStarts.subscribed },
@@ -110,5 +403,18 @@ export async function installDaemonWebSocketGate(page: Page) {
getClientRequestCount(type: string): number {
return clientRequestCounts.get(type) ?? 0;
},
getAgentStreamItemCount(type: string): number {
return agentStreamItemCounts.get(type) ?? 0;
},
async waitForServerMessage(type: string, count = 1): Promise<void> {
while ((serverMessageCounts.get(type) ?? 0) < count) {
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
}
},
async waitForAgentStreamItem(type: string, count = 1): Promise<void> {
while ((agentStreamItemCounts.get(type) ?? 0) < count) {
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
}
},
};
}

View File

@@ -177,6 +177,14 @@ export async function openGlobalNewWorkspaceComposer(page: Page): Promise<void>
});
}
export async function openNewWorkspaceProjectPickerWithShortcut(page: Page): Promise<void> {
await page.keyboard.press("Control+P");
const searchInput = page.getByPlaceholder("Search projects");
await expect(searchInput).toBeVisible({ timeout: 30_000 });
await expect(searchInput).toBeFocused();
}
export async function expectNewWorkspaceProjectSelected(
page: Page,
projectDisplayName: string,

View File

@@ -5,6 +5,7 @@ import {
expectNewWorkspaceProjectSelected,
openGlobalNewWorkspaceComposer,
openNewWorkspaceComposer,
openNewWorkspaceProjectPickerWithShortcut,
} from "./helpers/new-workspace";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
@@ -106,6 +107,20 @@ test.describe("New workspace entry points", () => {
}
});
test("Ctrl+P opens the project picker with search focused", async ({ page }) => {
const seeded: SeededWorkspace = await seedWorkspace({ repoPrefix: "entry-shortcut-" });
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openGlobalNewWorkspaceComposer(page);
await openNewWorkspaceProjectPickerWithShortcut(page);
} finally {
await seeded.cleanup();
}
});
test("keeps the in-progress form when the remembered workspace is archived elsewhere", async ({
page,
}) => {

View File

@@ -0,0 +1,52 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "./fixtures";
import { submitMessage } from "./helpers/composer";
import { cleanupRewindFlow, launchAgent, type AgentHandle } from "./helpers/rewind-flow";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
test.describe("Codex out-of-band commands", () => {
test.setTimeout(300_000);
test("settles the submitted row when a goal command completes without a turn", async ({
page,
}) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" });
await submitMessage(page, "/goal clear");
const command = page.getByTestId("user-message").filter({ hasText: "/goal clear" });
await expect(command).toBeVisible();
await expect(command).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("settles the submitted row when an older daemon omits submission disposition", async ({
page,
}) => {
const gate = await installDaemonWebSocketGate(page);
gate.setMessageSubmissionDispositionStripped(true);
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-compat-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" });
await submitMessage(page, "/goal clear");
const command = page.getByTestId("user-message").filter({ hasText: "/goal clear" });
await expect(command).toBeVisible();
await expect(command).toHaveAttribute("aria-busy", "false", { timeout: 30_000 });
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
} finally {
gate.restore();
await cleanupRewindFlow({ handle, cwd });
}
});
});

View File

@@ -1,6 +1,7 @@
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "./fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
import {
composerLocator,
expectComposerDraft,
@@ -23,7 +24,152 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
await expect(userMessage(page, text)).toBeVisible();
}
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
for (const host of cache.hosts ?? []) {
for (const item of host.timeline?.items ?? []) {
if (item.kind === "user_message" && item.text === messageText && item.messageId) {
return true;
}
}
}
return false;
}, prompt),
)
.toBe(true);
await page.evaluate((messageText) => {
const key = "@paseo:replica-cache";
const raw = localStorage.getItem(key);
if (!raw) throw new Error("Replica cache was not persisted");
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
const cachedMessage = cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.find((item) => item.kind === "user_message" && item.text === messageText);
if (!cachedMessage) throw new Error("Cached user message was not found");
delete cachedMessage.messageId;
localStorage.setItem(key, JSON.stringify(cache));
}, prompt);
}
async function waitForCurrentSubmissionExcludedFromCache(
page: Page,
prompt: string,
): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
return !cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" &&
item.text === messageText &&
typeof item.clientMessageId === "string" &&
item.messageId === undefined,
);
}, prompt),
)
.toBe(true);
}
async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise<void> {
await expect
.poll(() =>
page.evaluate((messageText) => {
const raw = localStorage.getItem("@paseo:replica-cache");
if (!raw) return false;
const cache = JSON.parse(raw) as {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>;
};
return cache.hosts
?.flatMap((host) => host.timeline?.items ?? [])
.some(
(item) =>
item.kind === "user_message" &&
item.text === messageText &&
item.messageId === undefined,
);
}, prompt),
)
.toBe(true);
}
async function expectPendingSubmissionNotRestoredAfterReload(page: Page): Promise<void> {
const prompt = "Keep this cached submission pending.";
const gate = await installDaemonWebSocketGate(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-current-cache-e2e-",
title: "Current cache submission e2e",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
gate.holdNextClientRequest("send_agent_message_request");
await submitMessage(page, prompt);
await gate.waitForHeldClientRequest();
await waitForCurrentSubmissionExcludedFromCache(page, prompt);
await gate.drop();
await page.reload();
await expect(userMessage(page, prompt)).toHaveCount(0);
} finally {
gate.restore();
await session.cleanup();
}
}
test.describe("Rewind sheet", () => {
test("does not restore a local-only submission from the display cache", async ({ page }) => {
await expectPendingSubmissionNotRestoredAfterReload(page);
});
test("does not invent rewind identity for an ID-less cached message", async ({ page }) => {
const prompt = "Restore this rewind identity from the legacy cache.";
const gate = await installDaemonWebSocketGate(page);
const session = await seedMockAgentWorkspace({
repoPrefix: "rewind-cache-upgrade-e2e-",
title: "Rewind cache upgrade e2e",
initialPrompt: prompt,
});
let heldTimelineRequest = false;
try {
await openAgentRoute(page, session);
await expectUserMessageVisible(page, prompt);
await rewriteCachedMessageAsLegacyRow(page, prompt);
gate.holdNextClientRequest("fetch_agent_timeline_request");
await page.reload();
await gate.waitForHeldClientRequest();
heldTimelineRequest = true;
const restoredMessage = userMessage(page, prompt);
await expect(restoredMessage).toBeVisible();
await restoredMessage.hover();
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
await waitForCachedMessageWithoutProviderId(page, prompt);
} finally {
if (heldTimelineRequest) gate.releaseHeldClientRequest();
gate.restore();
await session.cleanup();
}
});
test("rewinds from a user message sheet option", async ({ page }) => {
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
const secondPrompt = "Prepare deleted rewind turn assistant content.";

View File

@@ -0,0 +1,139 @@
import { describe, expect, it } from "vitest";
import {
beginDrag,
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
recordScroll,
releaseDrag,
type ScrollKeyboardDismissEvent,
type ScrollKeyboardDismissGesture,
} from "./model";
interface Point {
ts: number;
y: number;
nativeTs?: number | null;
}
function event(point: Point): ScrollKeyboardDismissEvent {
return {
timeStamp: point.ts,
nativeEvent: {
contentOffset: { y: point.y },
...(point.nativeTs === null ? {} : { timestamp: point.nativeTs ?? point.ts }),
},
};
}
function dragThrough(start: Point, points: Point[]): ScrollKeyboardDismissGesture {
return points.reduce(
(gesture, point) => recordScroll(gesture, event(point)),
beginDrag(event(start)),
);
}
function shouldDismiss(start: Point, points: Point[], release: Point): boolean {
return releaseDrag(dragThrough(start, points), event(release)).shouldDismiss;
}
describe("scroll keyboard dismissal", () => {
it("keeps the keyboard up for a slow read-scroll", () => {
expect(
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1040, y: 8 },
{ ts: 1080, y: 16 },
{ ts: 1120, y: 24 },
],
{ ts: 1160, y: 32 },
),
).toBe(false);
});
it("dismisses for an upward flick", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: 120 }], { ts: 1080, y: 240 })).toBe(
true,
);
});
it("keeps the keyboard up when the inverted list moves toward newer messages", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1040, y: -120 }], { ts: 1080, y: -240 })).toBe(
false,
);
});
it("keeps a fast-but-decelerating drag below the release threshold", () => {
expect(
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1100, y: 500 },
{ ts: 1200, y: 590 },
{ ts: 1270, y: 598 },
],
{ ts: 1300, y: 600 },
),
).toBe(false);
});
it("uses the whole drag when the gesture is too short to sample", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1020, y: 60 })).toBe(true);
});
it("steps back a sample when release lands immediately after one", () => {
expect(
shouldDismiss(
{ ts: 1000, y: 0 },
[
{ ts: 1040, y: 120 },
{ ts: 1075, y: 225 },
],
{ ts: 1080, y: 240 },
),
).toBe(true);
});
it("keeps the keyboard up when release has no measurable time span", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [], { ts: 1000, y: 90 })).toBe(false);
});
it("ignores scroll events that arrive before the minimum sample span", () => {
expect(shouldDismiss({ ts: 1000, y: 0 }, [{ ts: 1029, y: 1000 }], { ts: 1060, y: 1000 })).toBe(
true,
);
});
it("uses native gesture time when delayed callbacks arrive in a burst", () => {
expect(
shouldDismiss(
{ ts: 5000, nativeTs: 1000, y: 0 },
[
{ ts: 5001, nativeTs: 1200, y: 40 },
{ ts: 5002, nativeTs: 1400, y: 80 },
],
{ ts: 5003, nativeTs: 1600, y: 120 },
),
).toBe(false);
});
it("falls back to synthetic event time when native time is absent", () => {
expect(
shouldDismiss({ ts: 1000, nativeTs: null, y: 0 }, [{ ts: 1040, nativeTs: null, y: 120 }], {
ts: 1080,
nativeTs: null,
y: 240,
}),
).toBe(true);
});
it("ignores scroll and release events without a matching drag", () => {
const idle = recordScroll(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE, event({ ts: 1000, y: 200 }));
expect(idle).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
expect(releaseDrag(idle, event({ ts: 1010, y: 300 })).shouldDismiss).toBe(false);
});
it("returns to idle after release", () => {
const release = releaseDrag(beginDrag(event({ ts: 1000, y: 0 })), event({ ts: 1020, y: 60 }));
expect(release.gesture).toBe(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
});
});

View File

@@ -0,0 +1,124 @@
/**
* Pure state machine for the chat history's flick-to-dismiss gesture.
*
* Native keyboardDismissMode cannot express this interaction: "on-drag"
* dismisses on the first pixel, while "interactive" behaves incorrectly on an
* inverted list. We therefore classify the list's existing scroll gesture at
* release instead of introducing a competing pan recognizer.
*/
const DISMISS_VELOCITY_POINTS_PER_MS = 1.5;
const RELEASE_SAMPLE_MIN_MS = 30;
/**
* The subset of a native scroll event used by the classifier. React Native's
* type omits `nativeEvent.timestamp`, although iOS and Android both send it.
*/
export interface ScrollKeyboardDismissEvent {
timeStamp: number;
nativeEvent: {
contentOffset: { y: number };
timestamp?: number;
};
}
interface DragSamples {
startTs: number;
startY: number;
sampleTs: number;
sampleY: number;
previousSampleTs: number;
previousSampleY: number;
}
export type ScrollKeyboardDismissGesture =
| { phase: "idle" }
| { phase: "dragging"; samples: DragSamples };
export const IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE: ScrollKeyboardDismissGesture = Object.freeze({
phase: "idle",
});
function resolveEventTimeMs(event: ScrollKeyboardDismissEvent): number {
const nativeTimestamp = event.nativeEvent.timestamp;
if (typeof nativeTimestamp === "number" && nativeTimestamp > 0) {
return nativeTimestamp;
}
return event.timeStamp;
}
export function beginDrag(event: ScrollKeyboardDismissEvent): ScrollKeyboardDismissGesture {
const timestamp = resolveEventTimeMs(event);
const offsetY = event.nativeEvent.contentOffset.y;
return {
phase: "dragging",
samples: {
startTs: timestamp,
startY: offsetY,
sampleTs: timestamp,
sampleY: offsetY,
previousSampleTs: 0,
previousSampleY: 0,
},
};
}
export function recordScroll(
gesture: ScrollKeyboardDismissGesture,
event: ScrollKeyboardDismissEvent,
): ScrollKeyboardDismissGesture {
if (gesture.phase === "idle") {
return gesture;
}
const timestamp = resolveEventTimeMs(event);
if (timestamp - gesture.samples.sampleTs < RELEASE_SAMPLE_MIN_MS) {
return gesture;
}
const { samples } = gesture;
return {
phase: "dragging",
samples: {
startTs: samples.startTs,
startY: samples.startY,
sampleTs: timestamp,
sampleY: event.nativeEvent.contentOffset.y,
previousSampleTs: samples.sampleTs,
previousSampleY: samples.sampleY,
},
};
}
export function releaseDrag(
gesture: ScrollKeyboardDismissGesture,
event: ScrollKeyboardDismissEvent,
): { gesture: ScrollKeyboardDismissGesture; shouldDismiss: boolean } {
if (gesture.phase === "idle") {
return { gesture, shouldDismiss: false };
}
const releaseTs = resolveEventTimeMs(event);
const releaseY = event.nativeEvent.contentOffset.y;
const { samples } = gesture;
// The release event carries the gesture's true endpoint. The final onScroll
// event can still be stale when a short flick lands.
let spanStartTs = samples.startTs;
let spanStartY = samples.startY;
if (releaseTs - samples.sampleTs >= RELEASE_SAMPLE_MIN_MS) {
spanStartTs = samples.sampleTs;
spanStartY = samples.sampleY;
} else if (samples.previousSampleTs > 0) {
spanStartTs = samples.previousSampleTs;
spanStartY = samples.previousSampleY;
}
const spanDurationMs = releaseTs - spanStartTs;
const releaseVelocity = spanDurationMs > 0 ? (releaseY - spanStartY) / spanDurationMs : 0;
return {
gesture: IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
shouldDismiss: releaseVelocity > DISMISS_VELOCITY_POINTS_PER_MS,
};
}

View File

@@ -0,0 +1,57 @@
import { useRef } from "react";
import {
Keyboard,
TextInput,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
beginDrag,
IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE,
recordScroll,
releaseDrag,
} from "./model";
type ScrollEvent = NativeSyntheticEvent<NativeScrollEvent>;
/**
* Owns the chat history's flick-to-dismiss behavior. The native stream only
* forwards the FlatList scroll lifecycle; removing this hook and those three
* calls removes the feature completely.
*/
export function useScrollKeyboardDismiss() {
const { shift } = useKeyboardShift();
const gestureRef = useRef(IDLE_SCROLL_KEYBOARD_DISMISS_GESTURE);
const onScrollBeginDrag = useStableEvent((event: ScrollEvent) => {
gestureRef.current = beginDrag(event);
});
const onScroll = useStableEvent((event: ScrollEvent) => {
gestureRef.current = recordScroll(gestureRef.current, event);
});
const onScrollEndDrag = useStableEvent((event: ScrollEvent) => {
const release = releaseDrag(gestureRef.current, event);
gestureRef.current = release.gesture;
// `shift` is the app's UI-thread-derived keyboard inset. Besides avoiding a
// second calculation on JS, this prevents a hardware keyboard's focused
// composer from being blurred when no software keyboard occupies space.
if (!release.shouldDismiss || shift.value <= 0) {
return;
}
// Keep blur and dismiss paired: this exact sequence was validated on a
// physical Android device to clear both input focus and the IME inset.
const focusedInput = TextInput.State.currentlyFocusedInput();
if (focusedInput) {
TextInput.State.blurTextInput(focusedInput);
}
Keyboard.dismiss();
});
return { onScroll, onScrollBeginDrag, onScrollEndDrag };
}

View File

@@ -24,6 +24,7 @@ import type { StreamItem } from "@/types/stream";
import type { Theme } from "@/styles/theme";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useBottomAnchorController } from "./bottom-anchor-controller";
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
import {
createStreamStrategy,
@@ -52,7 +53,6 @@ const historyStartSlotStyle: ViewStyle = {
paddingTop: 4,
paddingBottom: 8,
};
interface HistoryRowDisplayVariants {
regular?: StreamItem;
compact?: StreamItem;
@@ -110,6 +110,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
const scrollOffsetYRef = useRef(0);
const isUserScrollActiveRef = useRef(false);
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
const userScrollEndFrameIdRef = useRef<number | null>(null);
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
@@ -335,6 +336,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
const previousOffsetY = scrollOffsetYRef.current;
scrollOffsetYRef.current = contentOffset.y;
scrollKeyboardDismiss.onScroll(event);
streamViewportMetricsRef.current = {
contentHeight: Math.max(0, contentSize.height),
viewportWidth: Math.max(0, layoutMeasurement.width),
@@ -365,7 +368,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
});
const handleScrollBeginDrag = useStableEvent(() => {
const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (!isLoadingOlderHistory) {
historyStartPaginationStateRef.current = rearmHistoryStartPagination(
historyStartPaginationStateRef.current,
@@ -373,6 +376,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
}
clearPendingUserScrollEnd();
isUserScrollActiveRef.current = true;
scrollKeyboardDismiss.onScrollBeginDrag(event);
bottomAnchorController.beginUserScroll();
evaluateHistoryStart();
});
@@ -381,6 +385,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
// gesture position now because layout may move the viewport in the meantime.
const handleScrollEndDrag = useStableEvent((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const isNearBottom = isScrollEventNearBottom(event);
scrollKeyboardDismiss.onScrollEndDrag(event);
clearPendingUserScrollEnd();
userScrollEndFrameIdRef.current = requestAnimationFrame(() => {
userScrollEndFrameIdRef.current = null;

View File

@@ -41,6 +41,7 @@ import {
} from "@/components/message";
import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream";
import type { PendingMessageSubmission } from "@/composer/submission/model";
import type { PendingPermission } from "@/types/shared";
import type {
AgentCapabilityFlags,
@@ -239,6 +240,7 @@ export interface AgentStreamViewProps {
streamItems: StreamItem[];
streamHead?: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
pendingMessageSubmissions?: readonly PendingMessageSubmission[];
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
toast?: ToastApi | null;
@@ -265,6 +267,7 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
];
const EMPTY_STREAM_HEAD: StreamItem[] = [];
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
function buildChatHistoryAttachment(input: {
@@ -327,6 +330,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
streamItems,
streamHead: providedStreamHead,
pendingPermissions,
pendingMessageSubmissions = EMPTY_PENDING_MESSAGE_SUBMISSIONS,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
toast,
@@ -341,6 +345,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
const viewportRef = useRef<StreamViewportHandle | null>(null);
const pendingClientMessageIds = useMemo(
() => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)),
[pendingMessageSubmissions],
);
const isMobile = useIsCompactFormFactor();
const streamRenderStrategy = useMemo(
() =>
@@ -656,7 +664,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
messageId={item.id}
messageId={item.messageId}
message={item.text}
images={item.images}
attachments={item.attachments}
@@ -665,10 +673,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
isPending={
item.clientMessageId !== undefined &&
pendingClientMessageIds.has(item.clientMessageId)
}
/>
);
},
[context.capabilities, agentId, client, resolvedServerId],
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -878,7 +890,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showRunningTurnFooter = baseRenderModel.turnTiming.isActive;
const showRunningTurnFooter =
context.status === "running" || pendingMessageSubmissions.length > 0;
const pendingPermissionsNode = useMemo(
() =>
renderPendingPermissionsNode({
@@ -1157,6 +1170,9 @@ function agentStreamViewPropsEqual(
if (left.streamItems !== right.streamItems) reasons.push("streamItems");
if (left.streamHead !== right.streamHead) reasons.push("streamHead");
if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions");
if (left.pendingMessageSubmissions !== right.pendingMessageSubmissions) {
reasons.push("pendingMessageSubmissions");
}
if (
!bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest)
) {

View File

@@ -296,6 +296,16 @@ describe("splitHtmlishMarkdown", () => {
);
});
it("normalizes HTML table cells and inline formatting into markdown", () => {
expect(
normalizeHtmlishMarkdown(
"<table><tr><td><strong>Score</strong>: 78</td></tr><tr><td><strong>No security concerns identified</strong></td></tr><tr><td><strong>Recommended focus areas for review</strong></td><td>Bearer authentication</td></tr></table>",
),
).toBe(
"\n- **Score**: 78\n- **No security concerns identified**\n- **Recommended focus areas for review**: Bearer authentication\n",
);
});
it("leaves complex code tags inert instead of parsing HTML", () => {
expect(normalizeHtmlishMarkdown('<code onclick="evil()"><script>x</script></code>')).toBe(
'<code onclick="evil()"><script>x</script></code>',

View File

@@ -30,6 +30,15 @@ const BACKTICK_RUN_RE = /`+/g;
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
const VOID_HTML_TAGS = new Set(["br", "img"]);
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
b: ["**", "**"],
del: ["~~", "~~"],
em: ["*", "*"],
i: ["*", "*"],
s: ["~~", "~~"],
strike: ["~~", "~~"],
strong: ["**", "**"],
};
interface ProtectedMarkdownRange {
start: number;
@@ -313,36 +322,83 @@ function renderInlineTokens(tokens: HtmlToken[]): string {
}
const children = tokens.slice(index + 1, closeIndex);
if (token.name === "a") {
output += renderLinkToken(token, children);
index = closeIndex;
continue;
}
if (token.name === "sub") {
output += renderInlineTokens(children);
index = closeIndex;
continue;
}
if (token.name === "code" && children.every((child) => child.kind === "text")) {
output += `\`${renderInlineTokens(children)}\``;
index = closeIndex;
continue;
}
const rawTag = token.raw;
const tagName = token.name;
if (isHeadingTag(token)) {
output += renderInlineTokens(children);
index = closeIndex;
continue;
}
output += `${rawTag}${renderInlineTokens(children)}</${tagName}>`;
output += renderHtmlTag(token, children);
index = closeIndex;
}
return output;
}
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
if (token.name === "a") {
return renderLinkToken(token, children);
}
if (token.name === "sub" || isHeadingTagName(token.name)) {
return renderInlineTokens(children);
}
if (token.name === "code" && children.every((child) => child.kind === "text")) {
return `\`${renderInlineTokens(children)}\``;
}
const wrapper = MARKDOWN_TAG_WRAPPERS[token.name];
if (wrapper) {
return `${wrapper[0]}${renderInlineTokens(children)}${wrapper[1]}`;
}
if (token.name === "table") {
return renderTableTokens(children);
}
if (token.name === "p" || token.name === "div") {
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
}
return `${token.raw}${renderInlineTokens(children)}</${token.name}>`;
}
function renderTableTokens(tokens: HtmlToken[]): string {
const rows: string[] = [];
for (let index = 0; index < tokens.length; index += 1) {
if (!isOpenTag(tokens[index], "tr")) {
continue;
}
const closeIndex = findMatchingClose(tokens, index, "tr");
if (closeIndex === null) {
continue;
}
const cells = renderTableCells(tokens.slice(index + 1, closeIndex));
if (cells.length === 1) {
rows.push(`- ${cells[0]}`);
} else if (cells.length > 1) {
const label =
cells[0].startsWith("**") && cells[0].endsWith("**") ? cells[0] : `**${cells[0]}**`;
rows.push(`- ${label}: ${cells.slice(1).join(" ")}`);
}
index = closeIndex;
}
return rows.length > 0 ? `\n${rows.join("\n")}\n` : "";
}
function renderTableCells(tokens: HtmlToken[]): string[] {
const cells: string[] = [];
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!isOpenTag(token, "td") && !isOpenTag(token, "th")) {
continue;
}
const closeIndex = findMatchingClose(tokens, index, token.name);
if (closeIndex === null) {
continue;
}
const cell = renderInlineTokens(tokens.slice(index + 1, closeIndex)).trim();
if (cell) {
cells.push(cell);
}
index = closeIndex;
}
return cells;
}
function renderImageToken(token: HtmlTagToken): string {
const image = imageTokenToInlineImage(token, undefined);
if (!image) {

View File

@@ -132,6 +132,7 @@ interface UserMessageProps {
client?: DaemonClient | null;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
isPending?: boolean;
disableOuterSpacing?: boolean;
}
@@ -430,6 +431,7 @@ export const UserMessage = memo(function UserMessage({
client,
isFirstInGroup = true,
isLastInGroup = true,
isPending = false,
disableOuterSpacing,
}: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
@@ -441,7 +443,7 @@ export const UserMessage = memo(function UserMessage({
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const hasAttachments = attachments.length > 0;
const showTrailingRow = hasText && (isCompact || isNative || isHovered);
const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered);
const formattedTimestamp = useMemo(
() => formatMessageTimestamp(new Date(timestamp)),
[timestamp],
@@ -494,7 +496,7 @@ export const UserMessage = memo(function UserMessage({
);
return (
<View style={containerStyle} testID="user-message">
<View style={containerStyle} testID="user-message" aria-busy={isPending}>
<View
style={userMessageStylesheet.content}
onPointerEnter={handlePointerEnter}
@@ -538,9 +540,15 @@ export const UserMessage = memo(function UserMessage({
) : null}
</View>
{hasText ? (
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
{capabilities ? (
<View
style={trailingRowStyle}
pointerEvents={showTrailingRow ? "auto" : "none"}
testID="user-message-trailing-row"
>
<Text style={userMessageStylesheet.timestampText} testID="user-message-timestamp">
{formattedTimestamp}
</Text>
{capabilities && messageId ? (
<RewindMenu
capabilities={capabilities}
isPending={rewindMutation.isPending}

View File

@@ -7,7 +7,6 @@ import type { RewindMode } from "./use-rewind-capabilities";
import { useRewindComposerRestore } from "./composer-restore";
import { useSessionStore } from "@/stores/session-store";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
import { clearOptimisticUserMessages } from "@/types/stream";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
interface UseRewindAgentMutationInput {
@@ -36,13 +35,6 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
}
await input.client.rewindAgent(input.agentId, input.messageId, mode);
if (mode !== "files") {
if (input.serverId) {
const session = useSessionStore.getState().sessions[input.serverId];
useSessionStore.getState().setAgentStreamState(input.serverId, input.agentId, {
tail: clearOptimisticUserMessages(session?.agentStreamTail.get(input.agentId) ?? []),
head: clearOptimisticUserMessages(session?.agentStreamHead.get(input.agentId) ?? []),
});
}
const cursor = input.serverId
? useSessionStore
.getState()

View File

@@ -131,6 +131,7 @@ import {
getIsElectron,
} from "@/constants/platform";
import { getDesktopHost } from "@/desktop/host";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -609,6 +610,10 @@ function ProjectKebabMenu({
{t("sidebar.project.actions.openNewWindow")}
</DropdownMenuItem>
) : null}
<OpenInFileManagerMenuItem
path={projectPath}
testID={`sidebar-project-menu-open-folder-${projectKey}`}
/>
<DropdownMenuItem
testID={`sidebar-project-menu-remove-${projectKey}`}
leading={trash2LeadingIcon}
@@ -660,6 +665,7 @@ function WorkspaceRowRightGroup({
isPinned?: boolean;
onTogglePin?: () => void;
}) {
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
const { t } = useTranslation();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
@@ -698,6 +704,7 @@ function WorkspaceRowRightGroup({
archiveShortcutKeys={archiveShortcutKeys}
isPinned={isPinned}
onTogglePin={onTogglePin}
openInFileManagerPath={workspacePath}
/>
) : null}
</SidebarWorkspaceTrailingActionOverlay>

View File

@@ -13,6 +13,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -58,6 +59,7 @@ interface SidebarWorkspaceMenuProps {
archiveShortcutKeys?: ShortcutKey[][] | null;
isPinned?: boolean;
onTogglePin?: () => void;
openInFileManagerPath?: string | null;
}
export function SidebarWorkspaceMenu({
@@ -73,6 +75,7 @@ export function SidebarWorkspaceMenu({
archiveShortcutKeys,
isPinned,
onTogglePin,
openInFileManagerPath,
}: SidebarWorkspaceMenuProps) {
const { t } = useTranslation();
const archiveTrailing = useMemo(
@@ -137,6 +140,10 @@ export function SidebarWorkspaceMenu({
{isPinned ? t("sidebar.workspace.actions.unpin") : t("sidebar.workspace.actions.pin")}
</DropdownMenuItem>
) : null}
<OpenInFileManagerMenuItem
path={openInFileManagerPath}
testID={`sidebar-workspace-menu-open-folder-${workspaceKey}`}
/>
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}

View File

@@ -8,7 +8,7 @@ import {
measureFloatingPanelPortalHost,
useFloatingPanelPortalHostName,
} from "@/components/ui/floating-panel-portal";
import { useKeyboardShift } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
import { SPACING } from "@/styles/theme";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";

View File

@@ -6,7 +6,17 @@ import type {
UserComposerAttachment,
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { StreamItem } from "@/types/stream";
import {
appendSubmittedUserMessage,
removeSubmittedUserMessage,
type StreamItem,
} from "@/types/stream";
import {
acceptMessageSubmission,
beginMessageSubmission,
rejectMessageSubmission,
type MessageSubmissionRecord,
} from "@/composer/submission/model";
import {
cancelComposerAgent,
dispatchComposerAgentMessage,
@@ -20,7 +30,7 @@ import {
sendQueuedComposerMessageNow,
toggleGithubAttachment,
toggleGithubAttachmentFromPicker,
type AgentStreamWriter,
type MessageSubmissionWriter,
type AttachmentPersister,
type ComposerCancelClient,
type ComposerSendClient,
@@ -168,14 +178,16 @@ interface FakeSendCall {
}
function createFakeSendClient(
options: { rejection?: Error } = {},
options: { rejection?: Error; beforeRejection?: (call: FakeSendCall) => void } = {},
): ComposerSendClient & { calls: FakeSendCall[] } {
const calls: FakeSendCall[] = [];
return {
calls,
sendAgentMessage: async (agentId, text, opts) => {
calls.push({ agentId, text, options: opts });
const call = { agentId, text, options: opts };
calls.push(call);
if (options.rejection) {
options.beforeRejection?.(call);
throw options.rejection;
}
},
@@ -183,7 +195,7 @@ function createFakeSendClient(
};
}
interface FakeStream extends AgentStreamWriter {
interface FakeStream extends MessageSubmissionWriter {
head: Map<string, StreamItem[]>;
tail: Map<string, StreamItem[]>;
}
@@ -192,18 +204,70 @@ function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): F
const fake: FakeStream = {
head: new Map(initialHead),
tail: new Map(),
getTail: (agentId) => fake.tail.get(agentId),
getHead: (agentId) => fake.head.get(agentId),
setHead: (updater) => {
fake.head = updater(fake.head);
begin: (agentId, message) => {
const current = readSubmission(fake, agentId);
const stream = appendSubmittedUserMessage({
tail: current.tail,
head: current.head,
message,
});
writeSubmission(fake, agentId, {
...stream,
submissions: beginMessageSubmission(current.submissions, {
clientMessageId: message.clientMessageId!,
submittedAt: message.timestamp,
}),
});
},
setTail: (updater) => {
fake.tail = updater(fake.tail);
accept: (agentId, clientMessageId) => {
const current = readSubmission(fake, agentId);
writeSubmission(fake, agentId, {
...current,
submissions: acceptMessageSubmission(current.submissions, clientMessageId, true, false),
});
},
reject: (agentId, clientMessageId) => {
const current = readSubmission(fake, agentId);
const result = rejectMessageSubmission(current.submissions, clientMessageId);
const stream =
result.outcome === "rejected"
? removeSubmittedUserMessage({
tail: current.tail,
head: current.head,
clientMessageId,
})
: current;
writeSubmission(fake, agentId, { ...stream, submissions: result.submissions });
return result.outcome;
},
};
return fake;
}
const submissionsByFakeStream = new WeakMap<FakeStream, Map<string, MessageSubmissionRecord[]>>();
interface FakeSubmissionState {
tail: StreamItem[];
head: StreamItem[];
submissions: MessageSubmissionRecord[];
}
function readSubmission(fake: FakeStream, agentId: string): FakeSubmissionState {
return {
tail: fake.tail.get(agentId) ?? [],
head: fake.head.get(agentId) ?? [],
submissions: submissionsByFakeStream.get(fake)?.get(agentId) ?? [],
};
}
function writeSubmission(fake: FakeStream, agentId: string, state: FakeSubmissionState): void {
fake.tail = new Map(fake.tail).set(agentId, state.tail);
fake.head = new Map(fake.head).set(agentId, state.head);
const submissions = submissionsByFakeStream.get(fake) ?? new Map();
submissions.set(agentId, state.submissions);
submissionsByFakeStream.set(fake, submissions);
}
function createFakeQueue(
initial: Map<string, QueuedComposerMessage[]> = new Map(),
): QueueWriter & { state: Map<string, QueuedComposerMessage[]> } {
@@ -337,7 +401,7 @@ describe("pickAndPersistImages", () => {
});
describe("dispatchComposerAgentMessage", () => {
it("removes the optimistic prompt when the host rejects it", async () => {
it("removes the submitted prompt when the host rejects it", async () => {
const rejection = new Error("Host rejected prompt");
const client = createFakeSendClient({ rejection });
const stream = createFakeStream();
@@ -349,14 +413,54 @@ describe("dispatchComposerAgentMessage", () => {
text: "rejected prompt",
attachments: [],
encodeImages: passthroughEncodeImages,
stream,
submission: stream,
}),
).rejects.toBe(rejection);
expect(stream.head.get("agent")).toBeUndefined();
expect(stream.head.get("agent")).toEqual([]);
expect(stream.tail.get("agent") ?? []).toEqual([]);
});
it("rolls back an already-running force send when its RPC fails", async () => {
const stream = createFakeStream();
const transportError = new Error("Force send failed while the prior turn was running");
const client = createFakeSendClient({ rejection: transportError });
await expect(
dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "force send",
attachments: [],
encodeImages: passthroughEncodeImages,
submission: stream,
}),
).rejects.toBe(transportError);
expect(stream.tail.get("agent") ?? []).toEqual([]);
});
it("does not swallow a transport error when submission state is missing", async () => {
const transportError = new Error("Connection lost with unknown submission state");
const client = createFakeSendClient({ rejection: transportError });
const submission: MessageSubmissionWriter = {
begin: () => {},
accept: () => {},
reject: () => "unknown",
};
await expect(
dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "unknown state",
attachments: [],
encodeImages: passthroughEncodeImages,
submission,
}),
).rejects.toBe(transportError);
});
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
@@ -371,7 +475,7 @@ describe("dispatchComposerAgentMessage", () => {
{ kind: "github_pr", item: prItem },
],
encodeImages: passthroughEncodeImages,
stream,
submission: stream,
});
expect(client.calls).toHaveLength(1);
@@ -393,7 +497,7 @@ describe("dispatchComposerAgentMessage", () => {
},
]);
expect(stream.head.get("agent")).toBeUndefined();
expect(stream.head.get("agent")).toEqual([]);
const tail = stream.tail.get("agent");
expect(tail).toHaveLength(1);
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
@@ -402,7 +506,8 @@ describe("dispatchComposerAgentMessage", () => {
expect(userMessage.images).toEqual([image]);
expect(userMessage.attachments).toEqual(call.options.attachments);
expect(userMessage.id).toBe(call.options.messageId);
expect(userMessage.optimistic).toBe(true);
expect(userMessage.clientMessageId).toBe(call.options.messageId);
expect(userMessage.messageId).toBeUndefined();
});
it("can send legacy GitHub attachment payloads for old daemons", async () => {
@@ -416,7 +521,7 @@ describe("dispatchComposerAgentMessage", () => {
attachments: [{ kind: "forge_change_request", item: prItem }],
attachmentSubmitFormat: "legacy-github",
encodeImages: passthroughEncodeImages,
stream,
submission: stream,
});
expect(client.calls[0].options.attachments).toEqual([
@@ -449,11 +554,11 @@ describe("dispatchComposerAgentMessage", () => {
text: "next message",
attachments: [],
encodeImages: passthroughEncodeImages,
stream,
submission: stream,
});
expect(stream.head.get("agent")).toHaveLength(2);
expect(stream.tail.get("agent")).toBeUndefined();
expect(stream.tail.get("agent")).toEqual([]);
});
it("submits empty wire arrays when no attachments are provided", async () => {
@@ -466,7 +571,7 @@ describe("dispatchComposerAgentMessage", () => {
text: "plain message",
attachments: [],
encodeImages: passthroughEncodeImages,
stream,
submission: stream,
});
expect(client.calls[0]?.options).toMatchObject({
@@ -486,7 +591,7 @@ describe("dispatchComposerAgentMessage", () => {
text: "review this",
attachments: [review],
encodeImages: passthroughEncodeImages,
stream,
submission: stream,
});
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
@@ -504,7 +609,7 @@ describe("dispatchComposerAgentMessage", () => {
text: "inspect element",
attachments: [browserElement],
encodeImages: passthroughEncodeImages,
stream,
submission: stream,
});
expect(client.calls[0]?.options.attachments).toEqual([

View File

@@ -12,13 +12,8 @@ import {
splitComposerAttachmentsForSubmit,
type ComposerAttachmentSubmitFormat,
} from "@/composer/attachments/submit";
import {
appendOptimisticUserMessageToStream,
buildOptimisticUserMessage,
generateMessageId,
type StreamItem,
type UserMessageItem,
} from "@/types/stream";
import { createUserMessage, generateMessageId, type UserMessageItem } from "@/types/stream";
import type { MessageSubmissionRejectionOutcome } from "@/composer/submission/model";
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
import { i18n } from "@/i18n/i18next";
@@ -51,7 +46,7 @@ export interface ComposerSendClient {
images: Array<{ data: string; mimeType: string }>;
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
},
) => Promise<void>;
) => Promise<void | { outOfBand?: boolean }>;
uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{
requestId: string;
file: {
@@ -70,11 +65,10 @@ export interface ComposerCancelClient {
cancelAgent: (agentId: string) => Promise<void> | void;
}
export interface AgentStreamWriter {
getTail: (agentId: string) => StreamItem[] | undefined;
getHead: (agentId: string) => StreamItem[] | undefined;
setHead: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
setTail: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
export interface MessageSubmissionWriter {
begin: (agentId: string, message: UserMessageItem) => void;
accept: (agentId: string, clientMessageId: string, outOfBand: boolean | undefined) => void;
reject: (agentId: string, clientMessageId: string) => MessageSubmissionRejectionOutcome;
}
export interface QueueWriter {
@@ -169,7 +163,7 @@ export interface DispatchComposerAgentMessageInput {
encodeImages: (
images: AttachmentMetadata[],
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
stream: AgentStreamWriter;
submission: MessageSubmissionWriter;
}
export async function dispatchComposerAgentMessage(
@@ -178,60 +172,30 @@ export async function dispatchComposerAgentMessage(
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
format: input.attachmentSubmitFormat,
});
const messageId = generateMessageId();
const userMessage = buildOptimisticUserMessage({
id: messageId,
const clientMessageId = generateMessageId();
const userMessage = createUserMessage({
clientMessageId,
text: input.text,
timestamp: new Date(),
images: wirePayload.images,
attachments: wirePayload.attachments,
});
const rollbackOptimisticMessage = appendUserMessageToStream(
input.agentId,
userMessage,
input.stream,
);
input.submission.begin(input.agentId, userMessage);
try {
const imagesData = await input.encodeImages(wirePayload.images);
await input.client.sendAgentMessage(input.agentId, input.text, {
messageId,
const result = await input.client.sendAgentMessage(input.agentId, input.text, {
messageId: clientMessageId,
images: imagesData ?? [],
attachments: wirePayload.attachments,
});
input.submission.accept(input.agentId, clientMessageId, result?.outOfBand);
} catch (error) {
rollbackOptimisticMessage();
const outcome = input.submission.reject(input.agentId, clientMessageId);
if (outcome === "accepted") return;
throw error;
}
}
function appendUserMessageToStream(
agentId: string,
userMessage: UserMessageItem,
stream: AgentStreamWriter,
): () => void {
const result = appendOptimisticUserMessageToStream({
tail: stream.getTail(agentId) ?? [],
head: stream.getHead(agentId) ?? [],
message: userMessage,
placement: "active-head",
});
const write = result.changedHead ? stream.setHead : stream.setTail;
const items = result.changedHead ? result.head : result.tail;
write((prev) => new Map(prev).set(agentId, items));
return () => {
write((prev) => {
const current = prev.get(agentId);
if (!current) return prev;
const nextItems = current.filter(
(item) => item.id !== userMessage.id || item.kind !== "user_message" || !item.optimistic,
);
if (nextItems.length === current.length) return prev;
return new Map(prev).set(agentId, nextItems);
});
};
}
export interface QueueComposerMessageInput {
agentId: string;
text: string;

View File

@@ -13,7 +13,7 @@ describe("useDraftAgentCreateFlow", () => {
useCreateFlowStore.setState({ pendingByDraftId: {} });
});
it("renders a prepared new-workspace create attempt as optimistic chat before continuing it", async () => {
it("renders a prepared new-workspace submission before continuing it", async () => {
const image: UserMessageImageAttachment = {
id: "image-1",
mimeType: "image/png",
@@ -60,13 +60,13 @@ describe("useDraftAgentCreateFlow", () => {
expect(result.current.isSubmitting).toBe(true);
expect(result.current.draftAgent).toEqual({ currentAttempt: attempt });
expect(result.current.optimisticStreamItems).toEqual([
expect(result.current.submittedStreamItems).toEqual([
{
kind: "user_message",
id: "msg-prepared",
clientMessageId: "msg-prepared",
text: "build this",
timestamp: attempt.timestamp,
optimistic: true,
images: [image],
attachments: [attachment],
},

View File

@@ -8,12 +8,13 @@ import {
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import {
buildOptimisticUserMessage,
createUserMessage,
generateMessageId,
type StreamItem,
type UserMessageImageAttachment,
} from "@/types/stream";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PendingMessageSubmission } from "@/composer/submission/model";
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
@@ -133,7 +134,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
const isSubmitting = machine.tag === "creating";
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
const submittedStreamItems = useMemo<StreamItem[]>(() => {
if (machine.tag !== "creating") {
return EMPTY_STREAM_ITEMS;
}
@@ -147,8 +148,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
}
return [
buildOptimisticUserMessage({
id: machine.attempt.clientMessageId,
createUserMessage({
clientMessageId: machine.attempt.clientMessageId,
text: machine.attempt.text,
timestamp: machine.attempt.timestamp,
images: machine.attempt.images,
@@ -156,6 +157,15 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
}),
];
}, [machine]);
const pendingMessageSubmissions = useMemo<readonly PendingMessageSubmission[]>(() => {
if (machine.tag !== "creating") return [];
return [
{
clientMessageId: machine.attempt.clientMessageId,
submittedAt: machine.attempt.timestamp,
},
];
}, [machine]);
const draftAgent = useMemo<TDraftAgent | null>(() => {
if (machine.tag !== "creating") {
@@ -195,8 +205,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
handoffCreatedAgentUserMessage(
pendingServerId,
createResult.agentId,
buildOptimisticUserMessage({
id: attempt.clientMessageId,
createUserMessage({
clientMessageId: attempt.clientMessageId,
text: attempt.text,
timestamp: attempt.timestamp,
images: attempt.images,
@@ -326,7 +336,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
machine,
formErrorMessage,
isSubmitting,
optimisticStreamItems,
submittedStreamItems,
pendingMessageSubmissions,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,

View File

@@ -479,7 +479,8 @@ export function WorkspaceDraftAgentTab({
const {
formErrorMessage,
isSubmitting,
optimisticStreamItems,
submittedStreamItems,
pendingMessageSubmissions,
draftAgent,
handleCreateFromInput,
continueCreateFromAttempt,
@@ -642,7 +643,8 @@ export function WorkspaceDraftAgentTab({
agentId={tabId}
serverId={serverId}
context={draftAgent}
streamItems={optimisticStreamItems}
streamItems={submittedStreamItems}
pendingMessageSubmissions={pendingMessageSubmissions}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>

View File

@@ -64,7 +64,6 @@ import {
sendQueuedComposerMessageNow,
toggleGithubAttachmentFromPicker,
uploadFileAttachments,
type AgentStreamWriter,
type QueueWriter,
type QueuedComposerMessage,
} from "@/composer/actions";
@@ -91,6 +90,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
import { submitAgentInput } from "@/composer/submit";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
@@ -1079,8 +1079,6 @@ export function Composer({
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
@@ -1283,12 +1281,6 @@ export function Composer({
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const stream: AgentStreamWriter = {
getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id),
getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id),
setHead: (updater) => setAgentStreamHead(serverId, updater),
setTail: (updater) => setAgentStreamTail(serverId, updater),
};
await dispatchComposerAgentMessage({
client,
agentId: targetAgentId,
@@ -1298,19 +1290,11 @@ export function Composer({
supportsForgeAttachments: supportsForgeSearch,
}),
encodeImages,
stream,
submission: createMessageSubmissionWriter(serverId),
});
onAttentionPromptSend?.();
};
}, [
client,
onAttentionPromptSend,
serverId,
setAgentStreamTail,
setAgentStreamHead,
supportsForgeSearch,
t,
]);
}, [client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage;

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import {
acceptMessageSubmission,
beginMessageSubmission,
getActiveMessageSubmissions,
getSendingClientMessageIds,
observeAcceptedMessageSubmissionsRunning,
observeMessageSubmissionCanonical,
rejectMessageSubmission,
} from "./model";
const submittedAt = new Date("2026-07-26T10:00:00.000Z");
describe("message submission transactions", () => {
it("tracks every in-flight submission independently", () => {
const first = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
const both = beginMessageSubmission(first, {
clientMessageId: "client-2",
submittedAt: new Date(submittedAt.getTime() + 1),
});
expect(getActiveMessageSubmissions(both).map((item) => item.clientMessageId)).toEqual([
"client-1",
"client-2",
]);
expect(getSendingClientMessageIds(both)).toEqual(["client-1", "client-2"]);
});
it("removes only the RPC-accepted transaction", () => {
const both = beginMessageSubmission(
beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }),
{ clientMessageId: "client-2", submittedAt },
);
expect(acceptMessageSubmission(both, "client-1", true, false)).toEqual([
{
clientMessageId: "client-2",
submittedAt,
rpcAccepted: false,
providerAcknowledged: false,
},
]);
});
it("bridges an accepted RPC until the correlated running state is observed", () => {
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
const accepted = acceptMessageSubmission(sending, "client-1", false, false);
expect(getActiveMessageSubmissions(accepted)).toHaveLength(1);
expect(accepted[0].rpcAccepted).toBe(true);
expect(observeAcceptedMessageSubmissionsRunning(accepted)).toEqual([]);
});
it("settles an accepted RPC when provider acknowledgement arrives after running was missed", () => {
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
const accepted = acceptMessageSubmission(sending, "client-1", false, false);
expect(observeMessageSubmissionCanonical(accepted, ["client-1"])).toEqual([]);
});
it("settles an explicitly out-of-band acceptance without lifecycle inference", () => {
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
expect(acceptMessageSubmission(sending, "client-1", false, true)).toEqual([]);
});
it("settles an idle acceptance from a daemon without submission disposition", () => {
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
expect(acceptMessageSubmission(sending, "client-1", false, undefined)).toEqual([]);
});
it("records provider acknowledgement without settling another transaction", () => {
const both = beginMessageSubmission(
beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }),
{ clientMessageId: "client-2", submittedAt },
);
const observed = observeMessageSubmissionCanonical(both, ["client-1"]);
expect(observed).toEqual([
{
clientMessageId: "client-1",
submittedAt,
rpcAccepted: false,
providerAcknowledged: true,
},
{
clientMessageId: "client-2",
submittedAt,
rpcAccepted: false,
providerAcknowledged: false,
},
]);
expect(getSendingClientMessageIds(observed)).toEqual(["client-2"]);
});
it("does not roll back a provider-acknowledged prompt on a later transport error", () => {
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
const observed = observeMessageSubmissionCanonical(sending, ["client-1"]);
expect(rejectMessageSubmission(observed, "client-1")).toEqual({
outcome: "accepted",
submissions: [],
});
});
it("rejects an unacknowledged transaction", () => {
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
expect(rejectMessageSubmission(sending, "client-1")).toEqual({
outcome: "rejected",
submissions: [],
});
});
it("does not create duplicate transaction identity", () => {
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
expect(() =>
beginMessageSubmission(sending, { clientMessageId: "client-1", submittedAt }),
).toThrow("Message submission already exists");
});
});

View File

@@ -0,0 +1,108 @@
export interface PendingMessageSubmission {
clientMessageId: string;
submittedAt: Date;
}
export type MessageSubmissionRecord = PendingMessageSubmission & {
rpcAccepted: boolean;
providerAcknowledged: boolean;
};
const EMPTY_MESSAGE_SUBMISSIONS: readonly MessageSubmissionRecord[] = [];
export function getActiveMessageSubmissions(
submissions: readonly MessageSubmissionRecord[] | null | undefined,
): readonly PendingMessageSubmission[] {
return submissions ?? EMPTY_MESSAGE_SUBMISSIONS;
}
export function getSendingClientMessageIds(
submissions: readonly MessageSubmissionRecord[] | null | undefined,
): string[] {
return (submissions ?? [])
.filter((submission) => !submission.providerAcknowledged)
.map((submission) => submission.clientMessageId);
}
export type MessageSubmissionRejectionOutcome = "rejected" | "accepted" | "unknown";
export interface MessageSubmissionRejectionResult {
submissions: MessageSubmissionRecord[];
outcome: MessageSubmissionRejectionOutcome;
}
export function beginMessageSubmission(
submissions: readonly MessageSubmissionRecord[],
input: PendingMessageSubmission,
): MessageSubmissionRecord[] {
if (submissions.some((submission) => submission.clientMessageId === input.clientMessageId)) {
throw new Error(`Message submission already exists: ${input.clientMessageId}`);
}
return [...submissions, { ...input, rpcAccepted: false, providerAcknowledged: false }];
}
export function acceptMessageSubmission(
submissions: readonly MessageSubmissionRecord[],
clientMessageId: string,
isAgentRunning: boolean,
outOfBand: boolean | undefined,
): MessageSubmissionRecord[] {
const index = submissions.findIndex(
(submission) => submission.clientMessageId === clientMessageId,
);
if (index < 0) return submissions as MessageSubmissionRecord[];
// COMPAT(messageSubmissionDisposition): daemons before v0.2.3 omitted outOfBand.
// Their normal-send response follows the ordered running/canonical events, while an
// out-of-band response arrives with the agent still idle. Remove after 2027-01-27.
const legacyOutOfBand = outOfBand === undefined && !isAgentRunning;
if (
outOfBand === true ||
legacyOutOfBand ||
isAgentRunning ||
submissions[index].providerAcknowledged
) {
return submissions.filter((_, submissionIndex) => submissionIndex !== index);
}
if (submissions[index].rpcAccepted) return submissions as MessageSubmissionRecord[];
const next = submissions.slice();
next[index] = { ...next[index], rpcAccepted: true };
return next;
}
export function observeAcceptedMessageSubmissionsRunning(
submissions: readonly MessageSubmissionRecord[],
): MessageSubmissionRecord[] {
const next = submissions.filter((submission) => !submission.rpcAccepted);
return next.length === submissions.length ? (submissions as MessageSubmissionRecord[]) : next;
}
export function observeMessageSubmissionCanonical(
submissions: readonly MessageSubmissionRecord[],
clientMessageIds: readonly string[],
): MessageSubmissionRecord[] {
if (clientMessageIds.length === 0) return submissions as MessageSubmissionRecord[];
const observed = new Set(clientMessageIds);
let changed = false;
const next = submissions.flatMap((submission): MessageSubmissionRecord[] => {
if (submission.providerAcknowledged || !observed.has(submission.clientMessageId)) {
return [submission];
}
changed = true;
return submission.rpcAccepted ? [] : [{ ...submission, providerAcknowledged: true }];
});
return changed ? next : (submissions as MessageSubmissionRecord[]);
}
export function rejectMessageSubmission(
submissions: readonly MessageSubmissionRecord[],
clientMessageId: string,
): MessageSubmissionRejectionResult {
const submission = submissions.find((item) => item.clientMessageId === clientMessageId);
if (!submission) {
return { outcome: "unknown", submissions: submissions as MessageSubmissionRecord[] };
}
return {
outcome: submission.providerAcknowledged || submission.rpcAccepted ? "accepted" : "rejected",
submissions: submissions.filter((item) => item.clientMessageId !== clientMessageId),
};
}

View File

@@ -0,0 +1,20 @@
import type { MessageSubmissionWriter } from "@/composer/actions";
import { useSessionStore } from "@/stores/session-store";
/**
* Binds the submission lifecycle to a host session. Every path that sends a message to an
* agent — composer send, queued send-now, automatic queue drain — goes through this so a
* submitted row and its pending state are always created together.
*/
export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
return {
begin: (agentId, message) =>
useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message),
accept: (agentId, clientMessageId, outOfBand) =>
useSessionStore
.getState()
.acceptAgentMessageSubmission(serverId, agentId, clientMessageId, outOfBand),
reject: (agentId, clientMessageId) =>
useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId),
};
}

View File

@@ -51,7 +51,7 @@ export async function submitAgentInput<TAttachment>(
return "queued";
}
// Clear immediately so optimistic stream updates and composer state stay in sync.
// Clear immediately so the submitted timeline row and composer state stay in sync.
if (shouldClearOnSubmit) {
input.setUserInput("");
input.setAttachments([]);

View File

@@ -53,6 +53,7 @@ import {
} from "@/utils/agent-initialization";
import { encodeImages } from "@/utils/encode-images";
import { derivePendingPermissionKey } from "@/utils/agent-snapshots";
import { getSendingClientMessageIds } from "@/composer/submission/model";
import type { AttachmentMetadata } from "@/attachments/types";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import { useToast } from "@/contexts/toast-context";
@@ -191,9 +192,7 @@ type WorkspaceSetupProgressPayload = Extract<
type SessionStoreActions = ReturnType<typeof useSessionStore.getState>;
type SetInitializingAgents = SessionStoreActions["setInitializingAgents"];
type SetAgentStreamTail = SessionStoreActions["setAgentStreamTail"];
type SetAgentStreamHead = SessionStoreActions["setAgentStreamHead"];
type ClearAgentStreamHead = SessionStoreActions["clearAgentStreamHead"];
type SetAgentStreamState = SessionStoreActions["setAgentStreamState"];
type SetAgentTimelineCursor = SessionStoreActions["setAgentTimelineCursor"];
type MarkAgentHistorySynchronized = SessionStoreActions["markAgentHistorySynchronized"];
type SetAgentAuthoritativeHistoryApplied =
@@ -236,9 +235,7 @@ function applyTimelineStreamPatches(input: {
serverId: string;
currentTail: StreamItem[];
currentHead: StreamItem[];
setAgentStreamTail: SetAgentStreamTail;
setAgentStreamHead: SetAgentStreamHead;
clearAgentStreamHead: ClearAgentStreamHead;
setAgentStreamState: SetAgentStreamState;
setAgentTimelineCursor: SetAgentTimelineCursor;
}): void {
const {
@@ -247,32 +244,24 @@ function applyTimelineStreamPatches(input: {
serverId,
currentTail,
currentHead,
setAgentStreamTail,
setAgentStreamHead,
clearAgentStreamHead,
setAgentStreamState,
setAgentTimelineCursor,
} = input;
if (result.tail !== currentTail) {
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, result.tail);
return next;
if (
result.tail !== currentTail ||
result.head !== currentHead ||
result.acknowledgedClientMessageIds.length > 0
) {
setAgentStreamState(serverId, agentId, {
...(result.tail !== currentTail ? { tail: result.tail } : {}),
...(result.head !== currentHead ? { head: result.head } : {}),
...(result.acknowledgedClientMessageIds.length > 0
? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds }
: {}),
});
}
if (result.head !== currentHead) {
if (result.head.length === 0) {
clearAgentStreamHead(serverId, agentId);
} else {
setAgentStreamHead(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, result.head);
return next;
});
}
}
if (result.cursorChanged) {
setAgentTimelineCursor(serverId, (prev) => {
const current = prev.get(agentId);
@@ -658,6 +647,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const currentCursor = session?.agentTimelineCursor.get(agentId);
const currentTail = session?.agentStreamTail.get(agentId) ?? [];
const currentHead = session?.agentStreamHead.get(agentId) ?? [];
const sendingClientMessageIds = getSendingClientMessageIds(
session?.messageSubmissions.get(agentId),
);
setAgentTimelineHasOlder(serverId, (prev) => {
if (prev.get(agentId) === payload.hasOlder) {
@@ -677,6 +669,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
isInitializing,
hasActiveInitDeferred,
initRequestDirection: activeInitDeferred?.requestDirection ?? "tail",
sendingClientMessageIds,
});
if (result.error) {
@@ -696,9 +689,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId,
currentTail,
currentHead,
setAgentStreamTail,
setAgentStreamHead,
clearAgentStreamHead,
setAgentStreamState,
setAgentTimelineCursor,
});
@@ -720,13 +711,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
});
},
[
clearAgentStreamHead,
markAgentHistorySynchronized,
recoverTimelineGap,
serverId,
setAgentAuthoritativeHistoryApplied,
setAgentStreamHead,
setAgentStreamTail,
setAgentStreamState,
setAgentTimelineCursor,
setAgentTimelineHasOlder,
setInitializingAgents,
@@ -808,7 +797,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
serverId,
setAgentStreamState,
setAgentTimelineCursor,
setAgents,
recoverTimelineGap,
});
@@ -825,7 +813,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
) {
voiceRuntime?.onTurnEvent(serverId, agentId, event.type);
}
agentStreamReducerQueue.enqueue(agentId, {
event: streamEvent,
seq,

View File

@@ -44,6 +44,24 @@ describe("buildForgeBranchTreeUrl", () => {
).toBe("https://codeberg.org/acme/repo/src/branch/main");
});
it("preserves a non-default port for a self-hosted https remote", () => {
expect(
buildForgeBranchTreeUrl("forgejo", {
remoteUrl: "https://home-git.example.com:60443/team/repo.git",
branch: "master",
}),
).toBe("https://home-git.example.com:60443/team/repo/src/branch/master");
});
it("omits the port for a self-hosted remote on the default port", () => {
expect(
buildForgeBranchTreeUrl("forgejo", {
remoteUrl: "https://home-git.example.com/team/repo.git",
branch: "master",
}),
).toBe("https://home-git.example.com/team/repo/src/branch/master");
});
it("returns null when the current branch is unavailable", () => {
expect(
buildForgeBranchTreeUrl("github", {
@@ -131,6 +149,18 @@ describe("buildForgeBlobUrl", () => {
).toBe("https://github.acme.internal/team/repo/blob/main/src/index.ts");
});
it("preserves a non-default port for a self-hosted https remote", () => {
expect(
buildForgeBlobUrl("forgejo", {
remoteUrl: "https://home-git.example.com:60443/team/repo.git",
branch: "master",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://home-git.example.com:60443/team/repo/src/branch/master/src/index.ts#L12-L20");
});
it("canonicalizes the github.com SSH-alias host to the web host", () => {
expect(
buildForgeBlobUrl("github", {

View File

@@ -29,6 +29,8 @@ export interface ForgeBranchTreeUrlInput {
interface ForgeWebLocation {
host: string;
/** Non-default port for a self-hosted http(s) origin, or undefined. */
port?: string;
repo: string;
}
@@ -58,18 +60,28 @@ function resolveForgeWebLocation(
if (!location || !isValidRepoPath(location.path)) {
return null;
}
const cloudHosts = getForgeDefinition(forge)?.cloudHosts;
const webHost =
cloudHosts && cloudHosts.length > 0 && cloudHosts.map(normalizeHost).includes(location.host)
? normalizeHost(cloudHosts[0])
: location.host;
return { host: webHost, repo: location.path };
const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost);
const isCloudHost = cloudHosts.includes(location.host);
const webHost = isCloudHost ? cloudHosts[0] : location.host;
// Carry a non-default port only for a self-hosted http(s) origin (e.g.
// `:60443`): the web UI shares that origin. An SSH/scp remote's port is not the
// web port, and a canonicalized cloud host always serves on the default port.
const port =
!isCloudHost && (location.transport === "http" || location.transport === "https")
? location.port
: undefined;
return { host: webHost, port, repo: location.path };
}
function encodeBranch(branch: string): string {
return branch.split("/").map(encodeURIComponent).join("/");
}
/** Host, plus `:port` when the remote pins a non-default port. */
function forgeAuthority(location: ForgeWebLocation): string {
return location.port ? `${location.host}:${location.port}` : location.host;
}
function normalizeBlobPath(path: string | null | undefined): string | null {
const segments: string[] = [];
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
@@ -102,7 +114,7 @@ export function buildForgeBranchTreeUrl(
if (!grammar || !location || !branch || branch === "HEAD") {
return null;
}
return `https://${location.host}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
return `https://${forgeAuthority(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
}
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
@@ -114,7 +126,7 @@ export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): stri
return null;
}
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
let url = `https://${location.host}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
let url = `https://${forgeAuthority(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
if (input.lineStart && input.lineStart > 0) {
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
}

View File

@@ -0,0 +1,18 @@
import { createContext, useContext } from "react";
import type { SharedValue } from "react-native-reanimated";
export interface KeyboardShiftContextValue {
shift: SharedValue<number>;
bottomInset: SharedValue<number>;
}
export const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
/** Read the app-wide keyboard inset without loading its native provider implementation. */
export function useKeyboardShift(): KeyboardShiftContextValue {
const context = useContext(KeyboardShiftContext);
if (!context) {
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
}
return context;
}

View File

@@ -1,11 +1,4 @@
import {
createContext,
createElement,
useContext,
useEffect,
useMemo,
type ReactNode,
} from "react";
import { createElement, useEffect, useMemo, type ReactNode } from "react";
import { Platform } from "react-native";
import type { ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -23,16 +16,10 @@ import {
DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT,
resolveKeyboardShift,
} from "@/hooks/keyboard-shift-policy";
import { KeyboardShiftContext, useKeyboardShift } from "@/hooks/keyboard-shift-context";
type KeyboardShiftMode = "translate" | "padding";
interface KeyboardShiftContextValue {
shift: SharedValue<number>;
bottomInset: SharedValue<number>;
}
const KeyboardShiftContext = createContext<KeyboardShiftContextValue | null>(null);
export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
const insets = useSafeAreaInsets();
const { height: keyboardHeight, progress: keyboardProgress } = useReanimatedKeyboardAnimation();
@@ -78,14 +65,6 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) {
return createElement(KeyboardShiftContext.Provider, { value }, children);
}
export function useKeyboardShift(): KeyboardShiftContextValue {
const context = useContext(KeyboardShiftContext);
if (!context) {
throw new Error("useKeyboardShift must be used inside KeyboardShiftProvider");
}
return context;
}
export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): {
shift: SharedValue<number>;
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;

View File

@@ -908,6 +908,8 @@ export const ar: TranslationResources = {
openSettings: "افتح إعدادات المشروع",
openNewWindow: "Open in new window",
openNewWindowFailed: "Couldn't open a new window",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "إزالة المشروع",
removing: "جارٍ الإزالة...",
},
@@ -1831,6 +1833,7 @@ export const ar: TranslationResources = {
sendMessage: "أرسل رسالة",
queueMessage: "رسالة قائمة الانتظار",
muteUnmuteVoiceMode: "كتم وضع الصوت /unmute",
switchProject: "تبديل المشروع",
},
helpNotes: {
showKeyboardShortcuts: "متاح عندما لا يكون التركيز في حقل نص أو محطة طرفية.",

View File

@@ -918,6 +918,8 @@ export const en = {
openSettings: "Open project settings",
openNewWindow: "Open in new window",
openNewWindowFailed: "Couldn't open a new window",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "Remove project",
removing: "Removing...",
},
@@ -1841,6 +1843,7 @@ export const en = {
sendMessage: "Send message",
queueMessage: "Queue message",
muteUnmuteVoiceMode: "Mute/unmute voice mode",
switchProject: "Switch project",
},
helpNotes: {
showKeyboardShortcuts: "Available when focus is not in a text field or terminal.",

View File

@@ -939,6 +939,8 @@ export const es: TranslationResources = {
openSettings: "Abrir la configuración del proyecto",
openNewWindow: "Open in new window",
openNewWindowFailed: "Couldn't open a new window",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "Eliminar proyecto",
removing: "Eliminando...",
},
@@ -1879,6 +1881,7 @@ export const es: TranslationResources = {
sendMessage: "enviar mensaje",
queueMessage: "mensaje de cola",
muteUnmuteVoiceMode: "Silenciar el modo de voz/unmute",
switchProject: "Cambiar proyecto",
},
helpNotes: {
showKeyboardShortcuts: "Disponible cuando el foco no está en un campo de texto o terminal.",

View File

@@ -938,6 +938,8 @@ export const fr: TranslationResources = {
openSettings: "Ouvrir les paramètres du projet",
openNewWindow: "Open in new window",
openNewWindowFailed: "Couldn't open a new window",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "Supprimer le projet",
removing: "Suppression...",
},
@@ -1881,6 +1883,7 @@ export const fr: TranslationResources = {
sendMessage: "Envoyer un message",
queueMessage: "Message de file d'attente",
muteUnmuteVoiceMode: "Mode vocal/unmutemuet",
switchProject: "Changer de projet",
},
helpNotes: {
showKeyboardShortcuts:

View File

@@ -919,6 +919,8 @@ export const ja: TranslationResources = {
openSettings: "プロジェクト設定を開く",
openNewWindow: "新しいウィンドウで開く",
openNewWindowFailed: "新しいウィンドウを開けませんでした",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "プロジェクトを削除",
removing: "削除中...",
},
@@ -1847,6 +1849,7 @@ export const ja: TranslationResources = {
sendMessage: "メッセージを送信",
queueMessage: "メッセージをキューに追加",
muteUnmuteVoiceMode: "音声モードのミュートを切り替え",
switchProject: "プロジェクトを切り替え",
},
helpNotes: {
showKeyboardShortcuts:

View File

@@ -930,6 +930,8 @@ export const ptBR: TranslationResources = {
openSettings: "Abrir configurações do projeto",
openNewWindow: "Abrir em nova janela",
openNewWindowFailed: "Não foi possível abrir uma nova janela",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "Remover projeto",
removing: "Removendo...",
},
@@ -1862,6 +1864,7 @@ export const ptBR: TranslationResources = {
sendMessage: "Enviar mensagem",
queueMessage: "Enfileirar mensagem",
muteUnmuteVoiceMode: "Silenciar/ativar modo de voz",
switchProject: "Trocar projeto",
},
helpNotes: {
showKeyboardShortcuts:

View File

@@ -930,6 +930,8 @@ export const ru: TranslationResources = {
openSettings: "Открыть настройки проекта",
openNewWindow: "Open in new window",
openNewWindowFailed: "Couldn't open a new window",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "Удалить проект",
removing: "Удаление...",
},
@@ -1869,6 +1871,7 @@ export const ru: TranslationResources = {
sendMessage: "Отправить сообщение",
queueMessage: "Сообщение в очереди",
muteUnmuteVoiceMode: "Отключить голосовой режим /unmute",
switchProject: "Сменить проект",
},
helpNotes: {
showKeyboardShortcuts: "Доступно, когда фокус находится не в текстовом поле или терминале.",

View File

@@ -899,6 +899,8 @@ export const zhCN: TranslationResources = {
openSettings: "打开 project 设置",
openNewWindow: "在新窗口中打开",
openNewWindowFailed: "无法打开新窗口",
openFolder: "Open in file manager",
openFolderFailed: "Couldn't open folder",
remove: "移除 project",
removing: "正在移除...",
},
@@ -1810,6 +1812,7 @@ export const zhCN: TranslationResources = {
sendMessage: "发送消息",
queueMessage: "消息排队",
muteUnmuteVoiceMode: "静音/取消静音语音模式",
switchProject: "切换项目",
},
helpNotes: {
showKeyboardShortcuts: "焦点不在文本输入框或终端内时可用。",

View File

@@ -44,6 +44,7 @@ export type KeyboardActionId =
| "shortcuts.dialog.toggle"
| "workspace.terminal.new"
| "workspace.new"
| "workspace.project.pick"
| "worktree.new"
| "workspace.archive"
| "workspace.pin"

View File

@@ -29,6 +29,7 @@ export type KeyboardActionId =
| "workspace.terminal.new"
| "sidebar.toggle.right"
| "workspace.new"
| "workspace.project.pick"
| "worktree.new"
| "workspace.archive"
| "workspace.pin";
@@ -62,6 +63,7 @@ export type KeyboardActionDefinition =
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
| { id: "workspace.new"; scope: KeyboardActionScope }
| { id: "workspace.project.pick"; scope: KeyboardActionScope }
| { id: "worktree.new"; scope: KeyboardActionScope }
| { id: "workspace.archive"; scope: KeyboardActionScope }
| { id: "workspace.pin"; scope: KeyboardActionScope };

View File

@@ -142,6 +142,18 @@ describe("keyboard-shortcuts", () => {
context: { isMac: false, commandCenterOpen: false, focusScope: "other" },
action: "workspace.new",
},
{
name: "matches Cmd+P to switch project on mac",
event: { key: "p", code: "KeyP", metaKey: true },
context: { isMac: true, commandCenterOpen: false },
action: "workspace.project.pick",
},
{
name: "matches Ctrl+P to switch project on non-mac",
event: { key: "p", code: "KeyP", ctrlKey: true },
context: { isMac: false, commandCenterOpen: false, focusScope: "other" },
action: "workspace.project.pick",
},
{
name: "matches question-mark shortcut to toggle the shortcuts dialog",
event: { key: "?", code: "Slash", shiftKey: true },
@@ -410,6 +422,16 @@ describe("keyboard-shortcuts", () => {
event: { key: "?", code: "Slash", shiftKey: true },
context: { focusScope: "message-input" },
},
{
name: "does not switch project with Ctrl+P on non-mac while terminal is focused",
event: { key: "p", code: "KeyP", ctrlKey: true },
context: { isMac: false, focusScope: "terminal" },
},
{
name: "does not switch project with Cmd+P while the command center is open",
event: { key: "p", code: "KeyP", metaKey: true },
context: { isMac: true, commandCenterOpen: true },
},
{
name: "does not close tab with Ctrl+W on mac desktop (Cmd+W only)",
event: { key: "w", code: "KeyW", ctrlKey: true },

View File

@@ -131,6 +131,7 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
"new-agent": "settings.shortcuts.help.openProject",
"new-workspace": "settings.shortcuts.help.newWorkspace",
"switch-project": "settings.shortcuts.help.switchProject",
"archive-workspace": "settings.shortcuts.help.archiveWorkspace",
"workspace-tab-new": "settings.shortcuts.help.newTab",
"workspace-tab-close-current": "settings.shortcuts.help.closeCurrentTab",
@@ -231,6 +232,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
},
},
// --- Switch project (New Workspace screen) ---
{
id: "workspace-project-pick-cmd-p-mac",
action: "workspace.project.pick",
combo: "Cmd+P",
when: { mac: true, commandCenter: false },
help: {
id: "switch-project",
section: "projects",
label: "Switch project",
keys: ["mod", "P"],
},
},
{
id: "workspace-project-pick-ctrl-p-non-mac",
action: "workspace.project.pick",
combo: "Ctrl+P",
when: { mac: false, commandCenter: false, terminal: false },
help: {
id: "switch-project",
section: "projects",
label: "Switch project",
keys: ["mod", "P"],
},
},
// --- Archive workspace ---
{
// COMPAT(workspaceArchiveShortcutOverride): added in v0.1.106; remove after

View File

@@ -30,6 +30,7 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => {
["agent.interrupt", { id: "agent.interrupt", scope: "global" }],
["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }],
["workspace.new", { id: "workspace.new", scope: "sidebar" }],
["workspace.project.pick", { id: "workspace.project.pick", scope: "workspace" }],
["workspace.archive", { id: "workspace.archive", scope: "sidebar" }],
["workspace.pin", { id: "workspace.pin", scope: "sidebar" }],
["worktree.new", { id: "worktree.new", scope: "sidebar" }],

View File

@@ -42,6 +42,7 @@ const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
"agent.interrupt": { id: "agent.interrupt", scope: "global" },
"workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" },
"workspace.new": { id: "workspace.new", scope: "sidebar" },
"workspace.project.pick": { id: "workspace.project.pick", scope: "workspace" },
"workspace.archive": { id: "workspace.archive", scope: "sidebar" },
"workspace.pin": { id: "workspace.pin", scope: "sidebar" },
"worktree.new": { id: "worktree.new", scope: "sidebar" },

View File

@@ -25,6 +25,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { SidebarCallout } from "@/components/sidebar-callout";
import { Composer } from "@/composer";
import { getActiveMessageSubmissions } from "@/composer/submission/model";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
import { getProviderIcon } from "@/components/provider-icons";
import {
@@ -442,6 +443,7 @@ export function useDraftPanelDescriptor(
}
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const EMPTY_MESSAGE_SUBMISSIONS = [] as const;
const EMPTY_PENDING_PERMISSIONS = new Map<string, PendingPermission>();
const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = [];
@@ -1288,6 +1290,11 @@ const AgentStreamSection = memo(function AgentStreamSection({
const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
);
const pendingMessageSubmissions = useSessionStore((state) =>
agentId
? getActiveMessageSubmissions(state.sessions[serverId]?.messageSubmissions.get(agentId))
: EMPTY_MESSAGE_SUBMISSIONS,
);
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
const pendingPermissionList = useStoreWithEqualityFn(
useSessionStore,
@@ -1327,6 +1334,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
toast={toast}
pendingMessageSubmissions={pendingMessageSubmissions}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);

View File

@@ -80,13 +80,16 @@ class FakeDaemonClient {
this.setConnectionState({ status: "disconnected", reason: "client_closed" });
}
async sendAgentMessage(...args: Parameters<DaemonClient["sendAgentMessage"]>): Promise<void> {
async sendAgentMessage(
...args: Parameters<DaemonClient["sendAgentMessage"]>
): ReturnType<DaemonClient["sendAgentMessage"]> {
this.sentAgentMessages.push(args);
for (const waiter of this.sentMessageWaiters) waiter();
const response = this.sendAgentMessageResponses.shift();
if (response) await response;
const failure = this.sendAgentMessageFailures.shift();
if (failure) throw failure;
return {};
}
async waitForSentMessages(count: number): Promise<void> {
@@ -2259,6 +2262,66 @@ describe("HostRuntimeStore", () => {
useSessionStore.getState().clearSession(host.serverId);
});
it("submits an automatically drained message through the submission producer", async () => {
const host = makeHost({ serverId: "srv_drain_submission" });
const fakeClient = new FakeDaemonClient();
const send = new Deferred<void>();
fakeClient.sendAgentMessageResponses.push(send.promise);
const store = new HostRuntimeStore({
deps: {
createClient: () => fakeClient as unknown as DaemonClient,
connectToDaemon: async () => ({
client: fakeClient as unknown as DaemonClient,
serverId: host.serverId,
hostname: null,
}),
getClientId: async () => "cid_drain_submission",
},
});
const sessionStore = useSessionStore.getState();
sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1);
sessionStore.setQueuedMessages(
host.serverId,
new Map([
[
"agent",
[
{
id: "queued-with-attachment",
text: "read this file",
attachments: [
{
kind: "workspace_file" as const,
path: "src/main.ts",
selection: { kind: "whole_file" as const },
},
],
},
],
],
]),
);
store.drainQueuedAgentMessage(host.serverId, "agent");
await fakeClient.waitForSentMessages(1);
// The row and the pending submission must exist while the RPC is still in flight —
// the user sees their message and the working footer immediately, exactly as when
// they press send.
const session = useSessionStore.getState().sessions[host.serverId];
const tail = session?.agentStreamTail.get("agent") ?? [];
expect(tail).toHaveLength(1);
expect(tail[0]).toMatchObject({
kind: "user_message",
text: "read this file",
attachments: [{ type: "text", title: "main.ts", text: "Workspace file: src/main.ts" }],
});
expect(session?.messageSubmissions.get("agent")).toBeDefined();
send.resolve();
useSessionStore.getState().clearSession(host.serverId);
});
it("restores an automatically drained message when sending fails", async () => {
const host = makeHost({ serverId: "srv_failed_queue_drain" });
const fakeClient = new FakeDaemonClient();

View File

@@ -49,11 +49,9 @@ import {
} from "@/data/push-router";
import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler";
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
import { sendQueuedComposerMessageNow } from "@/composer/actions";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
import { encodeImages } from "@/utils/encode-images";
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
import { ReplicaCache } from "@/runtime/replica-cache";
@@ -2071,14 +2069,16 @@ export class HostRuntimeStore {
submitMessage: async ({ text, attachments }) => {
const supportsForgeAttachments =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true;
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({ supportsForgeAttachments }),
});
const images = await encodeImages(wirePayload.images);
await client.sendAgentMessage(agentId, text, {
messageId: next.id,
...(images && images.length > 0 ? { images } : {}),
attachments: wirePayload.attachments,
await dispatchComposerAgentMessage({
client,
agentId,
text,
attachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments,
}),
encodeImages,
submission: createMessageSubmissionWriter(serverId),
});
},
})

View File

@@ -19,9 +19,10 @@ import {
} from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { getSendingClientMessageIds } from "@/composer/submission/model";
const STORAGE_KEY = "@paseo:replica-cache";
const CACHE_VERSION = 1;
const CACHE_VERSION = 2;
const PERSIST_DELAY_MS = 750;
const MAX_TIMELINE_ITEMS = 50;
const MAX_CACHE_BYTES = 1024 * 1024;
@@ -370,7 +371,22 @@ export class ReplicaCache {
(workspace) => workspace.workspaceDirectory === focusedAgent.cwd,
))
: undefined;
const items = focusedAgentId ? session.agentStreamTail.get(focusedAgentId) : undefined;
const localSubmissionIds = new Set(
getSendingClientMessageIds(
focusedAgentId ? session.messageSubmissions.get(focusedAgentId) : undefined,
),
);
const items = focusedAgentId
? session.agentStreamTail
.get(focusedAgentId)
?.filter(
(item) =>
item.kind !== "user_message" ||
item.messageId !== undefined ||
!item.clientMessageId ||
!localSubmissionIds.has(item.clientMessageId),
)
: undefined;
const timeline =
focusedAgent && items
? {

View File

@@ -55,6 +55,8 @@ import {
type PendingWorkspaceDraftSetup,
} from "@/stores/workspace-draft-submission-store";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { getForgePresentation } from "@/git/forge";
@@ -181,6 +183,8 @@ interface PickerOptionData {
const BRANCH_OPTION_PREFIX = "branch:";
const PR_OPTION_PREFIX = "github-pr:";
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
// Stable reference so the keyboard-action handler doesn't re-register each render.
const PROJECT_PICK_ACTIONS: readonly KeyboardActionId[] = ["workspace.project.pick"];
// Height of a single picker-trigger badge. The Base-row spacer reserves exactly
// this so toggling Isolation to Local hides the row without shifting the form.
const BADGE_HEIGHT = 28;
@@ -1788,6 +1792,22 @@ export function NewWorkspaceScreen({
setProjectPickerOpen(true);
}, []);
// Cmd/Ctrl+P opens the project picker with its search focused so the user can
// switch projects from the keyboard. Registered only while this screen is
// mounted, so the shortcut doesn't swallow the browser's native print
// elsewhere; gated on having projects to pick.
const handleProjectPick = useCallback(() => {
openProjectPicker();
return true;
}, [openProjectPicker]);
useKeyboardActionHandler({
handlerId: "new-workspace-project-pick",
actions: PROJECT_PICK_ACTIONS,
enabled: projectPickerOptions.length > 0,
priority: 0,
handle: handleProjectPick,
});
const openIsolationPicker = useCallback(() => {
setIsolationPickerOpen(true);
}, []);

View File

@@ -109,7 +109,10 @@ export const useCreateFlowStore = create<CreateFlowState>((set) => ({
set((state) => {
const next = Object.fromEntries(
Object.entries(state.pendingByDraftId).filter(
([, pending]) => pending.serverId !== serverId || pending.agentId !== agentId,
([, pending]) =>
pending.lifecycle !== "sent" ||
pending.serverId !== serverId ||
pending.agentId !== agentId,
),
);
if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) {

View File

@@ -5,10 +5,21 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import {
appendSubmittedUserMessage,
handoffCreatedAgentUserMessageToStream,
removeSubmittedUserMessage,
type StreamItem,
type UserMessageItem,
} from "@/types/stream";
import {
acceptMessageSubmission,
beginMessageSubmission,
observeAcceptedMessageSubmissionsRunning,
observeMessageSubmissionCanonical,
rejectMessageSubmission,
type MessageSubmissionRecord,
type MessageSubmissionRejectionOutcome,
} from "@/composer/submission/model";
import type { PendingPermission } from "@/types/shared";
import type { ComposerAttachment } from "@/attachments/types";
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
@@ -368,6 +379,7 @@ export interface SessionState {
// Stream state (head/tail model)
agentStreamTail: Map<string, StreamItem[]>;
agentStreamHead: Map<string, StreamItem[]>;
messageSubmissions: Map<string, MessageSubmissionRecord[]>;
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
agentTimelineHasOlder: Map<string, boolean>;
agentTimelineOlderFetchInFlight: Map<string, boolean>;
@@ -459,8 +471,28 @@ interface SessionStoreActions {
setAgentStreamState: (
serverId: string,
agentId: string,
state: { tail?: StreamItem[]; head?: StreamItem[] },
state: {
tail?: StreamItem[];
head?: StreamItem[];
acknowledgedClientMessageIds?: readonly string[];
},
) => void;
beginAgentMessageSubmission: (
serverId: string,
agentId: string,
message: UserMessageItem,
) => void;
acceptAgentMessageSubmission: (
serverId: string,
agentId: string,
clientMessageId: string,
outOfBand: boolean | undefined,
) => void;
rejectAgentMessageSubmission: (
serverId: string,
agentId: string,
clientMessageId: string,
) => MessageSubmissionRejectionOutcome;
handoffCreatedAgentUserMessage: (
serverId: string,
agentId: string,
@@ -567,6 +599,27 @@ type SessionStore = SessionStoreState & SessionStoreActions;
const agentLastActivityCoalescer = createAgentLastActivityCoalescer();
function applyRunningAgentsToAcceptedSubmissions(input: {
previousAgents: Map<string, Agent>;
nextAgents: Map<string, Agent>;
submissions: Map<string, MessageSubmissionRecord[]>;
}): Map<string, MessageSubmissionRecord[]> {
let nextSubmissions = input.submissions;
for (const [agentId, submissions] of input.submissions) {
const previousAgent = input.previousAgents.get(agentId);
const nextAgent = input.nextAgents.get(agentId);
if (!nextAgent || previousAgent?.status === "running" || nextAgent.status !== "running") {
continue;
}
const remaining = observeAcceptedMessageSubmissionsRunning(submissions);
if (remaining === submissions) continue;
if (nextSubmissions === input.submissions) nextSubmissions = new Map(input.submissions);
if (remaining.length > 0) nextSubmissions.set(agentId, remaining);
else nextSubmissions.delete(agentId);
}
return nextSubmissions;
}
// Helper to create initial session state
function createInitialSessionState(
serverId: string,
@@ -588,6 +641,7 @@ function createInitialSessionState(
currentAssistantMessage: "",
agentStreamTail: new Map(),
agentStreamHead: new Map(),
messageSubmissions: new Map(),
agentTimelineCursor: new Map(),
agentTimelineHasOlder: new Map(),
agentTimelineOlderFetchInFlight: new Map(),
@@ -1047,10 +1101,27 @@ export const useSessionStore = create<SessionStore>()(
}
}
if (!changedTail && !changedHead) {
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
const observedSubmissions = observeMessageSubmissionCanonical(
currentSubmissions,
state.acknowledgedClientMessageIds ?? [],
);
const changedSubmissions = observedSubmissions !== currentSubmissions;
if (!changedTail && !changedHead && !changedSubmissions) {
return prev;
}
let messageSubmissions = session.messageSubmissions;
if (changedSubmissions) {
messageSubmissions = new Map(session.messageSubmissions);
if (observedSubmissions.length > 0) {
messageSubmissions.set(agentId, observedSubmissions);
} else {
messageSubmissions.delete(agentId);
}
}
return {
...prev,
sessions: {
@@ -1059,12 +1130,129 @@ export const useSessionStore = create<SessionStore>()(
...session,
agentStreamTail: nextTail,
agentStreamHead: nextHead,
messageSubmissions,
},
},
};
});
},
beginAgentMessageSubmission: (serverId, agentId, message) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) return prev;
if (!message.clientMessageId) {
throw new Error("Beginning a message submission requires client identity");
}
const currentTail = session.agentStreamTail.get(agentId) ?? [];
const currentHead = session.agentStreamHead.get(agentId) ?? [];
const stream = appendSubmittedUserMessage({
tail: currentTail,
head: currentHead,
message,
});
const submissions = beginMessageSubmission(
session.messageSubmissions.get(agentId) ?? [],
{ clientMessageId: message.clientMessageId, submittedAt: message.timestamp },
);
const messageSubmissions = new Map(session.messageSubmissions);
messageSubmissions.set(agentId, submissions);
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: {
...session,
agentStreamTail:
stream.tail === currentTail
? session.agentStreamTail
: new Map(session.agentStreamTail).set(agentId, stream.tail),
agentStreamHead:
stream.head === currentHead
? session.agentStreamHead
: new Map(session.agentStreamHead).set(agentId, stream.head),
messageSubmissions,
},
},
};
});
},
acceptAgentMessageSubmission: (serverId, agentId, clientMessageId, outOfBand) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) return prev;
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
const submissions = acceptMessageSubmission(
currentSubmissions,
clientMessageId,
session.agents.get(agentId)?.status === "running",
outOfBand,
);
if (submissions === currentSubmissions) return prev;
const messageSubmissions = new Map(session.messageSubmissions);
if (submissions.length > 0) {
messageSubmissions.set(agentId, submissions);
} else {
messageSubmissions.delete(agentId);
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, messageSubmissions },
},
};
});
},
rejectAgentMessageSubmission: (serverId, agentId, clientMessageId) => {
let outcome: MessageSubmissionRejectionOutcome = "unknown";
set((prev) => {
const session = prev.sessions[serverId];
if (!session) return prev;
const currentTail = session.agentStreamTail.get(agentId) ?? [];
const currentHead = session.agentStreamHead.get(agentId) ?? [];
const currentSubmissions = session.messageSubmissions.get(agentId) ?? [];
const result = rejectMessageSubmission(currentSubmissions, clientMessageId);
outcome = result.outcome;
if (outcome === "unknown") return prev;
const stream =
outcome === "rejected"
? removeSubmittedUserMessage({
tail: currentTail,
head: currentHead,
clientMessageId,
})
: { tail: currentTail, head: currentHead };
const messageSubmissions = new Map(session.messageSubmissions);
if (result.submissions.length > 0) {
messageSubmissions.set(agentId, result.submissions);
} else {
messageSubmissions.delete(agentId);
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: {
...session,
agentStreamTail:
stream.tail === currentTail
? session.agentStreamTail
: new Map(session.agentStreamTail).set(agentId, stream.tail),
agentStreamHead:
stream.head === currentHead
? session.agentStreamHead
: new Map(session.agentStreamHead).set(agentId, stream.head),
messageSubmissions,
},
},
};
});
return outcome;
},
handoffCreatedAgentUserMessage: (serverId, agentId, message) => {
let didHandoff = false;
set((prev) => {
@@ -1298,7 +1486,12 @@ export const useSessionStore = create<SessionStore>()(
return prev;
}
const nextAgents = typeof agents === "function" ? agents(session.agents) : agents;
if (session.agents === nextAgents) {
const messageSubmissions = applyRunningAgentsToAcceptedSubmissions({
previousAgents: session.agents,
nextAgents,
submissions: session.messageSubmissions,
});
if (session.agents === nextAgents && session.messageSubmissions === messageSubmissions) {
return prev;
}
return {
@@ -1308,10 +1501,11 @@ export const useSessionStore = create<SessionStore>()(
[serverId]: {
...session,
agents: nextAgents,
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(
nextAgents,
session.workspaceAgentActivity,
),
messageSubmissions,
workspaceAgentActivity:
nextAgents === session.agents
? session.workspaceAgentActivity
: buildWorkspaceAgentActivityIndex(nextAgents, session.workspaceAgentActivity),
},
},
};

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import {
buildOptimisticUserMessage,
createUserMessage,
hydrateStreamState,
type AgentToolCallItem,
type StreamItem,
@@ -119,12 +119,12 @@ function makeAssistantItem(
};
}
function makeOptimisticUserMessage(
function makeSubmittedUserMessage(
text: string,
id = `optimistic-${text.length}`,
id = `submitted-${text.length}`,
): Extract<StreamItem, { kind: "user_message" }> {
return buildOptimisticUserMessage({
id,
return createUserMessage({
clientMessageId: id,
text,
timestamp: new Date(1000),
});
@@ -172,6 +172,7 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
isInitializing: false,
hasActiveInitDeferred: false,
initRequestDirection: "tail",
sendingClientMessageIds: [],
};
const baseStreamInput: ProcessAgentStreamEventInput = {
@@ -181,7 +182,6 @@ const baseStreamInput: ProcessAgentStreamEventInput = {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
timestamp: new Date(2000),
};
@@ -293,6 +293,75 @@ describe("processTimelineResponse", () => {
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
});
it("keeps a live assistant and submitted head prompt in one lane during replacement", () => {
const submitted = makeSubmittedUserMessage("New prompt", "client-new-prompt");
const liveAssistant = {
...makeAssistantItem("Live answer", "answer-1"),
messageId: "answer-1",
};
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [],
currentHead: [liveAssistant, submitted],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
sendingClientMessageIds: ["client-new-prompt"],
payload: {
...baseTimelineInput.payload,
reset: true,
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 1 },
entries: [
{
...makeTimelineEntry(1, "Live", "assistant_message"),
item: {
type: "assistant_message",
text: "Live",
messageId: "answer-1",
},
},
],
},
});
expect(result.tail).toEqual([]);
expect(result.head).toEqual([{ ...liveAssistant, text: "Live" }, submitted]);
});
it("preserves newer live head items when canonical replacement ends in a tool call", () => {
const liveThought: StreamItem = {
kind: "thought",
id: "live-thought",
text: "newer reasoning",
timestamp: new Date(3000),
status: "loading",
};
const liveAssistant = makeAssistantItem("newer answer", "live-answer");
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: [liveThought, liveAssistant],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
payload: {
...baseTimelineInput.payload,
reset: true,
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 1 },
entries: [
makeToolCallTimelineEntry(1, "canonical-call", "completed", {
type: "read",
filePath: "/tmp/older.ts",
}),
],
},
});
expect(result.tail.map((item) => item.kind)).toEqual(["tool_call"]);
expect(result.head).toEqual([liveThought, liveAssistant]);
});
it("uses the timeline entry timestamp as canonical", () => {
const result = processTimelineResponse({
...baseTimelineInput,
@@ -321,12 +390,12 @@ describe("processTimelineResponse", () => {
expect(assistant?.timestamp.toISOString()).toBe("2025-01-01T12:00:04.000Z");
});
it("reconciles an optimistic user message during tail replacement", () => {
it("reconciles a submitted user message during tail replacement", () => {
const image = {
id: "optimistic-image",
id: "submitted-image",
mimeType: "image/png",
storageType: "web-indexeddb" as const,
storageKey: "optimistic-image",
storageKey: "submitted-image",
createdAt: 1000,
};
const attachment = {
@@ -335,8 +404,8 @@ describe("processTimelineResponse", () => {
text: "attached context",
title: "context.txt",
};
const optimistic = buildOptimisticUserMessage({
id: "optimistic-create-user",
const submitted = createUserMessage({
clientMessageId: "submitted-create-user",
text: "Analyze this",
timestamp: new Date(1000),
images: [image],
@@ -345,7 +414,7 @@ describe("processTimelineResponse", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [optimistic],
currentTail: [submitted],
payload: {
...baseTimelineInput.payload,
reset: true,
@@ -358,6 +427,7 @@ describe("processTimelineResponse", () => {
type: "user_message",
text: "server-rendered attachment text",
messageId: "canonical-create-user",
clientMessageId: "submitted-create-user",
},
},
],
@@ -367,13 +437,14 @@ describe("processTimelineResponse", () => {
const userMessages = result.tail.filter((item) => item.kind === "user_message");
expect(userMessages).toHaveLength(1);
expect(userMessages[0]).toMatchObject({
id: "canonical-create-user",
id: "submitted-create-user",
clientMessageId: "submitted-create-user",
messageId: "canonical-create-user",
text: "Analyze this",
timestamp: new Date(1000),
images: [image],
attachments: [attachment],
});
expect(userMessages[0]?.optimistic).toBeUndefined();
const repeated = processTimelineResponse({
...baseTimelineInput,
@@ -390,6 +461,7 @@ describe("processTimelineResponse", () => {
type: "user_message",
text: "server-rendered attachment text",
messageId: "canonical-create-user",
clientMessageId: "submitted-create-user",
},
},
],
@@ -399,24 +471,93 @@ describe("processTimelineResponse", () => {
expect(repeated.tail.filter((item) => item.kind === "user_message")).toEqual(userMessages);
});
it("keeps an unmatched optimistic user message during tail replacement", () => {
const optimistic = makeOptimisticUserMessage("still sending", "optimistic-unmatched");
it("keeps an unmatched submitted user message during tail replacement", () => {
const submitted = makeSubmittedUserMessage("still sending", "submitted-unmatched");
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [optimistic],
currentTail: [submitted],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
sendingClientMessageIds: ["submitted-unmatched"],
payload: {
...baseTimelineInput.payload,
reset: true,
epoch: "epoch-2",
entries: [],
},
});
expect(result.tail).toEqual([optimistic]);
expect(result.tail).toEqual([submitted]);
});
it("does not move an unmatched submission during timeline replacement", () => {
const unmatched = makeOptimisticUserMessage("first submission", "client-first");
it("keeps every unresolved submission during replacement", () => {
const first = makeSubmittedUserMessage("first pending", "client-first");
const second = makeSubmittedUserMessage("second pending", "client-second");
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [first, second],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
sendingClientMessageIds: ["client-first", "client-second"],
payload: {
...baseTimelineInput.payload,
reset: true,
epoch: "epoch-2",
entries: [],
},
});
expect(result.tail).toEqual([first, second]);
});
it("drops an acknowledged local row omitted by a same-epoch replacement", () => {
const acknowledged = createUserMessage({
clientMessageId: "client-local-only",
text: "provider may not echo this",
timestamp: new Date(1000),
});
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [acknowledged],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
sendingClientMessageIds: [],
payload: {
...baseTimelineInput.payload,
reset: true,
epoch: "epoch-1",
entries: [],
},
});
expect(result.tail).toEqual([]);
});
it("drops an acknowledged local row omitted by a known epoch change", () => {
const acknowledged = createUserMessage({
clientMessageId: "client-prior-epoch",
text: "prior prompt",
timestamp: new Date(1000),
});
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [acknowledged],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
sendingClientMessageIds: [],
payload: {
...baseTimelineInput.payload,
reset: true,
epoch: "epoch-2",
entries: [],
},
});
expect(result.tail).toEqual([]);
});
it("keeps an unmatched submission after the canonical replacement range", () => {
const unmatched = makeSubmittedUserMessage("first submission", "client-first");
const acknowledged: StreamItem[] = [
{
kind: "user_message",
@@ -437,6 +578,7 @@ describe("processTimelineResponse", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [unmatched, ...acknowledged],
sendingClientMessageIds: ["client-first"],
payload: {
...baseTimelineInput.payload,
reset: true,
@@ -462,10 +604,10 @@ describe("processTimelineResponse", () => {
},
},
{
...makeTimelineEntry(4, "response to all three submissions"),
...makeTimelineEntry(4, "response to canonical submissions"),
item: {
type: "assistant_message",
text: "response to all three submissions",
text: "response to canonical submissions",
messageId: "assistant-response",
},
},
@@ -480,14 +622,14 @@ describe("processTimelineResponse", () => {
text: "text" in item ? item.text : undefined,
})),
).toEqual([
{ kind: "user_message", id: "client-first", text: "first submission" },
{ kind: "user_message", id: "provider-second", text: "second submission" },
{ kind: "user_message", id: "provider-third", text: "third submission" },
{
kind: "assistant_message",
id: "assistant-response",
text: "response to all three submissions",
text: "response to canonical submissions",
},
{ kind: "user_message", id: "client-first", text: "first submission" },
]);
});
@@ -620,11 +762,11 @@ describe("processTimelineResponse", () => {
startSeq: 1,
endSeq: 1,
};
const optimistic = makeOptimisticUserMessage("sent while catching up", "optimistic-after");
const submitted = makeSubmittedUserMessage("sent while catching up", "submitted-after");
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [optimistic],
currentTail: [submitted],
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
@@ -644,16 +786,19 @@ describe("processTimelineResponse", () => {
const userMessages = result.tail.filter((item) => item.kind === "user_message");
expect(userMessages).toHaveLength(1);
expect(userMessages[0]?.id).toBe("canonical-after");
expect(userMessages[0]?.optimistic).toBeUndefined();
expect(userMessages[0]).toMatchObject({
id: "submitted-after",
clientMessageId: "submitted-after",
messageId: "canonical-after",
});
});
it("reconciles an optimistic user message by client message id", () => {
const optimistic = makeOptimisticUserMessage("local presentation", "client-message");
it("reconciles a submitted user message by client message id", () => {
const submitted = makeSubmittedUserMessage("local presentation", "client-message");
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [optimistic],
currentTail: [submitted],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
payload: {
...baseTimelineInput.payload,
@@ -675,20 +820,21 @@ describe("processTimelineResponse", () => {
const userMessages = result.tail.filter((item) => item.kind === "user_message");
expect(userMessages).toEqual([
expect.objectContaining({
id: "provider-message",
id: "client-message",
clientMessageId: "client-message",
messageId: "provider-message",
text: "local presentation",
}),
]);
expect(userMessages[0]?.optimistic).toBeUndefined();
expect(result.acknowledgedClientMessageIds).toEqual(["client-message"]);
});
it("reconciles multiple optimistic user messages in canonical order", () => {
it("reconciles multiple submitted user messages in canonical order", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [
makeOptimisticUserMessage("first prompt", "optimistic-first"),
makeOptimisticUserMessage("second prompt", "optimistic-second"),
makeSubmittedUserMessage("first prompt", "submitted-first"),
makeSubmittedUserMessage("second prompt", "submitted-second"),
],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
payload: {
@@ -699,14 +845,14 @@ describe("processTimelineResponse", () => {
entries: [
{
...makeTimelineEntry(2, "first prompt", "user_message"),
item: { type: "user_message", text: "first prompt", messageId: "optimistic-first" },
item: { type: "user_message", text: "first prompt", messageId: "submitted-first" },
},
{
...makeTimelineEntry(3, "second prompt", "user_message"),
item: {
type: "user_message",
text: "second prompt",
messageId: "optimistic-second",
messageId: "submitted-second",
},
},
],
@@ -716,15 +862,15 @@ describe("processTimelineResponse", () => {
expect(
result.tail
.filter((item) => item.kind === "user_message")
.map((item) => ({ id: item.id, text: item.text, optimistic: item.optimistic })),
.map((item) => ({ id: item.id, text: item.text, messageId: item.messageId })),
).toEqual([
{ id: "optimistic-first", text: "first prompt", optimistic: undefined },
{ id: "optimistic-second", text: "second prompt", optimistic: undefined },
{ id: "submitted-first", text: "first prompt", messageId: "submitted-first" },
{ id: "submitted-second", text: "second prompt", messageId: "submitted-second" },
]);
});
it("keeps a tail optimistic prompt before a reconciled live assistant head", () => {
const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt");
it("keeps a tail submitted prompt before a reconciled live assistant head", () => {
const prompt = makeSubmittedUserMessage("new prompt", "submitted-new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -763,8 +909,8 @@ describe("processTimelineResponse", () => {
).toEqual(["new prompt", "Hello"]);
});
it("keeps a tail optimistic prompt before a live head flushed by catch-up", () => {
const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt");
it("keeps a tail submitted prompt before a live head flushed by catch-up", () => {
const prompt = makeSubmittedUserMessage("new prompt", "submitted-new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -835,7 +981,6 @@ describe("processTimelineResponse", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
const result = processTimelineResponse({
@@ -913,7 +1058,6 @@ describe("processTimelineResponse", () => {
currentTail: [],
currentHead: [],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
currentAgent: null,
});
expect(getAssistantTexts(live.tail)).toHaveLength(1);
expect(getAssistantTexts(live.head)).toHaveLength(1);
@@ -961,7 +1105,6 @@ describe("processTimelineResponse", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
const result = processTimelineResponse({
@@ -1000,7 +1143,7 @@ describe("processTimelineResponse", () => {
});
it("does not move a submitted prompt when catch-up history arrives", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1024,7 +1167,7 @@ describe("processTimelineResponse", () => {
});
it("does not move an unmatched head prompt when catch-up history arrives", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1052,8 +1195,8 @@ describe("processTimelineResponse", () => {
]);
});
it("acknowledges a head prompt in place while catch-up history arrives", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
it("moves an acknowledged head prompt to its catch-up sequence position", () => {
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1084,18 +1227,18 @@ describe("processTimelineResponse", () => {
expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([
"assistant_message",
"user_message",
"tool_call",
"user_message",
]);
expect(
[...result.tail, ...result.head]
.filter((item) => item.kind === "user_message")
.map((item) => item.optimistic),
).toEqual([undefined]);
.map((item) => item.clientMessageId),
).toEqual(["new-prompt"]);
});
it("does not move a prompt around unrelated catch-up history", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1141,7 +1284,7 @@ describe("processTimelineResponse", () => {
});
it("does not move a prompt or its live answer around catch-up history", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const live = processAgentStreamEvents({
events: [
makeStreamReducerEvent(
@@ -1152,7 +1295,6 @@ describe("processTimelineResponse", () => {
currentTail: [prompt],
currentHead: [],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
currentAgent: null,
});
const result = processTimelineResponse({
@@ -1186,7 +1328,7 @@ describe("processTimelineResponse", () => {
});
it("does not move a prompt or its live answer around catch-up tool history", () => {
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
const prompt = makeSubmittedUserMessage("New prompt", "new-prompt");
const live = processAgentStreamEvents({
events: [
makeStreamReducerEvent(
@@ -1197,7 +1339,6 @@ describe("processTimelineResponse", () => {
currentTail: [prompt],
currentHead: [],
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
currentAgent: null,
});
const result = processTimelineResponse({
@@ -1228,7 +1369,7 @@ describe("processTimelineResponse", () => {
});
it("never moves submitted messages behind a later assistant response", () => {
const unmatched = makeOptimisticUserMessage("first submission", "client-first");
const unmatched = makeSubmittedUserMessage("first submission", "client-first");
const acknowledged: StreamItem[] = [
{
kind: "user_message",
@@ -1286,8 +1427,8 @@ describe("processTimelineResponse", () => {
]);
});
it("acknowledges a local prompt in place when a remote user row also arrives", () => {
const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt");
it("places a local prompt after an earlier remote canonical row", () => {
const prompt = makeSubmittedUserMessage("Local prompt", "local-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1320,17 +1461,12 @@ describe("processTimelineResponse", () => {
});
expect(
result.tail
.filter((item) => item.kind === "user_message")
.map((item) => ({ text: item.text, optimistic: item.optimistic })),
).toEqual([
{ text: "Local prompt", optimistic: undefined },
{ text: "Remote prompt", optimistic: undefined },
]);
result.tail.filter((item) => item.kind === "user_message").map((item) => item.text),
).toEqual(["Remote prompt", "Local prompt"]);
});
it("keeps an unmatched optimistic prompt when catch-up contains only a remote user row", () => {
const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt");
it("keeps an unmatched submitted prompt when catch-up contains only a remote user row", () => {
const prompt = makeSubmittedUserMessage("Local prompt", "local-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1355,17 +1491,12 @@ describe("processTimelineResponse", () => {
});
expect(
result.tail
.filter((item) => item.kind === "user_message")
.map((item) => ({ text: item.text, optimistic: item.optimistic })),
).toEqual([
{ text: "Local prompt", optimistic: true },
{ text: "Remote prompt", optimistic: undefined },
]);
result.tail.filter((item) => item.kind === "user_message").map((item) => item.text),
).toEqual(["Local prompt", "Remote prompt"]);
});
it("does not match equal prompt text when canonical client message ids differ", () => {
const prompt = makeOptimisticUserMessage("continue", "local-prompt");
const prompt = makeSubmittedUserMessage("continue", "local-prompt");
const result = processTimelineResponse({
...baseTimelineInput,
@@ -1393,10 +1524,10 @@ describe("processTimelineResponse", () => {
expect(
result.tail
.filter((item) => item.kind === "user_message")
.map((item) => ({ id: item.id, optimistic: item.optimistic })),
.map((item) => ({ id: item.id, messageId: item.messageId })),
).toEqual([
{ id: "local-prompt", optimistic: true },
{ id: "remote-prompt", optimistic: undefined },
{ id: "local-prompt", messageId: undefined },
{ id: "remote-prompt", messageId: "remote-prompt" },
]);
});
@@ -1639,8 +1770,8 @@ describe("processTimelineResponse", () => {
});
});
it("does not reconcile an active optimistic user message from a before-page response", () => {
const optimistic = makeOptimisticUserMessage("active prompt", "optimistic-active");
it("does not reconcile an active submitted user message from a before-page response", () => {
const submitted = makeSubmittedUserMessage("active prompt", "submitted-active");
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 3,
@@ -1649,7 +1780,7 @@ describe("processTimelineResponse", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: [optimistic],
currentTail: [submitted],
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
@@ -1672,8 +1803,8 @@ describe("processTimelineResponse", () => {
const userMessages = result.tail.filter((item) => item.kind === "user_message");
expect(userMessages).toHaveLength(2);
expect(userMessages.map((item) => item.id)).toEqual(["canonical-before", "optimistic-active"]);
expect(userMessages[1]?.optimistic).toBe(true);
expect(userMessages.map((item) => item.id)).toEqual(["canonical-before", "submitted-active"]);
expect(userMessages[1]?.clientMessageId).toBe("submitted-active");
});
it("leaves the cursor alone when a before page makes no progress", () => {
@@ -1813,6 +1944,68 @@ describe("processTimelineResponse", () => {
]);
});
it("removes a reconciled submitted prompt before coalescing a tool call at the pagination seam", () => {
const clientMessageId = "client-boundary-prompt";
const callId = "toolu_submitted_boundary";
const currentTail = [
makeSubmittedUserMessage("Inspect the file", clientMessageId),
...hydrateStreamState(
[
{
event: {
type: "timeline",
provider: "claude",
item: makeToolCallTimelineEntry(3, callId, "completed", {
type: "read",
filePath: "/tmp/example.ts",
}).item,
} as AgentStreamEventPayload,
timestamp: new Date(3000),
},
],
{ source: "canonical" },
),
];
const result = processTimelineResponse({
...baseTimelineInput,
currentTail,
currentCursor: { epoch: "epoch-1", startSeq: 3, endSeq: 5 },
payload: {
...baseTimelineInput.payload,
direction: "before",
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 2 },
entries: [
{
...makeTimelineEntry(1, "Inspect the file", "user_message"),
item: {
type: "user_message",
text: "Inspect the file",
messageId: "provider-boundary-prompt",
clientMessageId,
},
},
makeToolCallTimelineEntry(2, callId, "running", {
type: "unknown",
input: { file_path: "/tmp/example.ts" },
output: null,
}),
],
},
});
expect(result.tail.filter((item) => item.kind === "user_message")).toHaveLength(1);
expect(getAgentToolCalls(result.tail)).toEqual([
expect.objectContaining({
payload: expect.objectContaining({
data: expect.objectContaining({ callId, status: "completed" }),
}),
}),
]);
});
it("does not coalesce tool call lifecycle rows away from the prepend boundary", () => {
const callId = "toolu_not_boundary";
const currentTail = hydrateStreamState(
@@ -2224,152 +2417,6 @@ describe("processAgentStreamEvent", () => {
endSeq: 1,
});
});
it("derives optimistic idle status on turn_completed for running agent", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(true);
expect(result.agent).not.toBe(null);
expect(result.agent!.status).toBe("idle");
expect(result.agent!.updatedAt.getTime()).toBe(2000);
expect(result.agent!.lastActivityAt.getTime()).toBe(2000);
});
it("derives optimistic error status on turn_failed for running agent", () => {
const turnFailedEvent: AgentStreamEventPayload = {
type: "turn_failed",
provider: "claude",
error: "something broke",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnFailedEvent,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(true);
expect(result.agent!.status).toBe("error");
});
it("does not derive optimistic idle status on turn_canceled for running agent", () => {
const turnCanceledEvent: AgentStreamEventPayload = {
type: "turn_canceled",
provider: "codex",
reason: "interrupted",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCanceledEvent,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
it("does not change agent when status is not running", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: {
status: "idle",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
it("does not change agent when no agent is provided", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: null,
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
it("preserves updatedAt when agent timestamp is newer than event", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: {
status: "running",
updatedAt: new Date(5000),
lastActivityAt: new Date(5000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(true);
expect(result.agent!.updatedAt.getTime()).toBe(5000);
expect(result.agent!.lastActivityAt.getTime()).toBe(5000);
});
it("does not produce agent patch for non-terminal events", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("just text"),
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
seq: 1,
epoch: "epoch-1",
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
});
describe("processAgentStreamEvents", () => {
@@ -2382,7 +2429,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(false);
@@ -2410,7 +2456,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(false);
@@ -2433,7 +2478,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2451,7 +2495,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2469,7 +2512,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2501,7 +2543,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2527,7 +2568,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2552,7 +2592,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(result.changedTail).toBe(true);
@@ -2644,7 +2683,6 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
});
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([
@@ -2655,7 +2693,7 @@ describe("processAgentStreamEvents", () => {
]);
});
it("returns the final optimistic lifecycle patch across a batch", () => {
it("does not derive lifecycle state from a terminal event in a batch", () => {
const result = processAgentStreamEvents({
events: [
makeStreamReducerEvent(makeTimelineEvent("Done"), 1),
@@ -2669,21 +2707,10 @@ describe("processAgentStreamEvents", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
});
expect(result.head).toEqual([]);
expect(result.tail).toHaveLength(1);
expect(result.agentChanged).toBe(true);
expect(result.agent).toMatchObject({
status: "idle",
updatedAt: new Date(3000),
lastActivityAt: new Date(3000),
});
});
it("keeps a live Claude assistant paragraph contiguous when init tail hydration lands mid-stream", () => {
@@ -2832,7 +2859,6 @@ describe("createAgentStreamReducerQueue", () => {
currentTail,
currentHead,
currentCursor: undefined,
currentAgent: null,
}),
commit: (agentId, result) => {
currentTail = result.tail;
@@ -2874,7 +2900,6 @@ describe("createAgentStreamReducerQueue", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
}),
commit: (agentId, result) => {
commits.push(
@@ -2904,7 +2929,6 @@ describe("createAgentStreamReducerQueue", () => {
currentTail,
currentHead,
currentCursor,
currentAgent: null,
}),
commit: (_agentId, result) => {
currentTail = result.tail;
@@ -2954,7 +2978,6 @@ describe("createAgentStreamReducerQueue", () => {
currentTail: [],
currentHead: [],
currentCursor: undefined,
currentAgent: null,
}),
commit: (agentId, result) => {
commits.push(

View File

@@ -1,15 +1,15 @@
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
import type { Agent } from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import type { AssistantMessageItem, StreamItem, UserMessageItem } from "@/types/stream";
import type { AssistantMessageItem, StreamItem } from "@/types/stream";
import {
applyStreamEvent,
flushHeadToTail,
hydrateStreamState,
isAgentToolCallItem,
mergeAgentToolCallItem,
replaceWithCanonicalStream,
reduceStreamUpdate,
upsertUserMessageAcrossStream,
} from "@/types/stream";
const AGENT_STREAM_REDUCER_FLUSH_DELAY_MS = 16 * 3;
@@ -88,6 +88,7 @@ export interface ProcessTimelineResponseInput {
isInitializing: boolean;
hasActiveInitDeferred: boolean;
initRequestDirection: InitRequestDirection;
sendingClientMessageIds: readonly string[];
}
export interface ProcessTimelineResponseOutput {
@@ -99,6 +100,7 @@ export interface ProcessTimelineResponseOutput {
clearInitializing: boolean;
error: string | null;
sideEffects: TimelineReducerSideEffect[];
acknowledgedClientMessageIds: string[];
}
interface TimelineUnit {
@@ -115,6 +117,7 @@ interface TimelinePathResult {
cursor: TimelineCursor | null | undefined;
cursorChanged: boolean;
sideEffects: TimelineReducerSideEffect[];
acknowledgedClientMessageIds: string[];
}
function classifySessionTimelineSeq({
@@ -201,76 +204,35 @@ function shouldResolveTimelineInit({
return responseDirection === initRequestDirection;
}
function deriveOptimisticLifecycleStatus(
currentStatus: AgentLifecycleStatus,
event: AgentStreamEventPayload,
): AgentLifecycleStatus | null {
if (currentStatus !== "running") {
return null;
}
switch (event.type) {
case "turn_completed":
return "idle";
case "turn_failed":
return "error";
case "turn_canceled":
// A canceled turn can be either a final user cancel or an interrupt before
// a replacement turn starts. The daemon snapshot is authoritative here.
return null;
default:
return null;
}
}
function preserveReplacePathAssistantHead(params: {
tail: StreamItem[];
currentHead: StreamItem[];
}): {
tail: StreamItem[];
head: StreamItem[];
} {
const { tail, currentHead } = params;
const liveAssistant = currentHead.findLast(
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
item.kind === "assistant_message",
);
if (!liveAssistant) {
return { tail, head: [] };
}
const tailAssistant = tail.at(-1);
if (!tailAssistant || tailAssistant.kind !== "assistant_message") {
return { tail, head: currentHead };
}
if (!liveAssistant.text.startsWith(tailAssistant.text)) {
return { tail, head: [] };
}
return {
tail: tail.slice(0, -1),
head: [{ ...liveAssistant, text: tailAssistant.text }],
};
}
function applyTimelineReplacePath(args: {
timelineUnits: TimelineUnit[];
payload: ProcessTimelineResponseInput["payload"];
bootstrapPolicy: ReturnType<typeof deriveBootstrapTailTimelinePolicy>;
currentTail: StreamItem[];
currentHead: StreamItem[];
sendingClientMessageIds: readonly string[];
preserveLiveHead: boolean;
toHydratedEvents: (
units: TimelineUnit[],
) => Array<{ event: AgentStreamEventPayload; timestamp: Date }>;
}): TimelinePathResult {
const { timelineUnits, payload, bootstrapPolicy, currentTail, currentHead, toHydratedEvents } =
args;
const {
timelineUnits,
payload,
bootstrapPolicy,
currentTail,
currentHead,
sendingClientMessageIds,
preserveLiveHead,
toHydratedEvents,
} = args;
const hydratedTail = hydrateStreamState(toHydratedEvents(timelineUnits), { source: "canonical" });
const reconciledTail = reconcileLocalUserPresentationAfterReplace({
canonicalTail: hydratedTail,
const { tail, head, acknowledgedClientMessageIds } = replaceWithCanonicalStream({
canonical: hydratedTail,
previousTail: currentTail,
previousHead: currentHead,
});
const { tail, head } = preserveReplacePathAssistantHead({
tail: reconciledTail,
currentHead,
sendingClientMessageIds,
preserveLiveHead,
});
const cursor: TimelineCursor | null =
payload.startCursor && payload.endCursor
@@ -284,136 +246,16 @@ function applyTimelineReplacePath(args: {
if (bootstrapPolicy.catchUpCursor) {
sideEffects.push({ type: "catch_up", cursor: bootstrapPolicy.catchUpCursor });
}
return { tail, head, cursor, cursorChanged: true, sideEffects };
}
function collectLocallyPresentedUserMessages(items: StreamItem[]): Array<{
ordinal: number;
item: UserMessageItem;
}> {
const localUsers: Array<{ ordinal: number; item: UserMessageItem }> = [];
let ordinal = 0;
for (const item of items) {
if (item.kind !== "user_message") {
continue;
}
if (item.optimistic || item.images?.length || item.attachments?.length) {
localUsers.push({ ordinal, item });
}
ordinal += 1;
}
return localUsers;
}
function mergeCanonicalUserWithLocalPresentation(
canonical: UserMessageItem,
local: UserMessageItem,
): UserMessageItem {
return {
kind: "user_message",
id: canonical.id,
...(canonical.clientMessageId ? { clientMessageId: canonical.clientMessageId } : {}),
text: local.text,
timestamp: local.timestamp,
...(local.images && local.images.length > 0 ? { images: local.images } : {}),
...(local.attachments && local.attachments.length > 0
? { attachments: local.attachments }
: {}),
tail,
head,
cursor,
cursorChanged: true,
sideEffects,
acknowledgedClientMessageIds,
};
}
interface CanonicalUserMessageIdentity {
messageId?: string;
clientMessageId?: string;
text: string;
}
function matchesLocalUserMessageIdentity(
canonical: CanonicalUserMessageIdentity,
optimistic: UserMessageItem,
): boolean {
if (canonical.clientMessageId !== undefined) {
return canonical.clientMessageId === optimistic.id;
}
if (canonical.messageId === optimistic.id) {
return true;
}
// COMPAT(userMessageClientId): added in v0.2.0, remove after 2027-01-20 once
// the supported daemon floor emits clientMessageId on submitted user messages.
return canonical.text.length > 0 && canonical.text === optimistic.text;
}
function reconcileLocalUserPresentationAfterReplace(params: {
canonicalTail: StreamItem[];
previousTail: StreamItem[];
previousHead: StreamItem[];
}): StreamItem[] {
const localUsers = collectLocallyPresentedUserMessages([
...params.previousTail,
...params.previousHead,
]);
if (localUsers.length === 0) {
return params.canonicalTail;
}
const canonicalUserIndexes: number[] = [];
params.canonicalTail.forEach((item, index) => {
if (item.kind === "user_message") {
canonicalUserIndexes.push(index);
}
});
const nextTail = [...params.canonicalTail];
const claimedCanonicalIndexes = new Set<number>();
const unmatched: UserMessageItem[] = [];
for (const local of localUsers) {
const exactIndex = canonicalUserIndexes.find((index) => {
if (claimedCanonicalIndexes.has(index)) return false;
const canonical = params.canonicalTail[index];
return (
canonical?.kind === "user_message" &&
matchesLocalUserMessageIdentity(
{
messageId: canonical.id,
clientMessageId: canonical.clientMessageId,
text: canonical.text,
},
local.item,
)
);
});
const ordinalIndex = canonicalUserIndexes[local.ordinal];
const ordinalItem = ordinalIndex === undefined ? undefined : params.canonicalTail[ordinalIndex];
const canonicalIndex =
exactIndex ??
(ordinalIndex !== undefined &&
!claimedCanonicalIndexes.has(ordinalIndex) &&
ordinalItem?.kind === "user_message" &&
ordinalItem.clientMessageId === undefined
? ordinalIndex
: undefined);
const canonicalItem = canonicalIndex === undefined ? undefined : nextTail[canonicalIndex];
if (canonicalIndex === undefined || !canonicalItem || canonicalItem.kind !== "user_message") {
if (local.item.optimistic) {
unmatched.push(local.item);
}
continue;
}
nextTail[canonicalIndex] = mergeCanonicalUserWithLocalPresentation(canonicalItem, local.item);
claimedCanonicalIndexes.add(canonicalIndex);
}
for (const item of unmatched) {
const insertionIndex = nextTail.findIndex(
(canonical) => canonical.timestamp.getTime() > item.timestamp.getTime(),
);
nextTail.splice(insertionIndex < 0 ? nextTail.length : insertionIndex, 0, item);
}
return nextTail;
}
interface IncrementalAcceptResult {
acceptedUnits: TimelineUnit[];
cursor: TimelineCursor | undefined;
@@ -515,6 +357,34 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea
return olderTail;
}
const remainingOlder: StreamItem[] = [];
let reconciledCurrent = currentTail;
for (const item of olderTail) {
if (item.kind !== "user_message") {
remainingOlder.push(item);
continue;
}
const result = upsertUserMessageAcrossStream({
tail: reconciledCurrent,
head: [],
message: item,
insert: "prepend-tail",
presentation: "existing",
});
if (result.location?.matched) {
remainingOlder.push(result.location.message);
reconciledCurrent = [
...result.tail.slice(0, result.location.index),
...result.tail.slice(result.location.index + 1),
];
} else {
remainingOlder.push(item);
}
}
olderTail = remainingOlder;
currentTail = reconciledCurrent;
if (olderTail.length === 0) return currentTail;
const olderLast = olderTail.at(-1);
const currentFirst = currentTail[0];
@@ -745,9 +615,24 @@ function applyCanonicalForwardUnit(params: {
head: StreamItem[];
unit: TimelineUnit;
epoch: string;
}): { tail: StreamItem[]; head: StreamItem[] } {
}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } {
const { event, timestamp, seqEnd } = params.unit;
const timelineCursor = { epoch: params.epoch, seq: seqEnd };
if (event.type === "timeline" && event.item.type === "user_message") {
const applied = applyStreamEvent({
tail: params.tail,
head: params.head,
event,
timestamp,
source: "canonical",
timelineCursor,
});
return {
tail: applied.tail,
head: applied.head,
acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [],
};
}
if (params.head.length === 0) {
return {
tail: reduceStreamUpdate(params.tail, event, timestamp, {
@@ -755,6 +640,7 @@ function applyCanonicalForwardUnit(params: {
timelineCursor,
}),
head: params.head,
acknowledgedClientMessageIds: [],
};
}
const replacedHead = replaceLiveAssistantWithProjectedText({
@@ -763,7 +649,9 @@ function applyCanonicalForwardUnit(params: {
timestamp,
timelineCursor,
});
if (replacedHead) return { tail: params.tail, head: replacedHead };
if (replacedHead) {
return { tail: params.tail, head: replacedHead, acknowledgedClientMessageIds: [] };
}
const activeAssistant = params.head.findLast(
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
@@ -781,6 +669,7 @@ function applyCanonicalForwardUnit(params: {
source: "canonical",
timelineCursor,
}),
acknowledgedClientMessageIds: [],
};
}
@@ -792,7 +681,11 @@ function applyCanonicalForwardUnit(params: {
source: "canonical",
timelineCursor,
});
return { tail: applied.tail, head: applied.head };
return {
tail: applied.tail,
head: applied.head,
acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [],
};
}
function applyAcceptedForwardTimelineUnits(params: {
@@ -801,7 +694,7 @@ function applyAcceptedForwardTimelineUnits(params: {
currentTail: StreamItem[];
currentHead: StreamItem[];
currentEndSeq: number | undefined;
}): { tail: StreamItem[]; head: StreamItem[] } {
}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } {
const reconciled = reconcileOverlappingProjectedStreamItems({
tail: params.currentTail,
head: params.currentHead,
@@ -811,15 +704,19 @@ function applyAcceptedForwardTimelineUnits(params: {
});
let tail = reconciled.tail;
let head = reconciled.head;
const acknowledgedClientMessageIds = new Set<string>();
for (const unit of params.units) {
if (reconciled.reconciledUnits.has(unit)) continue;
const applied = applyCanonicalForwardUnit({ tail, head, unit, epoch: params.epoch });
tail = applied.tail;
head = applied.head;
for (const clientMessageId of applied.acknowledgedClientMessageIds) {
acknowledgedClientMessageIds.add(clientMessageId);
}
}
return { tail, head };
return { tail, head, acknowledgedClientMessageIds: [...acknowledgedClientMessageIds] };
}
function applyTimelineIncrementalPath(args: {
@@ -835,9 +732,17 @@ function applyTimelineIncrementalPath(args: {
let nextCursor: TimelineCursor | null | undefined = currentCursor;
let cursorChanged = false;
const sideEffects: TimelineReducerSideEffect[] = [];
let acknowledgedClientMessageIds: string[] = [];
if (timelineUnits.length === 0) {
return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects };
return {
tail: nextTail,
head: nextHead,
cursor: nextCursor,
cursorChanged,
sideEffects,
acknowledgedClientMessageIds,
};
}
const { acceptedUnits, cursor, gapCursor } =
@@ -874,6 +779,7 @@ function applyTimelineIncrementalPath(args: {
});
nextTail = applied.tail;
nextHead = applied.head;
acknowledgedClientMessageIds = applied.acknowledgedClientMessageIds;
}
}
@@ -892,7 +798,14 @@ function applyTimelineIncrementalPath(args: {
sideEffects.push({ type: "catch_up", cursor: gapCursor });
}
return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects };
return {
tail: nextTail,
head: nextHead,
cursor: nextCursor,
cursorChanged,
sideEffects,
acknowledgedClientMessageIds,
};
}
export function processTimelineResponse(
@@ -906,6 +819,7 @@ export function processTimelineResponse(
isInitializing,
hasActiveInitDeferred,
initRequestDirection,
sendingClientMessageIds,
} = input;
// ------------------------------------------------------------------
@@ -921,6 +835,7 @@ export function processTimelineResponse(
clearInitializing: isInitializing,
error: payload.error,
sideEffects: [],
acknowledgedClientMessageIds: [],
};
}
@@ -967,7 +882,6 @@ export function processTimelineResponse(
hasActiveInitDeferred,
});
const replace = bootstrapPolicy.replace;
const sideEffects: TimelineReducerSideEffect[] = [];
const timelineResult = replace
? applyTimelineReplacePath({
@@ -976,6 +890,8 @@ export function processTimelineResponse(
bootstrapPolicy,
currentTail,
currentHead,
sendingClientMessageIds,
preserveLiveHead: currentCursor?.epoch === payload.epoch,
toHydratedEvents,
})
: applyTimelineIncrementalPath({
@@ -1024,6 +940,7 @@ export function processTimelineResponse(
clearInitializing,
error: null,
sideEffects,
acknowledgedClientMessageIds: timelineResult.acknowledgedClientMessageIds,
};
}
@@ -1038,20 +955,9 @@ export interface ProcessAgentStreamEventInput {
currentTail: StreamItem[];
currentHead: StreamItem[];
currentCursor: TimelineCursor | undefined;
currentAgent: {
status: AgentLifecycleStatus;
updatedAt: Date;
lastActivityAt: Date;
} | null;
timestamp: Date;
}
export interface AgentPatch {
status: AgentLifecycleStatus;
updatedAt: Date;
lastActivityAt: Date;
}
export interface ProcessAgentStreamEventOutput {
tail: StreamItem[];
head: StreamItem[];
@@ -1059,8 +965,7 @@ export interface ProcessAgentStreamEventOutput {
changedHead: boolean;
cursor: TimelineCursor | null;
cursorChanged: boolean;
agent: AgentPatch | null;
agentChanged: boolean;
acknowledgedClientMessageIds: string[];
sideEffects: AgentStreamReducerSideEffect[];
}
@@ -1079,18 +984,11 @@ interface TimelineSequencingGateResult {
sideEffects: AgentStreamReducerSideEffect[];
}
export interface AgentStreamReducerAgentSnapshot {
status: AgentLifecycleStatus;
updatedAt: Date;
lastActivityAt: Date;
}
export interface ProcessAgentStreamEventsInput {
events: AgentStreamReducerEvent[];
currentTail: StreamItem[];
currentHead: StreamItem[];
currentCursor: TimelineCursor | undefined;
currentAgent: AgentStreamReducerAgentSnapshot | null;
}
export type AgentStreamReducerSnapshot = Omit<ProcessAgentStreamEventsInput, "events">;
@@ -1114,20 +1012,6 @@ export interface CreateAgentStreamReducerQueueInput {
cancelFlush: (id: number) => void;
}
function applyAgentPatch(
currentAgent: AgentStreamReducerAgentSnapshot | null,
patch: AgentPatch | null,
): AgentStreamReducerAgentSnapshot | null {
if (!currentAgent || !patch) {
return currentAgent;
}
return {
status: patch.status,
updatedAt: patch.updatedAt,
lastActivityAt: patch.lastActivityAt,
};
}
function processTimelineSequencingGate(input: {
event: AgentStreamEventPayload;
seq: number | undefined;
@@ -1201,8 +1085,7 @@ function processTimelineSequencingGate(input: {
export function processAgentStreamEvent(
input: ProcessAgentStreamEventInput,
): ProcessAgentStreamEventOutput {
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
input;
const { event, seq, epoch, currentTail, currentHead, currentCursor, timestamp } = input;
const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor });
const timelineCursor =
@@ -1213,7 +1096,7 @@ export function processAgentStreamEvent(
// ------------------------------------------------------------------
// Apply stream event to tail/head
// ------------------------------------------------------------------
const { tail, head, changedTail, changedHead } = sequencing.shouldApplyStreamEvent
const applied = sequencing.shouldApplyStreamEvent
? applyStreamEvent({
tail: sequencing.resetLiveTimeline ? [] : currentTail,
head: sequencing.resetLiveTimeline ? [] : currentHead,
@@ -1229,43 +1112,14 @@ export function processAgentStreamEvent(
changedHead: false,
};
// ------------------------------------------------------------------
// Optimistic lifecycle status
// ------------------------------------------------------------------
let agentPatch: AgentPatch | null = null;
let agentChanged = false;
if (
currentAgent &&
(event.type === "turn_completed" ||
event.type === "turn_canceled" ||
event.type === "turn_failed")
) {
const optimisticStatus = deriveOptimisticLifecycleStatus(currentAgent.status, event);
if (optimisticStatus) {
const nextUpdatedAtMs = Math.max(currentAgent.updatedAt.getTime(), timestamp.getTime());
const nextLastActivityAtMs = Math.max(
currentAgent.lastActivityAt.getTime(),
timestamp.getTime(),
);
agentPatch = {
status: optimisticStatus,
updatedAt: new Date(nextUpdatedAtMs),
lastActivityAt: new Date(nextLastActivityAtMs),
};
agentChanged = true;
}
}
return {
tail,
head,
changedTail,
changedHead,
tail: applied.tail,
head: applied.head,
changedTail: applied.changedTail,
changedHead: applied.changedHead,
cursor: sequencing.nextTimelineCursor,
cursorChanged: sequencing.cursorChanged,
agent: agentPatch,
agentChanged,
acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [],
sideEffects: sequencing.sideEffects,
};
}
@@ -1276,12 +1130,10 @@ export function processAgentStreamEvents(
let tail = input.currentTail;
let head = input.currentHead;
let cursor = input.currentCursor;
let agent = input.currentAgent;
let changedTail = false;
let changedHead = false;
let cursorChanged = false;
let agentPatch: AgentPatch | null = null;
let agentChanged = false;
const acknowledgedClientMessageIds = new Set<string>();
const sideEffects: AgentStreamReducerSideEffect[] = [];
for (const reducerEvent of input.events) {
@@ -1292,7 +1144,6 @@ export function processAgentStreamEvents(
currentTail: tail,
currentHead: head,
currentCursor: cursor,
currentAgent: agent,
timestamp: reducerEvent.timestamp,
});
@@ -1301,17 +1152,14 @@ export function processAgentStreamEvents(
changedTail = changedTail || result.changedTail;
changedHead = changedHead || result.changedHead;
sideEffects.push(...result.sideEffects);
for (const clientMessageId of result.acknowledgedClientMessageIds) {
acknowledgedClientMessageIds.add(clientMessageId);
}
if (result.cursorChanged) {
cursor = result.cursor ?? undefined;
cursorChanged = true;
}
if (result.agentChanged) {
agentPatch = result.agent;
agentChanged = true;
agent = applyAgentPatch(agent, result.agent);
}
}
return {
@@ -1321,8 +1169,7 @@ export function processAgentStreamEvents(
changedHead,
cursor: cursor ?? null,
cursorChanged,
agent: agentPatch,
agentChanged,
acknowledgedClientMessageIds: [...acknowledgedClientMessageIds],
sideEffects,
};
}
@@ -1405,6 +1252,7 @@ export function createAgentStreamReducerQueue(
interface StreamStatePatch {
tail?: StreamItem[];
head?: StreamItem[];
acknowledgedClientMessageIds?: readonly string[];
}
export interface CreateSessionAgentStreamReducerQueueInput {
@@ -1414,7 +1262,6 @@ export interface CreateSessionAgentStreamReducerQueueInput {
serverId: string,
state: (prev: Map<string, TimelineCursor>) => Map<string, TimelineCursor>,
) => void;
setAgents: (serverId: string, state: (prev: Map<string, Agent>) => Map<string, Agent>) => void;
recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
}
@@ -1429,31 +1276,29 @@ function cancelAgentStreamReducerFlush(id: number) {
export function createSessionAgentStreamReducerQueue(
input: CreateSessionAgentStreamReducerQueueInput,
): AgentStreamReducerQueue {
const { serverId, setAgentStreamState, setAgentTimelineCursor, setAgents, recoverTimelineGap } =
input;
const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap } = input;
return createAgentStreamReducerQueue({
getSnapshot: (agentId) => {
const session = useSessionStore.getState().sessions[serverId];
const currentAgentEntry = session?.agents.get(agentId);
return {
currentTail: session?.agentStreamTail.get(agentId) ?? [],
currentHead: session?.agentStreamHead.get(agentId) ?? [],
currentCursor: session?.agentTimelineCursor.get(agentId),
currentAgent: currentAgentEntry
? {
status: currentAgentEntry.status,
updatedAt: currentAgentEntry.updatedAt,
lastActivityAt: currentAgentEntry.lastActivityAt,
}
: null,
};
},
commit: (agentId, result, events) => {
if (result.changedTail || result.changedHead) {
if (
result.changedTail ||
result.changedHead ||
result.acknowledgedClientMessageIds.length > 0
) {
setAgentStreamState(serverId, agentId, {
...(result.changedTail ? { tail: result.tail } : {}),
...(result.changedHead ? { head: result.head } : {}),
...(result.acknowledgedClientMessageIds.length > 0
? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds }
: {}),
});
}
@@ -1486,24 +1331,6 @@ export function createSessionAgentStreamReducerQueue(
return next;
});
}
if (result.agentChanged && result.agent) {
const nextAgent = result.agent;
setAgents(serverId, (prev) => {
const current = prev.get(agentId);
if (!current) {
return prev;
}
const next = new Map(prev);
next.set(agentId, {
...current,
status: nextAgent.status,
updatedAt: nextAgent.updatedAt,
lastActivityAt: nextAgent.lastActivityAt,
});
return next;
});
}
},
handleSideEffects: (agentId, sideEffects) => {
for (const effect of sideEffects) {

View File

@@ -22,34 +22,16 @@ function assistant(id: string, timestamp: Date): StreamItem {
}
describe("deriveStreamTurnTiming", () => {
it("reserves a running footer for an optimistic prompt before the host starts the turn", () => {
const optimisticPrompt = {
...user("optimistic", new Date("2026-05-15T00:00:00.000Z")),
optimistic: true as const,
};
const timing = deriveStreamTurnTiming({
agentStatus: "idle",
tail: [],
head: [optimisticPrompt],
});
assert.equal(timing.isActive, true);
});
it("does not start elapsed time from an optimistic prompt", () => {
const optimisticPrompt = {
...user("optimistic", new Date("2026-05-15T00:00:00.000Z")),
optimistic: true as const,
};
it("starts elapsed time from the submitted prompt", () => {
const submittedAt = new Date("2026-05-15T00:00:00.000Z");
const timing = deriveStreamTurnTiming({
agentStatus: "running",
tail: [],
head: [optimisticPrompt],
head: [user("submitted", submittedAt)],
});
assert.equal(timing.runningStartedAt, null);
assert.equal(timing.runningStartedAt, submittedAt);
});
it("uses the last user message as the running turn start", () => {

View File

@@ -9,7 +9,6 @@ export interface TurnTiming {
export interface StreamTurnTiming {
byAssistantId: Map<string, TurnTiming>;
runningStartedAt: Date | null;
isActive: boolean;
}
export function deriveStreamTurnTiming(params: {
@@ -19,8 +18,6 @@ export function deriveStreamTurnTiming(params: {
}): StreamTurnTiming {
const byAssistantId = new Map<string, TurnTiming>();
let currentUserAt: Date | null = null;
let currentAuthoritativeUserAt: Date | null = null;
let currentUserIsOptimistic = false;
let currentLastItemAt: Date | null = null;
let currentAssistantIds: string[] = [];
@@ -42,8 +39,6 @@ export function deriveStreamTurnTiming(params: {
if (item.kind === "user_message") {
flushCompletedTurn();
currentUserAt = item.timestamp;
currentAuthoritativeUserAt = item.optimistic ? null : item.timestamp;
currentUserIsOptimistic = item.optimistic === true;
currentLastItemAt = null;
currentAssistantIds = [];
return;
@@ -65,7 +60,7 @@ export function deriveStreamTurnTiming(params: {
}
const isRunning = params.agentStatus === "running";
const runningStartedAt = isRunning ? currentAuthoritativeUserAt : null;
const runningStartedAt = isRunning ? currentUserAt : null;
if (params.agentStatus !== "running") {
flushCompletedTurn();
}
@@ -73,6 +68,5 @@ export function deriveStreamTurnTiming(params: {
return {
byAssistantId,
runningStartedAt,
isActive: isRunning || currentUserIsOptimistic,
};
}

View File

@@ -4,9 +4,7 @@ import { describe, expect, it } from "vitest";
import {
applyStreamEvent,
appendOptimisticUserMessageToStream,
buildOptimisticUserMessage,
clearOptimisticUserMessages,
createUserMessage,
handoffCreatedAgentUserMessageToStream,
hydrateStreamState,
mergeToolCallDetail,
@@ -14,6 +12,8 @@ import {
type AgentToolCallItem,
type StreamItem,
isAgentToolCallItem,
upsertUserMessage,
upsertUserMessageAcrossStream,
} from "./stream";
import type { AgentProvider, ToolCallDetail } from "@getpaseo/protocol/agent-types";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
@@ -21,6 +21,109 @@ import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"
type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled";
describe("user message identity", () => {
it("adds provider identity without replacing local presentation", () => {
const timestamp = new Date("2026-07-26T10:00:00.000Z");
const local = createUserMessage({
clientMessageId: "client-1",
text: "local text",
timestamp,
images: [
{
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "image-1.png",
createdAt: timestamp.getTime(),
},
],
attachments: [{ type: "text", mimeType: "text/plain", text: "attachment" }],
});
const canonical = createUserMessage({
id: "provider-1",
messageId: "provider-1",
clientMessageId: "client-1",
text: "provider text",
timestamp: new Date("2026-07-26T10:00:01.000Z"),
});
const first = upsertUserMessage([local], canonical);
const second = upsertUserMessage(first, canonical);
expect(first).toEqual([
{
...local,
messageId: "provider-1",
clientMessageId: "client-1",
},
]);
expect(first[0]).toBe(second[0]);
});
it("keeps local presentation when a later canonical row omits provider identity", () => {
const timestamp = new Date("2026-07-27T10:00:00.000Z");
const local = createUserMessage({
clientMessageId: "client-1",
messageId: "provider-1",
text: "local text",
timestamp,
images: [
{
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "image-1.png",
createdAt: timestamp.getTime(),
},
],
attachments: [{ type: "text", mimeType: "text/plain", text: "local attachment" }],
});
const canonicalWithoutProviderIdentity = createUserMessage({
id: "canonical-page-row",
clientMessageId: "client-1",
text: "provider-shaped text",
timestamp: new Date("2026-07-27T10:00:01.000Z"),
});
const result = upsertUserMessage([local], canonicalWithoutProviderIdentity);
expect(result).toEqual([local]);
});
it("matches a submitted message against a legacy canonical row that has no client identity", () => {
// Daemons before v0.2.0 do not echo clientMessageId. During agent creation the
// legacy canonical row can land before the local submission is handed off, so the
// submitted row arrives as `incoming` and must still match by text.
const timestamp = new Date("2026-07-27T11:00:00.000Z");
const legacyCanonical = createUserMessage({
id: "provider-1",
messageId: "provider-1",
text: "review this",
timestamp,
});
const submitted = createUserMessage({
clientMessageId: "client-1",
text: "review this",
timestamp: new Date("2026-07-27T11:00:01.000Z"),
attachments: [{ type: "text", mimeType: "text/plain", text: "attachment" }],
});
const result = handoffCreatedAgentUserMessageToStream({
tail: [legacyCanonical],
head: [],
message: submitted,
});
expect(result.tail).toEqual([
{
...submitted,
id: "client-1",
messageId: "provider-1",
},
]);
});
});
function assistantTimeline(
text: string,
provider: AgentProvider = "claude",
@@ -895,15 +998,15 @@ describe("stream reducer canonical tool calls", () => {
assert.strictEqual(todos.items[0]?.text, "Task 1");
});
it("preserves optimistic user message images when authoritative user message arrives", () => {
it("preserves submitted user message images when authoritative user message arrives", () => {
const messageId = "msg-user-images";
const optimisticTimestamp = new Date("2025-01-01T11:10:00Z");
const optimisticImages = [
const submittedTimestamp = new Date("2025-01-01T11:10:00Z");
const submittedImages = [
{
id: "att-optimistic",
id: "att-submitted",
mimeType: "image/jpeg",
storageType: "native-file" as const,
storageKey: "/tmp/optimistic.jpg",
storageKey: "/tmp/submitted.jpg",
createdAt: Date.now(),
},
];
@@ -911,10 +1014,10 @@ describe("stream reducer canonical tool calls", () => {
{
kind: "user_message",
id: messageId,
clientMessageId: messageId,
text: "Analyze this image",
timestamp: optimisticTimestamp,
optimistic: true,
images: optimisticImages,
timestamp: submittedTimestamp,
images: submittedImages,
},
];
const event: AgentStreamEventPayload = {
@@ -933,9 +1036,9 @@ describe("stream reducer canonical tool calls", () => {
assert.ok(message);
assert.strictEqual(message.id, messageId);
assert.deepStrictEqual(message.images, optimisticImages);
assert.deepStrictEqual(message.images, submittedImages);
assert.strictEqual(message.text, "Analyze this image");
assert.strictEqual(message.timestamp.getTime(), optimisticTimestamp.getTime());
assert.strictEqual(message.timestamp.getTime(), submittedTimestamp.getTime());
});
it("keeps canonical assistant/user/assistant order during replay", () => {
@@ -981,7 +1084,7 @@ describe("stream reducer canonical tool calls", () => {
);
});
it("keeps live optimistic assistant merge behavior", () => {
it("keeps live submitted assistant merge behavior", () => {
const state: StreamItem[] = [
{
kind: "assistant_message",
@@ -1117,23 +1220,23 @@ describe("turn lifecycle events", () => {
});
it.each(["codex", "opencode", "pi"] satisfies AgentProvider[])(
"replaces an optimistic user message when a live %s provider-owned id echo arrives without text matching",
"replaces a submitted user message when a live %s provider-owned id echo arrives without text matching",
(provider) => {
const optimisticTimestamp = new Date("2025-01-01T15:02:00Z");
const submittedTimestamp = new Date("2025-01-01T15:02:00Z");
const serverTimestamp = new Date("2025-01-01T15:02:01Z");
const optimistic: StreamItem = {
const submitted: StreamItem = {
kind: "user_message",
id: "msg_optimistic",
id: "msg_submitted",
clientMessageId: "msg_submitted",
text: "same user text",
timestamp: optimisticTimestamp,
optimistic: true,
timestamp: submittedTimestamp,
images: [
{
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "image-1",
createdAt: optimisticTimestamp.getTime(),
createdAt: submittedTimestamp.getTime(),
},
],
attachments: [
@@ -1147,7 +1250,7 @@ describe("turn lifecycle events", () => {
};
const state = reduceStreamUpdate(
[optimistic],
[submitted],
{
type: "timeline",
provider,
@@ -1155,6 +1258,7 @@ describe("turn lifecycle events", () => {
type: "user_message",
text: "server-owned rendered text",
messageId: "provider-owned-id",
clientMessageId: "msg_submitted",
},
},
serverTimestamp,
@@ -1165,28 +1269,28 @@ describe("turn lifecycle events", () => {
assert.strictEqual(userMessages.length, 1);
const userMessage = userMessages[0];
invariant(userMessage?.kind === "user_message");
assert.strictEqual(userMessage.id, "provider-owned-id");
assert.strictEqual(userMessage.text, optimistic.text);
assert.strictEqual(userMessage.timestamp.getTime(), optimistic.timestamp.getTime());
assert.strictEqual(userMessage.optimistic, undefined);
assert.deepStrictEqual(userMessage.images, optimistic.images);
assert.deepStrictEqual(userMessage.attachments, optimistic.attachments);
assert.strictEqual(userMessage.id, "msg_submitted");
assert.strictEqual(userMessage.messageId, "provider-owned-id");
assert.strictEqual(userMessage.text, submitted.text);
assert.strictEqual(userMessage.timestamp.getTime(), submitted.timestamp.getTime());
assert.deepStrictEqual(userMessage.images, submitted.images);
assert.deepStrictEqual(userMessage.attachments, submitted.attachments);
},
);
it("replaces one optimistic plain-text user message with the next live server user message", () => {
const optimisticTimestamp = new Date("2025-01-01T15:03:00Z");
it("replaces one submitted plain-text user message with the next live server user message", () => {
const submittedTimestamp = new Date("2025-01-01T15:03:00Z");
const serverTimestamp = new Date("2025-01-01T15:03:01Z");
const optimistic: StreamItem = {
const submitted: StreamItem = {
kind: "user_message",
id: "msg_optimistic",
id: "msg_submitted",
clientMessageId: "msg_submitted",
text: "typed plain text",
timestamp: optimisticTimestamp,
optimistic: true,
timestamp: submittedTimestamp,
};
const state = reduceStreamUpdate(
[optimistic],
[submitted],
{
type: "timeline",
provider: "opencode",
@@ -1204,20 +1308,20 @@ describe("turn lifecycle events", () => {
assert.strictEqual(userMessages.length, 1);
const userMessage = userMessages[0];
invariant(userMessage?.kind === "user_message");
assert.strictEqual(userMessage.id, "msg_opencode_provider_owned");
assert.strictEqual(userMessage.id, "msg_submitted");
assert.strictEqual(userMessage.messageId, "msg_opencode_provider_owned");
assert.strictEqual(userMessage.text, "typed plain text");
assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime());
assert.strictEqual(userMessage.optimistic, undefined);
assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime());
});
it("replaces an optimistic image user message with the next canonical server user message", () => {
const optimisticTimestamp = new Date("2025-01-01T15:03:10Z");
it("replaces a submitted image user message with the next canonical server user message", () => {
const submittedTimestamp = new Date("2025-01-01T15:03:10Z");
const image = {
id: "image-canonical",
mimeType: "image/png",
storageType: "web-indexeddb" as const,
storageKey: "image-canonical",
createdAt: optimisticTimestamp.getTime(),
createdAt: submittedTimestamp.getTime(),
};
const attachment = {
type: "text" as const,
@@ -1225,16 +1329,16 @@ describe("turn lifecycle events", () => {
text: "context",
title: "context.txt",
};
const optimistic = buildOptimisticUserMessage({
id: "msg_optimistic_canonical",
const submitted = createUserMessage({
clientMessageId: "msg_submitted_canonical",
text: "Analyze this",
timestamp: optimisticTimestamp,
timestamp: submittedTimestamp,
images: [image],
attachments: [attachment],
});
const state = reduceStreamUpdate(
[optimistic],
[submitted],
{
type: "timeline",
provider: "claude",
@@ -1242,7 +1346,7 @@ describe("turn lifecycle events", () => {
type: "user_message",
text: "server-rendered attachment text",
messageId: "provider-owned-canonical",
clientMessageId: optimistic.id,
clientMessageId: submitted.id,
},
},
new Date("2025-01-01T15:03:11Z"),
@@ -1253,17 +1357,17 @@ describe("turn lifecycle events", () => {
assert.strictEqual(userMessages.length, 1);
const userMessage = userMessages[0];
invariant(userMessage?.kind === "user_message");
assert.strictEqual(userMessage.id, "provider-owned-canonical");
assert.strictEqual(userMessage.id, "msg_submitted_canonical");
assert.strictEqual(userMessage.messageId, "provider-owned-canonical");
assert.strictEqual(userMessage.text, "Analyze this");
assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime());
assert.strictEqual(userMessage.optimistic, undefined);
assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime());
assert.deepStrictEqual(userMessage.images, [image]);
assert.deepStrictEqual(userMessage.attachments, [attachment]);
});
it("places optimistic user messages through one append helper", () => {
const optimistic = buildOptimisticUserMessage({
id: "msg_append_once",
it("places submitted user messages through the identity producer", () => {
const submitted = createUserMessage({
clientMessageId: "msg_append_once",
text: "append once",
timestamp: new Date("2025-01-01T15:03:20Z"),
});
@@ -1274,28 +1378,30 @@ describe("turn lifecycle events", () => {
timestamp: new Date("2025-01-01T15:03:19Z"),
};
const first = appendOptimisticUserMessageToStream({
const first = upsertUserMessageAcrossStream({
tail: [],
head: [headItem],
message: optimistic,
placement: "active-head",
message: submitted,
insert: "head",
presentation: "existing",
});
const second = appendOptimisticUserMessageToStream({
const second = upsertUserMessageAcrossStream({
tail: first.tail,
head: first.head,
message: optimistic,
placement: "active-head",
message: submitted,
insert: "head",
presentation: "existing",
});
assert.deepStrictEqual(first.tail, []);
assert.deepStrictEqual(first.head, [headItem, optimistic]);
assert.deepStrictEqual(first.head, [headItem, submitted]);
assert.strictEqual(second.changedHead, false);
assert.strictEqual(second.head, first.head);
});
it("hands rich optimistic content to an authoritative create message without duplicating it", () => {
it("hands rich submitted content to its create message without overwriting an earlier user row", () => {
const timestamp = new Date("2025-01-01T15:03:20Z");
const optimistic = buildOptimisticUserMessage({
id: "client-user",
const submitted = createUserMessage({
clientMessageId: "client-user",
text: "",
timestamp,
images: [
@@ -1317,32 +1423,44 @@ describe("turn lifecycle events", () => {
},
],
});
const precedingProviderRow: StreamItem = {
kind: "user_message",
id: "provider-system-user",
messageId: "provider-system-user",
text: "provider setup prompt",
timestamp: new Date("2025-01-01T15:03:20.500Z"),
};
const canonical: StreamItem = {
kind: "user_message",
id: "provider-user",
messageId: "provider-user",
clientMessageId: "client-user",
text: "server-rendered attachment text",
timestamp: new Date("2025-01-01T15:03:21Z"),
};
const handedOff = handoffCreatedAgentUserMessageToStream({
tail: [canonical],
tail: [precedingProviderRow, canonical],
head: [],
message: optimistic,
message: submitted,
});
const repeated = handoffCreatedAgentUserMessageToStream({
tail: handedOff.tail,
head: handedOff.head,
message: optimistic,
message: submitted,
});
assert.deepStrictEqual(handedOff.tail, [
precedingProviderRow,
{
kind: "user_message",
id: "provider-user",
text: optimistic.text,
timestamp: optimistic.timestamp,
images: optimistic.images,
attachments: optimistic.attachments,
id: "client-user",
clientMessageId: "client-user",
messageId: "provider-user",
text: submitted.text,
timestamp: submitted.timestamp,
images: submitted.images,
attachments: submitted.attachments,
},
]);
assert.deepStrictEqual(handedOff.head, []);
@@ -1364,22 +1482,22 @@ describe("turn lifecycle events", () => {
);
assert.deepStrictEqual(
afterNextUser.filter((item) => item.kind === "user_message").map((item) => item.id),
["provider-user", "provider-next-user"],
["provider-system-user", "client-user", "provider-next-user"],
);
});
it("reconciles an optimistic user message that was pending in the streaming head", () => {
const optimistic: StreamItem = {
it("flushes an interrupted head when its submitted prompt becomes canonical", () => {
const submitted: StreamItem = {
kind: "user_message",
id: "msg_head_optimistic",
id: "msg_head_submitted",
clientMessageId: "msg_head_submitted",
text: "plain text in head",
timestamp: new Date("2025-01-01T15:03:02Z"),
optimistic: true,
};
const result = applyStreamEvent({
tail: [],
head: [optimistic],
head: [submitted],
event: {
type: "timeline",
provider: "opencode",
@@ -1393,33 +1511,80 @@ describe("turn lifecycle events", () => {
source: "live",
});
assert.strictEqual(result.head.length, 0);
assert.deepStrictEqual(result.head, []);
const userMessages = result.tail.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 1);
assert.strictEqual(userMessages[0]?.id, "provider-owned-head");
assert.strictEqual(userMessages[0]?.optimistic, undefined);
assert.strictEqual(userMessages[0]?.id, "msg_head_submitted");
assert.strictEqual(userMessages[0]?.messageId, "provider-owned-head");
});
it("replaces multiple optimistic user messages in FIFO order", () => {
const optimisticTimestamp = new Date("2025-01-01T15:04:00Z");
const serverTimestamp = new Date("2025-01-01T15:04:01Z");
const firstOptimistic: StreamItem = {
kind: "user_message",
id: "msg_optimistic_1",
text: "first typed text",
timestamp: optimisticTimestamp,
optimistic: true,
it("keeps a replacement assistant separate after an interrupted prompt is reconciled", () => {
const interruptedAssistant: StreamItem = {
kind: "assistant_message",
id: "interrupted",
text: "old answer",
timestamp: new Date("2025-01-01T15:03:01Z"),
};
const secondOptimistic: StreamItem = {
const submitted = createUserMessage({
clientMessageId: "msg_interrupt",
text: "replacement prompt",
timestamp: new Date("2025-01-01T15:03:02Z"),
});
const reconciled = applyStreamEvent({
tail: [],
head: [interruptedAssistant, submitted],
event: {
type: "timeline",
provider: "opencode",
item: {
type: "user_message",
text: submitted.text,
messageId: "provider-prompt",
clientMessageId: submitted.clientMessageId,
},
},
timestamp: new Date("2025-01-01T15:03:03Z"),
});
const replacement = applyStreamEvent({
tail: reconciled.tail,
head: reconciled.head,
event: {
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text: "new answer" },
},
timestamp: new Date("2025-01-01T15:03:04Z"),
});
expect(replacement.tail.map((item) => item.kind)).toEqual([
"assistant_message",
"user_message",
]);
expect(replacement.head).toEqual([
expect.objectContaining({ kind: "assistant_message", text: "new answer" }),
]);
});
it("replaces multiple submitted user messages in FIFO order", () => {
const submittedTimestamp = new Date("2025-01-01T15:04:00Z");
const serverTimestamp = new Date("2025-01-01T15:04:01Z");
const firstSubmitted: StreamItem = {
kind: "user_message",
id: "msg_optimistic_2",
id: "msg_submitted_1",
clientMessageId: "msg_submitted_1",
text: "first typed text",
timestamp: submittedTimestamp,
};
const secondSubmitted: StreamItem = {
kind: "user_message",
id: "msg_submitted_2",
clientMessageId: "msg_submitted_2",
text: "second typed text",
timestamp: new Date("2025-01-01T15:04:00.500Z"),
optimistic: true,
};
const afterFirstEcho = reduceStreamUpdate(
[firstOptimistic, secondOptimistic],
[firstSubmitted, secondSubmitted],
{
type: "timeline",
provider: "opencode",
@@ -1427,6 +1592,7 @@ describe("turn lifecycle events", () => {
type: "user_message",
text: "first server text",
messageId: "provider-owned-first",
clientMessageId: "msg_submitted_1",
},
},
serverTimestamp,
@@ -1441,6 +1607,7 @@ describe("turn lifecycle events", () => {
type: "user_message",
text: "second server text",
messageId: "provider-owned-second",
clientMessageId: "msg_submitted_2",
},
},
new Date("2025-01-01T15:04:02Z"),
@@ -1450,30 +1617,30 @@ describe("turn lifecycle events", () => {
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 2);
assert.deepStrictEqual(
userMessages.map((item) => [item.id, item.text, item.optimistic]),
userMessages.map((item) => [item.id, item.text, item.messageId]),
[
["provider-owned-first", "first typed text", undefined],
["provider-owned-second", "second typed text", undefined],
["msg_submitted_1", "first typed text", "provider-owned-first"],
["msg_submitted_2", "second typed text", "provider-owned-second"],
],
);
});
it("does not shift later prompts when an earlier optimistic prompt has no canonical echo", () => {
it("does not shift later prompts when an earlier submitted prompt has no canonical echo", () => {
const staleTimestamp = new Date("2025-01-01T15:04:00Z");
const submittedTimestamp = new Date("2025-01-01T15:04:01Z");
const stalePrompt: StreamItem = {
kind: "user_message",
id: "msg_stale",
clientMessageId: "msg_stale",
text: "first prompt without an echo",
timestamp: staleTimestamp,
optimistic: true,
};
const submittedPrompt: StreamItem = {
kind: "user_message",
id: "msg_submitted",
clientMessageId: "msg_submitted",
text: "later submitted prompt",
timestamp: submittedTimestamp,
optimistic: true,
};
const state = reduceStreamUpdate(
@@ -1496,15 +1663,16 @@ describe("turn lifecycle events", () => {
stalePrompt,
{
kind: "user_message",
id: "provider-owned-submitted",
id: "msg_submitted",
clientMessageId: submittedPrompt.id,
messageId: "provider-owned-submitted",
text: submittedPrompt.text,
timestamp: submittedPrompt.timestamp,
},
]);
});
it("appends a live server user message when no optimistic user message is pending", () => {
it("appends a live server user message when no submitted user message is pending", () => {
const state = reduceStreamUpdate(
[],
{
@@ -1523,21 +1691,11 @@ describe("turn lifecycle events", () => {
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 1);
assert.strictEqual(userMessages[0]?.id, "provider-owned-resume");
assert.strictEqual(userMessages[0]?.optimistic, undefined);
});
it("does not match a server user message to an optimistic from a rewound turn after pending optimistics are cleared", () => {
const optimistic: StreamItem = {
kind: "user_message",
id: "msg_rewound_optimistic",
text: "rewound text",
timestamp: new Date("2025-01-01T15:04:04Z"),
optimistic: true,
};
const cleared = clearOptimisticUserMessages([optimistic]);
it("appends a server user message after a rewound local row was removed", () => {
const state = reduceStreamUpdate(
cleared,
[],
{
type: "timeline",
provider: "opencode",
@@ -1555,7 +1713,6 @@ describe("turn lifecycle events", () => {
assert.strictEqual(userMessages.length, 1);
assert.strictEqual(userMessages[0]?.id, "provider-owned-after-rewind");
assert.strictEqual(userMessages[0]?.text, "future server echo");
assert.strictEqual(userMessages[0]?.optimistic, undefined);
});
it("keeps canonical repeated user messages distinct during hydration", () => {

View File

@@ -87,22 +87,440 @@ export interface UserMessageItem {
kind: "user_message";
id: string;
clientMessageId?: string;
text: string;
timestamp: Date;
optimistic?: true;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
}
export interface OptimisticUserMessageInput {
id: string;
messageId?: string;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
}
export type OptimisticUserMessagePlacement = "tail" | "active-head";
export interface UserMessageInput {
id?: string;
clientMessageId?: string;
messageId?: string;
text: string;
timestamp: Date;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
}
export function createUserMessage(input: UserMessageInput): UserMessageItem {
const id = input.id ?? input.clientMessageId ?? input.messageId;
if (!id) {
throw new Error("User message identity is required");
}
return {
kind: "user_message",
id,
...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}),
...(input.messageId ? { messageId: input.messageId } : {}),
text: input.text,
timestamp: input.timestamp,
...(input.images && input.images.length > 0 ? { images: input.images } : {}),
...(input.attachments && input.attachments.length > 0
? { attachments: input.attachments }
: {}),
};
}
export function appendSubmittedUserMessage(input: {
tail: StreamItem[];
head: StreamItem[];
message: UserMessageItem;
}): { tail: StreamItem[]; head: StreamItem[] } {
const clientMessageId = input.message.clientMessageId;
if (!clientMessageId) {
throw new Error("Submitted user message requires client identity");
}
const alreadyExists = [...input.tail, ...input.head].some(
(item) => item.kind === "user_message" && item.clientMessageId === clientMessageId,
);
if (alreadyExists) {
throw new Error(`Submitted user message already exists: ${clientMessageId}`);
}
return input.head.length > 0
? { tail: input.tail, head: [...input.head, input.message] }
: { tail: [...input.tail, input.message], head: input.head };
}
export function removeSubmittedUserMessage(input: {
tail: StreamItem[];
head: StreamItem[];
clientMessageId: string;
}): { tail: StreamItem[]; head: StreamItem[] } {
const remove = (items: StreamItem[]) => {
const next = items.filter(
(item) => item.kind !== "user_message" || item.clientMessageId !== input.clientMessageId,
);
return next.length === items.length ? items : next;
};
return { tail: remove(input.tail), head: remove(input.head) };
}
// COMPAT(userMessageClientId): added in v0.2.0, remove after 2027-01-20 once the
// supported daemon floor emits clientMessageId on submitted user messages. Until then a
// locally submitted row (clientMessageId, no messageId) and its canonical twin from an
// old daemon (messageId, no clientMessageId) share no identifier, so canonical ingestion
// may match an explicit local candidate by the id supplied over the wire or by text.
function matchesLegacyCanonicalUserMessage(
submitted: UserMessageItem,
canonical: UserMessageItem,
): boolean {
if (submitted.clientMessageId === undefined || submitted.messageId !== undefined) return false;
if (canonical.messageId === undefined) return false;
return canonical.messageId === submitted.clientMessageId || canonical.text === submitted.text;
}
type UserMessageMatchPolicy = "canonical-incoming" | "handoff";
function matchesUserMessage(
existing: UserMessageItem,
incoming: UserMessageItem,
policy: UserMessageMatchPolicy,
): boolean {
if (existing.clientMessageId && incoming.clientMessageId) {
return existing.clientMessageId === incoming.clientMessageId;
}
if (existing.messageId && incoming.messageId) {
return existing.messageId === incoming.messageId;
}
if (matchesLegacyCanonicalUserMessage(existing, incoming)) return true;
return policy === "handoff" && matchesLegacyCanonicalUserMessage(incoming, existing);
}
export function upsertUserMessage(
items: StreamItem[],
incoming: UserMessageItem,
insertAt = items.length,
): StreamItem[] {
return produceUserMessage(items, incoming, insertAt, "existing").items;
}
type UserMessagePresentationPolicy = "existing" | "incoming";
interface UserMessageProductionResult {
items: StreamItem[];
index: number;
message: UserMessageItem;
matched: boolean;
}
function produceUserMessage(
items: StreamItem[],
incoming: UserMessageItem,
insertAt: number | null,
presentationPolicy: UserMessagePresentationPolicy,
matchPolicy: UserMessageMatchPolicy = "canonical-incoming",
): UserMessageProductionResult {
const index = items.findIndex(
(item) => item.kind === "user_message" && matchesUserMessage(item, incoming, matchPolicy),
);
if (index < 0) {
if (insertAt === null) {
return { items, index: -1, message: incoming, matched: false };
}
return {
items: [...items.slice(0, insertAt), incoming, ...items.slice(insertAt)],
index: insertAt,
message: incoming,
matched: false,
};
}
const existing = items[index];
if (!existing || existing.kind !== "user_message") {
throw new Error("User message upsert matched a non-user row");
}
const presentation = presentationPolicy === "incoming" ? incoming : existing;
const merged = createUserMessage({
...presentation,
clientMessageId: incoming.clientMessageId ?? existing.clientMessageId,
messageId: incoming.messageId ?? existing.messageId,
});
if (
existing.id === merged.id &&
existing.clientMessageId === merged.clientMessageId &&
existing.messageId === merged.messageId &&
existing.text === merged.text &&
existing.timestamp === merged.timestamp &&
existing.images === merged.images &&
existing.attachments === merged.attachments
) {
return { items, index, message: existing, matched: true };
}
const next = [...items];
next[index] = merged;
return { items: next, index, message: merged, matched: true };
}
export interface UserMessageStreamUpsertInput {
tail: StreamItem[];
head: StreamItem[];
message: UserMessageItem;
insert: "tail" | "head" | "prepend-tail" | "none";
presentation: UserMessagePresentationPolicy;
matchPolicy?: UserMessageMatchPolicy;
}
export interface UserMessageStreamUpsertResult extends ApplyStreamEventResult {
location: {
lane: "tail" | "head";
index: number;
message: UserMessageItem;
matched: boolean;
} | null;
}
export function upsertUserMessageAcrossStream(
input: UserMessageStreamUpsertInput,
): UserMessageStreamUpsertResult {
const tailResult = produceUserMessage(
input.tail,
input.message,
null,
input.presentation,
input.matchPolicy,
);
if (tailResult.matched) {
return {
tail: tailResult.items,
head: input.head,
changedTail: tailResult.items !== input.tail,
changedHead: false,
location: {
lane: "tail",
index: tailResult.index,
message: tailResult.message,
matched: true,
},
};
}
const headResult = produceUserMessage(
input.head,
input.message,
null,
input.presentation,
input.matchPolicy,
);
if (headResult.matched) {
return {
tail: input.tail,
head: headResult.items,
changedTail: false,
changedHead: headResult.items !== input.head,
location: {
lane: "head",
index: headResult.index,
message: headResult.message,
matched: true,
},
};
}
if (input.insert === "none") {
return {
tail: input.tail,
head: input.head,
changedTail: false,
changedHead: false,
location: null,
};
}
if (input.insert === "head") {
const inserted = produceUserMessage(
input.head,
input.message,
input.head.length,
input.presentation,
input.matchPolicy,
);
return {
tail: input.tail,
head: inserted.items,
changedTail: false,
changedHead: true,
location: {
lane: "head",
index: inserted.index,
message: inserted.message,
matched: false,
},
};
}
const inserted = produceUserMessage(
input.tail,
input.message,
input.insert === "prepend-tail" ? 0 : input.tail.length,
input.presentation,
input.matchPolicy,
);
return {
tail: inserted.items,
head: input.head,
changedTail: true,
changedHead: false,
location: {
lane: "tail",
index: inserted.index,
message: inserted.message,
matched: false,
},
};
}
function placeCanonicalUserMessageAtTail(
tail: StreamItem[],
message: UserMessageItem,
insertWhenUnmatched: boolean,
): Pick<UserMessageProductionResult, "items" | "message" | "matched"> {
const produced = produceUserMessage(tail, message, null, "existing");
if (!produced.matched && !insertWhenUnmatched) {
return produced;
}
const preceding = produced.matched
? [...produced.items.slice(0, produced.index), ...produced.items.slice(produced.index + 1)]
: produced.items;
return {
items: [...preceding, produced.message],
message: produced.message,
matched: produced.matched,
};
}
export interface CanonicalStreamReplacementInput {
canonical: StreamItem[];
previousTail: StreamItem[];
previousHead: StreamItem[];
sendingClientMessageIds: readonly string[];
preserveLiveHead: boolean;
}
export interface CanonicalStreamReplacementResult {
tail: StreamItem[];
head: StreamItem[];
acknowledgedClientMessageIds: string[];
}
function removeUserMessageAt(items: UserMessageItem[], index: number): UserMessageItem[] {
return [...items.slice(0, index), ...items.slice(index + 1)];
}
function preserveReplacementHead(
tail: StreamItem[],
currentHead: StreamItem[],
preserveLiveHead: boolean,
sendingClientMessageIds: ReadonlySet<string>,
): CanonicalStreamReplacementResult {
const retainedHead = preserveLiveHead
? currentHead
: currentHead.filter(
(item) =>
item.kind === "user_message" &&
item.clientMessageId !== undefined &&
sendingClientMessageIds.has(item.clientMessageId),
);
const tailIds = new Set(tail.map((item) => item.id));
const unreconciledHead = retainedHead.filter(
(item) => item.kind === "assistant_message" || !tailIds.has(item.id),
);
const liveAssistantIndex = unreconciledHead.findLastIndex(
(item) => item.kind === "assistant_message",
);
if (liveAssistantIndex < 0) {
return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
}
const liveAssistant = unreconciledHead[liveAssistantIndex];
const tailAssistant = tail.at(-1);
if (
liveAssistant.kind !== "assistant_message" ||
!tailAssistant ||
tailAssistant.kind !== "assistant_message" ||
!liveAssistant.text.startsWith(tailAssistant.text)
) {
return { tail, head: unreconciledHead, acknowledgedClientMessageIds: [] };
}
const head = [
...unreconciledHead.slice(0, liveAssistantIndex),
{ ...liveAssistant, text: tailAssistant.text },
...unreconciledHead.slice(liveAssistantIndex + 1),
];
return { tail: tail.slice(0, -1), head, acknowledgedClientMessageIds: [] };
}
export function replaceWithCanonicalStream(
input: CanonicalStreamReplacementInput,
): CanonicalStreamReplacementResult {
const sendingClientMessageIds = new Set(input.sendingClientMessageIds);
let unmatchedTailMessages = input.previousTail.filter(
(item): item is UserMessageItem =>
item.kind === "user_message" && item.clientMessageId !== undefined,
);
let nextHead = input.previousHead;
const nextTail: StreamItem[] = [];
const acknowledgedClientMessageIds = new Set<string>();
for (const item of input.canonical) {
if (item.kind !== "user_message") {
nextTail.push(item);
continue;
}
const tailResult = produceUserMessage(unmatchedTailMessages, item, null, "existing");
if (tailResult.matched) {
unmatchedTailMessages = removeUserMessageAt(unmatchedTailMessages, tailResult.index);
nextTail.push(tailResult.message);
if (
tailResult.message.clientMessageId &&
sendingClientMessageIds.has(tailResult.message.clientMessageId)
) {
acknowledgedClientMessageIds.add(tailResult.message.clientMessageId);
}
continue;
}
const headResult = produceUserMessage(nextHead, item, null, "existing");
if (headResult.matched) {
nextHead = [
...headResult.items.slice(0, headResult.index),
...headResult.items.slice(headResult.index + 1),
];
nextTail.push(headResult.message);
if (
headResult.message.clientMessageId &&
sendingClientMessageIds.has(headResult.message.clientMessageId)
) {
acknowledgedClientMessageIds.add(headResult.message.clientMessageId);
}
continue;
}
nextTail.push(item);
}
for (const local of unmatchedTailMessages) {
if (!local.clientMessageId || !sendingClientMessageIds.has(local.clientMessageId)) {
continue;
}
nextTail.push(local);
}
nextHead = nextHead.filter((item) => {
if (item.kind !== "user_message" || !item.clientMessageId) return true;
return sendingClientMessageIds.has(item.clientMessageId);
});
const replacement = preserveReplacementHead(
nextTail,
nextHead,
input.preserveLiveHead,
sendingClientMessageIds,
);
return {
...replacement,
acknowledgedClientMessageIds: [...acknowledgedClientMessageIds],
};
}
export interface AssistantMessageItem {
kind: "assistant_message";
@@ -237,124 +655,24 @@ function markThoughtReady(item: ThoughtItem): ThoughtItem {
};
}
function buildUserMessageItem(input: {
id: string;
clientMessageId?: string;
text: string;
timestamp: Date;
optimistic?: UserMessageItem | null;
}): UserMessageItem {
if (input.optimistic) {
return {
kind: "user_message",
id: input.id,
...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}),
text: input.optimistic.text,
timestamp: input.optimistic.timestamp,
...(input.optimistic.images && input.optimistic.images.length > 0
? { images: input.optimistic.images }
: {}),
...(input.optimistic.attachments && input.optimistic.attachments.length > 0
? { attachments: input.optimistic.attachments }
: {}),
};
}
return {
kind: "user_message",
id: input.id,
...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}),
text: input.text,
timestamp: input.timestamp,
};
}
export function buildOptimisticUserMessage(input: OptimisticUserMessageInput): UserMessageItem {
return {
kind: "user_message",
id: input.id,
text: input.text,
timestamp: input.timestamp,
optimistic: true,
...(input.images && input.images.length > 0 ? { images: input.images } : {}),
...(input.attachments && input.attachments.length > 0
? { attachments: input.attachments }
: {}),
};
}
export function appendOptimisticUserMessageToStream(params: {
tail: StreamItem[];
head: StreamItem[];
message: UserMessageItem;
placement: OptimisticUserMessagePlacement;
}): ApplyStreamEventResult {
const { tail, head, message, placement } = params;
if (tail.some((item) => item.id === message.id) || head.some((item) => item.id === message.id)) {
return { tail, head, changedTail: false, changedHead: false };
}
if (placement === "active-head" && head.length > 0) {
return {
tail,
head: [...head, message],
changedTail: false,
changedHead: true,
};
}
return {
tail: [...tail, message],
head,
changedTail: true,
changedHead: false,
};
}
export function handoffCreatedAgentUserMessageToStream(params: {
tail: StreamItem[];
head: StreamItem[];
message: UserMessageItem;
}): ApplyStreamEventResult {
const { tail, head, message } = params;
const items = [...tail, ...head];
const userIndex = items.findIndex((item) => item.kind === "user_message");
if (userIndex < 0) {
return appendOptimisticUserMessageToStream({
tail,
head,
message,
placement: "tail",
});
}
const userMessage = items[userIndex];
if (!userMessage || userMessage.kind !== "user_message" || userMessage.optimistic) {
return { tail, head, changedTail: false, changedHead: false };
}
const handedOffMessage = buildUserMessageItem({
id: userMessage.id,
text: message.text,
timestamp: message.timestamp,
optimistic: message,
return upsertUserMessageAcrossStream({
...params,
insert: "tail",
presentation: "incoming",
matchPolicy: "handoff",
});
if (userIndex < tail.length) {
const nextTail = [...tail];
nextTail[userIndex] = handedOffMessage;
return { tail: nextTail, head, changedTail: true, changedHead: false };
}
const nextHead = [...head];
nextHead[userIndex - tail.length] = handedOffMessage;
return { tail, head: nextHead, changedTail: false, changedHead: true };
}
function appendUserMessage(
state: StreamItem[],
text: string,
timestamp: Date,
source: StreamUpdateSource,
_source: StreamUpdateSource,
messageId?: string,
clientMessageId?: string,
): StreamItem[] {
@@ -364,37 +682,14 @@ function appendUserMessage(
}
const chunkSeed = chunk.trim() || chunk;
const entryId = messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp);
const optimisticIndex = state.findIndex(
(entry) =>
entry.kind === "user_message" &&
entry.optimistic &&
(clientMessageId !== undefined
? entry.id === clientMessageId
: source === "live" || entry.id === messageId || entry.text === chunk),
);
const optimistic = optimisticIndex >= 0 ? (state[optimisticIndex] as UserMessageItem) : null;
const nextItem = buildUserMessageItem({
id: entryId,
const nextItem = createUserMessage({
id: messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp),
clientMessageId,
messageId,
text: chunk,
timestamp,
optimistic,
});
if (optimisticIndex >= 0) {
const next = [...state];
next[optimisticIndex] = nextItem;
return next;
}
return [...state, nextItem];
}
export function clearOptimisticUserMessages(state: StreamItem[]): StreamItem[] {
const next = state.filter((item) => item.kind !== "user_message" || !item.optimistic);
return next.length === state.length ? state : next;
return upsertUserMessage(state, nextItem);
}
function appendAssistantMessage(
@@ -426,8 +721,8 @@ function appendAssistantMessage(
return [...state.slice(0, -1), updated];
}
// If the last item is a user_message (optimistic append to head during
// interrupt), look one further back for the streaming assistant_message.
// A submitted user row can follow the streaming assistant during interrupt.
// In that case, look one row further back for the assistant to extend.
const secondLast = state[state.length - 2];
if (
source === "live" &&
@@ -1149,7 +1444,6 @@ export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamI
if (newItems.length === 0) {
return tail;
}
return [...tail, ...newItems];
}
@@ -1177,8 +1471,7 @@ function shouldFlushHead(input: {
return true;
}
// Find the last streamable item in head (skip trailing non-streamable
// items like an optimistic user_message appended during interrupt).
// Find the last streamable item in head (skip trailing non-streamable items).
let lastStreamable: StreamItem | undefined;
for (let i = head.length - 1; i >= 0; i--) {
if (isStreamableKind(head[i].kind)) {
@@ -1209,6 +1502,41 @@ export interface ApplyStreamEventResult {
head: StreamItem[];
changedTail: boolean;
changedHead: boolean;
acknowledgedClientMessageIds?: string[];
}
function applyCanonicalUserMessageEvent(params: {
tail: StreamItem[];
head: StreamItem[];
event: AgentStreamEventPayload;
timestamp: Date;
}): ApplyStreamEventResult | null {
const { tail, head, event, timestamp } = params;
if (event.type !== "timeline" || event.item.type !== "user_message") return null;
const normalized = normalizeChunk(event.item.text);
const flushedTail = head.length > 0 ? flushHeadToTail(tail, head) : tail;
const flushedHead = head.length > 0 ? [] : head;
const canonical = createUserMessage({
id:
event.item.messageId ??
createUniqueTimelineId([...tail, ...head], "user", normalized.chunk.trim(), timestamp),
messageId: event.item.messageId,
clientMessageId: event.item.clientMessageId,
text: normalized.chunk,
timestamp,
});
const reconciled = placeCanonicalUserMessageAtTail(flushedTail, canonical, normalized.hasContent);
return {
tail: reconciled.items,
head: flushedHead,
changedTail: flushedTail !== tail || reconciled.items !== flushedTail,
changedHead: flushedHead !== head,
acknowledgedClientMessageIds:
reconciled.matched && reconciled.message.clientMessageId
? [reconciled.message.clientMessageId]
: [],
};
}
/**
@@ -1231,12 +1559,13 @@ export function applyStreamEvent(params: {
timelineCursor?: TimelinePosition;
}): ApplyStreamEventResult {
const { tail, head, event, timestamp } = params;
const canonicalUserResult = applyCanonicalUserMessageEvent({ tail, head, event, timestamp });
if (canonicalUserResult) return canonicalUserResult;
const source = params.source ?? "live";
let nextTail = tail;
let nextHead = head;
let changedTail = false;
let changedHead = false;
const flushHead = () => {
if (nextHead.length === 0) {
return;

View File

@@ -7,6 +7,7 @@ import { useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import { createUserMessage } from "@/types/stream";
import { applyAgentDirectoryDelta, replaceFetchedAgentDirectory } from "./agent-directory-sync";
function createAgentPayload(
@@ -64,6 +65,111 @@ function permission(id: string): AgentPermissionRequest {
return { id, provider: "codex", name: id, kind: "tool", title: id };
}
function beginPendingSubmission(serverId: string, agentId: string): string {
const clientMessageId = `client-${agentId}`;
useSessionStore.getState().beginAgentMessageSubmission(
serverId,
agentId,
createUserMessage({
clientMessageId,
text: "Run this",
timestamp: new Date("2026-07-27T10:00:00.000Z"),
}),
);
return clientMessageId;
}
function applyAgentStatus(input: {
serverId: string;
agentId: string;
status: AgentSnapshotPayload["status"];
updatedAt: string;
}): void {
const agent = createAgentPayload({
id: input.agentId,
status: input.status,
updatedAt: input.updatedAt,
});
applyAgentDirectoryDelta({
serverId: input.serverId,
delta: { kind: "upsert", agent, project: createEntry(agent).project },
});
}
describe("message submission authority", () => {
it("does not settle a submission from an unrelated running transition", () => {
const serverId = "server-running-is-not-submission-ack";
const agentId = "agent-1";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
applyAgentStatus({
serverId,
agentId,
status: "idle",
updatedAt: "2026-07-27T10:00:00.000Z",
});
const clientMessageId = beginPendingSubmission(serverId, agentId);
applyAgentStatus({
serverId,
agentId,
status: "running",
updatedAt: "2026-07-27T10:00:01.000Z",
});
expect(useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)).toEqual([
{
clientMessageId,
submittedAt: new Date("2026-07-27T10:00:00.000Z"),
rpcAccepted: false,
providerAcknowledged: false,
},
]);
store.clearSession(serverId);
});
it("settles provider acknowledgement only when timeline ingestion reports it", () => {
const serverId = "server-explicit-provider-ack";
const agentId = "agent-1";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
applyAgentStatus({
serverId,
agentId,
status: "idle",
updatedAt: "2026-07-27T10:00:00.000Z",
});
const clientMessageId = beginPendingSubmission(serverId, agentId);
store.setAgentStreamState(serverId, agentId, {
tail: [
createUserMessage({
id: "provider-message",
messageId: "provider-message",
clientMessageId,
text: "Run this",
timestamp: new Date("2026-07-27T10:00:01.000Z"),
}),
],
head: [],
});
expect(
useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0]
?.providerAcknowledged,
).toBe(false);
store.setAgentStreamState(serverId, agentId, {
acknowledgedClientMessageIds: [clientMessageId],
});
expect(
useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0]
?.providerAcknowledged,
).toBe(true);
store.clearSession(serverId);
});
});
describe("replaceFetchedAgentDirectory", () => {
it("preserves timeline initialization while replacing directory state", () => {
const serverId = "server-initializing";

View File

@@ -0,0 +1,54 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { FolderOpen } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { getIsElectron } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import type { Theme } from "@/styles/theme";
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
interface OpenInFileManagerMenuItemProps {
path?: string | null;
testID: string;
}
const ThemedFolderOpen = withUnistyles(FolderOpen);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const leadingIcon = <ThemedFolderOpen size={14} uniProps={foregroundMutedColorMapping} />;
export function OpenInFileManagerMenuItem({ path, testID }: OpenInFileManagerMenuItemProps) {
const { t } = useTranslation();
const toast = useToast();
const isElectron = getIsElectron();
const workspacePath = path?.trim() ?? "";
const { targets } = useDesktopOpenTargets({
isLocalExecution: isElectron && workspacePath.length > 0,
});
const fileManagerTarget = targets.find((target) => target.kind === "file-manager");
const openInFileManager = useCallback(() => {
if (!fileManagerTarget || workspacePath.length === 0) return;
void openDesktopTarget({
editorId: fileManagerTarget.id,
workspacePath,
}).catch((error) => {
console.warn("[open-in-file-manager] open failed", error);
toast.error(t("sidebar.project.actions.openFolderFailed"));
});
}, [fileManagerTarget, t, toast, workspacePath]);
if (!isElectron || !fileManagerTarget || workspacePath.length === 0) {
return null;
}
return (
<DropdownMenuItem testID={testID} leading={leadingIcon} onSelect={openInFileManager}>
{t("sidebar.project.actions.openFolder")}
</DropdownMenuItem>
);
}

View File

@@ -35,6 +35,19 @@ describe("canonical CLI surface", () => {
expect(run?.helpInformation()).not.toContain("--detach");
});
it("offers thinking configuration when running, updating, and scheduling agents", () => {
const cli = createCli();
const run = cli.commands.find((command) => command.name() === "run");
const agent = cli.commands.find((command) => command.name() === "agent");
const update = agent?.commands.find((command) => command.name() === "update");
const schedule = cli.commands.find((command) => command.name() === "schedule");
const scheduleCreate = schedule?.commands.find((command) => command.name() === "create");
expect(run?.helpInformation()).toContain("--thinking <id>");
expect(update?.helpInformation()).toContain("--thinking <id>");
expect(scheduleCreate?.helpInformation()).toContain("--thinking <id>");
});
it("offers opening an existing agent in the desktop app", () => {
const agent = createCli().commands.find((command) => command.name() === "agent");
const open = agent?.commands.find((command) => command.name() === "open");

View File

@@ -92,9 +92,10 @@ export function createAgentCommand(): Command {
addJsonAndDaemonHostOptions(
agent
.command("update")
.description("Update an agent's metadata")
.description("Update an agent's settings or metadata")
.argument("<id>", "Agent ID (or prefix)")
.option("--name <name>", "Update the agent's display name")
.option("--thinking <id>", "Update the agent's thinking option ID")
.option(
"--label <label>",
"Add/set label(s) on the agent (can be used multiple times or comma-separated)",

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
import {
applyAgentChanges,
toAgentUpdateResult,
type AgentMetadataChanges,
type AgentUpdateClient,
} from "./update.js";
class RecordingAgentUpdateClient implements AgentUpdateClient {
readonly metadataUpdates: Array<{
agentId: string;
updates: AgentMetadataChanges;
}> = [];
readonly thinkingUpdates: Array<{ agentId: string; thinkingOptionId: string }> = [];
thinkingNotice: AgentProviderNotice | null = null;
constructor(private readonly supportsThinkingUpdate = true) {}
getLastServerInfoMessage() {
return { features: { agentThinkingUpdate: this.supportsThinkingUpdate } };
}
async updateAgent(agentId: string, updates: AgentMetadataChanges): Promise<void> {
this.metadataUpdates.push({ agentId, updates });
}
async setAgentThinkingOption(
agentId: string,
thinkingOptionId: string,
): Promise<AgentProviderNotice | null> {
this.thinkingUpdates.push({ agentId, thinkingOptionId });
return this.thinkingNotice;
}
}
describe("applyAgentChanges", () => {
it("updates an agent's thinking without issuing an empty metadata update", async () => {
const client = new RecordingAgentUpdateClient();
const result = await applyAgentChanges(client, "agent-1", {
type: "thinking",
thinkingOptionId: "high",
});
expect(client.metadataUpdates).toEqual([]);
expect(client.thinkingUpdates).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]);
expect(result).toEqual({
notice: null,
});
});
it("requires a daemon that advertises thinking updates", async () => {
const client = new RecordingAgentUpdateClient(false);
await expect(
applyAgentChanges(client, "agent-1", { type: "thinking", thinkingOptionId: "high" }),
).rejects.toMatchObject({
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to use agent thinking updates.",
});
expect(client.metadataUpdates).toEqual([]);
expect(client.thinkingUpdates).toEqual([]);
});
it("returns the provider notice from a thinking update", async () => {
const client = new RecordingAgentUpdateClient();
client.thinkingNotice = {
type: "warning",
message: "Thinking changes apply to the next turn.",
};
const notice = await applyAgentChanges(client, "agent-1", {
type: "thinking",
thinkingOptionId: "high",
});
expect(notice).toEqual({
notice: {
type: "warning",
message: "Thinking changes apply to the next turn.",
},
});
});
});
describe("toAgentUpdateResult", () => {
it("reports the current thinking option after a metadata update", () => {
const result = toAgentUpdateResult(
{
id: "agent-1",
title: "Renamed agent",
labels: { team: "platform" },
effectiveThinkingOptionId: "high",
},
{ notice: null },
);
expect(result).toEqual({
agentId: "agent-1",
name: "Renamed agent",
labels: "team=platform",
thinkingOptionId: "high",
noticeType: null,
notice: null,
});
});
});

View File

@@ -1,4 +1,6 @@
import type { Command } from "commander";
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
@@ -12,6 +14,9 @@ export interface AgentUpdateResult {
agentId: string;
name: string | null;
labels: string;
thinkingOptionId: string | null;
noticeType: AgentProviderNotice["type"] | null;
notice: string | null;
}
/** Schema for update command output */
@@ -21,17 +26,80 @@ export const updateSchema: OutputSchema<AgentUpdateResult> = {
{ header: "AGENT ID", field: "agentId" },
{ header: "NAME", field: "name" },
{ header: "LABELS", field: "labels" },
{ header: "THINKING", field: "thinkingOptionId" },
{ header: "NOTICE", field: "notice" },
],
};
export interface AgentUpdateOptions extends CommandOptions {
name?: string;
label?: string[];
thinking?: string;
host?: string;
}
export type AgentUpdateCommandResult = SingleResult<AgentUpdateResult>;
export interface AgentMetadataChanges {
name?: string;
labels?: Record<string, string>;
}
interface AgentUpdateServerInfo {
features?: { agentThinkingUpdate?: boolean };
}
export interface AgentUpdateClient {
getLastServerInfoMessage(): AgentUpdateServerInfo | null;
updateAgent(agentId: string, updates: AgentMetadataChanges): Promise<void>;
setAgentThinkingOption(
agentId: string,
thinkingOptionId: string,
): Promise<AgentProviderNotice | null>;
}
export type AgentChanges =
| { type: "metadata"; updates: AgentMetadataChanges }
| { type: "thinking"; thinkingOptionId: string };
export interface AppliedAgentChanges {
notice: AgentProviderNotice | null;
}
export function toAgentUpdateResult(
agent: Pick<AgentSnapshotPayload, "id" | "title" | "labels" | "effectiveThinkingOptionId">,
appliedChanges: AppliedAgentChanges,
): AgentUpdateResult {
return {
agentId: agent.id,
name: agent.title,
labels: formatLabels(agent.labels),
thinkingOptionId: agent.effectiveThinkingOptionId ?? null,
noticeType: appliedChanges.notice?.type ?? null,
notice: appliedChanges.notice?.message ?? null,
};
}
export async function applyAgentChanges(
client: AgentUpdateClient,
agentId: string,
changes: AgentChanges,
): Promise<AppliedAgentChanges> {
if (changes.type === "thinking") {
// COMPAT(agentThinkingUpdate): added in v0.2.4, remove gate after 2027-01-28.
if (client.getLastServerInfoMessage()?.features?.agentThinkingUpdate !== true) {
throw {
code: "DAEMON_UPDATE_REQUIRED",
message: "Update the host to use agent thinking updates.",
} satisfies CommandError;
}
const notice = await client.setAgentThinkingOption(agentId, changes.thinkingOptionId);
return { notice };
}
await client.updateAgent(agentId, changes.updates);
return { notice: null };
}
function parseLabelOptions(labels: string[] | undefined): Record<string, string> {
const parsed: Record<string, string> = {};
if (!labels) {
@@ -81,6 +149,55 @@ function formatLabels(labels: Record<string, string>): string {
return entries.map(([key, value]) => `${key}=${value}`).join(",");
}
function parseAgentChanges(options: AgentUpdateOptions): AgentChanges {
const name = options.name?.trim();
if (options.name !== undefined && !name) {
throw {
code: "INVALID_NAME",
message: "Name cannot be empty",
details: "Use --name <name> with a non-empty value",
} satisfies CommandError;
}
const labels = parseLabelOptions(options.label);
const thinkingOptionId = options.thinking?.trim();
if (options.thinking !== undefined && !thinkingOptionId) {
throw {
code: "INVALID_THINKING_OPTION",
message: "--thinking cannot be empty",
details:
'Provide a thinking option ID. Use "paseo provider models <provider> --thinking" to list valid IDs.',
} satisfies CommandError;
}
const hasMetadataUpdates = Boolean(name) || Object.keys(labels).length > 0;
if (hasMetadataUpdates && thinkingOptionId) {
throw {
code: "INVALID_OPTIONS",
message: "--thinking cannot be combined with --name or --label",
details: "Run separate agent update commands for runtime settings and metadata.",
} satisfies CommandError;
}
if (!hasMetadataUpdates && !thinkingOptionId) {
throw {
code: "NO_CHANGES_PROVIDED",
message: "Nothing to update",
details: "Provide at least one of: --name <name>, --label <key=value>, --thinking <id>",
} satisfies CommandError;
}
if (thinkingOptionId) {
return { type: "thinking", thinkingOptionId };
}
return {
type: "metadata",
updates: {
...(name ? { name } : {}),
...(Object.keys(labels).length > 0 ? { labels } : {}),
},
};
}
export async function runUpdateCommand(
agentIdArg: string,
options: AgentUpdateOptions,
@@ -98,25 +215,7 @@ export async function runUpdateCommand(
throw error;
}
const name = options.name?.trim();
if (options.name !== undefined && !name) {
const error: CommandError = {
code: "INVALID_NAME",
message: "Name cannot be empty",
details: "Use --name <name> with a non-empty value",
};
throw error;
}
const labels = parseLabelOptions(options.label);
if (!name && Object.keys(labels).length === 0) {
const error: CommandError = {
code: "NO_CHANGES_PROVIDED",
message: "Nothing to update",
details: "Provide at least one of: --name <name>, --label <key=value>",
};
throw error;
}
const changes = parseAgentChanges(options);
let client;
try {
@@ -143,10 +242,7 @@ export async function runUpdateCommand(
}
const agentId = fetchResult.agent.id;
await client.updateAgent(agentId, {
...(name ? { name } : {}),
...(Object.keys(labels).length > 0 ? { labels } : {}),
});
const appliedChanges = await applyAgentChanges(client, agentId, changes);
const updatedResult = await client.fetchAgent({ agentId });
if (!updatedResult) {
@@ -157,11 +253,7 @@ export async function runUpdateCommand(
return {
type: "single",
data: {
agentId,
name: updatedResult.agent.title,
labels: formatLabels(updatedResult.agent.labels),
},
data: toAgentUpdateResult(updatedResult.agent, appliedChanges),
schema: updateSchema,
};
} catch (err) {

View File

@@ -18,6 +18,7 @@ export interface ScheduleCreateOptions extends ScheduleCommandOptions {
target?: string;
provider?: string;
mode?: string;
thinking?: string;
cwd?: string;
maxRuns?: string;
expiresIn?: string;
@@ -40,6 +41,7 @@ export async function runCreateCommand(
target: options.target,
provider: options.provider,
mode: options.mode,
thinking: options.thinking,
cwd: options.cwd,
host: options.host,
maxRuns: options.maxRuns,

View File

@@ -32,6 +32,7 @@ export function createScheduleCommand(): Command {
"--mode <mode>",
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
)
.option("--thinking <id>", "Thinking option ID for new-agent runs")
.option("--cwd <path>", "Working directory (default: current; required with --host)")
.option("--run-now", "Fire one immediate run on creation")
.option("--max-runs <n>", "Maximum number of runs")

View File

@@ -116,6 +116,25 @@ describe("parseScheduleCreateInput first-run timing", () => {
});
});
describe("parseScheduleCreateInput thinking", () => {
test("sets the thinking option for each scheduled new-agent run", () => {
const input = parseScheduleCreateInput({
...baseOptions,
cwd: "/project",
thinking: " high ",
});
expect(input.target).toEqual({
type: "new-agent",
config: {
provider: "claude",
cwd: "/project",
thinkingOptionId: "high",
},
});
});
});
describe("parseScheduleUpdateInput", () => {
test("rejects calls with no fields to update", () => {
expect(() => parseScheduleUpdateInput({ id: "abc" })).toThrow(

View File

@@ -114,7 +114,7 @@ function resolveScheduleTarget(args: {
if (hasExplicitNewAgentOption) {
throw {
code: "INVALID_TARGET",
message: "--provider/--mode can only be used with a new-agent target",
message: "--provider/--mode/--thinking can only be used with a new-agent target",
details: "Use --target new-agent or omit --target to create a new agent schedule",
} satisfies CommandError;
}
@@ -144,6 +144,7 @@ export function parseScheduleCreateInput(options: {
target?: string;
provider?: string;
mode?: string;
thinking?: string;
cwd?: string;
host?: string;
maxRuns?: string;
@@ -179,7 +180,15 @@ export function parseScheduleCreateInput(options: {
const targetValue = options.target?.trim();
const modeId = options.mode?.trim();
const hasExplicitNewAgentOption = options.provider !== undefined || options.mode !== undefined;
const thinkingOptionId = options.thinking?.trim();
if (options.thinking !== undefined && !thinkingOptionId) {
throw {
code: "INVALID_THINKING_OPTION",
message: "--thinking cannot be empty",
} satisfies CommandError;
}
const hasExplicitNewAgentOption =
options.provider !== undefined || options.mode !== undefined || options.thinking !== undefined;
const createNewAgentTarget = (): ScheduleTarget => {
const resolvedProviderModel = resolveProviderAndModel({
provider: options.provider,
@@ -191,6 +200,7 @@ export function parseScheduleCreateInput(options: {
cwd: cwdInput ?? process.cwd(),
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
...(modeId ? { modeId } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
},
};
};

View File

@@ -108,16 +108,6 @@ const EXPECTED_CLAUDE_MODELS = [
] as const;
const EXPECTED_CLAUDE_CONTEXT_MODELS = [
{
id: "claude-opus-5[1m]",
model: "Opus 5 1M",
descriptionFragment: "1M context window",
},
{
id: "claude-opus-5",
model: "Opus 5",
descriptionFragment: "200K context window",
},
{
id: "claude-fable-5[1m]",
model: "Fable 5 1M",

View File

@@ -33,6 +33,7 @@ try {
assert.strictEqual(result.exitCode, 0, "agent update --help should exit 0");
assert(result.stdout.includes("--name"), "help should mention --name flag");
assert(result.stdout.includes("--label"), "help should mention --label flag");
assert(result.stdout.includes("--thinking"), "help should mention --thinking flag");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("<id>"), "help should mention required id argument");
console.log("✓ agent update --help shows options\n");
@@ -103,6 +104,30 @@ try {
assert(result.stdout.includes("update"), "help should mention update subcommand");
console.log("✓ agent --help shows update subcommand\n");
}
// Test 7: agent update accepts thinking as an update field
{
console.log("Test 7: agent update accepts thinking as an update field");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --thinking high`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("Nothing to update"), "should treat --thinking as an update field");
console.log("✓ agent update accepts thinking as an update field\n");
}
// Test 8: thinking cannot be combined with metadata updates
{
console.log("Test 8: thinking cannot be combined with metadata updates");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name renamed --thinking high`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should reject a combined update");
const output = result.stdout + result.stderr;
assert(
output.includes("--thinking cannot be combined with --name or --label"),
"should explain that thinking and metadata updates are separate operations",
);
console.log("✓ thinking cannot be combined with metadata updates\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true });

View File

@@ -100,6 +100,8 @@ try {
"10m",
"--provider",
"codex/gpt-5.4",
"--thinking",
"high",
"--json",
],
{ timeout: 30000 },
@@ -113,6 +115,7 @@ try {
const inspectedJson = JSON.parse(inspected.stdout);
assert.strictEqual(inspectedJson.target.config.provider, "codex");
assert.strictEqual(inspectedJson.target.config.model, "gpt-5.4");
assert.strictEqual(inspectedJson.target.config.thinkingOptionId, "high");
const deleted = await ctx.paseo(["schedule", "delete", createdJson.id, "--json"]);
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);

View File

@@ -324,6 +324,11 @@ export interface SendMessageOptions {
attachments?: SendAgentMessageRequest["attachments"];
}
export interface SendMessageResult {
/** Undefined when connected to a daemon predating message submission disposition. */
outOfBand?: boolean;
}
export interface AgentAttentionRequiredNotification {
agentId: string;
reason: "finished" | "error" | "permission";
@@ -2859,7 +2864,7 @@ export class DaemonClient {
agentId: string,
text: string,
options?: SendMessageOptions,
): Promise<void> {
): Promise<SendMessageResult> {
const requestId = this.createRequestId();
const messageId = options?.messageId ?? crypto.randomUUID();
const message = SessionInboundMessageSchema.parse({
@@ -2888,6 +2893,7 @@ export class DaemonClient {
if (!payload.accepted) {
throw new Error(payload.error ?? "sendAgentMessage rejected");
}
return payload.outOfBand === undefined ? {} : { outOfBand: payload.outOfBand };
}
async sendMessage(agentId: string, text: string, options?: SendMessageOptions): Promise<void> {

View File

@@ -478,7 +478,9 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
latest = result?.agent ?? null;
return result;
},
send: (text, options) => daemonClient.sendAgentMessage(id, text, options),
send: async (text, options) => {
await daemonClient.sendAgentMessage(id, text, options);
},
archive: async () => {
const result = await daemonClient.archiveAgent(id);
if (latest) {

View File

@@ -8,7 +8,7 @@ import { inheritLoginShellEnv } from "./login-shell-env.js";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs";
import { existsSync, statSync } from "node:fs";
import { execFileSync } from "node:child_process";
import {
app,
@@ -40,6 +40,7 @@ import {
buildStandardContextMenuItems,
} from "./window/window-manager.js";
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
import { configureLinuxSandbox } from "./system/linux-sandbox.js";
import { registerDialogHandlers } from "./features/dialogs.js";
import {
registerNotificationHandlers,
@@ -309,12 +310,13 @@ if (forcedUserDataDir) {
}
}
// AppImage runtimes mount the app from /tmp under the user's UID, so the SUID
// chrome-sandbox helper we ship in .deb/.rpm cannot work there. Disable the
// sandbox only in that case; .deb/.rpm keep the sandbox on, matching VS Code.
if (process.platform === "linux" && process.env.APPIMAGE) {
app.commandLine.appendSwitch("no-sandbox");
}
configureLinuxSandbox({
platform: process.platform,
resourcesPath: process.resourcesPath,
statSandbox: (sandboxPath) => statSync(sandboxPath),
disableSandbox: () => app.commandLine.appendSwitch("no-sandbox"),
reportInspectionError: (error) => log.error("[linux-sandbox] failed to inspect helper", error),
});
// Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging
// rendering issues (e.g. "--disable-gpu --ozone-platform=x11").

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { configureLinuxSandbox } from "./linux-sandbox";
interface SandboxMetadata {
mode: number;
uid: number;
}
function configureWithSandbox(
sandbox: SandboxMetadata | Error,
platform: NodeJS.Platform = "linux",
) {
const disabledSwitches: string[] = [];
const inspectionErrors: unknown[] = [];
configureLinuxSandbox({
platform,
resourcesPath: "/opt/Paseo/resources",
statSandbox: () => {
if (sandbox instanceof Error) {
throw sandbox;
}
return sandbox;
},
disableSandbox: () => disabledSwitches.push("no-sandbox"),
reportInspectionError: (error) => inspectionErrors.push(error),
});
return { disabledSwitches, inspectionErrors };
}
function createFileSystemError(code: string): NodeJS.ErrnoException {
const error: NodeJS.ErrnoException = new Error(code);
error.code = code;
return error;
}
describe("configureLinuxSandbox", () => {
it("disables the sandbox when an AppImage mount strips SUID", () => {
expect(configureWithSandbox({ uid: 1000, mode: 0o755 })).toEqual({
disabledSwitches: ["no-sandbox"],
inspectionErrors: [],
});
});
it("keeps the sandbox for a root-owned 4755 helper", () => {
expect(configureWithSandbox({ uid: 0, mode: 0o4755 })).toEqual({
disabledSwitches: [],
inspectionErrors: [],
});
});
it("disables the sandbox when a SUID helper is not root-owned", () => {
expect(configureWithSandbox({ uid: 1000, mode: 0o4755 })).toEqual({
disabledSwitches: ["no-sandbox"],
inspectionErrors: [],
});
});
it("disables the sandbox when the helper is missing", () => {
expect(configureWithSandbox(createFileSystemError("ENOENT"))).toEqual({
disabledSwitches: ["no-sandbox"],
inspectionErrors: [],
});
});
it("keeps the sandbox and reports unexpected inspection failures", () => {
const permissionError = createFileSystemError("EACCES");
expect(configureWithSandbox(permissionError)).toEqual({
disabledSwitches: [],
inspectionErrors: [permissionError],
});
});
it("does not inspect or configure the sandbox outside Linux", () => {
expect(configureWithSandbox(createFileSystemError("EACCES"), "darwin")).toEqual({
disabledSwitches: [],
inspectionErrors: [],
});
});
});

View File

@@ -0,0 +1,45 @@
import path from "node:path";
const REQUIRED_SANDBOX_MODE = 0o4755;
const PERMISSION_BITS = 0o7777;
interface SandboxMetadata {
mode: number;
uid: number;
}
interface LinuxSandboxConfiguration {
platform: NodeJS.Platform;
resourcesPath: string;
statSandbox: (sandboxPath: string) => SandboxMetadata;
disableSandbox: () => void;
reportInspectionError: (error: unknown) => void;
}
function isMissingSandbox(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}
export function configureLinuxSandbox(input: LinuxSandboxConfiguration): void {
if (input.platform !== "linux") {
return;
}
try {
const sandboxPath = path.join(input.resourcesPath, "..", "chrome-sandbox");
const sandbox = input.statSandbox(sandboxPath);
const hasUsableSandbox =
sandbox.uid === 0 && (sandbox.mode & PERMISSION_BITS) === REQUIRED_SANDBOX_MODE;
if (!hasUsableSandbox) {
input.disableSandbox();
}
} catch (error) {
if (isMissingSandbox(error)) {
input.disableSandbox();
return;
}
input.reportInspectionError(error);
}
}

View File

@@ -27,3 +27,25 @@ describe("isCompleteGitRemote", () => {
}
});
});
describe("parseGitRemoteLocation port", () => {
it("preserves an explicit non-default port from an https remote", () => {
expect(parseGitRemoteLocation("https://home-git.example.com:60443/team/repo.git")?.port).toBe(
"60443",
);
});
it("preserves a port from a plain http remote", () => {
expect(parseGitRemoteLocation("http://internal.example.com:3000/team/repo.git")?.port).toBe(
"3000",
);
});
it("omits the port for a default-port remote", () => {
expect(parseGitRemoteLocation("https://github.com/acme/repo.git")?.port).toBeUndefined();
});
it("has no port for an scp-form remote", () => {
expect(parseGitRemoteLocation("git@host.example.com:team/repo.git")?.port).toBeUndefined();
});
});

View File

@@ -11,6 +11,13 @@ const TRANSPORT_BY_PROTOCOL: Record<string, GitRemoteLocation["transport"]> = {
export interface GitRemoteLocation {
transport: "scp" | "ssh" | "http" | "https";
host: string;
/**
* Explicit non-default port from the remote (e.g. a self-hosted forge on
* `:60443`), or undefined for a default-port or scp-form remote. Kept separate
* from `host` so host-identity matching (forge detection, cloud-host checks)
* stays port-agnostic; only consumers that reconstruct a URL (web links) use it.
*/
port?: string;
path: string;
}
@@ -71,7 +78,7 @@ export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | n
const normalizedPath = normalizeRemotePath(path);
if (!isValidRemoteHost(host) || !normalizedPath) return null;
return { transport, host, path: normalizedPath };
return { transport, host, port: parsed.port || undefined, path: normalizedPath };
}
export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {

View File

@@ -168,4 +168,14 @@ describe("project command-center protocol", () => {
expect(parsed.features?.projectGithubClone).toBeUndefined();
expect(parsed.features?.projectCreateDirectory).toBeUndefined();
});
it("parses the agent thinking update capability", () => {
const parsed = parseServerInfoStatusPayload({
status: "server_info",
serverId: "server-new",
features: { agentThinkingUpdate: true },
});
expect(parsed.features?.agentThinkingUpdate).toBe(true);
});
});

View File

@@ -2792,6 +2792,8 @@ export const ServerInfoStatusPayloadSchema = z
providerUsageList: z.boolean().optional(),
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
agentDetach: z.boolean().optional(),
// COMPAT(agentThinkingUpdate): added in v0.2.4, remove gate after 2027-01-28.
agentThinkingUpdate: z.boolean().optional(),
// COMPAT(daemonDiagnostics): added in v0.1.100, remove gate after 2026-12-25 once daemon floor >= v0.1.100.
daemonDiagnostics: z.boolean().optional(),
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
@@ -3708,6 +3710,8 @@ export const SendAgentMessageResponseMessageSchema = z.object({
agentId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
// COMPAT(messageSubmissionDisposition): added in v0.2.3, remove optional parsing after 2027-01-27.
outOfBand: z.boolean().optional(),
}),
});

View File

@@ -49,7 +49,7 @@
"speech:download": "tsx scripts/download-speech-models.ts",
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",

View File

@@ -35,6 +35,7 @@ import type {
AgentStreamEvent,
AgentTimelineItem,
ImportProviderSessionInput,
ImportProviderSessionContext,
ResolveAgentDefaultModeInput,
} from "./agent-sdk-types.js";
import type { PaseoToolCatalog } from "./tools/types.js";
@@ -1706,6 +1707,7 @@ test("createAgent passes daemon launch env through the provider launch context",
agentId: snapshot.id,
env: {
PASEO_AGENT_ID: snapshot.id,
PASEO_AGENT_CWD: workdir,
},
});
});
@@ -2511,6 +2513,7 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
agentId: resumed.id,
env: {
PASEO_AGENT_ID: resumed.id,
PASEO_AGENT_CWD: workdir,
},
});
});
@@ -2525,14 +2528,16 @@ test("importProviderSession imports the selected session without listing and pub
class ImportClient extends TestAgentClient {
listCalls = 0;
importInput: unknown = null;
importLaunchContext: AgentLaunchContext | undefined;
async listImportableSessions() {
this.listCalls += 1;
return [];
}
async importSession(input: ImportProviderSessionInput) {
async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) {
this.importInput = input;
this.importLaunchContext = context.launchContext;
return {
session,
config: { provider: "codex" as const, cwd: workdir },
@@ -2612,6 +2617,13 @@ test("importProviderSession imports the selected session without listing and pub
expect(client.listCalls).toBe(0);
expect(client.importInput).toEqual({ providerHandleId: "thread-selected", cwd: workdir });
expect(client.importLaunchContext).toEqual({
agentId: imported.id,
env: {
PASEO_AGENT_ID: imported.id,
PASEO_AGENT_CWD: workdir,
},
});
expect(imported.lifecycle).toBe("idle");
expect(imported.historyPrimed).toBe(true);
expect(manager.getTimeline(imported.id)).toEqual([
@@ -2712,6 +2724,7 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
agentId: snapshot.id,
env: {
PASEO_AGENT_ID: snapshot.id,
PASEO_AGENT_CWD: workdir,
},
});
@@ -2723,6 +2736,7 @@ test("reloadAgentSession passes daemon launch env through the provider launch co
agentId: snapshot.id,
env: {
PASEO_AGENT_ID: snapshot.id,
PASEO_AGENT_CWD: workdir,
},
});
});

View File

@@ -1047,7 +1047,12 @@ export class AgentManager {
const client = await this.requireAvailableClient({
provider: storedConfig.provider,
});
const launchContext = await this.buildLaunchContext(resolvedAgentId, client, options?.env);
const launchContext = await this.buildLaunchContext(
resolvedAgentId,
client,
storedConfig.cwd,
options?.env,
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const createOptions = this.buildCreateSessionOptions(options);
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
@@ -1125,7 +1130,7 @@ export class AgentManager {
`Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`,
);
}
const launchContext = await this.buildLaunchContext(resolvedAgentId, client);
const launchContext = await this.buildLaunchContext(resolvedAgentId, client, storedConfig.cwd);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const session = await client.resumeSession(
handle,
@@ -1172,7 +1177,7 @@ export class AgentManager {
},
resolvedAgentId,
);
const launchContext = await this.buildLaunchContext(resolvedAgentId, client);
const launchContext = await this.buildLaunchContext(resolvedAgentId, client, storedConfig.cwd);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const imported = await client.importSession(
{
@@ -1253,7 +1258,7 @@ export class AgentManager {
provider,
} as AgentSessionConfig;
const { storedConfig, launchConfig } = await this.prepareSessionConfig(refreshConfig, agentId);
const launchContext = await this.buildLaunchContext(agentId, client);
const launchContext = await this.buildLaunchContext(agentId, client, storedConfig.cwd);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const session = handle
@@ -4253,6 +4258,7 @@ export class AgentManager {
private async buildLaunchContext(
agentId: string,
client: AgentClient,
cwd: string,
env?: Record<string, string>,
): Promise<AgentLaunchContext> {
const context: AgentLaunchContext = {
@@ -4260,6 +4266,7 @@ export class AgentManager {
env: {
...env,
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
},
};
if (

View File

@@ -416,7 +416,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
});
expect(models.map((m) => m.id)).toEqual([
"claude-opus-5[1m]",
"claude-opus-5",
"claude-fable-5[1m]",
"claude-fable-5",
@@ -439,7 +438,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
}
const defaultModel = models.find((m) => m.isDefault);
expect(defaultModel?.id).toBe("claude-opus-5[1m]");
expect(defaultModel?.id).toBe("claude-opus-5");
} finally {
await fs.rm(emptyConfigDir, { recursive: true, force: true });
}
@@ -461,7 +460,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
force: false,
});
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5[1m]");
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5");
expect(models.map((model) => model.id)).toContain("claude-fable-5[1m]");
} finally {
await fs.rm(emptyConfigDir, { recursive: true, force: true });

View File

@@ -32,24 +32,15 @@ export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode";
export const CLAUDE_MODEL_MANIFEST = [
{
id: "claude-opus-5[1m]",
label: "Opus 5 1M",
description: "Opus 5 with 1M context window",
id: "claude-opus-5",
label: "Opus 5",
description: "Opus 5 · Latest release",
defaultPriority: 2,
minimumClaudeCodeVersion: "2.1.219",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsThinkingDisabled: true,
},
{
id: "claude-opus-5",
label: "Opus 5",
description: "Opus 5 · 200K context window",
minimumClaudeCodeVersion: "2.1.219",
contextWindowMaxTokens: 200_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsThinkingDisabled: true,
},
{
id: "claude-fable-5[1m]",
label: "Fable 5 1M",

View File

@@ -50,7 +50,6 @@ describe("getClaudeModels", () => {
it("returns all claude models", () => {
const models = getClaudeModels();
expect(models.map((m) => m.id)).toEqual([
"claude-opus-5[1m]",
"claude-opus-5",
"claude-fable-5[1m]",
"claude-fable-5",
@@ -72,7 +71,7 @@ describe("getClaudeModels", () => {
const models = getClaudeModels();
const defaults = models.filter((m) => m.isDefault);
expect(defaults).toHaveLength(1);
expect(defaults[0].id).toBe("claude-opus-5[1m]");
expect(defaults[0].id).toBe("claude-opus-5");
});
it("defines context window sizes in the catalog", () => {
@@ -82,8 +81,7 @@ describe("getClaudeModels", () => {
expect(contextWindows).toEqual(
new Map([
["claude-opus-5[1m]", 1_000_000],
["claude-opus-5", 200_000],
["claude-opus-5", 1_000_000],
["claude-fable-5[1m]", 1_000_000],
["claude-fable-5", 200_000],
["claude-opus-4-8[1m]", 1_000_000],
@@ -103,10 +101,8 @@ describe("getClaudeModels", () => {
it("filters models by their minimum Claude Code version", () => {
const oldVersionModels = getClaudeModels("2.1.218");
expect(oldVersionModels.map((model) => model.id)).not.toContain("claude-opus-5[1m]");
expect(oldVersionModels.map((model) => model.id)).not.toContain("claude-opus-5");
expect(oldVersionModels.find((model) => model.isDefault)?.id).toBe("claude-opus-4-8");
expect(getClaudeModels("2.1.219").map((model) => model.id)).toContain("claude-opus-5[1m]");
expect(getClaudeModels("2.1.219").map((model) => model.id)).toContain("claude-opus-5");
expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5[1m]");
@@ -347,7 +343,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
describe("normalizeClaudeRuntimeModelId", () => {
it("returns exact match for known model IDs", () => {
expect(normalizeClaudeRuntimeModelId("claude-opus-5[1m]")).toBe("claude-opus-5[1m]");
expect(normalizeClaudeRuntimeModelId("claude-opus-5")).toBe("claude-opus-5");
expect(normalizeClaudeRuntimeModelId("claude-fable-5")).toBe("claude-fable-5");
expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]");
@@ -366,7 +361,6 @@ describe("normalizeClaudeRuntimeModelId", () => {
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6");
expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5");
expect(normalizeClaudeRuntimeModelId("claude-opus-5-20260724[1m]")).toBe("claude-opus-5[1m]");
expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301[1m]")).toBe("claude-fable-5[1m]");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5-20260101[1m]")).toBe(
"claude-sonnet-5[1m]",
@@ -374,7 +368,6 @@ describe("normalizeClaudeRuntimeModelId", () => {
});
it("preserves [1m] suffix from runtime model strings", () => {
expect(normalizeClaudeRuntimeModelId("claude-opus-5[1m]")).toBe("claude-opus-5[1m]");
expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]");
expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5[1m]");
expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]");
@@ -421,6 +414,32 @@ describe("findClaudeModel", () => {
});
});
describe("Claude Opus 5 catalog", () => {
it("offers a single Opus 5 entry with a 1M context window", () => {
const opus5Models = getClaudeModels()
.filter((model) => model.id.startsWith("claude-opus-5"))
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
expect(opus5Models).toEqual([
{ id: "claude-opus-5", label: "Opus 5", contextWindowMaxTokens: 1_000_000 },
]);
});
it("resolves retired and dated Opus 5 IDs to the single catalog entry", () => {
expect(findClaudeModel("claude-opus-5[1m]")?.id).toBe("claude-opus-5");
expect(findClaudeModel("claude-opus-5-20260724")?.id).toBe("claude-opus-5");
expect(findClaudeModel("claude-opus-5-20260724[1m]")?.id).toBe("claude-opus-5");
expect(findClaudeModel("claude-opus-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000);
});
it("keeps disabled thinking available for agents persisted on the retired 1M ID", () => {
expect(resolveClaudeDisabledThinkingForModel("claude-opus-5[1m]")).toEqual({
supported: true,
fallbackThinkingOptionId: "low",
});
});
});
describe("claudeManifestModelSupportsFastMode", () => {
it("keeps fast mode strict to first-party manifest model IDs", () => {
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();

View File

@@ -271,14 +271,19 @@ async function listCommandsFromFakeCodex(skills: unknown[]): Promise<AgentSlashC
`
let buffer = "";
function resultFor(method) {
function resultFor(method, params) {
if (method === "initialize") return {};
if (method === "collaborationMode/list") return { data: [] };
if (method === "skills/list") {
const cwds = params && params.cwds;
const projectCwd = "/tmp/codex-question-test";
if (!Array.isArray(cwds) || cwds.length !== 1 || cwds[0] !== projectCwd) {
return { data: [] };
}
return {
data: [
{
cwd: "/tmp/codex-question-test",
cwd: projectCwd,
skills: ${JSON.stringify(skills)},
errors: [],
},
@@ -299,7 +304,7 @@ process.stdin.on("data", (chunk) => {
const message = JSON.parse(line);
if (typeof message.id !== "number") continue;
try {
process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n");
process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method, message.params) }) + "\\n");
} catch (error) {
process.stdout.write(JSON.stringify({ id: message.id, error: { message: error.message } }) + "\\n");
}
@@ -1498,6 +1503,23 @@ describe("Codex app-server provider", () => {
);
});
test("lists project skill commands when app-server receives the project cwd in cwds", async () => {
const commands = await listCommandsFromFakeCodex([
{
name: "project-skill-discovery-regression",
description: "A skill discovered from this project.",
path: "/tmp/codex-question-test/.agents/skills/project-skill-discovery-regression/SKILL.md",
},
]);
expect(commands).toContainEqual({
name: "project-skill-discovery-regression",
description: "A skill discovered from this project.",
argumentHint: "",
kind: "skill",
});
});
test("deduplicates Codex skill slash commands returned from multiple skill roots", async () => {
const commands = await listCommandsFromFakeCodex([
{
@@ -3892,8 +3914,8 @@ describe("Codex app-server provider", () => {
},
actions: [
expect.objectContaining({
id: "reject",
label: "Reject",
id: "dismiss",
label: "Dismiss",
behavior: "deny",
}),
expect.objectContaining({
@@ -3957,6 +3979,216 @@ describe("Codex app-server provider", () => {
});
});
test("replaces a pending synthetic plan approval when a later plan completes", () => {
const session = createSession({
featureValues: { plan_mode: true },
});
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-first" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the first plan", status: "pending" }],
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-second" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the revised plan", status: "pending" }],
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
expect(session.getPendingPermissions()).toEqual([
expect.objectContaining({
kind: "plan",
input: { plan: "- Implement the revised plan" },
}),
]);
});
test("dismisses a pending synthetic plan approval after a new prompt is accepted", async () => {
const session = createSession({
featureValues: { plan_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-pending" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the original plan", status: "pending" }],
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const pendingPlan = session.getPendingPermissions()[0];
expect(pendingPlan).toBeDefined();
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({
request: async (method) => {
if (method === "thread/loaded/list") return { data: ["test-thread"] };
if (method === "turn/start") return {};
throw new Error(`Unexpected request: ${method}`);
},
});
await session.startTurn("Revise the plan to include tests");
expect(session.getPendingPermissions()).toEqual([]);
expect(events).toContainEqual({
type: "permission_resolved",
provider: "codex",
requestId: pendingPlan!.id,
resolution: {
behavior: "deny",
message: "Dismissed by a new prompt",
},
});
});
test("makes the old plan non-actionable while a new prompt is being prepared", async () => {
const session = createSession({
featureValues: { plan_mode: true },
});
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-pending" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the original plan", status: "pending" }],
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const pendingPlan = session.getPendingPermissions()[0];
expect(pendingPlan).toBeDefined();
let continuePromptSetup: (() => void) | undefined;
let markPromptSetupStarted: (() => void) | undefined;
const promptSetupStarted = new Promise<void>((resolve) => {
markPromptSetupStarted = resolve;
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({
request: async (method) => {
if (method === "thread/loaded/list") {
markPromptSetupStarted?.();
await new Promise<void>((resolve) => {
continuePromptSetup = resolve;
});
return { data: ["test-thread"] };
}
if (method === "turn/start") return {};
throw new Error(`Unexpected request: ${method}`);
},
});
const startTurn = session.startTurn("Revise the plan");
await promptSetupStarted;
expect(session.getPendingPermissions()).toEqual([]);
await expect(
session.respondToPermission(pendingPlan!.id, {
behavior: "allow",
selectedActionId: "implement",
}),
).rejects.toThrow(
`No pending Codex app-server permission request with id '${pendingPlan!.id}'`,
);
continuePromptSetup?.();
await startTurn;
});
test("does not dismiss a new plan approval emitted while a prompt is being accepted", async () => {
const session = createSession({
featureValues: { plan_mode: true },
});
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-pending" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the original plan", status: "pending" }],
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
let acceptPrompt: (() => void) | undefined;
let markPromptRequested: (() => void) | undefined;
const promptRequested = new Promise<void>((resolve) => {
markPromptRequested = resolve;
});
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({
request: async (method) => {
if (method === "thread/loaded/list") return { data: ["test-thread"] };
if (method === "turn/start") {
markPromptRequested?.();
return await new Promise<void>((resolve) => {
acceptPrompt = resolve;
});
}
throw new Error(`Unexpected request: ${method}`);
},
});
const startTurn = session.startTurn("Revise the plan");
await promptRequested;
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the newer plan", status: "pending" }],
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
acceptPrompt?.();
await startTurn;
expect(session.getPendingPermissions()).toEqual([
expect.objectContaining({ input: { plan: "- Implement the newer plan" } }),
]);
});
test("keeps a synthetic plan dismissed when a new prompt is rejected", async () => {
const session = createSession({
featureValues: { plan_mode: true },
});
asInternals(session).handleNotification("turn/started", {
turn: { id: "turn-plan-pending" },
});
asInternals(session).handleNotification("turn/plan/updated", {
plan: [{ step: "Implement the original plan", status: "pending" }],
});
asInternals(session).handleNotification("turn/completed", {
turn: { status: "completed", error: null },
});
const pendingPlan = session.getPendingPermissions()[0];
expect(pendingPlan).toBeDefined();
session.activeForegroundTurnId = null;
session.client = createStub<CodexClientLike>({
request: async (method) => {
if (method === "thread/loaded/list") return { data: ["test-thread"] };
if (method === "turn/start") throw new Error("Prompt rejected");
throw new Error(`Unexpected request: ${method}`);
},
});
await expect(session.startTurn("Revise the plan")).rejects.toThrow("Prompt rejected");
expect(session.getPendingPermissions()).toEqual([]);
});
test("emits imageView paths with spaces as valid assistant markdown images", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];

Some files were not shown because too many files have changed in this diff Show More