mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
22 Commits
v0.2.3
...
provider-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bddfd1629 | ||
|
|
bbf3d0f9cc | ||
|
|
963d4f9240 | ||
|
|
e241e02afb | ||
|
|
fdee3236f7 | ||
|
|
fd7061a8b2 | ||
|
|
76e336a1be | ||
|
|
f0d7eeb98c | ||
|
|
f91a984348 | ||
|
|
cbbf6c1684 | ||
|
|
89c2fac3c6 | ||
|
|
f8dd0fc2e0 | ||
|
|
b55acfc60f | ||
|
|
a6fdcb469c | ||
|
|
5bd317205c | ||
|
|
e59c94812b | ||
|
|
1c8fabd293 | ||
|
|
717f195f0c | ||
|
|
869edcbf11 | ||
|
|
c596e058cd | ||
|
|
fa1198c2be | ||
|
|
1f253d92e2 |
233
.github/workflows/ci.yml
vendored
233
.github/workflows/ci.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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 拡張機能
|
||||
|
||||
## ライセンス
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-DaVBk1PxsNr1AQu/mTG1Inp67pLLDmc+Ed0IZ5GyzgY=
|
||||
sha256-n7k3zQ1NOm7dGmpqKE6RaEkl50/M2eFek6XIQJbYCEc=
|
||||
|
||||
@@ -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",
|
||||
|
||||
685
packages/app/e2e/agent-message-submission.spec.ts
Normal file
685
packages/app/e2e/agent-message-submission.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
86
packages/app/e2e/helpers/agent-message-gate.ts
Normal file
86
packages/app/e2e/helpers/agent-message-gate.ts
Normal 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." });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
52
packages/app/e2e/out-of-band-command.codex.real.spec.ts
Normal file
52
packages/app/e2e/out-of-band-command.codex.real.spec.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
124
packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts
Normal file
124
packages/app/src/agent-stream/scroll-keyboard-dismiss/model.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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>',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
123
packages/app/src/composer/submission/model.test.ts
Normal file
123
packages/app/src/composer/submission/model.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
108
packages/app/src/composer/submission/model.ts
Normal file
108
packages/app/src/composer/submission/model.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
20
packages/app/src/composer/submission/writer.ts
Normal file
20
packages/app/src/composer/submission/writer.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
18
packages/app/src/hooks/keyboard-shift-context.ts
Normal file
18
packages/app/src/hooks/keyboard-shift-context.ts
Normal 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;
|
||||
}
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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: "متاح عندما لا يكون التركيز في حقل نص أو محطة طرفية.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: "Доступно, когда фокус находится не в текстовом поле или терминале.",
|
||||
|
||||
@@ -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: "焦点不在文本输入框或终端内时可用。",
|
||||
|
||||
@@ -44,6 +44,7 @@ export type KeyboardActionId =
|
||||
| "shortcuts.dialog.toggle"
|
||||
| "workspace.terminal.new"
|
||||
| "workspace.new"
|
||||
| "workspace.project.pick"
|
||||
| "worktree.new"
|
||||
| "workspace.archive"
|
||||
| "workspace.pin"
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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)",
|
||||
|
||||
108
packages/cli/src/commands/agent/update.test.ts
Normal file
108
packages/cli/src/commands/agent/update.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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").
|
||||
|
||||
82
packages/desktop/src/system/linux-sandbox.test.ts
Normal file
82
packages/desktop/src/system/linux-sandbox.test.ts
Normal 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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
45
packages/desktop/src/system/linux-sandbox.ts
Normal file
45
packages/desktop/src/system/linux-sandbox.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user