Compare commits

..
Author SHA1 Message Date
Mohamed Boudra da3472fe51 fix(app): preserve the reader when older pages extend tool groups
Resolve grouped calls and folded turns through one presentation-owned row lookup. Keep retained group indexing off the streaming path and verify item-page group replacement against its failing baseline.
2026-10-27 12:32:24 +01:00
Mohamed Boudra e34aa277a2 fix(app): preserve reading position when older turns fold
Item-based history pages can complete and collapse the row at the reading position. Transfer its anchor to the folded turn host so reaching history start does not drain older pages. Also omit reasoning from action summaries and align sender attribution with its message bubble.
2026-10-27 11:57:50 +01:00
Mohamed Boudraandluke ef58ace424 Fix history compatibility and turn presentation regressions (#8010)
* fix(client): preserve history loading on older hosts

Whole-turn paging is optional, but requiring its capability blocked existing history reads before any request was sent. Negotiate the paging enhancement at the client boundary while preserving item-based requests for older daemons.

* fix(app): preserve row ownership during late turn updates

Late lifecycle events retagged the newest row even when they updated an earlier call, causing the next update to append a duplicate tool ID. Assign turn membership only to rows written by the event before finalizing unrelated reasoning.

Co-authored-by: luke <luopeihuan@gmail.com>

* fix(app): preserve reasoning continuity and turn expansion controls

* fix(app): hide reasoning inside collapsed turns

---------

Co-authored-by: luke <luopeihuan@gmail.com>
2026-10-27 15:49:42 +07:00
Mohamed Boudra 8a542048b6 Let plugins contribute MCP servers on every agent session open (#7983)
* feat(plugins): contribute launch-only MCP servers at session open

* docs(plugins): explain saved configuration and launch-only MCP servers
2026-10-27 15:20:06 +07:00
Mohamed Boudra 6b7d49f0d8 test(server): keep responsiveness gates independent of runner scheduling (#8002)
Raw timer gaps and elapsed callback or GC spans include Windows runner preemption. Share callback CPU accounting across the daemon hot-path probes and retain deterministic async admission, real yields, and sync-IO tracing at filesystem boundaries.
2026-10-27 07:35:44 +00:00
paseo-ai[bot] 07007a485d fix: update lockfile signatures and Nix hash [skip ci] 2026-10-27 07:20:13 +00:00
Mohamed Boudra 26ee20b371 fix(ci): hoist expo-module-scripts so root overrides reach its Expo peers (#7998)
`npm audit signatures` in the quality job fails on some runners with
EMISSINGSIGNATUREKEY for @react-native/babel-preset@0.88.0. Nothing in
the repo uses React Native 0.88: the package came from a second Expo SDK
that npm placed under packages/expo-two-way-audio/node_modules because
expo-module-scripts was declared there. Root `overrides` are not applied
to packages nested under a workspace, so jest-expo's `expo@*` peer
resolved to the latest SDK and pulled 200+ duplicate packages into the
lockfile.

The failure itself is a CDN cache problem: one Cloudflare edge (ORD,
which serves GitHub runners in Azure northcentralus) cached the
attestations document for that version seconds after publish, before
the publish attestation existed, and keeps serving the provenance-only
document for a year. Every quality job that lands in that region fails;
every other region passes. The audit code path checks the registry
signature before attestations, so the key list is fine and reruns only
succeed by landing elsewhere.

Declaring expo-module-scripts in the root devDependencies puts it under
the overrides, which removes the nested SDK and the poisoned URL from
the lockfile. The module's tsconfig points its typeRoots at the hoisted
copy; its scripts resolve `expo-module` from the root `.bin`.

Closes #7730
2026-10-27 07:10:54 +00:00
Mohamed Boudra b12eefb28f Keep conversation turns distinct and history stable (#7990)
* fix(app): keep autonomous turns distinct and readable

Hidden system prompts merged autonomous work into the previous response and reopened completed turns. Preserve canonical turn boundaries and expose their triggers, while making received messages attributable and expanded turn details readable in one step.

* fix(timeline): mount history at actual run boundaries

* fix(timeline): load and retain complete available turns

* fix(app): summarize actions and preserve conversation events

* test(timeline): verify whole-turn paging stays responsive

* fix(app): group reasoning according to thinking preference

* test(app): align history journeys with whole-turn loading
2026-10-27 13:57:17 +07:00
Mohamed Boudra 3a1b146690 Guard daemon modules against synchronous Node IO (#7991)
* chore(server): guard against synchronous Node IO in daemon modules

* chore(server): retire unused sync IO exceptions automatically

* chore(server): remove the attachment IO exception after async materialization
2026-10-27 13:41:54 +07:00
Mohamed Boudra 2ce6681d6e Keep image attachments responsive during prompts and replay (#7954)
* fix(server): keep image materialization responsive

Decode and hash attachments in bounded yielding units, deduplicate before writing, and publish complete private files before provider timeline events. Await mapping through existing replay boundaries and serialize notification dispatch so turn completion cannot overtake an image.

* fix(server): reject attachment starts closed during conversion

Keep interruption terminal for prompts still being materialized, and reject a start if its session closes before provider submission. Cover both providers with real large-image conversions and make history reconciliation tests await the async timeline capability.

* fix(server): publish OpenCode snapshots before live suffixes

Commit snapshot cursors and publish each event together. Holding those events until attachment IO completed let live deltas overtake text that had already advanced the cursor. Require snapshot publishers and exercise text and reasoning interleaving against real image materialization.
2026-10-27 13:15:38 +07:00
Mohamed Boudra 63ed15e933 test(server): make the reconciliation responsiveness probe independent of idle waits (#7988)
* test(server): measure reconciliation callback work instead of idle gaps

Timer gaps include Windows runner scheduling waits. Keep the 20 ms gate on actual main-thread callback spans and GC, retaining raw gaps and thread CPU as diagnostics.

* test(server): exclude preemption within reconciliation callbacks

Zero-CPU scheduling waits also occur inside callbacks. Budget charged thread CPU and independently require async filesystem admission so synchronous inspection waits remain a deterministic regression.
2026-10-27 05:51:49 +00:00
Mohamed Boudra 9e4aab16cc fix(server): keep the daemon responsive during plugin builds (#7967)
* fix(server): keep plugin compilation off the daemon event loop

TypeScript import validation runs in esbuild callbacks on the daemon thread. Isolate preparation and compilation behind one bounded compiler queue, returning bundles and metadata only after runtime-boundary validation. Recheck lifecycle intent before executing a compiled plugin.

* fix(server): reuse the compiler worker during plugin build batches

Amortize TypeScript startup across built-ins and queued builds, then retire the worker after one idle second. Carry preparation environments explicitly across worker reuse. Keep dependency-backed example coverage with its existing app fixture.

* fix(server): retire the compiler worker after build errors

A failed parallel target can leave the other target compiling. Await worker termination before the next queued request instead of reusing an isolate with unfinished build work.

* fix(server): preserve compiler error metadata across workers

* test(server): isolate plugin validation in the loop-gap fixture

Exercise erased TypeScript syntax with a small runtime bundle so parser validation, not multi-MiB result transfer, determines the CI fixture. Keep the 20 ms gate and report event-loop active/idle time for scheduler diagnostics. Resolve the development loader at module initialization.

* test(server): probe event-loop turns during plugin compilation

Windows timer wake-up lateness measured 22 ms while the loop was idle for 21.96 ms. Use check-phase callbacks to measure loop availability, retaining the raw 20 ms limit and the failing base-branch reproduction.

* test: run plugin responsiveness probe in serial integration suite

* test: diagnose compiler probe gaps with per-thread CPU readings

* test: distinguish compiler CPU blocking from runner scheduling

* test: classify the largest compiler CPU accounting window

* test: isolate compiler responsiveness from harness GC and idle accounting

* test: measure callback blocking with a high-resolution clock
2026-10-27 10:30:41 +07:00
Mohamed Boudra 173fca3c0b Keep the daemon responsive during Claude history replay (#7940)
* fix(server): keep Claude history replay responsive

Load persisted history through session readiness rather than the constructor. Async descriptor-owned replay preserves ordering while serving other requests, and rebind retires the old query before rebuilding history and rewind state.

* fix(server): recover Claude history readiness after rebind errors

* test(server): calibrate Claude replay fixture for CI

* fix(server): report Claude rewind rebind failures to callers
2026-10-27 10:23:59 +07:00
Luke LaBonte 7f6a805e0e feat(app): choose the composer send shortcut (#5503)
Add a client-local Send shortcut selector with Enter and Command/Ctrl+Enter choices. Preserve Enter as the default and route the selected shortcut through the existing send behavior. Cover selection, persistence, editing, and running-agent send behavior with browser tests.

Co-authored-by: Luke LaBonte <contact@lukelabonte.com>
2026-10-27 10:05:16 +07:00
Mohamed Boudra 9bc998fa38 fix(server): keep Git observation and worktree IO off the event loop (#7943)
* fix(server): keep Git observation and worktree IO off the event loop

Resolve subscription path identities before callbacks and serialize async worktree metadata mutations by canonical ownership. Git command tracing must not probe the working directory before spawning.

* test(server): verify Git trace records without filesystem probes

* fix(server): establish Git identities at observation setup

Read-only checkout refreshes should not resolve watcher namespaces. Observation tests await setup and compare cross-workspace fan-out without depending on completion order.

* fix(server): retain literal symlink targets in Git event identity

Canonical realpaths can erase a symlink target spelling when its parent is also a symlink, such as /var on macOS. Resolve these aliases once during setup so deleted and ignored event paths still match without per-event IO.

* fix(server): preserve Windows worktree path spelling

* fix(server): retain native aliases in worktree ownership

* test(server): write Git IO trace markers asynchronously
2026-10-27 09:57:35 +07:00
Mohamed Boudra 67a2459254 fix(server): keep project config work off the daemon hot path (#7944)
Project settings and script health updates synchronously read unbounded user files. Share admitted async snapshots across consumers, budget script validation, and serialize revision-checked edits while keeping lexical project identity.
2026-10-27 09:56:51 +07:00
Mohamed Boudra f14a4cc112 fix(server): keep password-protected config reload responsive (#7959)
* fix(server): keep password-protected config reload responsive

Resolve authentication only during startup. Runtime config writes and reloads share an async queue so durable state and live owners stay ordered, including overlapping plugin changes and rollback.

* test(server): adapt config consumers to async service-port reads

Provide the service-port capability in the existing Session and tool-bridge fixtures. Use the platform temporary directory for the plugin agent fixture so its working directory exists on Windows.
2026-10-27 09:56:47 +07:00
Mohamed Boudra a25f4f5065 perf(server): serve timeline pages from retained projection (#7958)
* perf(server): select timeline pages from retained projection

Keep page selection and source coverage indexes with the retained projection so bounded fetches and last-message lookups do not copy or reproject history. Omitted after limits use the existing 200-item store default; explicit zero remains unlimited for released CLI clients.

* test(server): keep timeline page oracle outside production builds
2026-10-27 09:56:42 +07:00
Mohamed Boudra 6077bc6219 Keep daemon diff highlighting responsive and bounded (#7945)
* fix(server): keep diff highlighting responsive and bounded

Tiny patches previously parsed both full source versions synchronously. Yield between parser work units and admit source by UTF-8 bytes, keeping large patches renderable without tokens. Cancel obsolete highlighting when a newer diff supersedes it.

* fix(server): keep diff cancellation with its requester

Independent commit viewers must not cancel each other. Use request-source cancellation for commit highlighting and cache invalidation for checkout highlighting. Oversized source versions use the existing budgeted hunk reconstruction so large lockfiles retain useful syntax tokens.
2026-10-27 09:56:36 +07:00
Mohamed Boudra 9c7d4ace65 fix(server): reject oversized provider stdout before parsing (#7960)
Admit raw UTF-8 line bytes before accumulation and fail the provider session explicitly on overflow. Share line framing so fragmented JSONL input no longer repeatedly scans and copies the complete prefix.
2026-10-27 09:56:31 +07:00
Mohamed Boudra 8d971876bf fix(omp): keep transcript replay responsive (#7948)
* fix(omp): keep transcript replay responsive

Index transcript branches in bounded asynchronous chunks and retain payloads only for the active chain, so large histories do not block unrelated daemon requests.

* test(omp): enforce responsive history replay in CI

Use a smaller structured transcript and a short selected chain to expose whole-file parsing without making large-output GC part of the CI gate. Disable unrelated provider discovery and route the regression through the existing integration job.

* test(ci): include OMP replay in the routing contract
2026-10-27 09:56:27 +07:00
Mohamed Boudra fc0cbbb323 fix(server): keep workspace reconciliation responsive on slow paths (#7941)
* fix(server): keep workspace reconciliation responsive on slow paths

Bound asynchronous directory inspection without releasing timed-out filesystem slots. Preserve unavailable records and make workspace archival atomic and idempotent so concurrent user archival cannot be repeated by a scan.

* fix(server): wait for healthy reconciliation inspection capacity
2026-10-27 09:56:21 +07:00
david828019-ossandQTom 64b0afe010 fix(claude): identify SDK processes with the Paseo entrypoint (#7976)
Co-authored-by: QTom <qt@truesightai.com>
2026-10-27 09:38:49 +07:00
Mohamed Boudra 10bc10c99f Let plugins give specific agents tools (#7938)
* feat(plugins): attach tools to individual agent sessions

* feat(plugins): reopen idle agent sessions after hook changes

Defer automatic plugin changes until the next idle prompt so running turns keep steering. Expose session reload separately from snapshot refresh in the SDK.

* docs(plugins): show how to attach tools to agents

* test(agent): update permission follow-up adapter
2026-10-27 09:23:50 +07:00
264 changed files with 16341 additions and 7570 deletions
+145
View File
@@ -85,6 +85,151 @@
"max-nested-callbacks": ["error", { "max": 3 }]
},
"overrides": [
// Exceptions name individual APIs in individual modules; new APIs still fail in these files.
// TODO entries are runtime debt, not startup exemptions. Do not add directory-wide exceptions.
{
"files": ["packages/server/src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"],
"rules": {
"paseo/no-sync-io": [
"error",
{
"allow": {
// TODO: literal executableExists is a synchronous public capability; move its callers and Windows existence predicates to async together.
"executable-resolution/executable-resolution.ts": ["existsSync"],
// TODO: winget enumeration shares synchronous existence predicates with literal lookup; convert the executable-resolution boundary together.
"executable-resolution/windows.ts": ["readdirSync"],
// TODO: live file-change classification is a synchronous event mapper; move the probe to async event ingestion.
"server/agent/providers/claude/agent.ts": ["existsSync"],
// TODO: new-session construction still resolves its project directory synchronously; move preparation before construction.
"server/agent/providers/claude/project-dir.ts": ["realpathSync"],
// TODO: live subagent output is read by a synchronous event mapper; add bounded async loading at ingestion.
"server/agent/providers/claude/subagents/workflow-output.ts": [
"closeSync",
"openSync",
"readSync"
],
// TODO: diagnostics probes provider-owned auth/session storage; convert alongside provider discovery and readiness.
"server/agent/providers/omp/agent.ts": ["existsSync"],
// TODO: synchronous provider configuration discovery; move discovery into async launch preparation.
"server/agent/providers/omp/provider-config.ts": ["existsSync"],
// TODO: synchronous session-root selection is shared by descriptor builders; migrate callers with explicit readiness.
"server/agent/providers/omp/session-descriptor.ts": ["existsSync"],
// TODO: launch directory creation shares the server generation/start state machine; audit concurrent rotation before adding an await.
"server/agent/providers/opencode/server-manager.ts": ["mkdirSync"],
// TODO: launch preparation owns MCP config merge, extension generation and synchronous cleanup; migrate lifecycle and diagnostics together.
"server/agent/providers/pi/agent.ts": [
"existsSync",
"mkdtempSync",
"readFileSync",
"rmSync",
"writeFileSync"
],
// Provider child process: Pi loads this extension outside the daemon; it reads child-agent status there.
"server/agent/providers/pi/extensions/pi-subagents/index.ts": ["readFileSync"],
// Startup/shutdown only: remove stale Unix listening sockets before bind and during disposal.
"server/bootstrap.ts": ["existsSync", "unlinkSync"],
// Startup configuration resolution: detect the bundled app distribution before the daemon listens.
"server/config.ts": ["existsSync"],
// Startup identity: load or create the daemon keypair before accepting connections.
"server/daemon-keypair.ts": ["existsSync", "readFileSync"],
// Fatal exit diagnostics: synchronously flush the exit reason when the worker is about to terminate.
"server/daemon-worker.ts": ["appendFileSync", "mkdirSync"],
// Startup host identity: memoized scutil lookup is warmed before listening; later callers use the cache.
"server/host-name.ts": ["execFileSync"],
// TODO: relationship persistence is atomic with enrollment/revocation state transitions; serialize those transitions before async writes.
"server/hub/relationship-controller.ts": [
"existsSync",
"readFileSync",
"renameSync",
"rmSync"
],
// Local client bootstrap: reads the local credential and pid lock to select/authenticate a daemon connection.
"server/local-credential.ts": ["readFileSync"],
// Startup logger construction: create the log directory before opening the logging destination.
"server/logger.ts": ["mkdirSync"],
// TODO: runtime skill commands resolve their bundled source through a synchronous targets capability; migrate the capability and controller together.
"server/orchestration-skills/internal/paths.ts": ["existsSync"],
// Module initialization: the package version is resolved once and cached at import.
"server/package-version.ts": ["existsSync", "readFileSync"],
// Startup config and exported synchronous configuration API; runtime DaemonConfigStore uses the async load/save capabilities.
"server/persisted-config.ts": ["existsSync", "readFileSync"],
// Startup pid identity: read and memoize the Linux boot id for the daemon lock.
"server/pid-lock.ts": ["readFileSync"],
// Startup builtin loader construction: select the bundled plugin root before loading plugins.
"server/plugins/builtin/index.ts": ["existsSync"],
// Build child process: plugin graph traversal and esbuild preparation run in the isolated compiler process.
"server/plugins/compiler-build.ts": ["existsSync", "readFileSync", "realpathSync"],
// Build child process: select the esbuild binary for the isolated plugin compiler.
"server/plugins/compiler-esbuild.ts": ["existsSync"],
// Build child process: TypeScript resolution and source reads run in the isolated plugin compiler.
"server/plugins/compiler-imports.ts": ["readFileSync", "realpathSync"],
// TODO: plugin install/update/remove commits sources.json synchronously; preserve record/write ordering with an async mutation queue.
"server/plugins/managed-source.ts": ["existsSync", "readFileSync"],
// TODO: shared synchronous persistence supports startup identity plus Hub/plugin/push runtime writers; migrate those owners before removing these exports.
"server/private-files.ts": [
"chmodSync",
"mkdirSync",
"realpathSync",
"renameSync",
"rmSync",
"writeFileSync"
],
// TODO: constructor load plus runtime renew/revoke/prune use synchronous persistence; introduce ordered async mutations and readiness.
"server/push/token-store.ts": ["existsSync", "readFileSync"],
// Startup identity: load or create the server id before accepting connections.
"server/server-id.ts": ["existsSync", "readFileSync"],
// Listener lifecycle only: unlink the Unix socket before binding or after stopping the proxy.
"server/service-proxy.ts": ["existsSync", "unlinkSync"],
// TODO: self-update origin validation exposes synchronous package-root and realpath checks; migrate validation and its runtime interface together.
"server/session/daemon/install-origin.ts": ["existsSync", "readFileSync"],
// TODO: npm self-update inspection probes the reported install path; move the probe into bounded async installation inspection.
"server/session/daemon/npm-global-cli.ts": ["lstatSync"],
// Speech child process: model-file validation runs in the worker-backed local speech provider.
"server/speech/providers/local/sherpa/sherpa-offline-recognizer.ts": ["existsSync"],
// Speech child process: native addon discovery and patchelf run outside the daemon.
"server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts": [
"existsSync",
"spawnSync"
],
// Speech child process: model-file validation runs in the worker-backed local speech provider.
"server/speech/providers/local/sherpa/sherpa-tts.ts": ["existsSync"],
// TODO: static request selection and index injection need bounded async serving with response/error ownership; keep this as a separate request-path conversion.
"server/web-ui.ts": ["readFileSync", "statSync"],
// Startup registry hydration: check persisted workspace directories before the runtime reconciliation service starts.
"server/workspace-registry-bootstrap.ts": ["statSync"],
// Terminal child process: hook config installation/removal is owned by terminal creation and teardown in the worker.
"terminal/agent-hooks/agent-hook-installer.ts": [
"existsSync",
"readFileSync",
"rmSync"
],
// Node loader thread: TypeScript terminal-worker resolution runs in the loader, outside the daemon event loop.
"terminal/terminal-ts-loader.mjs": ["existsSync"],
// Terminal child process: executable preparation and shell integration run in the worker-backed terminal manager.
"terminal/terminal.ts": [
"accessSync",
"chmodSync",
"existsSync",
"mkdirSync",
"readFileSync",
"statSync"
],
// TODO: legacy realpath helpers still serve install-origin validation and other synchronous consumers; migrate remaining callers before removing them.
"utils/path.ts": ["realpathSync"]
}
}
]
}
},
{
"files": [
"packages/server/src/**/*.{test,spec,fixture}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}",
"packages/server/src/**/test-utils/**",
"packages/server/src/server/daemon-e2e/**",
"packages/server/src/server/agent/providers/pi/extensions/*-test.ts"
],
"rules": { "paseo/no-sync-io": "off" }
},
{
"files": [
"plugins/*-provider/server/**/*.ts",
+33
View File
@@ -542,6 +542,39 @@ patch every build the consumers use: Metro resolves the `react-native` field of
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
## Dependency overrides and workspace tooling
Root `package.json` `overrides` pin one Expo SDK across the tree (`expo`, `expo-router`,
`react-native`, …). npm does not apply those overrides to packages it places under a
workspace's own `node_modules`. When `expo-module-scripts` was declared in
`packages/expo-two-way-audio`, npm nested it there and its `jest-expo` peer `expo@*` resolved to
a second, newer Expo SDK and React Native: 200+ duplicate packages nothing imported (#7730).
Declare tooling that peer-depends on `expo` or `react-native` in the root `devDependencies`,
where the overrides apply; workspace scripts still find the binary through the root
`node_modules/.bin`.
## Dependency signature audit
The `quality` CI job runs `npm audit signatures`. It verifies every lockfile package's registry
signature and, for packages published with provenance, fetches
`/-/npm/v1/attestations/<name>@<version>` from the registry. Cloudflare caches that document
for a year. An edge that cached it in the seconds before the registry attached the publish
attestation keeps serving a document with only the provenance bundle, and npm fails with:
```
npm error code EMISSINGSIGNATUREKEY
npm error <name>@<version> has attestations but no corresponding public key(s) can be found
```
The same package's registry signature verified a moment earlier with the same keys, so the
key list is intact. The failure names one package, repeats only for that package, and depends
on the Azure region the runner landed in, because each region maps to one Cloudflare colo. A
rerun passes only by landing elsewhere.
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
replace that version in the lockfile and report the URL to npm so they purge it.
## ACP provider catalog versions
The in-app ACP provider catalog pins package-runner entries (`npx`, `npm exec`,
+8 -1
View File
@@ -430,8 +430,15 @@ The [public reference](../public-docs/plugins/reference.md#icons) owns the autho
Server entries register lifecycle observers with `server.on()` and request transforms with
`server.before()`. The [public reference](../public-docs/plugins/reference.md#lifecycle-hooks)
owns callback shapes, ordering, and failure behavior. `plugin-examples/lifecycle-logger` registers all
owns callback shapes, ordering, and failure behavior. `agent.create` edits the agent's saved
configuration; `agent.session_open` contributes launch-only `env`, `tools`, and `mcpServers`,
re-derived on each open and following the plugin's lifecycle. See
[Inject MCP servers](../public-docs/plugins/reference.md#inject-mcp-servers) for choosing the hook.
`plugin-examples/lifecycle-logger` registers all
eleven hooks; `plugin-examples/lifecycle-actions` demonstrates common automation callbacks.
Session-open hooks also [attach tools to individual agents](../public-docs/plugins/reference.md#attach-tools-to-agents),
as shown in `plugin-examples/agent-tools`. The agent manager stamps their metadata per session;
executable definitions stay in the plugin process.
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Observers must not be awaited inside agent mutations: a callback can send a prompt or
+2 -2
View File
@@ -2,7 +2,7 @@
The app and the daemon are separate products that ship separately. A user updates the app from an app store or a desktop auto-update; they update the daemon when they feel like it. Every combination happens in the wild: new app against an old daemon, old app against a new daemon, and both sides months apart.
In development both sides are always the same version, which is why this is the constraint contributors miss most often.
Development can have the same version drift: an app reload picks up new code while a long-running daemon keeps its loaded version. Check the connected daemon version when diagnosing behavior that crosses this boundary.
Two contracts follow from it.
@@ -38,7 +38,7 @@ The app checks for the capability and either runs the feature or tells the user
- **No defensive branches spread through the feature.** Detection happens in one place, and everything downstream reads a clean shape.
- Capability flags live in `features` on the `server_info` message (`packages/protocol/src/messages.ts`, the `server_info` schema).
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
Existing functionality keeps working across versions. Never gate an established request on a new enhancement: negotiate the optional enhancement at the client boundary and preserve the existing request for older hosts. Whole-turn pagination can require a capability; reading conversation history cannot.
## Client capability ownership
+25 -10
View File
@@ -13,7 +13,7 @@ and rebuilds the projection when an agent resumes.
The invariants are:
> A continuously subscribed client applies every committed row in order. Opening or resuming an
> agent establishes the daemon's current tail in one bounded request, with older history reachable
> agent establishes the daemon's current tail in one complete-turn request, with older history reachable
> through backward pagination.
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
@@ -44,7 +44,21 @@ recovery is complete only when `hasNewer: false`.
Initialization timeouts guard lack of catch-up progress, not the full multi-page sync. A successful page that queues the next `after` page refreshes the watchdog.
Opening or resuming an agent fetches one bounded latest tail page. Older history remains
Opening or resuming an agent fetches the latest available turns with a soft target of 40
projected items. Backward pages and prompt-jump windows also include whole turns, including
all currently available rows of an active run. There is no per-turn cap: a large run can
exceed the target. Local history mounting uses the same boundaries so revealing older rows
does not change the collapse state of a turn already displayed.
`wholeTurns` is a preference negotiated once at the client fetch boundary. Hosts advertising
`features.wholeTurnTimeline` receive it; other hosts receive the existing item-based request.
Reading history remains available on older hosts, whose pages can still split turns. Forward
catch-up stays incremental, but its reset snapshot honors a supported whole-turn request.
Source coverage still includes interleaved tool updates.
Canonical IDs define runs. With missing IDs, human prompts, heartbeats and typed received
agent messages each start a fallback turn. This deterministic fallback is shared by paging
and presentation; it cannot recover historical batching or steering. Older history remains
user-driven by scrolling upward.
A failed catch-up or subscription reconcile retries on its own, doubling from 1s to a 30s ceiling.
@@ -149,12 +163,13 @@ range describes those exact items: `startSeq` drives older pagination and `endSe
catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older
history. Code outside the owner does not distinguish cached and network timelines.
The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail
The first resume request is bounded. If it reports more newer history, fetch one latest complete-turn tail
instead of replaying every missed page. Live gap recovery still pages forward until current.
If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a
If the canonical window exceeds the cache item target, contains a discontiguous retained range, has a
live head, or includes presentation data the cache cannot encode losslessly, persistence drops the
range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that
range and keeps a display-only tail of complete available turns. The item target is soft;
the byte budget still evicts entire cache rows. Never slice items while retaining the pre-slice range; that
falsely certifies discarded source rows. A display-only row paints without granting synchronization
authority, so the owner uses the ordinary bounded `tail` bootstrap.
@@ -238,11 +253,11 @@ foreground control ownership remains a separate daemon concern. Cancellation req
with that record rather than in a React component, so an old request cannot clear a newer one. Submissions
remain a separate pre-turn registry and retire on canonical acknowledgement.
Canonical turns and visible responses are different boundaries. Agent-originated prompts and lifecycle
notifications project into tool rows, so they do not create a human-message boundary. Legacy system
prompts, including schedule injections, remain hidden. One visible response can span several canonical
turns; layout and copy group that response together while lifecycle, timing, tool sequences, and exact
fork positions retain the canonical `turnId` boundaries.
Every canonical run owns a visible turn, including heartbeats and agent notifications.
Notifications delivered during a run stay inside it; batched notifications share the run
that starts or accepts them. Received messages retain sender attribution, while lifecycle
notifications remain visible events. Collapse, timing and locally mounted history share
these boundaries.
Agent-message XML belongs to the daemon's `agent/agent-messages` boundary. Prompt delivery encodes
provenance; acceptance, provider echoes, import, and replay share its projection. The delivery ID is
+1 -1
View File
@@ -1 +1 @@
sha256-eNiKmiZ3XXZMRZRSoGxUbMn2RtY6AX079vUatBYM+qE=
sha256-dk7kJUdmQBF8L8Vkj6Ryza3n4/S+YqgxWY3R59Dc3mY=
+2320 -4061
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -131,6 +131,7 @@
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "44.2.0",
"expo-module-scripts": "^5.0.8",
"get-port-cli": "^3.0.0",
"js-yaml": "^4.1.1",
"knip": "^5.82.1",
@@ -53,6 +53,7 @@ import {
import { expectInFlightForkAvailable } from "../support/helpers/assistant-fork";
import {
scrollTimelineToNewestLoadedEdge,
userScrollsTimelineToHistoryStart,
scrollTimelineUntilOlderHistoryIsReachable,
} from "../support/helpers/timeline-pagination";
import {
@@ -986,7 +987,7 @@ test.describe("Agent message submission", () => {
}
});
test("keeps one prompt when its provider echo crosses the bounded tail", async ({
test("keeps one prompt when a delayed provider echo belongs to a large whole turn", async ({
page,
}, testInfo) => {
const workspace = await seedWorkspace({
@@ -1003,8 +1004,10 @@ test.describe("Agent message submission", () => {
await expectVisibleAgentSurfacesIdle(page);
await page.reload();
await expect(submittedPrompt).toHaveCount(0);
await scrollTimelineUntilOlderHistoryIsReachable(page, prompt);
// The complete turn arrives on reload; scrolling mounts its oldest rows
// without requiring another page or an increase in total content height.
await userScrollsTimelineToHistoryStart(page);
await expect(submittedPrompt).toBeVisible();
await expect(submittedPrompt).toHaveCount(1);
await expect(submittedPrompt).toHaveAttribute("aria-busy", "false");
} finally {
@@ -27,6 +27,10 @@ import {
seedRealisticOlderTimeline,
sendLiveTurnBeforeHydration,
expectTimelineViewportAnchoredAfterPrepend,
expectToolCallGroupPositionPreserved,
rememberToolCallGroupPosition,
seedItemPagedToolGroupTimeline,
useChatDisplay,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
@@ -100,6 +104,32 @@ test.describe("Agent timeline pagination", () => {
}
});
// Hosts paged by items can end a page inside a tool run. The older page extends the group
// the reader is on, which changes the row hosting it; the reader must stay with that group
// rather than land at history start and drain every older page.
test("keeps the reading position when an older item page extends the tool-call group being read", async ({
page,
}) => {
test.setTimeout(120_000);
await useChatDisplay(page, "collapseToolCalls");
const agent = await seedItemPagedToolGroupTimeline();
try {
const history = await holdOlderHistoryPages(page, agent, { itemPagedHost: true });
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const reading = await rememberToolCallGroupPosition(page, agent.loadedCommands);
history.releasePage(1);
await expectToolCallGroupPositionPreserved(page, agent.totalCommands, reading);
await history.expectSettledWithRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("loads older history when the user navigates to history start with the keyboard", async ({
page,
}) => {
@@ -1,29 +1,90 @@
import { randomUUID } from "node:crypto";
import { submitMessage, expectComposerEditable } from "../support/helpers/composer";
import {
focusTerminalSurface,
typeInTerminal,
getTerminalBufferText,
} from "../support/helpers/terminal-perf";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { metroTest as test, expect } from "../support/fixtures";
import { metroTest as test, expect, type Page } from "../support/fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
expectTimelinePromptVisible,
expectFoldedTurnAtReadingPosition,
expectOldestLoadedTurnPartial,
holdOlderHistoryPages,
openAgentTimeline,
scrollThroughOlderHistoryPages,
expectTimelinePromptVisible,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
test("does not repeat an assistant block when the current app paginates a published 0.2.5 daemon", async ({
interface PublishedDaemonAgent {
serverId: string;
daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
agent: MockAgentWorkspace;
/** Register the host in the browser and apply app settings before the first load. */
seedBrowser(page: Page, appSettings?: Record<string, unknown>): Promise<void>;
close(): Promise<void>;
}
async function startPublishedDaemonAgent(options: {
publishedVersion: string;
serverIdPrefix: string;
repoPrefix: string;
title: string;
}): Promise<PublishedDaemonAgent> {
const serverId = `${options.serverIdPrefix}${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, {
publishedVersion: options.publishedVersion,
});
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: daemon.port });
const createdAgent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: options.title,
modeId: "load-test",
model: "ten-second-stream",
});
const agent: MockAgentWorkspace = {
agentId: createdAgent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
return {
serverId,
daemon,
agent,
async seedBrowser(page, appSettings) {
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences, settings }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
if (settings) localStorage.setItem("@paseo:app-settings", JSON.stringify(settings));
},
{ seededHost: host, preferences: buildCreateAgentPreferences(), settings: appSettings },
);
},
async close() {
await agent.cleanup();
await daemon.close();
},
};
}
test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({
page,
}) => {
test.setTimeout(120_000);
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
const observationSockets = new Set<unknown>();
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
@@ -37,104 +98,84 @@ test("does not repeat an assistant block when the current app paginates a publis
observationSockets.add(socket);
});
});
const workspace = await seedWorkspace({
const published = await startPublishedDaemonAgent({
publishedVersion: "0.2.5",
serverIdPrefix: "srv_old_pagination_",
repoPrefix: "timeline-old-daemon-pagination-",
port: daemon.port,
});
const createdAgent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Published daemon pagination regression",
modeId: "load-test",
model: "ten-second-stream",
});
const agent: MockAgentWorkspace = {
agentId: createdAgent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
const { agent, serverId } = published;
try {
for (let index = 0; index < 40; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-older-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await agent.client.sendAgentMessage(agent.agentId, "build a realistic long mock timeline");
await agent.client.waitForFinish(agent.agentId, 20_000);
for (let index = 0; index < 20; index += 1) {
await agent.client.sendAgentMessage(
agent.agentId,
`timeline-pagination-turn-${index}: emit 1 coalesced agent stream updates`,
);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates");
await agent.client.waitForFinish(agent.agentId, 15_000);
await published.seedBrowser(page);
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ seededHost: host, preferences: buildCreateAgentPreferences() },
);
const history = await holdOlderHistoryPages(page, agent, daemon.port);
await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(
page,
"timeline-pagination-turn-19: emit 1 coalesced agent stream updates",
);
await scrollThroughOlderHistoryPages(page, 3, history);
history.expectRepeatedEntries();
await history.expectOwnedTextRendered("Now I have a clearer picture.");
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
expect(observationSockets.size).toBe(1);
await test.step("send a live turn after reconnecting the old daemon", async () => {
await daemon.restart();
await expect.poll(() => observationSockets.size, { timeout: 30_000 }).toBe(2);
await expectComposerEditable(page);
await submitMessage(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
await expectTimelinePromptVisible(
page,
"compatibility-after-reconnect: emit 1 coalesced agent stream updates",
);
// The browser and the seed client reconnect independently after the restart.
await agent.client.connect();
await agent.client.waitForFinish(agent.agentId, 20_000);
await page.screenshot({ path: test.info().outputPath("old-daemon-live-reconnect.png") });
await test.step("reconnect the browser and reload the old daemon history", async () => {
observationSockets.clear();
await page.reload();
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
await expect.poll(() => observationSockets.size).toBe(1);
});
await test.step("open a terminal and receive its output", async () => {
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
expect(terminal.error).toBeNull();
const terminalId = terminal.terminal!.id;
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
const route = buildHostWorkspaceRoute(serverId, agent.workspaceId);
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
await focusTerminalSurface(page);
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
await expect
.poll(() => getTerminalBufferText(page), { timeout: 15_000 })
.toContain("compatibility-terminal-output");
await page.screenshot({ path: test.info().outputPath("old-daemon-terminal.png") });
await agent.client.killTerminal(terminalId);
});
} finally {
await agent.cleanup();
await daemon.close();
await published.close();
}
});
// Hosts without whole-turn pages split turns across item pages. The oldest loaded turn
// starts mid-turn and only folds once the next page completes it; the reader must stay
// with that turn instead of landing at history start and draining every older page.
test("keeps the reader on the turn that folds when an older item page completes it", async ({
page,
}) => {
test.setTimeout(240_000);
const published = await startPublishedDaemonAgent({
publishedVersion: "0.13.0-beta.2",
serverIdPrefix: "srv_old_fold_",
repoPrefix: "timeline-old-daemon-fold-",
title: "Published daemon folded turn pagination",
});
const { agent, serverId } = published;
const prompts = Array.from({ length: 5 }, (_, index) => `Realistic turn ${index}: inspect it`);
try {
for (const prompt of prompts) {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await agent.client.waitForFinish(agent.agentId, 30_000);
}
await published.seedBrowser(page, { chatDisplay: "collapseTurns" });
const history = await holdOlderHistoryPages(page, agent, {
daemonPort: published.daemon.port,
});
await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(page, prompts.at(-1)!);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const partialTurnPrompt = await expectOldestLoadedTurnPartial(page, prompts);
history.releasePage(1);
await expectFoldedTurnAtReadingPosition(page, partialTurnPrompt);
await history.expectSettledWithRequestedPages(1);
} finally {
await published.close();
}
});
@@ -1,3 +1,4 @@
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "../support/fixtures";
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
@@ -274,3 +275,123 @@ test.describe("Settings toggle tab regression", () => {
}
});
});
async function selectSendingOption(
page: import("@playwright/test").Page,
setting: string,
option: string,
) {
await page.getByRole("button", { name: new RegExp(`^${setting}:`) }).click();
await page.getByRole("menuitem", { name: option, exact: true }).click();
await expect(
page.getByRole("button", { name: `${setting}: ${option}`, exact: true }),
).toBeVisible();
}
async function sendDraftWithShortcut(
page: import("@playwright/test").Page,
draft: string,
shortcut: string,
) {
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill(draft);
await composer.press(shortcut);
await expect(composer).toHaveValue("");
await expect(page.getByText(draft, { exact: true })).toBeVisible();
}
test("send shortcut selection persists and controls the composer", async ({ page }, testInfo) => {
test.setTimeout(180_000);
const workspace = await seedWorkspace({ repoPrefix: "send-shortcut-" });
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
const agent = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Send shortcut",
});
await openAgentRouteAndExpectFocused({
page,
serverId: getServerId(),
workspaceId: workspace.workspaceId,
agentId: agent.id,
});
await openComposerTab(page, agent.id);
const composer = page.getByRole("textbox", { name: "Message agent..." });
await test.step("Enter sends by default", async () => {
await sendDraftWithShortcut(page, "Default Enter submission", "Enter");
});
await test.step("choose the explicit modifier shortcut and retain it after reload", async () => {
await pressSettingsToggleShortcut(page);
await expect(
page.getByRole("button", { name: "Send shortcut: Enter", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Send shortcut: Enter", exact: true }).click();
await expect(page.getByRole("menuitem")).toHaveText(["Enter", `${modifierLabel}+Enter`]);
// Let the menu opening animation settle for visual QA.
await page.waitForTimeout(300);
await page.screenshot({ path: testInfo.outputPath("send-shortcut-selector.png") });
await page.getByRole("menuitem", { name: `${modifierLabel}+Enter`, exact: true }).click();
await page.reload();
await expect(
page.getByRole("button", { name: `Send shortcut: ${modifierLabel}+Enter`, exact: true }),
).toBeVisible();
await pressSettingsToggleShortcut(page);
await expect(composer).toBeVisible();
});
await test.step("Enter and Shift+Enter keep editing; modifier Enter sends", async () => {
await composer.fill("First line");
await composer.press("Enter");
await expect(composer).toHaveValue("First line\n");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("First line\n\n");
await sendDraftWithShortcut(page, "Sent with Control", "Control+Enter");
await sendDraftWithShortcut(page, "Sent with Command", "Meta+Enter");
});
await test.step("switch back to Enter without changing the default send behavior", async () => {
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", "Enter");
await pressSettingsToggleShortcut(page);
await composer.fill("Shift still edits");
await composer.press("Shift+Enter");
await expect(composer).toHaveValue("Shift still edits\n");
await sendDraftWithShortcut(page, "Restored Enter submission", "Enter");
});
} finally {
await workspace.cleanup();
}
});
for (const behavior of ["Steer", "Interrupt", "Queue"] as const) {
test(`modifier send shortcut follows ${behavior} while an agent runs`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "running-send-shortcut-",
title: "Running send shortcut",
model: "one-minute-stream",
});
const modifierLabel = process.platform === "darwin" ? "⌘" : "Ctrl";
try {
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await pressSettingsToggleShortcut(page);
await selectSendingOption(page, "Send shortcut", `${modifierLabel}+Enter`);
await selectSendingOption(page, "Default send", behavior);
await pressSettingsToggleShortcut(page);
await sendDraftWithShortcut(page, "Stay running while I write a follow-up", "Control+Enter");
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await composer.fill("Follow-up");
await composer.press("Enter");
await expect(composer).toHaveValue("Follow-up\n");
await composer.press("Control+Enter");
await expect(composer).toHaveValue("");
await expect(page.getByText("Follow-up", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Send queued message" })).toHaveCount(
behavior === "Queue" ? 1 : 0,
);
} finally {
await agent.cleanup();
}
});
}
+368 -3
View File
@@ -1,8 +1,29 @@
import type { Page } from "@playwright/test";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import path from "node:path";
import {
holdOlderHistoryPages,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
import { getActiveTabTestId } from "../support/helpers/launcher";
import type { Page, Locator } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
providers: {
codex: {
enabled: true,
command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")],
},
},
},
},
});
type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise<void> {
@@ -19,7 +40,10 @@ async function seedAnsweredAgent(title: string) {
initialPrompt: "Why does the chat stop following new output?",
});
await agent.client.waitForFinish(agent.agentId, 30_000);
await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script.");
await agent.client.sendAgentMessage(
agent.agentId,
"Make the fix and run the burst script.\n" + "Keep the previous response visible.\n".repeat(40),
);
await agent.client.waitForFinish(agent.agentId, 30_000);
return agent;
}
@@ -36,17 +60,84 @@ async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
});
}
async function groupedToolsInsideLatestTurn(page: Page): Promise<string[]> {
return page.evaluate(() => {
const rows = [...document.querySelectorAll("[data-message-id]")];
const start = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
const end = rows.findLastIndex((row) => row.querySelector("[data-testid=assistant-message]"));
return rows
.slice(start + 1, end)
.flatMap((row) =>
[...row.querySelectorAll("[data-testid=tool-call-group]")].map(
(group) => group.textContent ?? "",
),
);
});
}
async function expectCompactMessageToggle(prompt: Locator, label: string) {
const button = prompt.getByRole("button", { name: label, exact: true });
const text = prompt.getByText(label, { exact: true });
await expect
.poll(
async () => {
const control = (await button.boundingBox())!;
const glyphs = (await text.boundingBox())!;
return control.height - glyphs.height;
},
{ message: "The inline toggle adds no vertical space beyond its text" },
)
.toBe(0);
}
async function expandAndCollapseLongPrompt(page: Page) {
const prompt = page.getByTestId("user-message").last();
const before = await prompt.boundingBox();
const text = prompt.locator('[data-message-text="true"]');
const messageLeft = (await text.boundingBox())!.x;
await expect
.poll(async () => (await prompt.getByText("Show more", { exact: true }).boundingBox())!.x)
.toBe(messageLeft);
await expectCompactMessageToggle(prompt, "Show more");
await prompt.getByRole("button", { name: "Show more", exact: true }).click();
await expect(prompt.getByRole("button", { name: "Show less", exact: true })).toBeVisible();
await expect
.poll(async () => (await prompt.getByText("Show less", { exact: true }).boundingBox())!.x)
.toBe(messageLeft);
await expect
.poll(async () => (await prompt.boundingBox())?.height ?? 0)
.toBeGreaterThan(before!.height);
await expectCompactMessageToggle(prompt, "Show less");
await prompt.getByRole("button", { name: "Show less", exact: true }).click();
await expect.poll(async () => (await prompt.boundingBox())?.height ?? 0).toBe(before!.height);
await prompt.scrollIntoViewIfNeeded();
const bounds = (await prompt.boundingBox())!;
const body = (await text.boundingBox())!;
await page.screenshot({
path: "/tmp/message-toggle-aligned.png",
clip: {
x: body.x - 16,
y: bounds.y,
width: bounds.x + bounds.width - body.x + 16,
height: bounds.height,
},
});
}
test.describe("Turn collapse", () => {
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
test.setTimeout(120_000);
await page.setViewportSize({ width: 1280, height: 1000 });
await useChatDisplay(page, "collapseTurns");
const agent = await seedAnsweredAgent("Turn collapse");
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await expandAndCollapseLongPrompt(page);
const summary = page.getByTestId("turn-summary").last().getByRole("button");
await expect(summary).toHaveText(/^Wrote \d+ messages?, edited 1 file/);
await expect(summary).toHaveText(/^Edited 1 file/);
// Text chunks that stream back to back merge into one message, so the reply holds
// at least the stream's last chunk.
await expect(page.getByTestId("assistant-message").last()).toHaveText(
@@ -57,6 +148,9 @@ test.describe("Turn collapse", () => {
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
await expect(summary).toBeInViewport();
await summary.evaluate((element) => element.scrollIntoView({ block: "start" }));
await page.screenshot({ path: "/tmp/turn-rail-aligned.png" });
await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]);
await summary.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
@@ -65,3 +159,274 @@ test.describe("Turn collapse", () => {
}
});
});
test("received messages show their sender and open that agent", async ({ page }) => {
await page.addInitScript(() =>
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "dark" })),
);
const recipient = await seedMockAgentWorkspace({
repoPrefix: "sender-attribution-",
title: "Implementation",
});
try {
const sender = await recipient.client.createAgent({
provider: "codex",
cwd: recipient.cwd,
workspaceId: recipient.workspaceId,
title: "Reviewer",
model: "gpt-6.1-sol",
modeId: "full-access",
});
await recipient.client.sendAgentMessage(
recipient.agentId,
"I reviewed the turn-grouping changes. Heartbeats now keep their own timing footer, and the previous response stays visible.",
{ sourceAgentId: sender.id },
);
await openAgentRoute(page, recipient);
await expectComposerVisible(page);
const attribution = page.getByRole("button", { name: "Sent by Reviewer", exact: true });
await expect(attribution).toBeVisible();
await expect(page.getByTestId("user-message").filter({ has: attribution })).toContainText(
"I reviewed the turn-grouping changes.",
);
const labelColor = await attribution
.getByText("Sent by Reviewer", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(attribution.locator("svg")).toHaveCSS("fill", labelColor);
await expectSenderAlignedWithBubble(attribution);
await attribution.scrollIntoViewIfNeeded();
await page.screenshot({ path: "/tmp/sender-attribution.png" });
await attribution.click();
await expect.poll(() => getActiveTabTestId(page)).toContain(sender.id);
} finally {
await recipient.cleanup();
}
});
async function expectSenderAlignedWithBubble(attribution: Locator) {
const edges = await attribution.evaluate((button) => {
const bubble = button.nextElementSibling;
const label = button.querySelector('[dir="auto"]');
if (!bubble || !label) throw new Error("Sender label or message bubble missing");
return {
labelRight: label.getBoundingClientRect().right,
bubbleRight: bubble.getBoundingClientRect().right,
};
});
expect(
Math.abs(edges.labelRight - edges.bubbleRight),
"sender and bubble share the right edge",
).toBeLessThanOrEqual(1);
}
async function seedWholeTurnHistory() {
const agent = await seedMockAgentWorkspace({
repoPrefix: "whole-turn-history-",
title: "Whole turns",
model: "e2e-fast-stream",
});
for (const prompt of [
...Array.from(
{ length: 25 },
(_, index) => `Short ${index}: emit 1 coalesced agent stream updates`,
),
"Older long turn: emit 60 tool agent stream updates",
"Latest long turn: emit 120 tool agent stream updates",
]) {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await agent.client.waitForFinish(agent.agentId, 15_000);
}
return agent;
}
test("loads complete long turns and preserves expansion while revealing older history", async ({
page,
}) => {
test.setTimeout(120_000);
await useChatDisplay(page, "collapseTurns");
const agent = await seedWholeTurnHistory();
try {
const history = await holdOlderHistoryPages(page, agent);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const latest = page
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 120 commands", exact: true });
await expect(latest).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
await expect(
page
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 60 commands", exact: true }),
).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
history.expectNoRepeatedEntries();
await latest.scrollIntoViewIfNeeded();
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
await agent.cleanup();
}
});
async function useThinkingDisplay(
page: Page,
autoExpandReasoning: boolean,
chatDisplay: ChatDisplay,
) {
await page.addInitScript(
(settings) => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({
...settings,
chatDisplay: sessionStorage.getItem("e2e-chat-display") ?? settings.chatDisplay,
}),
);
},
{ autoExpandReasoning, chatDisplay },
);
}
for (const autoExpandReasoning of [false, true]) {
test(`reasoning follows Expand thinking (${autoExpandReasoning}) when opening its turn`, async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 1000 });
await useThinkingDisplay(page, autoExpandReasoning, "collapseToolCalls");
const agent = await seedMockAgentWorkspace({
repoPrefix: "thinking-group-",
title: "Thinking with tools",
model: "ten-second-stream",
initialPrompt: "Inspect the scroll container",
});
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const reasoning = page
.getByText(/Need to find the scroll container, the layout effect/)
.filter({ visible: true })
.first();
await expectThinkingPresentation(page, autoExpandReasoning);
await switchToCollapsedTurns(page);
await expectComposerVisible(page);
const summary = page.getByTestId("turn-summary").last().getByRole("button");
await expect(summary).toBeVisible();
await expect(summary).not.toContainText(/thought/i);
await expect(reasoning).toHaveCount(0);
await summary.click();
await expect(reasoning).toHaveCount(autoExpandReasoning ? 1 : 0);
await expect.poll(() => groupedToolsInsideLatestTurn(page)).toEqual([]);
await openOneToolOutput(page);
await summary.click();
await expect(reasoning).toHaveCount(0);
} finally {
await agent.cleanup();
}
});
}
async function openOneToolOutput(page: Page) {
const output = page.getByText(/\[burst\] tick 1 userIsAtBottom=true/).filter({ visible: true });
await expect(output).toHaveCount(0);
const command = page
.getByTestId("tool-call-badge")
.filter({ hasText: "node scripts/simulate-stream-burst.mjs" })
.first();
await command.getByRole("button").first().click();
await expect(output.first()).toBeVisible();
await command.getByRole("button").first().click();
await expect(output).toHaveCount(0);
}
async function expectThinkingPresentation(page: Page, autoExpandReasoning: boolean) {
const reasoning = page
.getByText(/Need to find the scroll container, the layout effect/)
.filter({ visible: true })
.first();
if (autoExpandReasoning) {
await expect(reasoning).toBeVisible();
await expect(page.getByTestId("tool-call-group").filter({ hasText: /thought/ })).toHaveCount(0);
} else {
await expect(reasoning).toHaveCount(0);
const group = page
.getByTestId("tool-call-group")
.filter({ hasText: /read 1 file/i, visible: true })
.last();
await expect(group).toContainText(/read 1 file/i);
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
await group.getByRole("button").first().click();
await expect(reasoning).toBeVisible();
}
}
async function switchToCollapsedTurns(page: Page) {
await page.evaluate(() => {
sessionStorage.setItem("e2e-chat-display", "collapseTurns");
});
await page.reload();
}
test("a scheduled heartbeat opens a separate turn without reopening the previous response", async ({
page,
}) => {
test.setTimeout(90_000);
await useChatDisplay(page, "collapseTurns");
const agent = await seedMockAgentWorkspace({
repoPrefix: "heartbeat-turn-",
title: "Heartbeat turns",
model: "ten-second-stream",
initialPrompt: "Inspect the scroll container",
});
const schedules = agent.client as unknown as Pick<
DaemonClient,
"scheduleCreate" | "scheduleRunOnce" | "scheduleDelete"
>;
let scheduleId: string | undefined;
try {
await agent.client.waitForFinish(agent.agentId, 30_000);
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const summaries = page.getByTestId("turn-summary");
await expect(summaries).toHaveCount(1);
const previousSummary = await summaries.first().innerText();
const previousReplyId = await page
.getByTestId("assistant-message")
.last()
.evaluate((element) => element.closest("[data-message-id]")!.getAttribute("data-message-id"));
const created = await schedules.scheduleCreate({
name: "Check the completed work",
prompt: "Inspect the scroll container again",
cadence: { type: "cron", expression: "0 0 1 1 *" },
target: { type: "self", agentId: agent.agentId },
runOnCreate: false,
});
expect(created.error).toBeNull();
scheduleId = created.schedule!.id;
const fired = await schedules.scheduleRunOnce({ id: scheduleId });
expect(fired.error).toBeNull();
await expect(
page.getByTestId("tool-call-badge").filter({ hasText: /^Heartbeat/ }),
).toBeVisible();
await expect(summaries.first()).toHaveText(previousSummary);
const previousReply = page
.locator(`[data-message-id="${previousReplyId}"]`)
.getByTestId("assistant-message");
await expect(previousReply).toBeVisible();
await expect(previousReply).toContainText("(end of synthetic stream)");
await agent.client.waitForFinish(agent.agentId, 30_000);
await expect(summaries).toHaveCount(2);
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await expect(page.getByRole("button", { name: /^Worked for/ })).toHaveCount(2);
await page.screenshot({ path: "/tmp/heartbeat-turn-footer.png" });
} finally {
if (scheduleId) await schedules.scheduleDelete({ id: scheduleId });
await agent.cleanup();
}
});
@@ -338,11 +338,32 @@ export async function delayAgentOlderTimelineResponse(
return delayAgentTimelineResponse(page, agentId, "before");
}
export interface OlderTimelinePagesOptions {
daemonPort?: number;
/**
* Hide whole-turn paging from the app so it reads this host with item pages, as it reads
* daemons released before whole-turn pages. An item page can end inside a turn or a tool run.
*/
itemPagedHost?: boolean;
}
function withoutWholeTurnPages(message: WebSocketMessage): WebSocketMessage {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (sessionMessage?.type !== "status" || payload?.status !== "server_info") return message;
const envelope = parseWebSocketJson(message) as {
message: { payload: { features?: Record<string, unknown> } };
};
delete envelope.message.payload.features?.wholeTurnTimeline;
return JSON.stringify(envelope);
}
export async function holdAgentOlderTimelinePages(
page: Page,
agentId: string,
daemonPort?: number,
options: OlderTimelinePagesOptions = {},
): Promise<OlderTimelinePagesGate> {
const { daemonPort } = options;
let requestCount = 0;
let responseCount = 0;
let repeatedEntryCount = 0;
@@ -376,7 +397,8 @@ export async function holdAgentOlderTimelinePages(
}
server.send(message);
});
server.onMessage((message) => {
server.onMessage((received) => {
const message = options.itemPagedHost ? withoutWholeTurnPages(received) : received;
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (
@@ -19,6 +19,9 @@ export async function addCustomModel(page: Page, id: string): Promise<void> {
.getByRole("button", { name: "Add", exact: true })
.click();
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
// Config updates before provider refresh finishes and dismisses the add sheet.
// Resizing or interacting with the model list must wait for that save to finish.
await expect(page.getByTestId("add-custom-model-sheet")).toBeHidden();
}
export async function removeCustomModel(page: Page, id: string): Promise<void> {
@@ -149,7 +149,11 @@ export interface SeedDaemonClient {
timeout?: number,
): Promise<{ status: string }>;
cancelAgent(agentId: string): Promise<void>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
sendAgentMessage(
agentId: string,
text: string,
options?: { sourceAgentId?: string },
): Promise<void>;
waitForFinish(
agentId: string,
timeout?: number,
@@ -1,4 +1,4 @@
import { expect, type TestInfo, type Page } from "@playwright/test";
import { expect, type Locator, type TestInfo, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { readReplicaCache } from "./replica-cache-storage";
import {
@@ -9,6 +9,7 @@ import {
type AgentTimelineResponseGate,
type BootstrapTimelineGate,
type OlderTimelinePagesGate,
type OlderTimelinePagesOptions,
} from "./agent-timeline-gate";
export { holdDaemonHydration };
@@ -35,6 +36,19 @@ interface RealisticOlderTimelineAgent extends MockAgentWorkspace {
newestPrompt: string;
}
export interface ItemPagedToolGroupAgent extends MockAgentWorkspace {
newestPrompt: string;
/** Commands in one run, more than an item page holds. */
totalCommands: number;
/** Commands of that run the initial tail loads, so the size of the group as first shown. */
loadedCommands: number;
}
interface ToolCallGroupPositionSnapshot {
top: number;
}
const ITEM_PAGE_SIZE = 40;
const PROMPT_PREFIX = "timeline-pagination-turn";
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
const HISTORY_START_THRESHOLD_PX = 96;
@@ -126,6 +140,93 @@ export async function seedRealisticOlderTimeline(): Promise<RealisticOlderTimeli
};
}
/**
* A run of commands longer than an item page, followed by enough short turns that the
* initial tail ends inside the run. Read with item pages, the tail loads the newest
* commands as one group; the older page carries the rest of that run and the turns before
* it, with a further page behind it.
*/
export async function seedItemPagedToolGroupTimeline(): Promise<ItemPagedToolGroupAgent> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-item-page-tool-group-",
title: "Item page tool group regression",
});
const olderTurns = 10;
const newerTurns = 12;
const totalCommands = 50;
// A short turn is its prompt and one reply; the run's turn ends with one reply.
const loadedCommands = ITEM_PAGE_SIZE - newerTurns * 2 - 1;
const shortTurnPrompt = (label: string, index: number) =>
`item-page-${label}-turn-${index}: emit 1 coalesced agent stream updates`;
const sendTurn = async (prompt: string) => {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await agent.client.waitForFinish(agent.agentId, 15_000);
};
for (let index = 0; index < olderTurns; index += 1) {
await sendTurn(shortTurnPrompt("older", index));
}
await sendTurn(`emit ${totalCommands} tool agent stream updates`);
for (let index = 0; index < newerTurns; index += 1) {
await sendTurn(shortTurnPrompt("newer", index));
}
return {
...agent,
newestPrompt: shortTurnPrompt("newer", newerTurns - 1),
totalCommands,
loadedCommands,
};
}
export async function useChatDisplay(
page: Page,
chatDisplay: "full" | "collapseToolCalls" | "collapseTurns",
): Promise<void> {
await page.addInitScript((value) => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
}, chatDisplay);
}
function toolCallGroup(page: Page, commandCount: number): Locator {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
return timeline
.getByTestId("tool-call-group")
.getByRole("button", { name: `Ran ${commandCount} commands`, exact: true });
}
export async function rememberToolCallGroupPosition(
page: Page,
commandCount: number,
): Promise<ToolCallGroupPositionSnapshot> {
const group = toolCallGroup(page, commandCount);
await expect(group).toBeVisible();
const box = await group.boundingBox();
if (!box) {
throw new Error(`Expected a rendered tool-call group of ${commandCount} commands`);
}
return { top: box.y };
}
/** The group the reader was on keeps its place on screen, now counting the older page's calls. */
export async function expectToolCallGroupPositionPreserved(
page: Page,
commandCount: number,
before: ToolCallGroupPositionSnapshot,
): Promise<void> {
const group = toolCallGroup(page, commandCount);
await expect(group).toBeVisible();
await expect
.poll(
async () => {
const box = await group.boundingBox();
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
},
{ message: `the group of ${commandCount} commands keeps its viewport position` },
)
.toBeLessThanOrEqual(2);
}
export async function scrollThroughOlderHistoryPages(
page: Page,
pageCount: number,
@@ -316,13 +417,14 @@ export async function holdNextOlderTimelinePage(
export async function holdOlderHistoryPages(
page: Page,
agent: MockAgentWorkspace,
daemonPort?: number,
options?: OlderTimelinePagesOptions,
): Promise<OlderHistoryPages> {
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(
page,
agent.agentId,
daemonPort,
options,
);
const loader = page.getByTestId("load-older-history-spinner");
return {
async expectRequestedPages(count) {
await gate.waitForRequestCount(count);
@@ -330,7 +432,17 @@ export async function holdOlderHistoryPages(
},
async expectSettledWithRequestedPages(count) {
await waitForTimelineGeometryToSettle(page);
expect(gate.getRequestCount()).toBe(count);
// The loader stays until the prepend settles and the viewport decides whether to
// keep loading. Once it hides, no further page loads without new user intent.
await expect
.poll(
async () => ({
requestedPages: gate.getRequestCount(),
loading: await loader.isVisible(),
}),
{ message: `older history settles after ${count} requested page(s)` },
)
.toEqual({ requestedPages: count, loading: false });
},
expectNoRepeatedEntries() {
expect(gate.getRepeatedEntryCount()).toBe(0);
@@ -423,6 +535,53 @@ export async function expectTimelineViewportAnchoredAfterPrepend(
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
}
/**
* Item-based pages from older hosts split turns, so the oldest loaded turn starts
* mid-turn: its prompt is above loaded history. Returns that prompt.
*/
export async function expectOldestLoadedTurnPartial(
page: Page,
prompts: readonly string[],
): Promise<string> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const oldestLoadedPrompt = await timeline
.getByTestId("user-message")
.first()
.evaluate((element) => element.textContent ?? "");
const loadedIndex = prompts.findIndex((prompt) => oldestLoadedPrompt.includes(prompt));
expect(loadedIndex, "oldest loaded prompt is a seeded turn").toBeGreaterThan(0);
const oldestRow = timeline.locator("[data-history-row-id]").first();
await expect(oldestRow.getByTestId("user-message"), "rows above the oldest prompt").toHaveCount(
0,
);
const partialPrompt = prompts[loadedIndex - 1]!;
await expectTimelinePromptNotMounted(page, partialPrompt);
return partialPrompt;
}
/** The reader stays with the turn they were reading once it folds behind its summary. */
export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
await expect(promptRow).toBeVisible({ timeout: 15_000 });
// The host row carries the folded turn's summary line.
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
await expect
.poll(
async () => {
const [timelineBox, promptBox] = await Promise.all([
timeline.boundingBox(),
promptRow.boundingBox(),
]);
if (!timelineBox || !promptBox) return false;
const offset = promptBox.y - timelineBox.y;
return offset >= 0 && offset <= timelineBox.height / 2;
},
{ message: "folded turn sits in the upper half of the viewport" },
)
.toBe(true);
}
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
@@ -71,7 +71,7 @@ function layoutFor(input: { platform: "web" | "native"; isTurnActive: boolean })
isTurnActive: input.isTurnActive,
history: strategy.orderTail([userMessage("user"), assistant]),
liveHead: [],
timingByAssistantId: timingFor(assistant.id),
timingByItemId: timingFor(assistant.id),
}),
};
}
@@ -1,4 +1,5 @@
import type { StreamItem } from "@/types/stream";
import { continuesTurn } from "./turn-membership";
export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20;
@@ -32,7 +33,7 @@ export function findMountedWindowStart(input: {
}
let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) {
startIndex -= 1;
}
return startIndex;
+6 -6
View File
@@ -104,7 +104,7 @@ function layoutFor(input: {
strategy,
streamHead: input.head ?? [],
}),
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
timingByItemId: timingFor(...(input.timingIds ?? [])),
});
}
@@ -148,7 +148,7 @@ function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
}
describe("layoutStream", () => {
it("places one response footer after an adjacent tagged tool-only turn", () => {
it("keeps separate footers for an assistant turn and an adjacent tool-only turn", () => {
const priorAssistant = assistantMessage("prior", 1, undefined, "turn-1");
const nextTool = toolCall("next-tool", 2, "turn-2");
const layout = layoutFor({
@@ -157,8 +157,8 @@ describe("layoutStream", () => {
timingIds: [priorAssistant.id],
});
expect(layout.auxiliaryTurnFooter?.itemId).toBe(priorAssistant.id);
expect(footerOwners(layout)).toEqual([priorAssistant.id]);
expect(layout.auxiliaryTurnFooter?.itemId).toBe(nextTool.id);
expect(footerOwners(layout)).toEqual([priorAssistant.id, nextTool.id]);
});
it("recomputes cached history layout when only the live boundary turn changes", () => {
@@ -171,7 +171,7 @@ describe("layoutStream", () => {
isTurnActive: true,
history,
liveHead,
timingByAssistantId: timingFor(priorAssistant.id),
timingByItemId: timingFor(priorAssistant.id),
});
liveHead[0] = { ...liveHead[0]!, turnId: "turn-2" };
@@ -180,7 +180,7 @@ describe("layoutStream", () => {
isTurnActive: true,
history,
liveHead,
timingByAssistantId: timingFor(priorAssistant.id),
timingByItemId: timingFor(priorAssistant.id),
});
expect(footerOwners(first)).toEqual([]);
+29 -26
View File
@@ -39,13 +39,13 @@ export interface StreamLayoutInput {
isTurnActive: boolean;
history: StreamItem[];
liveHead: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
timingByItemId: Map<string, TurnTiming>;
}
interface LayoutSegmentInput {
strategy: StreamStrategy;
items: StreamItem[];
timingByAssistantId: Map<string, TurnTiming>;
timingByItemId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
hasAuxiliaryFooter: boolean;
frameOrder: StreamFrameChildOrder;
@@ -57,8 +57,8 @@ interface LayoutSegmentInput {
phase: "streaming" | "complete";
}
interface AssistantFooterSource {
item: Extract<StreamItem, { kind: "assistant_message" }>;
interface TurnFooterSource {
item: StreamItem;
items: StreamItem[];
index: number;
}
@@ -67,23 +67,26 @@ function createTurnFooterHost(input: {
item: StreamItem;
items: StreamItem[];
index: number;
timingByAssistantId: Map<string, TurnTiming>;
timingByItemId: Map<string, TurnTiming>;
}): TurnFooterHost {
return {
itemId: input.item.id,
items: input.items,
timing: input.timingByAssistantId.get(input.item.id),
timing: input.timingByItemId.get(input.item.id),
startIndex: input.index,
};
}
function findLatestAssistantInResponse(input: {
function findTurnFooterSource(input: {
strategy: StreamStrategy;
items: StreamItem[];
startIndex: number;
boundaryAboveItems?: StreamItem[] | null;
boundaryAboveIndex?: number | null;
}): AssistantFooterSource | null {
}): TurnFooterSource | null {
const last = input.items[input.startIndex];
const toolFallback: TurnFooterSource | null =
last?.kind === "tool_call" ? { item: last, items: input.items, index: input.startIndex } : null;
let items = input.items;
let index = input.startIndex;
let canCrossBoundary = true;
@@ -97,7 +100,7 @@ function findLatestAssistantInResponse(input: {
) {
const item = items[index];
if (!item || (laterItem && !continuesResponse(item, laterItem))) {
return null;
return toolFallback;
}
if (item.kind === "assistant_message") {
return { item, items, index };
@@ -111,7 +114,7 @@ function findLatestAssistantInResponse(input: {
input.boundaryAboveIndex === null ||
input.boundaryAboveIndex === undefined
) {
return null;
return toolFallback;
}
items = input.boundaryAboveItems;
@@ -131,20 +134,20 @@ function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost |
return null;
}
const assistant = findLatestAssistantInResponse({
const source = findTurnFooterSource({
strategy: input.strategy,
items: footerItems,
startIndex: latestIndex,
});
if (!assistant) {
if (!source) {
return null;
}
return createTurnFooterHost({
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
item: source.item,
items: source.items,
index: source.index,
timingByItemId: input.timingByItemId,
});
}
@@ -154,7 +157,7 @@ function resolveCompletedFooter(input: {
index: number;
item: StreamItem;
belowItem: StreamItem | null;
timingByAssistantId: Map<string, TurnTiming>;
timingByItemId: Map<string, TurnTiming>;
auxiliaryTurnFooter: TurnFooterHost | null;
boundaryAboveItems: StreamItem[] | null;
boundaryAboveIndex: number | null;
@@ -163,21 +166,21 @@ function resolveCompletedFooter(input: {
return null;
}
const assistant = findLatestAssistantInResponse({
const source = findTurnFooterSource({
strategy: input.strategy,
items: input.items,
startIndex: input.index,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
});
if (!assistant || input.auxiliaryTurnFooter?.itemId === assistant.item.id) {
if (!source || input.auxiliaryTurnFooter?.itemId === source.item.id) {
return null;
}
return createTurnFooterHost({
item: assistant.item,
items: assistant.items,
index: assistant.index,
timingByAssistantId: input.timingByAssistantId,
item: source.item,
items: source.items,
index: source.index,
timingByItemId: input.timingByItemId,
});
}
@@ -306,7 +309,7 @@ function layoutSegmentItem(
index,
item,
belowItem,
timingByAssistantId: input.timingByAssistantId,
timingByItemId: input.timingByItemId,
auxiliaryTurnFooter: input.auxiliaryTurnFooter,
boundaryAboveItems: input.boundaryAboveItems,
boundaryAboveIndex: input.boundaryAboveIndex,
@@ -375,7 +378,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
history = layoutSegment({
strategy: input.strategy,
items: input.history,
timingByAssistantId: input.timingByAssistantId,
timingByItemId: input.timingByItemId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
@@ -395,7 +398,7 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
const liveHead = layoutSegment({
strategy: input.strategy,
items: input.liveHead,
timingByAssistantId: input.timingByAssistantId,
timingByItemId: input.timingByItemId,
auxiliaryTurnFooter,
hasAuxiliaryFooter,
frameOrder,
+9 -9
View File
@@ -69,8 +69,8 @@ describe("buildAgentStreamRenderModel", () => {
historyStart: 2,
});
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
expect(model.turnTiming.byItemId.has("hidden-a")).toBe(false);
expect(model.turnTiming.byItemId.get("visible-a")).toEqual({
completedAt: tail[3]?.timestamp,
durationMs: 1000,
});
@@ -184,7 +184,7 @@ describe("buildAgentStreamRenderModel", () => {
});
expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp);
expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false);
expect(model.turnTiming.byItemId.has("live-a")).toBe(false);
});
it("maps completed turn timing to assistant ids across committed history and live head", () => {
@@ -201,7 +201,7 @@ describe("buildAgentStreamRenderModel", () => {
});
expect(model.turnTiming.runningStartedAt).toBe(null);
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
expect(model.turnTiming.byItemId.get("live-a")).toEqual({
completedAt: head[0]?.timestamp,
durationMs: 3000,
});
@@ -220,7 +220,7 @@ describe("buildAgentStreamRenderModel", () => {
});
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
expect(model.turnTiming.byItemId.get("a1")).toEqual({
completedAt: tail[1]?.timestamp,
durationMs: 3000,
});
@@ -238,7 +238,7 @@ describe("buildAgentStreamRenderModel", () => {
isMobileBreakpoint: false,
});
expect(model.turnTiming.byAssistantId.size).toBe(0);
expect(model.turnTiming.byItemId.size).toBe(0);
});
it.each(["web", "native"] as const)(
@@ -274,10 +274,10 @@ describe("buildAgentStreamRenderModel", () => {
expect(second.history).toBe(first.history);
expect(second.segments.historyMounted).toBe(first.segments.historyMounted);
expect(second.turnTiming.byAssistantId).toBe(first.turnTiming.byAssistantId);
expect(second.turnTiming.byItemId).toBe(first.turnTiming.byItemId);
expect(first.history).toHaveLength(20);
expect(first.turnTiming.byAssistantId.has("a29")).toBe(false);
expect(first.turnTiming.byAssistantId.get("a38")).toEqual({
expect(first.turnTiming.byItemId.has("a29")).toBe(false);
expect(first.turnTiming.byItemId.get("a38")).toEqual({
completedAt: tail[77]?.timestamp,
durationMs: 1000,
});
@@ -15,6 +15,7 @@ import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { buildAgentStreamRenderModel } from "./model";
import { createStreamPresentation } from "./presentation";
import { createReadingAnchor } from "./reading-anchor";
const runtime = {
paseo: {},
@@ -588,3 +589,175 @@ describe("timeline presentation", () => {
expect(projectTimelineItems([source], () => [])).toEqual([]);
});
});
it("opening a turn reveals individual calls rather than another tool group", () => {
const present = createStreamPresentation();
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
toolCall("one", "Bash"),
toolCall("two", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
const result = present({
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns",
isTurnActive: false,
expandedTurnHostIds: new Set(["prompt"]),
});
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
expect(result.groupsByHostId.size).toBe(0);
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
});
it.each(["collapseToolCalls", "collapseTurns"] as const)(
"preserves the reader when a prepend extends a group in %s",
(display) => {
const present = createStreamPresentation();
const anchor = createReadingAnchor();
const calls = [toolCall("one", "Bash"), toolCall("two", "Bash")];
const input = {
...presentationOptions,
transform: undefined,
display,
head: [],
isTurnActive: false,
};
const partial = present({ ...input, tail: calls.slice(1) });
anchor.reconcile(0, [{ id: partial.tail[0]!.id, top: 48, height: 200 }]);
const completed = present({ ...input, tail: calls });
const groupId = completed.tail[0]!.id;
expect(anchor.getRowId(completed.readingRowReplacements)).toBe(groupId);
expect(
anchor.reconcile(
0,
[{ id: groupId, top: 448, height: 200 }],
false,
completed.readingRowReplacements,
),
).toBe(400);
expect(anchor.getRowId()).toBe(groupId);
const timestamp = new Date();
const folded = present({
...input,
display: "collapseTurns",
tail: [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
...calls,
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
],
});
// Both the original partial group and its replacement resolve directly to the turn.
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
const opened = present({ ...input, tail: calls, display: "full" });
expect(opened.readingRowReplacements.get(calls[1]!.id)).toBeUndefined();
},
);
it("presents received agent messages as attributed bubbles with the original turn identity", () => {
const call = toolCall("received", "agent_message");
if (call.payload.source !== "agent") throw new Error("Expected agent tool");
call.turnId = "turn";
call.payload.data.agentMessage = {
event: "message",
sender: { id: "sender", title: "Reviewer" },
text: "Ready",
};
const result = createStreamPresentation()({
...presentationOptions,
transform: undefined,
tail: [call],
head: [],
});
expect(result.tail[0]).toMatchObject({
kind: "user_message",
id: call.id,
turnId: "turn",
text: "Ready",
sender: { id: "sender", title: "Reviewer" },
});
});
it("does not reopen the previous turn while a new run waits for its first row", () => {
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
for (const item of tail) item.turnId = "completed";
const result = createStreamPresentation()({
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns",
isTurnActive: true,
activeTurnId: "new",
});
expect(result.tail.map((item) => item.id)).toEqual(["prompt", "reply"]);
});
it("groups reasoning with tools unless Expand thinking is enabled, including setting changes", () => {
const present = createStreamPresentation();
const timestamp = new Date();
const thought: StreamItem = {
kind: "thought",
id: "thought",
text: "Consider this",
status: "ready",
timestamp,
};
const tail: StreamItem[] = [toolCall("one", "Bash"), thought, toolCall("two", "Bash")];
for (const item of tail) item.turnId = "run";
const input = {
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseToolCalls" as const,
isTurnActive: false,
};
const grouped = present({ ...input, autoExpandReasoning: false });
expect(grouped.tail).toHaveLength(1);
expect(grouped.groupsByHostId.get("tool-one")?.run.calls).toEqual(tail);
const separate = present({ ...input, autoExpandReasoning: true });
expect(separate.tail.map((item) => item.id)).toEqual(["tool-one", "thought", "tool-two"]);
expect(separate.groupsByHostId.has("thought")).toBe(false);
const next = { ...thought, id: "next", turnId: "next-run" };
const bounded = present({ ...input, tail: [...tail, next], autoExpandReasoning: false });
expect(bounded.tail.map((item) => item.id)).toEqual(["tool-one", "next"]);
});
it.each([false, true])(
"reveals raw reasoning when opening a turn (Expand thinking %s)",
(autoExpandReasoning) => {
const timestamp = new Date();
const tail: StreamItem[] = [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
{ kind: "thought", id: "thought", text: "Consider this", status: "ready", timestamp },
toolCall("one", "Bash"),
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
];
const input = {
...presentationOptions,
transform: undefined,
tail,
head: [],
display: "collapseTurns" as const,
isTurnActive: false,
autoExpandReasoning,
};
const present = createStreamPresentation();
const closed = present(input);
expect(closed.tail.some((item) => item.id === "thought")).toBe(false);
const opened = present({ ...input, expandedTurnHostIds: new Set(["prompt"]) });
expect(opened.tail).toEqual(tail);
expect(opened.turns.nestedItemIds.has("thought")).toBe(true);
expect(opened.groupsByHostId.size).toBe(0);
},
);
+100 -14
View File
@@ -1,3 +1,4 @@
import { isTurnBoundary } from "./turn-membership";
import type { ChatDisplay } from "@/hooks/use-settings";
import type { TimelineItemTransform } from "@/plugins/timeline/model";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
@@ -10,19 +11,38 @@ import {
} from "@/tool-calls/detail-level/projection";
import type { StreamItem, UserMessageItem } from "@/types/stream";
import { collapseTurns, type TurnCollapse } from "./turn-collapse/model";
import type { ReadingRowReplacements } from "./reading-anchor";
interface PresentationInput {
tail: StreamItem[];
head: StreamItem[];
transform: TimelineItemTransform | undefined;
display: ChatDisplay;
autoExpandReasoning?: boolean;
/** User messages whose collapsed turn the reader opened. */
expandedTurnHostIds: ReadonlySet<string>;
isTurnActive: boolean;
activeTurnId?: string | null;
}
export interface StreamPresentation extends ToolCallDetailProjection {
turns: Omit<TurnCollapse, "tail">;
readingRowReplacements: ReadingRowReplacements;
}
function groupedRowHosts(
rows: readonly StreamItem[],
groups: ToolCallDetailProjection["groupsByHostId"],
): ReadonlyMap<string, string> {
const hosts = new Map<string, string>();
for (const row of rows) {
const group = groups.get(row.id);
if (!group) continue;
for (const call of group.run.calls) {
if (call.id !== row.id) hosts.set(call.id, row.id);
}
}
return hosts;
}
const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
@@ -55,27 +75,52 @@ export function createStreamPresentation() {
let historyTransform: TimelineItemTransform | undefined;
let historyRows: StreamItem[] = [];
let preparedTail: StreamItem[] | undefined;
let preparedAutoExpandReasoning: boolean | undefined;
let preparedLevel: ToolCallDetailLevel | undefined;
let preparedHistory: PreparedToolCallHistory | null = null;
let historyGroupHosts: ReadonlyMap<string, string> = new Map();
let collapsedSource: StreamItem[] | undefined;
let collapsedIsLatestTurnOpen = false;
let collapsedExpandedHostIds: ReadonlySet<string> | undefined;
let collapsed: TurnCollapse | undefined;
function presentRows(item: StreamItem): StreamItem[] {
if (item.kind === "tool_call" && item.payload.source === "agent") {
const message = item.payload.data.agentMessage;
if (message?.event === "message") {
return [
{
kind: "user_message",
id: item.id,
turnId: item.turnId,
timelineCursor: item.timelineCursor,
timestamp: item.timestamp,
text: message.text,
sender: message.sender,
},
];
}
}
return [item.kind === "user_message" ? presentUserMessage(item) : item];
}
function collapse(
projection: ToolCallDetailProjection,
input: PresentationInput,
): StreamPresentation {
): Omit<StreamPresentation, "readingRowReplacements"> {
if (input.display !== "collapseTurns") {
return { ...projection, turns: NO_COLLAPSED_TURNS };
}
// Live text updates reach the head only, so the folded history is reused until the
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0;
const isLatestTurnOpen =
(input.isTurnActive || projection.head.length > 0) &&
!isTurnBoundary(projection.tail.at(-1) ?? null, projection.head[0] ?? null) &&
!(
input.activeTurnId &&
projection.tail.at(-1)?.turnId &&
input.activeTurnId !== projection.tail.at(-1)?.turnId
);
if (
!collapsed ||
collapsedSource !== projection.tail ||
@@ -93,7 +138,20 @@ export function createStreamPresentation() {
collapsedExpandedHostIds = input.expandedTurnHostIds;
}
const { tail, ...turns } = collapsed;
return { ...projection, tail, turns };
if (turns.nestedItemIds.size === 0) return { ...projection, tail, turns };
// Opening a turn reveals its raw calls; those rows must not become group hosts again.
const withoutExpandedRows = (groups: StreamPresentation["groupsByHostId"]) => ({
size: groups.size - [...turns.nestedItemIds].filter((id) => groups.has(id)).length,
get: (id: string) => (turns.nestedItemIds.has(id) ? undefined : groups.get(id)),
has: (id: string) => !turns.nestedItemIds.has(id) && groups.has(id),
});
return {
...projection,
tail,
turns,
groupsByHostId: withoutExpandedRows(projection.groupsByHostId),
historyGroupUpdatesByHostId: withoutExpandedRows(projection.historyGroupUpdatesByHostId),
};
}
return (input: PresentationInput): StreamPresentation => {
@@ -111,20 +169,48 @@ export function createStreamPresentation() {
const blocks = presentRows(item);
head.push(...blocks);
}
if (preparedTail !== historyRows || preparedLevel !== level) {
preparedHistory = prepareToolCallHistory(level, historyRows);
if (
preparedTail !== historyRows ||
preparedLevel !== level ||
preparedAutoExpandReasoning !== input.autoExpandReasoning
) {
preparedHistory = prepareToolCallHistory(level, historyRows, input.autoExpandReasoning);
historyGroupHosts = preparedHistory
? groupedRowHosts(preparedHistory.grouped.tail, preparedHistory.grouped.groupsByHostId)
: new Map();
preparedTail = historyRows;
preparedLevel = level;
preparedAutoExpandReasoning = input.autoExpandReasoning;
}
return collapse(
projectToolCallDetailLevel({
level,
tail: historyRows,
head,
preparedHistory,
isTurnActive: input.isTurnActive,
}),
input,
const projection = projectToolCallDetailLevel({
level,
tail: historyRows,
head,
preparedHistory,
isTurnActive: input.isTurnActive,
});
const result = collapse(projection, input);
// Only the trailing retained group can absorb head rows. Reuse the retained
// index across streaming updates rather than walking the full history again.
const last = projection.tail.at(-1);
const liveGroupHosts = groupedRowHosts(
last ? [last, ...projection.head] : projection.head,
projection.groupsByHostId,
);
const retainedGroupHosts = historyGroupHosts;
return {
...result,
readingRowReplacements: {
get(id) {
if (result.turns.nestedItemIds.has(id)) return undefined;
const groupHost = liveGroupHosts.get(id) ?? retainedGroupHosts.get(id) ?? id;
return (
result.turns.hostIdByHiddenRowId.get(id) ??
result.turns.hostIdByHiddenRowId.get(groupHost) ??
(groupHost === id ? undefined : groupHost)
);
},
},
};
};
}
@@ -2,6 +2,24 @@ import { describe, expect, it } from "vitest";
import { createReadingAnchor } from "./reading-anchor";
describe("reading anchor", () => {
it("pins the folded turn host when a prepend replaces the row being read", () => {
const anchor = createReadingAnchor();
anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]);
const replacements = new Map([["tool", "prompt"]]);
expect(anchor.getRowId(replacements)).toBe("prompt");
expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000);
// Projection does not consume the correction; an unmounted host keeps the pin.
expect(anchor.reconcile(0, [], false, replacements)).toBe(0);
expect(anchor.getRowId()).toBe("tool");
expect(
anchor.reconcile(0, [{ id: "prompt", top: 1048, height: 100 }], false, replacements),
).toBe(1000);
expect(anchor.getRowId()).toBe("prompt");
expect(
anchor.reconcile(1000, [{ id: "prompt", top: 1048, height: 100 }], false, replacements),
).toBe(1000);
});
const rows = [
{ id: "above", top: 0, height: 500 },
{ id: "reading", top: 500, height: 500 },
@@ -6,6 +6,10 @@ interface RowGeometry {
// A row must clear the reading line before the next row takes ownership.
const READING_POSITION_OFFSET_PX = 8;
export interface ReadingRowReplacements {
get(id: string): string | undefined;
}
const NO_REPLACEMENTS: ReadingRowReplacements = new Map();
// Content coordinates survive user scrolling. Layout commits replace the geometry;
// scroll events reuse it, without another DOM measurement pass.
@@ -14,10 +18,15 @@ export function createReadingAnchor() {
let geometry: readonly RowGeometry[] = [];
const readingRow = (scrollTop: number) =>
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
const project = (scrollTop: number, row: Pick<RowGeometry, "id" | "top"> | undefined) =>
scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0);
const getRowId = (replacements = NO_REPLACEMENTS) =>
anchor ? (replacements.get(anchor.id) ?? anchor.id) : null;
const project = (
scrollTop: number,
row: Pick<RowGeometry, "id" | "top"> | undefined,
replacements = NO_REPLACEMENTS,
) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0);
return {
getRowId: () => anchor?.id ?? null,
getRowId,
getReadingRowId: (scrollTop: number) =>
readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null,
project,
@@ -28,13 +37,18 @@ export function createReadingAnchor() {
const next = readingRow(scrollTop);
anchor = next ? { id: next.id, top: next.top } : null;
},
reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number {
reconcile(
scrollTop: number,
rows: readonly RowGeometry[],
userScrolled = false,
replacements = NO_REPLACEMENTS,
): number {
geometry = rows;
const previous = anchor && rows.find((row) => row.id === anchor?.id);
const previous = anchor && rows.find((row) => row.id === getRowId(replacements));
// The first virtualized commit can precede its mounted range. Keep the
// pinned reader until it mounts, rather than adopting an unrelated row.
if (anchor && !previous && !userScrolled) return scrollTop;
const correctedTop = project(scrollTop, previous ?? undefined);
const correctedTop = project(scrollTop, previous ?? undefined, replacements);
// A prepend can expose the bottom of an estimated row above the reader.
// Do not transfer ownership to it until the user moves the reading position.
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
@@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => {
).toBe("assistant-1\n\nassistant-2");
});
it("collects copy content across adjacent turns without a visible prompt", () => {
it("keeps copied content within the canonical turn without a visible prompt", () => {
const chronological: StreamItem[] = [
{ ...assistantMessage("a1", "first turn", 1), turnId: "turn-1" },
{ ...toolCall("tool", 2), turnId: "turn-2" },
@@ -228,7 +228,7 @@ describe("neighbor and traversal semantics", () => {
items: chronological,
startIndex: 2,
}),
).toBe("first turn\n\nsecond turn");
).toBe("second turn");
});
it("returns undefined neighbor when index would be out of bounds", () => {
@@ -7,7 +7,7 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RetainedPanelActivity } from "@/components/retained-panel";
import type { StreamItem } from "@/types/stream";
import { reduceStreamUpdate, type StreamItem } from "@/types/stream";
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
import { createWebStreamStrategy } from "./strategy-web";
@@ -131,6 +131,79 @@ describe("createWebStreamStrategy", () => {
vi.restoreAllMocks();
});
it.each(["liveHead", "historyMounted"] as const)(
"keeps one rendered tool row after late prior-turn updates in %s",
(lane) => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const input: StreamRenderInput = {
agentId: "agent",
segments: { historyVirtualized: [], historyMounted: [], liveHead: [] },
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: lane === "historyMounted",
hasLiveHead: lane === "liveHead",
},
renderers: createRenderers(() => {}),
listEmptyComponent: null,
viewportRef: React.createRef<StreamViewportHandle>(),
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: () => {},
onNearHistoryStart: () => true,
isLoadingOlderHistory: false,
hasOlderHistory: false,
olderHistoryProgressKey: null,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
};
let items: StreamItem[] = [];
const updates = [
{ callId: "background", turnId: "old", status: "running" },
{ callId: "exec", turnId: "current", status: "running" },
{ callId: "background", turnId: "old", status: "completed" },
{ callId: "exec", turnId: "current", status: "completed" },
] as const;
for (const [index, update] of updates.entries()) {
items = reduceStreamUpdate(
items,
{
type: "timeline",
provider: "codex",
turnId: update.turnId,
item: {
type: "tool_call",
callId: update.callId,
name: "exec_command",
status: update.status,
error: null,
detail: { type: "shell", command: update.callId },
},
},
new Date(index),
);
act(() =>
root?.render(
strategy.render({
...input,
segments: { ...input.segments, [lane]: items },
}),
),
);
}
const rows = [...container.querySelectorAll("[data-history-row-id]")];
expect(rows.map((row) => row.getAttribute("data-history-row-id"))).toEqual([
"agent_tool_turn:old/background",
"agent_tool_turn:current/exec",
]);
},
);
it("mounts virtualized history without recursive row measurement updates", () => {
const rowRenderCount = vi.fn();
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
+16 -7
View File
@@ -394,7 +394,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const rangeExtractor = useCallback(
(range: VirtualRange): number[] => {
const anchorIndex = segments.historyVirtualized.findIndex(
(row) => row.id === readingAnchor.getRowId(),
(row) => row.id === readingAnchor.getRowId(props.readingRowReplacements),
);
const container = scrollContainerRef.current;
// TanStack has computed the new measurements before calling the extractor.
@@ -404,10 +404,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const measurement = virtualizer.measurementsCache[anchorIndex];
const correctedOffset =
container && measurement
? readingAnchor.project(container.scrollTop, {
id: String(measurement.key),
top: measurement.start,
})
? readingAnchor.project(
container.scrollTop,
{
id: String(measurement.key),
top: measurement.start,
},
props.readingRowReplacements,
)
: container?.scrollTop;
let visibleRange = range;
if (
@@ -428,7 +432,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]),
].sort((left, right) => left - right);
},
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized],
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized, props.readingRowReplacements],
);
const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null);
const observeVirtualOffset = useCallback<typeof observeElementOffset>((instance, onOffset) => {
@@ -742,7 +746,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
);
const shouldAnchor = !followOutputRef.current && !isJumpSettling();
if (!shouldAnchor) readingAnchor.reset();
const correctedTop = readingAnchor.reconcile(container.scrollTop, rows);
const correctedTop = readingAnchor.reconcile(
container.scrollTop,
rows,
false,
props.readingRowReplacements,
);
if (!shouldAnchor) {
readingAnchor.reset();
return;
@@ -1,6 +1,7 @@
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
import type { ReadingRowReplacements } from "./reading-anchor";
import { continuesResponse } from "./turn-membership";
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
import type {
@@ -78,6 +79,8 @@ export interface StreamRenderInput {
segments: StreamRenderSegments;
historyRowRevision?: StreamHistoryRowRevision;
liveHeadRowRevision?: unknown;
/** Presentation rows replaced by another row after history is folded. */
readingRowReplacements?: ReadingRowReplacements;
boundary: StreamHistoryBoundary;
renderers: StreamSegmentRenderers;
listEmptyComponent: ReactNode;
@@ -110,7 +110,7 @@ function runTicks(input: { tail: StreamItem[]; historyStart: number; heads: Stre
isTurnActive: true,
history: model.history,
liveHead: model.segments.liveHead,
timingByAssistantId: model.turnTiming.byAssistantId,
timingByItemId: model.turnTiming.byItemId,
});
}
}
@@ -48,6 +48,49 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
const ids = (items: StreamItem[]) => items.map((item) => item.id);
describe("collapseTurns", () => {
it("maps hidden rows only while their turn is closed", () => {
const result = collapse([user("u1"), shell("c1"), reply("final")]);
expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1");
const expanded = collapse([user("u1"), shell("c1"), reply("final")], {
expandedHostIds: new Set(["u1"]),
});
expect(expanded.hostIdByHiddenRowId.size).toBe(0);
});
it("keeps lifecycle events and compaction recognizable beside a semantic action summary", () => {
const create = shell("create");
if (create.payload.source !== "agent") throw new Error("Expected agent tool");
create.payload.data.name = "mcp__paseo__create_agent";
create.payload.data.detail = { type: "unknown", input: { title: "Side fix" }, output: null };
const finished = shell("finished");
if (finished.payload.source !== "agent") throw new Error("Expected agent tool");
finished.payload.data.name = "agent_notification";
finished.payload.data.agentMessage = {
event: "finished",
sender: { id: "worker", title: "PR helper" },
text: "Finished",
};
const items: StreamItem[] = [
user("u1"),
create,
finished,
{
kind: "compaction",
id: "compaction",
timestamp: at,
status: "completed",
} satisfies StreamItem,
shell("bash"),
reply("done"),
].map((item) => Object.assign(item, { turnId: "same-run" }));
const result = collapse(items);
expect(ids(result.tail)).toEqual(["u1", "finished", "compaction", "done"]);
expect(result.turnsByHostId.get("u1")?.summary.tools).toMatchObject({
commandCount: 1,
otherToolCount: 0,
paseoActions: { "Created agent": 1 },
});
});
it("folds a finished turn's work into its user message and keeps the reply", () => {
const result = collapse([
user("u1"),
@@ -63,7 +106,6 @@ describe("collapseTurns", () => {
hostId: "u1",
expanded: false,
summary: {
messageCount: 1,
tools: expect.objectContaining({ commandCount: 2 }),
},
});
@@ -121,6 +163,34 @@ describe("collapseTurns", () => {
expect(result.turnsByHostId.size).toBe(0);
});
it("keeps a completed response folded when a promptless canonical turn starts", () => {
const first = [user("u1"), shell("c1"), reply("r1")].map((item) =>
Object.assign(item, { turnId: "first" }),
);
const next = { ...shell("heartbeat"), turnId: "second" };
const result = collapse([...first, next], { isLatestTurnOpen: true });
expect(ids(result.tail)).toEqual(["u1", "r1", "heartbeat"]);
expect(result.turnsByHostId.has("u1")).toBe(true);
});
it("keeps mid-turn messages visible when work is folded", () => {
const tail = [user("prompt"), shell("work"), user("steer"), shell("more"), reply("done")];
for (const item of tail) item.turnId = "same-turn";
const result = collapse(tail);
expect(ids(result.tail)).toEqual(["prompt", "steer", "done"]);
expect(result.hostIdByFoldedMessageId.has("steer")).toBe(false);
});
it("reveals every raw call inside an expanded turn", () => {
const calls = [shell("one"), shell("two")];
const result = collapse([user("prompt"), calls[0]!, reply("done")], {
expandedHostIds: new Set(["prompt"]),
getGroupedCalls: (id) => (id === "one" ? calls : undefined),
});
expect(ids(result.tail)).toEqual(["prompt", "one", "two", "done"]);
expect([...result.nestedItemIds]).toEqual(["one", "two"]);
});
it("counts every call a tool-call group row stands for", () => {
const group = [shell("c1"), shell("c2"), shell("c3")];
const result = collapse([user("u1"), group[0]!, reply("final")], {
@@ -1,13 +1,18 @@
import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream";
import { isTurnBoundary, isTurnTrigger } from "../turn-membership";
import {
getStreamItemMessageId,
type StreamItem,
type ThoughtItem,
type ToolCallItem,
} from "@/types/stream";
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
export interface TurnSummary {
messageCount: number;
tools: OverviewSummary;
}
export interface CollapsedTurn {
/** The user message the turn answers. Its row carries the turn's summary line. */
/** The input or first loaded row that carries the turn's summary line. */
hostId: string;
expanded: boolean;
summary: TurnSummary;
@@ -20,6 +25,8 @@ export interface TurnCollapse {
nestedItemIds: ReadonlySet<string>;
/** The turn a folded message belongs to, for surfaces that must reveal it. */
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
/** Mounted rows replaced by a closed turn's host, for preserving the reading position. */
hostIdByHiddenRowId: ReadonlyMap<string, string>;
}
export interface CollapseTurnsInput {
@@ -28,7 +35,7 @@ export interface CollapseTurnsInput {
isLatestTurnOpen: boolean;
expandedHostIds: ReadonlySet<string>;
/** The calls a tool-call group row stands for; undefined for a single call. */
getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined;
getGroupedCalls: (itemId: string) => readonly (ToolCallItem | ThoughtItem)[] | undefined;
}
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
@@ -39,17 +46,21 @@ function summarizeTurn(
items: readonly StreamItem[],
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
): TurnSummary {
const calls: ToolCallItem[] = [];
let messageCount = 0;
const calls: (ToolCallItem | ThoughtItem)[] = [];
for (const item of items) {
if (item.kind === "assistant_message") messageCount += 1;
else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item]));
if ((item.kind === "tool_call" || item.kind === "thought") && !isTurnTrigger(item))
calls.push(...(getGroupedCalls(item.id) ?? [item]));
}
return { messageCount, tools: summarizeToolCalls(calls) };
return { tools: summarizeToolCalls(calls) };
}
function hasWork(summary: TurnSummary): boolean {
return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0);
const { paseoActions, thoughtCount: _thoughtCount, ...counts } = summary.tools;
return Object.keys(paseoActions).length > 0 || Object.values(counts).some((count) => count > 0);
}
function staysVisibleWhenCollapsed(item: StreamItem): boolean {
return isTurnTrigger(item) || item.kind === "compaction";
}
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
@@ -63,7 +74,7 @@ function findLastAssistantIndex(tail: StreamItem[], start: number, end: number):
* Folds each answered turn into its user message: everything the agent did before its last
* message collapses into one summary line, and that message stays in the chat. A turn folds
* only once it is complete, its user message is loaded, and it sent messages or called tools
* before its reply. Thoughts fold with the turn but are not counted.
* before its reply. Reasoning is revealed only when the turn opens.
*/
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
const { tail } = input;
@@ -71,34 +82,55 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
const turnsByHostId = new Map<string, CollapsedTurn>();
const nestedItemIds = new Set<string>();
const hostIdByFoldedMessageId = new Map<string, string>();
const hostIdByHiddenRowId = new Map<string, string>();
let runStart = 0;
const flushRun = (runEnd: number) => {
const host = tail[runStart - 1];
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
if (host?.kind !== "user_message" || !isClosed || replyIndex <= runStart) {
if (
!host ||
(host.kind !== "user_message" && host.kind !== "tool_call") ||
!isClosed ||
replyIndex <= runStart
) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const folded = tail.slice(runStart, replyIndex);
const summary = summarizeTurn(folded, input.getGroupedCalls);
if (!hasWork(summary)) {
if (!hasWork(summary) && !folded.some((item) => item.kind === "assistant_message")) {
output.push(...tail.slice(runStart, runEnd));
return;
}
const expanded = input.expandedHostIds.has(host.id);
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
for (const item of folded) {
const revealed = expanded
? folded.flatMap<StreamItem>((item) =>
item.kind === "tool_call" || item.kind === "thought"
? [...(input.getGroupedCalls(item.id) ?? [item])]
: [item],
)
: folded;
for (const item of revealed) {
if (staysVisibleWhenCollapsed(item)) continue;
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
if (expanded) nestedItemIds.add(item.id);
else hostIdByHiddenRowId.set(item.id, host.id);
}
if (expanded) output.push(...folded);
output.push(
...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))),
);
output.push(...tail.slice(replyIndex, runEnd));
};
for (let index = 0; index < tail.length; index += 1) {
if (tail[index]?.kind !== "user_message") continue;
if (
!(index === 0 && isTurnTrigger(tail[index]!)) &&
!isTurnBoundary(tail[index - 1] ?? null, tail[index] ?? null)
)
continue;
flushRun(index);
output.push(tail[index]!);
runStart = index + 1;
@@ -111,12 +143,17 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
turnsByHostId: EMPTY_TURNS,
nestedItemIds: EMPTY_IDS,
hostIdByFoldedMessageId: EMPTY_HOSTS,
hostIdByHiddenRowId: EMPTY_HOSTS,
};
}
return {
tail: output.length === tail.length ? tail : output,
tail:
output.length === tail.length && output.every((item, index) => item === tail[index])
? tail
: output,
turnsByHostId,
nestedItemIds,
hostIdByFoldedMessageId,
hostIdByHiddenRowId,
};
}
@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo } from "react";
import { memo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Summary } from "lucide-react-native";
import { ExpandableBadge } from "@/components/message";
@@ -17,19 +17,7 @@ export const TurnSummaryLine = memo(function TurnSummaryLine({
onExpandedChange: (hostId: string, expanded: boolean) => void;
}) {
const { t } = useTranslation();
const { messageCount, tools } = turn.summary;
const messageParts = useMemo(
() =>
messageCount > 0
? [
t(`turnSummary.messages.${messageCount === 1 ? "one" : "other"}`, {
count: messageCount,
}),
]
: [],
[messageCount, t],
);
const label = useOverviewSummaryLabel(tools, messageParts);
const label = useOverviewSummaryLabel(turn.summary.tools) || t("turnSummary.details");
const toggle = useCallback(
() => onExpandedChange(turn.hostId, !turn.expanded),
[onExpandedChange, turn.expanded, turn.hostId],
@@ -197,7 +197,7 @@ function CompletedTurnFooter({
return (
<View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter
getContent={getContent}
getContent={items[startIndex]?.kind === "assistant_message" ? getContent : undefined}
completedAt={timing?.completedAt}
durationMs={timing?.durationMs}
onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
@@ -53,7 +53,7 @@ function layoutFor(items: StreamItem[], isTurnActive: boolean) {
isTurnActive,
history: model.segments.historyMounted,
liveHead: model.segments.liveHead,
timingByAssistantId: model.turnTiming.byAssistantId,
timingByItemId: model.turnTiming.byItemId,
}),
};
}
@@ -67,6 +67,24 @@ function completedFooterIds(layout: ReturnType<typeof layoutFor>["layout"]): str
}
describe("canonical turn membership", () => {
it("uses trigger fallback for missing IDs without pretending to recover batching", () => {
const previous = assistant("reply", 1, "first");
const trigger = runningTool("heartbeat", 2, "first");
if (trigger.kind !== "tool_call" || trigger.payload.source !== "agent")
throw new Error("Expected agent tool");
trigger.payload.data.name = "heartbeat";
expect(continuesTurn(previous, trigger)).toBe(true);
delete trigger.turnId;
expect(continuesTurn(previous, trigger)).toBe(false);
trigger.payload.data.name = "agent_message";
trigger.payload.data.agentMessage = {
event: "finished",
sender: { id: "worker" },
text: "Done",
};
expect(continuesTurn(previous, trigger)).toBe(false);
});
it("stops tagged traversal at every mismatching turn ID", () => {
expect(continuesTurn(assistant("first", 1, "turn-1"), runningTool("next", 2, "turn-2"))).toBe(
false,
@@ -76,7 +94,7 @@ describe("canonical turn membership", () => {
);
});
it("renders adjacent turns without visible prompts as one response", () => {
it("renders a separate footer for each canonical turn without visible prompts", () => {
const items = [
user("prompt", 1, "turn-1"),
assistant("first reply", 2, "turn-1"),
@@ -88,7 +106,11 @@ describe("canonical turn membership", () => {
const completed = layoutFor(items, false);
expect(completedFooterIds(completed.layout)).toEqual(["heartbeat-2-reply"]);
expect(completedFooterIds(completed.layout)).toEqual([
"first reply",
"heartbeat-1-reply",
"heartbeat-2-reply",
]);
expect(completed.layout.auxiliaryTurnFooter?.timing).toEqual({
completedAt: at(6),
durationMs: null,
@@ -123,7 +145,7 @@ describe("canonical turn membership", () => {
const completedItems = [...build(turnId), assistant("done", 9, turnId)];
const completed = layoutFor(completedItems, false);
expect(completedFooterIds(completed.layout)).toEqual(["done"]);
expect(completed.model.turnTiming.byAssistantId.get("done")).toMatchObject({
expect(completed.model.turnTiming.byItemId.get("done")).toMatchObject({
completedAt: at(9),
durationMs: 8000,
});
@@ -1,24 +1,21 @@
import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn";
import type { StreamItem } from "@/types/stream";
/**
* Canonical turn IDs take precedence. Timelines without them retain the legacy
* user-message boundary rule so old daemons and persisted rows need no rewrite.
* trigger boundary rule; missing IDs cannot reconstruct historical batching.
*/
export function continuesTurn(previous: StreamItem | null, next: StreamItem | null): boolean {
if (!previous || !next) return false;
if (previous.turnId !== undefined && next.turnId !== undefined) {
return previous.turnId === next.turnId;
}
return next.kind !== "user_message";
return continuesTimelineTurn(previous.turnId, next.turnId, isTurnTrigger(next));
}
/**
* A visible response can span multiple canonical turns when their prompts are
* system-injected and therefore absent from the Paseo timeline.
* Every canonical turn owns its response, including system-triggered runs.
*/
export function continuesResponse(previous: StreamItem | null, next: StreamItem | null): boolean {
if (!previous || !next) return false;
return continuesTurn(previous, next) || next.kind !== "user_message";
return continuesTurn(previous, next);
}
export function isTurnBoundary(previous: StreamItem | null, next: StreamItem | null): boolean {
@@ -37,3 +34,12 @@ export function startsNewTurn(item: StreamItem, previous: StreamItem | null): bo
export function belongsToTurn(item: StreamItem, turnId: string | null): boolean {
return turnId !== null && item.turnId === turnId;
}
/** Inputs that start a turn when older history has no canonical turn IDs. */
export function isTurnTrigger(item: StreamItem): boolean {
return isTimelineTurnTrigger(
item.kind === "tool_call" && item.payload.source === "agent"
? { type: item.kind, ...item.payload.data }
: { type: item.kind },
);
}
+46 -11
View File
@@ -524,8 +524,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
transform: transformTimelineItem,
display: chatDisplay,
autoExpandReasoning,
expandedTurnHostIds,
isTurnActive,
activeTurnId: effectiveTurnPresentation.turnId,
}),
[
presentStream,
@@ -533,8 +535,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
effectiveStreamHead,
transformTimelineItem,
chatDisplay,
autoExpandReasoning,
expandedTurnHostIds,
isTurnActive,
effectiveTurnPresentation.turnId,
],
);
const {
@@ -576,12 +580,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isTurnActive,
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
timingByItemId: baseRenderModel.turnTiming.byItemId,
}),
[
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId,
baseRenderModel.turnTiming.byItemId,
isTurnActive,
streamRenderStrategy,
],
@@ -722,6 +726,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId={agentId}
messageId={item.messageId}
message={item.text}
sender={item.sender}
images={item.images}
attachments={item.attachments}
timestamp={item.timestamp.getTime()}
@@ -868,10 +873,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
presentation.groupsByHostId.get(hostId),
);
const renderToolCallItem = useCallback(
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "tool_call" }>) => {
(
layoutItem: StreamLayoutItem,
item: Extract<StreamItem, { kind: "tool_call" | "thought" }>,
) => {
const group = getToolCallGroup(item.id);
if (!group) {
return renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
return item.kind === "thought"
? renderThoughtItem(layoutItem, item)
: renderSingleToolCallItem(item, layoutItem.isLastInToolSequence);
}
const expanded = expandedToolCallGroupIds.has(group.run.id);
return (
@@ -884,10 +894,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
{expanded
? group.run.calls.map((call, index) => (
<React.Fragment key={call.id}>
{renderSingleToolCallItem(
call,
index === group.run.calls.length - 1,
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
{call.kind === "thought" ? (
<ThoughtSlot
itemId={call.id}
text={call.text}
status={call.status}
isLastInSequence={index === group.run.calls.length - 1}
defaultExpanded
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
/>
) : (
renderSingleToolCallItem(
call,
index === group.run.calls.length - 1,
GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT,
)
)}
</React.Fragment>
))
@@ -897,6 +918,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
},
[
expandedToolCallGroupIds,
renderThoughtItem,
setInlineDetailsExpanded,
getToolCallGroup,
renderSingleToolCallItem,
setToolCallGroupExpanded,
@@ -914,10 +937,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return renderAssistantMessageItem(layoutItem, item);
case "thought":
return renderThoughtItem(layoutItem, item);
return renderToolCallItem(layoutItem, item);
case "tool_call":
return renderToolCallItem(layoutItem, item);
return (
<>
{renderToolCallItem(layoutItem, item)}
{getCollapsedTurn(item.id) ? (
<TurnSummaryLine
turn={getCollapsedTurn(item.id)!}
onExpandedChange={setTurnExpanded}
/>
) : null}
</>
);
case "notification":
return <Notification level={item.level} message={item.message} />;
@@ -947,8 +980,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agentId,
renderUserMessageItem,
renderAssistantMessageItem,
renderThoughtItem,
renderToolCallItem,
getCollapsedTurn,
setTurnExpanded,
resolvedServerId,
],
);
@@ -1169,6 +1203,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<AssistantSelectionCopySurface style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
{streamRenderStrategy.render({
readingRowReplacements: presentation.readingRowReplacements,
agentId,
segments: renderModel.segments,
historyRowRevision,
@@ -70,6 +70,17 @@ function indexEntries(items: StreamItem[]): IndexedStreamItem[] {
}
describe("findMountedWindowStart", () => {
it("mounts complete autonomous runs and keeps same-run steers together", () => {
const items = [
{ ...userMessage("human", 1), turnId: "first" },
{ ...assistantMessage("reply", 2), turnId: "first" },
{ ...toolCall("wake", 3), turnId: "autonomous" },
{ ...userMessage("steer", 4), turnId: "autonomous" },
{ ...assistantMessage("done", 5), turnId: "autonomous" },
];
expect(findMountedWindowStart({ items, minMountedCount: 1 })).toBe(2);
});
it("keeps all items mounted when the chat is below the threshold", () => {
const items = [userMessage("u1", 1), assistantMessage("a1", 2)];
+3 -4
View File
@@ -3,11 +3,10 @@ import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
/**
* Collapsed chat rows badge their summary with an icon whose center sits 7px into the content
* column (`ExpandableBadge` bleeds 13px left, then 1px border, 8px padding, 22px icon). The rail
* hangs from that center so opened rows read as children of the line that opened them.
* Align the rail with the expanded caret's left edge. Keep the content offset fixed so
* expanded tool cards stay in place, with a gap between their border and the rail.
*/
const RAIL_LEFT = 7;
const RAIL_LEFT = 1;
const RAIL_WIDTH = 1;
export const INDENT_RAIL_CONTENT_OFFSET = 21;
+48 -40
View File
@@ -1,3 +1,4 @@
import { MessagePreview, MessageSender } from "@/components/user-message";
import { useStableEvent } from "@/hooks/use-stable-event";
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
import { AssistantForkMenu, type AssistantForkTarget } from "@/components/assistant-fork-menu";
@@ -89,6 +90,7 @@ export type { InlinePathTarget } from "@/assistant-file-links";
export type { AssistantForkTarget };
interface UserMessageProps {
sender?: AgentMessage["sender"];
serverId?: string;
agentId?: string;
messageId?: string;
@@ -387,6 +389,7 @@ function UserMessageImagePill({ image, onOpen, accessibilityLabel }: UserMessage
const MESSAGE_TEXT_DATASET = { messageText: "true" };
export const UserMessage = memo(function UserMessage({
sender,
serverId,
agentId,
messageId,
@@ -473,42 +476,45 @@ export const UserMessage = memo(function UserMessage({
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
{sender ? <MessageSender sender={sender} serverId={serverId} /> : null}
<View style={userMessageStylesheet.bubble}>
{hasImages ? (
<View style={imagePreviewContainerStyle}>
{images.map((image) => (
<UserMessageImagePill
key={image.id}
image={image}
onOpen={setLightboxMetadata}
accessibilityLabel={t("composer.attachments.openImage")}
/>
))}
</View>
) : null}
{hasAttachments ? (
<View style={attachmentPreviewContainerStyle}>
{attachments.map((attachment, index) => {
const content = getAgentAttachmentPillContent(attachment, t);
return (
<AttachmentFrame
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
>
<AttachmentLabel
icon={content.icon}
title={content.title}
subtitle={content.subtitle}
/>
</AttachmentFrame>
);
})}
</View>
) : null}
{hasText ? (
<Text selectable style={userMessageStylesheet.text} dataSet={MESSAGE_TEXT_DATASET}>
{message}
</Text>
) : null}
<MessagePreview>
{hasImages ? (
<View style={imagePreviewContainerStyle}>
{images.map((image) => (
<UserMessageImagePill
key={image.id}
image={image}
onOpen={setLightboxMetadata}
accessibilityLabel={t("composer.attachments.openImage")}
/>
))}
</View>
) : null}
{hasAttachments ? (
<View style={attachmentPreviewContainerStyle}>
{attachments.map((attachment, index) => {
const content = getAgentAttachmentPillContent(attachment, t);
return (
<AttachmentFrame
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
>
<AttachmentLabel
icon={content.icon}
title={content.title}
subtitle={content.subtitle}
/>
</AttachmentFrame>
);
})}
</View>
) : null}
{hasText ? (
<Text selectable style={userMessageStylesheet.text} dataSet={MESSAGE_TEXT_DATASET}>
{message}
</Text>
) : null}
</MessagePreview>
</View>
{hasText ? (
<View
@@ -541,7 +547,7 @@ export const UserMessage = memo(function UserMessage({
});
interface AssistantTurnFooterProps {
getContent: () => string;
getContent?: () => string;
completedAt?: Date;
durationMs?: number | null;
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
@@ -641,10 +647,12 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
return (
<View style={assistantTurnFooterStylesheet.container}>
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
{getContent ? (
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
) : null}
{canFork ? <AssistantForkMenu onFork={handleFork} /> : null}
{primaryLabel ? (
<Pressable
@@ -1,3 +1,4 @@
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
@@ -42,8 +43,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
: undefined;
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
direction: "tail",
projection: "projected",
...planTimelineTailFetch(),
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
});
}
+2 -1
View File
@@ -4,9 +4,10 @@ import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "reac
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace";
type EdgeFadeBackdrop = "surface0" | "surfaceWorkspace" | "surface3";
const backdropColorMappings: Record<EdgeFadeBackdrop, (theme: Theme) => { color: string }> = {
surface3: (theme) => ({ color: theme.colors.surface3 }),
surface0: (theme) => ({ color: theme.colors.surface0 }),
surfaceWorkspace: (theme) => ({ color: theme.colors.surfaceWorkspace }),
};
@@ -0,0 +1,91 @@
import { useState, useCallback, useMemo, type ReactNode } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
import { Button } from "@/components/ui/button";
import { EdgeFade } from "@/components/ui/edge-fade";
import { useProviderIcon } from "@/components/provider-icons";
import { useSessionStore } from "@/stores/session-store";
import { navigateToAgent } from "@/utils/navigate-to-agent";
const PREVIEW_HEIGHT = 240;
export function MessagePreview({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const [height, setHeight] = useState(0);
const overflowing = height > PREVIEW_HEIGHT;
const measure = useCallback(
(event: LayoutChangeEvent) => setHeight(event.nativeEvent.layout.height),
[],
);
const toggle = useCallback(() => setExpanded((value) => !value), []);
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
return (
<>
<View style={!expanded && styles.preview}>
<View style={styles.body} onLayout={measure}>
{children}
</View>
{overflowing && !expanded ? (
<EdgeFade edge="bottom" solidFraction={0} backdrop="surface3" style={styles.fade} />
) : null}
</View>
{overflowing ? (
<Button
variant="ghost"
size="sm"
style={styles.toggle}
accessibilityState={accessibilityState}
onPress={toggle}
>
{t(expanded ? "turnSummary.showLess" : "turnSummary.showMore")}
</Button>
) : null}
</>
);
}
export function MessageSender({
sender,
serverId,
}: {
sender: AgentMessage["sender"];
serverId?: string;
}) {
const { t } = useTranslation();
const host = sender.serverId ?? serverId;
const agent = useSessionStore((state) => {
const session = host ? state.sessions[host] : undefined;
return session?.agents.get(sender.id) ?? session?.agentDetails.get(sender.id);
});
const ProviderIcon = useProviderIcon(agent?.provider ?? "", host);
const renderProviderIcon = useCallback(
(color: string) => <ProviderIcon size={16} color={color} />,
[ProviderIcon],
);
const openSender = useCallback(() => {
if (host) navigateToAgent({ serverId: host, agentId: sender.id });
}, [host, sender.id]);
return (
<Button
variant="ghost"
size="sm"
leftIcon={renderProviderIcon}
style={styles.sender}
disabled={!host}
onPress={openSender}
>
{t("turnSummary.sentBy", { name: agent?.title ?? sender.title ?? sender.id })}
</Button>
);
}
const styles = StyleSheet.create((theme) => ({
preview: { maxHeight: PREVIEW_HEIGHT, overflow: "hidden" },
body: { flexShrink: 0 },
sender: { paddingRight: 0, borderRightWidth: 0 },
toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 },
fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] },
}));
+1
View File
@@ -2495,6 +2495,7 @@ function ComposerContentImpl({
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={activeSendBehavior}
sendShortcut={appSettings.sendShortcut}
onQueue={handleQueue}
onSubmitLoadingPress={submitLoadingPressHandler}
onKeyPress={handleCommandKeyPress}
+34 -4
View File
@@ -1,3 +1,4 @@
import type { SendShortcut } from "@/hooks/use-settings";
import {
useComposerEditingSession,
useComposerFullscreen,
@@ -88,6 +89,7 @@ import {
resolveAlternateSendAction,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveMessageInputEnterAction,
resolveRetainedSelection,
runAlternateSendAction,
runDefaultSendAction,
@@ -96,6 +98,11 @@ import {
} from "./state";
const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]];
const MOD_ENTER_SEND_KEYS: ShortcutKey[][] = [["mod", "Enter"]];
function resolveSendKeys(sendShortcut: SendShortcut): ShortcutKey[][] {
return sendShortcut === "mod-enter" ? MOD_ENTER_SEND_KEYS : DEFAULT_SEND_KEYS;
}
const COMPOSER_INPUT_DATASET = { composerInput: "" } as const;
export interface AttachmentMenuItem {
@@ -167,6 +174,7 @@ export interface MessageInputProps {
* running. "interrupt" and "steer" send immediately, "queue" queues. Required so the default
* lives only in DEFAULT_CLIENT_SETTINGS. */
defaultSendBehavior: "interrupt" | "steer" | "queue";
sendShortcut: SendShortcut;
/** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void;
/** Optional handler used when submit button is in loading state. */
@@ -402,6 +410,9 @@ interface DesktopKeyPressContext {
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
input: ComposerKeyPressEvent["input"];
submitOnEnter: boolean;
sendShortcut: SendShortcut;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
isSubmitDisabled: boolean;
isSubmitLoading: boolean;
disabled: boolean;
@@ -428,12 +439,21 @@ function handleDesktopKeyPressImpl(
}
if (event.nativeEvent.key !== "Enter") return;
if (!ctx.submitOnEnter) return;
if (shiftKey) return;
const action = resolveMessageInputEnterAction({
submitOnEnter: ctx.submitOnEnter,
sendShortcut: ctx.sendShortcut,
shiftKey: shiftKey ?? false,
metaKey: metaKey ?? false,
ctrlKey: ctrlKey ?? false,
isAgentRunning: ctx.isAgentRunning,
onQueue: ctx.onQueue,
});
if (action === null) return;
if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return;
event.preventDefault();
if (metaKey || ctrlKey) {
if (action === "alternate-send") {
ctx.handleAlternateSendAction();
return;
}
@@ -1112,6 +1132,7 @@ interface ResolvedMessageInputProps {
voiceAgentId: string | undefined;
isAgentRunning: boolean;
defaultSendBehavior: "interrupt" | "steer" | "queue";
sendShortcut: SendShortcut;
onQueue: ((payload: MessagePayload) => void) | undefined;
onSubmitLoadingPress: (() => void) | undefined;
onKeyPressCallback: ((event: ComposerKeyPressEvent) => boolean) | undefined;
@@ -1159,6 +1180,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
voiceAgentId: props.voiceAgentId,
isAgentRunning: props.isAgentRunning ?? false,
defaultSendBehavior: props.defaultSendBehavior,
sendShortcut: props.sendShortcut,
onQueue: props.onQueue,
onSubmitLoadingPress: props.onSubmitLoadingPress,
onKeyPressCallback: props.onKeyPress,
@@ -1236,6 +1258,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
voiceAgentId,
isAgentRunning,
defaultSendBehavior,
sendShortcut,
onQueue,
onSubmitLoadingPress,
onKeyPressCallback,
@@ -1755,6 +1778,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
selectionRef.current,
),
submitOnEnter: shouldSubmitOnEnter,
sendShortcut,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
@@ -1766,6 +1792,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
shouldHandleWebKeyPress,
onKeyPressCallback,
shouldSubmitOnEnter,
sendShortcut,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
@@ -1980,7 +2009,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
submitLabel={submitLabel}
submitButtonTestID={submitButtonTestID}
buttonIconSize={buttonIconSize}
sendKeys={DEFAULT_SEND_KEYS}
sendKeys={resolveSendKeys(sendShortcut)}
sendTooltipLabel={sendTooltipLabel}
/>
),
@@ -2001,6 +2030,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
submitLabel,
submitButtonTestID,
buttonIconSize,
sendShortcut,
sendTooltipLabel,
],
);
@@ -2,10 +2,12 @@ import { describe, expect, it, vi } from "vitest";
import {
applyDictationTranscript,
computeCanStartDictation,
type MessageInputEnterContext,
resolveActiveSendBehavior,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
resolveGlyphRailOffset,
resolveMessageInputEnterAction,
resolveRetainedSelection,
resolveAlternateSendAction,
runAlternateSendAction,
@@ -320,6 +322,212 @@ describe("composer send behavior", () => {
});
});
describe("resolveMessageInputEnterAction", () => {
function enterContext(
overrides: Partial<MessageInputEnterContext> = {},
): MessageInputEnterContext {
return {
submitOnEnter: true,
sendShortcut: "enter",
shiftKey: false,
metaKey: false,
ctrlKey: false,
isAgentRunning: false,
onQueue: () => undefined,
...overrides,
};
}
it("returns null for plain, Shift, and modifier Enter when submit on enter is disabled", () => {
expect(resolveMessageInputEnterAction(enterContext({ submitOnEnter: false }))).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, shiftKey: true })),
).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, metaKey: true })),
).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ submitOnEnter: false, ctrlKey: true })),
).toBeNull();
});
it("still returns null when modifier Enter sends and submit on enter is disabled", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", submitOnEnter: false, metaKey: true }),
),
).toBeNull();
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", submitOnEnter: false }),
),
).toBeNull();
});
it("sends on plain Enter when Enter sends", () => {
expect(resolveMessageInputEnterAction(enterContext())).toBe("default-send");
});
it("returns null for Shift+Enter when Enter sends", () => {
expect(resolveMessageInputEnterAction(enterContext({ shiftKey: true }))).toBeNull();
});
it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter so Shift keeps its newline", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ shiftKey: true, metaKey: true, isAgentRunning: true }),
),
).toBeNull();
expect(
resolveMessageInputEnterAction(
enterContext({ shiftKey: true, ctrlKey: true, isAgentRunning: true }),
),
).toBeNull();
});
it("sends on Cmd+Enter and Ctrl+Enter while idle even with a queue handler", () => {
expect(resolveMessageInputEnterAction(enterContext({ metaKey: true }))).toBe("default-send");
expect(resolveMessageInputEnterAction(enterContext({ ctrlKey: true }))).toBe("default-send");
});
it("queues on Cmd+Enter and Ctrl+Enter while the agent is running with a queue handler", () => {
expect(
resolveMessageInputEnterAction(enterContext({ metaKey: true, isAgentRunning: true })),
).toBe("alternate-send");
expect(
resolveMessageInputEnterAction(enterContext({ ctrlKey: true, isAgentRunning: true })),
).toBe("alternate-send");
});
it("sends on Cmd+Enter and Ctrl+Enter while running when no queue handler exists", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ metaKey: true, isAgentRunning: true, onQueue: undefined }),
),
).toBe("default-send");
expect(
resolveMessageInputEnterAction(
enterContext({ ctrlKey: true, isAgentRunning: true, onQueue: undefined }),
),
).toBe("default-send");
});
it("returns null for plain and Shift+Enter when modifier Enter sends", () => {
expect(resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter" }))).toBeNull();
expect(
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", shiftKey: true })),
).toBeNull();
});
it("returns null for Shift+Cmd+Enter and Shift+Ctrl+Enter when modifier Enter sends", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", shiftKey: true, metaKey: true }),
),
).toBeNull();
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", shiftKey: true, ctrlKey: true }),
),
).toBeNull();
});
it("sends on Cmd+Enter and Ctrl+Enter while idle when modifier Enter sends", () => {
expect(
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", metaKey: true })),
).toBe("default-send");
expect(
resolveMessageInputEnterAction(enterContext({ sendShortcut: "mod-enter", ctrlKey: true })),
).toBe("default-send");
});
it("uses the default action on modifier Enter while running when modifier Enter sends", () => {
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }),
),
).toBe("default-send");
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", ctrlKey: true, isAgentRunning: true }),
),
).toBe("default-send");
});
describe("composed with the send actions", () => {
function actions() {
const calls: string[] = [];
return {
calls,
handleSendMessage: () => calls.push("send"),
handleQueueMessage: () => calls.push("queue"),
onQueue: () => undefined,
};
}
function resolveCommandEnterToSendModifierEnter() {
return resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: true }),
);
}
it("queues through the default send action when queue behavior is selected and the agent is running", () => {
const sendAction = actions();
expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send");
runDefaultSendAction({
defaultSendBehavior: "queue",
isAgentRunning: true,
onQueue: sendAction.onQueue,
handleSendMessage: sendAction.handleSendMessage,
handleQueueMessage: sendAction.handleQueueMessage,
});
expect(sendAction.calls).toEqual(["queue"]);
});
it("sends through the default send action when queue behavior is selected and the agent is idle", () => {
const sendAction = actions();
expect(
resolveMessageInputEnterAction(
enterContext({ sendShortcut: "mod-enter", metaKey: true, isAgentRunning: false }),
),
).toBe("default-send");
runDefaultSendAction({
defaultSendBehavior: "queue",
isAgentRunning: false,
onQueue: sendAction.onQueue,
handleSendMessage: sendAction.handleSendMessage,
handleQueueMessage: sendAction.handleQueueMessage,
});
expect(sendAction.calls).toEqual(["send"]);
});
it.each([{ defaultSendBehavior: "steer" }, { defaultSendBehavior: "interrupt" }] as const)(
"sends through the default send action when $defaultSendBehavior behavior is selected and the agent is running",
({ defaultSendBehavior }) => {
const sendAction = actions();
expect(resolveCommandEnterToSendModifierEnter()).toBe("default-send");
runDefaultSendAction({
defaultSendBehavior,
isAgentRunning: true,
onQueue: sendAction.onQueue,
handleSendMessage: sendAction.handleSendMessage,
handleQueueMessage: sendAction.handleQueueMessage,
});
expect(sendAction.calls).toEqual(["send"]);
},
);
});
});
describe("stopRealtimeVoice", () => {
it("keeps voice mode active when the running agent refuses cancellation", async () => {
const cancellationError = new Error("active run cancellation was not acknowledged");
+24
View File
@@ -1,3 +1,4 @@
import type { SendShortcut } from "@/hooks/use-settings";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import type { MessagePayload, TextReplacement } from "@/composer/types";
@@ -151,6 +152,29 @@ export function runAlternateSendAction(ctx: SendActionContext): void {
runDefaultSendAction(ctx);
}
export type MessageInputEnterAction = "alternate-send" | "default-send";
export interface MessageInputEnterContext {
submitOnEnter: boolean;
sendShortcut: SendShortcut;
shiftKey: boolean;
metaKey: boolean;
ctrlKey: boolean;
isAgentRunning: boolean;
onQueue: ((payload: MessagePayload) => void) | undefined;
}
export function resolveMessageInputEnterAction(
ctx: MessageInputEnterContext,
): MessageInputEnterAction | null {
if (!ctx.submitOnEnter) return null;
if (ctx.shiftKey) return null;
const hasModifier = ctx.metaKey || ctx.ctrlKey;
if (ctx.sendShortcut === "mod-enter") return hasModifier ? "default-send" : null;
if (hasModifier && ctx.isAgentRunning && ctx.onQueue) return "alternate-send";
return "default-send";
}
export function runMessageInputKeyboardAction(
action: MessageInputKeyboardActionKind,
actions: MessageInputKeyboardActions,
@@ -78,6 +78,7 @@ describe("ensureAgentIsInitialized", () => {
cursor: { epoch: "epoch-1", seq: 42 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
},
},
]);
@@ -105,6 +106,7 @@ describe("ensureAgentIsInitialized", () => {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
},
},
]);
@@ -148,6 +150,7 @@ describe("ensureAgentIsInitialized", () => {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
},
},
]);
@@ -192,6 +195,7 @@ describe("ensureAgentIsInitialized", () => {
cursor: { epoch: "epoch-1", seq: 12 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
},
},
]);
@@ -283,6 +287,7 @@ describe("refreshAgent", () => {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
},
},
]);
@@ -152,6 +152,7 @@ describe("loadOlderAgentHistory", () => {
cursor: { epoch: "epoch-1", seq: 10 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
},
},
]);
@@ -49,6 +49,7 @@ import {
type KeyValueStorage,
type ReleaseChannel,
type SendBehavior,
type SendShortcut,
type ServiceUrlBehavior,
type Settings,
type SidebarWorkspaceTrailing,
@@ -93,6 +94,7 @@ export type {
KeyValueStorage,
ReleaseChannel,
SendBehavior,
SendShortcut,
ServiceUrlBehavior,
Settings,
SettingsDeps,
@@ -227,6 +227,39 @@ describe("loadAppSettingsFromStorage", () => {
expect(result.chatOutlineEnabled).toBe(false);
});
it("uses Enter to send by default", async () => {
const deps = makeDeps();
const result = await loadAppSettingsFromStorage(deps);
expect(result.sendShortcut).toBe("enter");
expect(DEFAULT_CLIENT_SETTINGS.sendShortcut).toBe("enter");
});
it("loads the modifier Enter send preference", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "mod-enter" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.sendShortcut).toBe("mod-enter");
});
it("falls back to Enter when the stored send shortcut is invalid", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ sendShortcut: "yes" }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.sendShortcut).toBe("enter");
});
it("defaults sidebar navigation items to an empty preference list", async () => {
const deps = makeDeps();
@@ -33,6 +33,8 @@ import { migrateAppSettings } from "./migrations";
export { APP_SETTINGS_KEY } from "./keys";
export const APP_SETTINGS_QUERY_KEY = ["app-settings"];
export type SendShortcut = "enter" | "mod-enter";
export type SendBehavior = ActiveTurnBehavior | "queue";
export type ReleaseChannel = "stable" | "beta";
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
@@ -104,6 +106,7 @@ export interface AppSettings {
chatDisplay: ChatDisplay;
chatOutlineEnabled: boolean;
vimKeybindings: boolean;
sendShortcut: SendShortcut;
filePanelAssociations: Record<string, string>;
/** Desktop-only preferences for implicit opens into the ordinary side pane. */
openInSidePane: OpenInSidePanePreferences;
@@ -160,6 +163,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
chatDisplay: "collapseTurns",
chatOutlineEnabled: true,
vimKeybindings: false,
sendShortcut: "enter",
filePanelAssociations: {},
openInSidePane: DEFAULT_OPEN_IN_SIDE_PANE_PREFERENCES,
pullRequestOpenLocation: "explorer",
@@ -264,6 +268,7 @@ const StoredAppSettingsSchema = z
compactToolCalls: z.boolean().optional().catch(undefined),
chatOutlineEnabled: z.boolean().catch(true),
vimKeybindings: z.boolean().catch(false),
sendShortcut: z.enum(["enter", "mod-enter"]).catch("enter"),
filePanelAssociations: z.record(z.string(), z.string()).catch({}),
openInSidePane: z
.object({
+18 -4
View File
@@ -2018,12 +2018,13 @@ export const ar: TranslationResources = {
output: "الإخراج",
},
turnSummary: {
messages: {
one: "كتب {{count}} رسالة",
other: "كتب {{count}} رسائل",
},
details: "عرض تفاصيل الجولة",
sentBy: "أرسلها {{name}}",
showMore: "عرض المزيد",
showLess: "عرض أقل",
},
toolCallGroup: {
thought: "فكّر",
editedFiles: {
one: "حرّر {{count}} ملفًا",
other: "حرّر {{count}} ملفات",
@@ -2369,12 +2370,25 @@ export const ar: TranslationResources = {
"عند تشغيل الوكيل، يوجّه Enter الجولة النشطة. يضيف Command/Ctrl+Enter إلى قائمة الانتظار.",
queue: "عند تشغيل الوكيل، يضيف Enter إلى قائمة الانتظار. Command/Ctrl+Enter يرسل.",
},
commandEnterDescriptions: {
interrupt: "عند تشغيل الوكيل، يوقف {{modifier}}+Enter التشغيل. يُدرج Enter سطرًا جديدًا.",
steer: "عند تشغيل الوكيل، يوجّه {{modifier}}+Enter الجولة النشطة. يُدرج Enter سطرًا جديدًا.",
queue:
"عند تشغيل الوكيل، يضيف {{modifier}}+Enter إلى قائمة الانتظار. يُدرج Enter سطرًا جديدًا.",
},
options: {
interrupt: "مقاطعة",
steer: "توجيه",
queue: "طابور",
},
},
sendShortcut: {
label: "اختصار الإرسال",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "بسأل",
+19 -4
View File
@@ -2043,12 +2043,13 @@ export const en = {
output: "Output",
},
turnSummary: {
messages: {
one: "wrote {{count}} message",
other: "wrote {{count}} messages",
},
details: "View turn details",
sentBy: "Sent by {{name}}",
showMore: "Show more",
showLess: "Show less",
},
toolCallGroup: {
thought: "Thought",
editedFiles: {
one: "edited {{count}} file",
other: "edited {{count}} files",
@@ -2457,12 +2458,26 @@ export const en = {
"When the agent is running, Enter steers the active turn. Command/Ctrl+Enter queues.",
queue: "When the agent is running, Enter queues. Command/Ctrl+Enter submits.",
},
commandEnterDescriptions: {
interrupt:
"When the agent is running, {{modifier}}+Enter interrupts. Enter inserts a new line.",
steer:
"When the agent is running, {{modifier}}+Enter steers the active turn. Enter inserts a new line.",
queue: "When the agent is running, {{modifier}}+Enter queues. Enter inserts a new line.",
},
options: {
interrupt: "Interrupt",
steer: "Steer",
queue: "Queue",
},
},
sendShortcut: {
label: "Send shortcut",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Ask",
+20 -4
View File
@@ -2066,12 +2066,13 @@ export const es: TranslationResources = {
output: "Producción",
},
turnSummary: {
messages: {
one: "escribió {{count}} mensaje",
other: "escribió {{count}} mensajes",
},
details: "Ver detalles del turno",
sentBy: "Enviado por {{name}}",
showMore: "Mostrar más",
showLess: "Mostrar menos",
},
toolCallGroup: {
thought: "Razonó",
editedFiles: {
one: "editó {{count}} archivo",
other: "editó {{count}} archivos",
@@ -2384,12 +2385,27 @@ export const es: TranslationResources = {
queue:
"Cuando el agente se está ejecutando, Enter pone en cola. Command/Ctrl+Enter envía.",
},
commandEnterDescriptions: {
interrupt:
"Cuando el agente se está ejecutando, {{modifier}}+Enter interrumpe. Enter inserta una línea nueva.",
steer:
"Cuando el agente se está ejecutando, {{modifier}}+Enter dirige el turno activo. Enter inserta una línea nueva.",
queue:
"Cuando el agente se está ejecutando, {{modifier}}+Enter pone en cola. Enter inserta una línea nueva.",
},
options: {
interrupt: "Interrumpir",
steer: "Dirigir",
queue: "Cola",
},
},
sendShortcut: {
label: "Atajo de envío",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Preguntar",
+20 -4
View File
@@ -2065,12 +2065,13 @@ export const fr: TranslationResources = {
output: "Sortie",
},
turnSummary: {
messages: {
one: "{{count}} message écrit",
other: "{{count}} messages écrits",
},
details: "Voir les détails du tour",
sentBy: "Envoyé par {{name}}",
showMore: "Afficher plus",
showLess: "Afficher moins",
},
toolCallGroup: {
thought: "Réfléchi",
editedFiles: {
one: "{{count}} fichier modifié",
other: "{{count}} fichiers modifiés",
@@ -2383,12 +2384,27 @@ export const fr: TranslationResources = {
queue:
"Quand l’agent est en cours d’exécution, Entrée met en file d’attente. Cmd/Ctrl+Entrée envoie.",
},
commandEnterDescriptions: {
interrupt:
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée interrompt. Entrée insère une nouvelle ligne.",
steer:
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée oriente le tour actif. Entrée insère une nouvelle ligne.",
queue:
"Lorsque l'agent est en cours d'exécution, {{modifier}}+Entrée met en file d'attente. Entrée insère une nouvelle ligne.",
},
options: {
interrupt: "Interrompre",
steer: "Réorienter",
queue: "File d’attente",
},
},
sendShortcut: {
label: "Raccourci d’envoi",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Demander",
+18 -4
View File
@@ -2038,12 +2038,13 @@ export const ja: TranslationResources = {
output: "出力",
},
turnSummary: {
messages: {
one: "{{count}}件のメッセージを送信",
other: "{{count}}件のメッセージを送信",
},
details: "ターンの詳細を表示",
sentBy: "{{name}} から送信",
showMore: "もっと見る",
showLess: "折りたたむ",
},
toolCallGroup: {
thought: "思考",
editedFiles: {
one: "{{count}}個のファイルを編集",
other: "{{count}}個のファイルを編集",
@@ -2353,12 +2354,25 @@ export const ja: TranslationResources = {
"エージェント実行中、Enterで現在のターンに指示を送ります。Command/Ctrl+Enterでキューに追加。",
queue: "エージェント実行中、Enterでキューに追加します。Command/Ctrl+Enterで送信。",
},
commandEnterDescriptions: {
interrupt: "エージェント実行中、{{modifier}}+Enterで中断します。Enterで改行します。",
steer:
"エージェント実行中、{{modifier}}+Enterで現在のターンに指示を送ります。Enterで改行します。",
queue: "エージェント実行中、{{modifier}}+Enterでキューに追加します。Enterで改行します。",
},
options: {
interrupt: "中断",
steer: "指示",
queue: "キュー",
},
},
sendShortcut: {
label: "送信ショートカット",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "確認する",
+20 -4
View File
@@ -2028,12 +2028,13 @@ export const ko: TranslationResources = {
output: "출력",
},
turnSummary: {
messages: {
one: "{{count}} 메시지를 작성했습니다.",
other: "{{count}} 메시지를 작성했습니다.",
},
details: "턴 세부 정보 보기",
sentBy: "{{name}}이(가) 보냄",
showMore: "더 보기",
showLess: "접기",
},
toolCallGroup: {
thought: "생각함",
editedFiles: {
one: "{{count}} 파일을 편집했습니다.",
other: "{{count}} 파일을 편집했습니다.",
@@ -2344,12 +2345,27 @@ export const ko: TranslationResources = {
queue:
"에이전트가 실행 중일 때 Enter는 대기열에 추가합니다. Command/Ctrl+Enter는 제출합니다.",
},
commandEnterDescriptions: {
interrupt:
"에이전트가 실행 중일 때 {{modifier}}+Enter는 중단합니다. Enter는 줄바꿈합니다.",
steer:
"에이전트가 실행 중일 때 {{modifier}}+Enter는 현재 턴에 지시합니다. Enter는 줄바꿈합니다.",
queue:
"에이전트가 실행 중일 때 {{modifier}}+Enter는 대기열에 추가합니다. Enter는 줄바꿈합니다.",
},
options: {
interrupt: "중단",
steer: "지시",
queue: "대기열",
},
},
sendShortcut: {
label: "전송 단축키",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "물어보기",
+20 -4
View File
@@ -2051,12 +2051,13 @@ export const ptBR: TranslationResources = {
output: "Saída",
},
turnSummary: {
messages: {
one: "escreveu {{count}} mensagem",
other: "escreveu {{count}} mensagens",
},
details: "Ver detalhes do turno",
sentBy: "Enviado por {{name}}",
showMore: "Mostrar mais",
showLess: "Mostrar menos",
},
toolCallGroup: {
thought: "Pensou",
editedFiles: {
one: "editou {{count}} arquivo",
other: "editou {{count}} arquivos",
@@ -2367,12 +2368,27 @@ export const ptBR: TranslationResources = {
"Quando o agente está em execução, Enter orienta o turno ativo. Command/Ctrl+Enter enfileira.",
queue: "Quando o agente está em execução, Enter enfileira. Command/Ctrl+Enter envia.",
},
commandEnterDescriptions: {
interrupt:
"Quando o agente está em execução, {{modifier}}+Enter interrompe. Enter insere uma nova linha.",
steer:
"Quando o agente está em execução, {{modifier}}+Enter orienta o turno ativo. Enter insere uma nova linha.",
queue:
"Quando o agente está em execução, {{modifier}}+Enter enfileira. Enter insere uma nova linha.",
},
options: {
interrupt: "Interromper",
steer: "Orientar",
queue: "Fila",
},
},
sendShortcut: {
label: "Atalho de envio",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Perguntar",
+20 -4
View File
@@ -2051,12 +2051,13 @@ export const ru: TranslationResources = {
output: "Выходные данные",
},
turnSummary: {
messages: {
one: "написано {{count}} сообщение",
other: "написаны сообщения ({{count}})",
},
details: "Показать подробности хода",
sentBy: "Отправлено агентом {{name}}",
showMore: "Показать больше",
showLess: "Свернуть",
},
toolCallGroup: {
thought: "Обдумал",
editedFiles: {
one: "изменён {{count}} файл",
other: "изменены файлы ({{count}})",
@@ -2370,12 +2371,27 @@ export const ru: TranslationResources = {
queue:
"Когда агент работает, Enter ставит сообщение в очередь, а Command/Ctrl+Enter отправляет его.",
},
commandEnterDescriptions: {
interrupt:
"Когда агент работает, {{modifier}}+Enter прерывает его работу, а Enter вставляет новую строку.",
steer:
"Когда агент работает, {{modifier}}+Enter дополняет текущий ответ новым указанием, а Enter вставляет новую строку.",
queue:
"Когда агент работает, {{modifier}}+Enter ставит сообщение в очередь, а Enter вставляет новую строку.",
},
options: {
interrupt: "Прерывать",
steer: "Направить",
queue: "Поставить в очередь",
},
},
sendShortcut: {
label: "Сочетание клавиш для отправки",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "Спрашивать",
+17 -4
View File
@@ -1996,12 +1996,13 @@ export const zhCN: TranslationResources = {
output: "输出",
},
turnSummary: {
messages: {
one: "写了 {{count}} 条消息",
other: "写了 {{count}} 条消息",
},
details: "查看轮次详情",
sentBy: "由 {{name}} 发送",
showMore: "展开",
showLess: "收起",
},
toolCallGroup: {
thought: "已思考",
editedFiles: {
one: "编辑了 {{count}} 个文件",
other: "编辑了 {{count}} 个文件",
@@ -2308,12 +2309,24 @@ export const zhCN: TranslationResources = {
steer: "Agent 运行时,Enter 会引导当前回合。Command/Ctrl+Enter 会排队。",
queue: "Agent 运行时,Enter 会排队。Command/Ctrl+Enter 会提交。",
},
commandEnterDescriptions: {
interrupt: "Agent 运行时,{{modifier}}+Enter 会中断。Enter 会插入换行。",
steer: "Agent 运行时,{{modifier}}+Enter 会引导当前回合。Enter 会插入换行。",
queue: "Agent 运行时,{{modifier}}+Enter 会排队。Enter 会插入换行。",
},
options: {
interrupt: "中断",
steer: "引导",
queue: "排队",
},
},
sendShortcut: {
label: "发送快捷键",
options: {
enter: "Enter",
"mod-enter": "{{modifier}}+Enter",
},
},
serviceUrls: {
options: {
ask: "询问",
@@ -306,6 +306,21 @@ async function expectLegacyWorkspaceRefresh({
}
describe("ReplicaCache", () => {
it("persists the complete newest turn beyond the cache item target", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
const items = Array.from({ length: 60 }, (_, index) => ({
...timelineItem(`item-${index}`),
id: `item-${index}`,
turnId: "long-run",
}));
writer.commitTimeline(SERVER_ID, "agent-1", { ...timeline(), items });
await writer.flush();
const restored = await createCache(storage).readTimeline(SERVER_ID, "agent-1");
expect(restored?.items.map((item) => item.id)).toEqual(items.map((item) => item.id));
expect(restored?.range).toBeNull();
});
it("preserves workspace sorting timestamps across cache reopen", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
@@ -1,3 +1,4 @@
import { findMountedWindowStart } from "@/agent-stream/history-window";
import { z } from "zod";
import {
AgentStatusSchema,
@@ -766,7 +767,14 @@ function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null {
const canonicalItems = timeline.items.filter(
(item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item),
);
const items = canonicalItems.map(serializeTimelineItem).filter((item) => item !== null);
const start = findMountedWindowStart({
items: canonicalItems,
minMountedCount: MAX_TIMELINE_ITEMS,
});
const items = canonicalItems
.slice(start)
.map(serializeTimelineItem)
.filter((item) => item !== null);
const range = timeline.range;
const canPersistCoverage =
range !== null &&
@@ -783,7 +791,7 @@ function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null {
canonicalItems.some((item) => item.timelineCursor?.seq === range.endSeq);
return {
agentId: timeline.agentId,
items: items.slice(-MAX_TIMELINE_ITEMS),
items,
range: canPersistCoverage
? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq }
: null,
@@ -1,13 +1,17 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { SettingsCard, SettingsSection, SettingsSelect } from "@/components/settings";
import { useAppSettings, type SendBehavior } from "@/hooks/use-settings";
import { useAppSettings, type SendShortcut, type SendBehavior } from "@/hooks/use-settings";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { getShortcutOs } from "@/utils/shortcut-platform";
const SEND_BEHAVIORS: readonly SendBehavior[] = ["interrupt", "steer", "queue"];
export function SendingSection() {
const { t } = useTranslation();
const { settings, updateSettings } = useAppSettings();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const modifier = getShortcutOs() === "mac" ? "⌘" : "Ctrl";
const options = useMemo(
() =>
SEND_BEHAVIORS.map((value) => ({
@@ -20,12 +24,37 @@ export function SendingSection() {
(sendBehavior: SendBehavior) => void updateSettings({ sendBehavior }),
[updateSettings],
);
const shortcutOptions = useMemo(
() =>
(["enter", "mod-enter"] as const).map((value) => ({
value,
label: t(`settings.general.sendShortcut.options.${value}`, { modifier }),
})),
[t, modifier],
);
const changeSendShortcut = useCallback(
(sendShortcut: SendShortcut) => void updateSettings({ sendShortcut }),
[updateSettings],
);
return (
<SettingsSection title={t("settings.general.sending")}>
<SettingsCard>
{shortcutsAvailable ? (
<SettingsSelect
label={t("settings.general.sendShortcut.label")}
value={settings.sendShortcut}
options={shortcutOptions}
onValueChange={changeSendShortcut}
/>
) : null}
<SettingsSelect
label={t("settings.general.defaultSend.label")}
hint={t(`settings.general.defaultSend.descriptions.${settings.sendBehavior}`)}
hint={t(
shortcutsAvailable && settings.sendShortcut === "mod-enter"
? `settings.general.defaultSend.commandEnterDescriptions.${settings.sendBehavior}`
: `settings.general.defaultSend.descriptions.${settings.sendBehavior}`,
{ modifier },
)}
value={settings.sendBehavior}
options={options}
onValueChange={change}
@@ -1,3 +1,4 @@
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { useHostFeature } from "@/runtime/host-features";
import {
AssistantFileLinkResolverProvider,
@@ -2179,8 +2180,7 @@ function WorkspaceScreenContent({
const sessionState = useSessionStore.getState().sessions[normalizedServerId];
const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, {
direction: "tail",
projection: "projected",
...planTimelineTailFetch(),
...(currentCursor
? { cursor: { epoch: currentCursor.epoch, seq: currentCursor.endSeq } }
: {}),
@@ -19,6 +19,66 @@ import {
type TimelineCursor,
} from "./session-stream-reducers";
it("keeps streamed reasoning together while the initial history is loading", () => {
let head: StreamItem[] = [];
const chunks = ["Checking a long oper", "ation before", " the re", "sult arrives."];
for (const [index, text] of chunks.entries()) {
const result = processAgentStreamEvent({
event: {
type: "timeline",
provider: "codex",
turnId: "turn-18",
item: { type: "reasoning", text },
},
seq: index + 1,
epoch: "epoch-1",
currentTail: [],
currentHead: head,
currentCursor: undefined,
timestamp: new Date(1000 + index),
hasAuthoritativeBaseline: false,
});
head = result.head;
expect(head).toHaveLength(1);
expect(head[0]).toMatchObject({
kind: "thought",
text: chunks.slice(0, index + 1).join(""),
turnId: "turn-18",
});
}
});
it.each([false, true])(
"keeps adjacent reasoning from different turns separate (history loaded: %s)",
(hasAuthoritativeBaseline) => {
let head: StreamItem[] = [];
let tail: StreamItem[] = [];
for (const [index, turnId] of ["turn-1", "turn-2"].entries()) {
const result = processAgentStreamEvent({
event: {
type: "timeline",
provider: "codex",
turnId,
item: { type: "reasoning", text: turnId },
},
seq: index + 1,
epoch: "epoch-1",
currentTail: tail,
currentHead: head,
currentCursor: undefined,
timestamp: new Date(1000),
hasAuthoritativeBaseline,
});
head = result.head;
tail = result.tail;
}
expect([...tail, ...head]).toMatchObject([
{ kind: "thought", text: "turn-1", turnId: "turn-1" },
{ kind: "thought", text: "turn-2", turnId: "turn-2" },
]);
},
);
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
@@ -1615,19 +1615,17 @@ export function processAgentStreamEvent(
unmatchedUserMessageInsert: "head",
});
} else {
const overlay = applyStreamEvent({
tail: currentHead,
head: [],
event,
timestamp,
// Before history arrives, the overlay is one ordered stream. Reducing it
// directly keeps consecutive reasoning chunks in the same row.
const overlay = reduceStreamUpdate(currentHead, event, timestamp, {
source: "live",
timelineCursor,
});
streamResult = {
tail: currentTail,
head: [...overlay.tail, ...overlay.head],
head: overlay,
changedTail: false,
changedHead: overlay.changedTail || overlay.changedHead,
changedHead: overlay !== currentHead,
};
}
} else {
@@ -15,7 +15,7 @@ describe("requestTimelineReplacement", () => {
await expect(requestTimelineReplacement(client, "agent-1")).rejects.toThrow("fetch complete");
expect(requests).toEqual([
["agent-1", { direction: "tail", projection: "projected", limit: 40 }],
["agent-1", { direction: "tail", projection: "projected", wholeTurns: true, limit: 40 }],
]);
});
});
@@ -17,6 +17,7 @@ describe("timeline sync planning", () => {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
});
});
@@ -26,6 +27,7 @@ describe("timeline sync planning", () => {
cursor: { epoch: "epoch-1", seq: 49 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
});
});
@@ -37,6 +39,7 @@ describe("timeline sync planning", () => {
cursor: { epoch: "epoch-1", seq: 25 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
});
});
@@ -49,6 +52,7 @@ describe("timeline sync planning", () => {
},
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
mergeWindow: true,
});
});
@@ -9,6 +9,7 @@ export interface ProjectedTimelineTailFetchPlan {
direction: "tail";
limit: number;
projection: "projected";
wholeTurns: true;
}
export interface ProjectedTimelineAfterFetchPlan {
@@ -16,6 +17,7 @@ export interface ProjectedTimelineAfterFetchPlan {
cursor: TimelineSyncCursor;
limit: number;
projection: "projected";
wholeTurns: true;
}
export interface ProjectedTimelineBeforeFetchPlan {
@@ -23,6 +25,7 @@ export interface ProjectedTimelineBeforeFetchPlan {
cursor: TimelineSyncCursor;
limit: number;
projection: "projected";
wholeTurns: true;
}
export type ProjectedTimelineFetchPlan =
@@ -40,6 +43,7 @@ export function planTimelineCatchUpAfter(cursor: TimelineSyncCursor) {
cursor,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
} as const;
}
@@ -48,6 +52,7 @@ export function planTimelineTailFetch() {
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
} as const;
}
@@ -65,6 +70,7 @@ export function planTimelineOlderFetch(cursor: TimelineSyncCursor) {
cursor,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
} as const;
}
@@ -75,6 +81,7 @@ export function planTimelinePromptJump(target: TimelineSyncCursor) {
cursor: { epoch: target.epoch, seq: target.seq + newerRows + 1 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "projected",
wholeTurns: true,
mergeWindow: true,
} as const;
}
+44 -16
View File
@@ -51,7 +51,7 @@ describe("deriveStreamTurnTiming", () => {
});
assert.equal(timing.runningStartedAt, secondUserAt);
assert.equal(timing.byAssistantId.has("a2"), false);
assert.equal(timing.byItemId.has("a2"), false);
});
it("derives completed turn timing from user and assistant item timestamps", () => {
@@ -69,7 +69,7 @@ describe("deriveStreamTurnTiming", () => {
head: [],
});
assert.deepEqual(timing.byAssistantId.get("a1"), {
assert.deepEqual(timing.byItemId.get("a1"), {
completedAt: assistantAt,
durationMs: 7000,
});
@@ -95,8 +95,8 @@ describe("deriveStreamTurnTiming", () => {
completedAt: lastAssistantAt,
durationMs: 7000,
};
assert.deepEqual(timing.byAssistantId.get("a1"), expected);
assert.deepEqual(timing.byAssistantId.get("a2"), expected);
assert.deepEqual(timing.byItemId.get("a1"), expected);
assert.deepEqual(timing.byItemId.get("a2"), expected);
});
it("preserves the completion timestamp when a canonical turn has no visible prompt", () => {
@@ -120,7 +120,7 @@ describe("deriveStreamTurnTiming", () => {
head: [],
});
assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), {
assert.deepEqual(timing.byItemId.get("hidden-prompt-a2"), {
completedAt: hiddenPromptTurnAt,
durationMs: null,
});
@@ -143,12 +143,12 @@ describe("deriveStreamTurnTiming", () => {
const first = derive("one", "2026-05-15T00:01:04.000Z");
const second = derive("one two", "2026-05-15T00:01:05.000Z");
assert.equal(second.byAssistantId, first.byAssistantId);
assert.deepEqual(first.byAssistantId.get("a1"), {
assert.equal(second.byItemId, first.byItemId);
assert.deepEqual(first.byItemId.get("a1"), {
completedAt: tail[1]?.timestamp,
durationMs: 7000,
});
assert.equal(first.byAssistantId.has("a2"), false);
assert.equal(first.byItemId.has("a2"), false);
});
it("keeps the timing map identity when the head closed the tail's open turn", () => {
@@ -168,8 +168,8 @@ describe("deriveStreamTurnTiming", () => {
const first = derive("one", "2026-05-15T00:01:04.000Z");
const second = derive("one two", "2026-05-15T00:01:05.000Z");
assert.equal(second.byAssistantId, first.byAssistantId);
assert.deepEqual(first.byAssistantId.get("a1"), {
assert.equal(second.byItemId, first.byItemId);
assert.deepEqual(first.byItemId.get("a1"), {
completedAt: lastTailAt,
durationMs: 7000,
});
@@ -193,9 +193,9 @@ describe("deriveStreamTurnTiming", () => {
head,
});
assert.notEqual(completed.byAssistantId, active.byAssistantId);
assert.equal(active.byAssistantId.has("a1"), false);
assert.deepEqual(completed.byAssistantId.get("a1"), { completedAt, durationMs: 9000 });
assert.notEqual(completed.byItemId, active.byItemId);
assert.equal(active.byItemId.has("a1"), false);
assert.deepEqual(completed.byItemId.get("a1"), { completedAt, durationMs: 9000 });
});
it("derives fresh timing when the tail itself changes", () => {
@@ -211,11 +211,39 @@ describe("deriveStreamTurnTiming", () => {
const first = derive(firstTail);
const second = derive(secondTail);
assert.notEqual(second.byAssistantId, first.byAssistantId);
assert.equal(first.byAssistantId.has("a2"), false);
assert.deepEqual(second.byAssistantId.get("a2"), {
assert.notEqual(second.byItemId, first.byItemId);
assert.equal(first.byItemId.has("a2"), false);
assert.deepEqual(second.byItemId.get("a2"), {
completedAt: secondTail[3]?.timestamp,
durationMs: 3000,
});
});
});
it("times a heartbeat that ends with tools and no assistant text", () => {
const trigger: StreamItem = {
kind: "tool_call",
id: "trigger",
timestamp: new Date("2026-05-15T00:00:00Z"),
turnId: "heartbeat",
payload: {
source: "agent",
data: {
provider: "claude",
callId: "trigger",
name: "heartbeat",
status: "completed",
error: null,
detail: { type: "plain_text", text: "Check progress" },
},
},
};
const tool: StreamItem = { ...trigger, id: "last", timestamp: new Date("2026-05-15T00:00:05Z") };
const timing = deriveStreamTurnTiming({
tail: [trigger, tool],
head: [],
isTurnActive: false,
activeTurnStartedAt: null,
});
assert.equal(timing.byItemId.get("last")?.durationMs, 5000);
});
+17 -13
View File
@@ -1,5 +1,5 @@
import type { StreamItem } from "@/types/stream";
import { startsNewTurn } from "@/agent-stream/turn-membership";
import { startsNewTurn, isTurnTrigger } from "@/agent-stream/turn-membership";
export interface TurnTiming {
completedAt: Date;
@@ -7,7 +7,7 @@ export interface TurnTiming {
}
export interface StreamTurnTiming {
byAssistantId: Map<string, TurnTiming>;
byItemId: Map<string, TurnTiming>;
runningStartedAt: Date | null;
}
@@ -20,7 +20,7 @@ interface OpenTurn {
interface HeadTurnTiming {
additions: Map<string, TurnTiming>;
byAssistantId: Map<string, TurnTiming>;
byItemId: Map<string, TurnTiming>;
}
interface TailTurnTiming {
@@ -37,14 +37,18 @@ const EMPTY_OPEN_TURN: OpenTurn = {
};
function closeTurn(turn: OpenTurn, into: Map<string, TurnTiming>): void {
if (!turn.lastItemAt || turn.assistantIds.length === 0) {
if (!turn.lastItemAt) {
return;
}
const timing: TurnTiming = {
completedAt: turn.lastItemAt,
durationMs: turn.userAt ? Math.max(0, turn.lastItemAt.getTime() - turn.userAt.getTime()) : null,
};
for (const id of turn.assistantIds) {
let footerIds = turn.assistantIds;
if (footerIds.length === 0 && turn.previousItem?.kind === "tool_call") {
footerIds = [turn.previousItem.id];
}
for (const id of footerIds) {
into.set(id, timing);
}
}
@@ -61,7 +65,7 @@ function walkTurns(
for (const item of items) {
if (startsNewTurn(item, previousItem)) {
closeTurn({ userAt, lastItemAt, assistantIds, previousItem }, into);
userAt = item.kind === "user_message" ? item.timestamp : null;
userAt = isTurnTrigger(item) ? item.timestamp : null;
lastItemAt = null;
assistantIds = [];
}
@@ -108,7 +112,7 @@ function getTailTurnTiming(tail: StreamItem[]): TailTurnTiming {
return timing;
}
function resolveByAssistantId(
function resolveByItemId(
tail: TailTurnTiming,
additions: Map<string, TurnTiming>,
): Map<string, TurnTiming> {
@@ -116,14 +120,14 @@ function resolveByAssistantId(
return tail.completed;
}
if (tail.lastHead && areTimingsEqual(tail.lastHead.additions, additions)) {
return tail.lastHead.byAssistantId;
return tail.lastHead.byItemId;
}
const byAssistantId = new Map(tail.completed);
const byItemId = new Map(tail.completed);
for (const [id, timing] of additions) {
byAssistantId.set(id, timing);
byItemId.set(id, timing);
}
tail.lastHead = { additions, byAssistantId };
return byAssistantId;
tail.lastHead = { additions, byItemId };
return byItemId;
}
export function deriveStreamTurnTiming(params: {
@@ -139,7 +143,7 @@ export function deriveStreamTurnTiming(params: {
closeTurn(openTurn, additions);
}
return {
byAssistantId: resolveByAssistantId(tail, additions),
byItemId: resolveByItemId(tail, additions),
runningStartedAt: params.isTurnActive ? params.activeTurnStartedAt : null,
};
}
@@ -189,6 +189,7 @@ class TimelineWorld {
direction: "tail",
limit: 40,
projection: "projected",
wholeTurns: true,
});
} finally {
this.forcedTimelineTailReplacements.delete(agentId);
@@ -341,7 +342,12 @@ test("uses a tail fetch when an agent becomes visible", async () => {
membership.succeed();
const fetch = await world.nextFetch("agent-a");
expect(fetch.request).toEqual({ direction: "tail", limit: 40, projection: "projected" });
expect(fetch.request).toEqual({
direction: "tail",
limit: 40,
projection: "projected",
wholeTurns: true,
});
fetch.respond({ hasNewer: false });
});
@@ -366,6 +372,7 @@ test("loads the cache before choosing the authoritative network request", async
cursor: { epoch: "cached-epoch", seq: 17 },
limit: 40,
projection: "projected",
wholeTurns: true,
});
fetch.respond({ hasNewer: false });
});
@@ -384,6 +391,7 @@ test("catches up after the restored cursor when an agent becomes visible", async
cursor: { epoch: "epoch-agent-a", seq: 42 },
limit: 40,
projection: "projected",
wholeTurns: true,
});
fetch.respond({ hasNewer: false });
});
@@ -401,7 +409,12 @@ test("falls back to the latest tail when a restored cursor has more than one cat
probe.respond({ hasNewer: true, seq: 82 });
const fallback = await world.nextFetch("agent-a");
expect(fallback.request).toEqual({ direction: "tail", limit: 40, projection: "projected" });
expect(fallback.request).toEqual({
direction: "tail",
limit: 40,
projection: "projected",
wholeTurns: true,
});
expect(
world.applyTimelineResponse({
requestId: "fallback-tail",
@@ -444,6 +457,7 @@ test("a gap absorbed by a running tail is recovered after the tail completes", a
cursor: { epoch: "epoch-agent-a", seq: 9 },
limit: 40,
projection: "projected",
wholeTurns: true,
});
recovery.respond({ hasNewer: false });
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
@@ -527,6 +541,7 @@ test("unchanged visible-set publication does not cancel paged catch-up", async (
cursor: { epoch: "epoch-agent-a", seq: 5 },
limit: 40,
projection: "projected",
wholeTurns: true,
});
world.expectNoPendingMembership();
});
@@ -897,12 +912,14 @@ test("gap recovery supersedes completed catch-up and pages through the current t
cursor: { epoch: "epoch-agent-a", seq: 10 },
limit: 40,
projection: "projected",
wholeTurns: true,
},
{
direction: "after",
cursor: { epoch: "epoch-agent-a", seq: 15 },
limit: 40,
projection: "projected",
wholeTurns: true,
},
]);
});
@@ -1,5 +1,6 @@
import { isTurnTrigger, isTurnBoundary } from "@/agent-stream/turn-membership";
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
import type { StreamItem, ToolCallItem } from "@/types/stream";
import type { StreamItem, ToolCallItem, ThoughtItem } from "@/types/stream";
export interface ToolCallDescriptor {
detail: ToolCallDetail;
@@ -9,17 +10,19 @@ export interface ToolCallDescriptor {
metadata?: Record<string, unknown>;
}
export type GroupableItem = ToolCallItem | ThoughtItem;
export interface ToolCallRun {
id: string;
calls: readonly ToolCallItem[];
latest: ToolCallItem;
calls: readonly GroupableItem[];
latest: GroupableItem;
isSealed: boolean;
}
export interface GroupedHistory<TGroup> {
tail: StreamItem[];
groupsByHostId: Map<string, TGroup>;
pendingCalls: readonly ToolCallItem[];
pendingCalls: readonly GroupableItem[];
}
export interface GroupedToolCalls<TGroup> {
@@ -62,15 +65,19 @@ export function describeToolCall(item: ToolCallItem): ToolCallDescriptor {
};
}
export function isGroupableToolCall(item: StreamItem): item is ToolCallItem {
if (item.kind !== "tool_call") {
export function isGroupableToolCall(
item: StreamItem,
autoExpandReasoning = false,
): item is GroupableItem {
if (item.kind === "thought") return !autoExpandReasoning;
if (item.kind !== "tool_call" || isTurnTrigger(item)) {
return false;
}
const descriptor = describeToolCall(item);
return descriptor.detail.type !== "plan" && descriptor.name.trim().toLowerCase() !== "speak";
}
function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallRun {
function createRun(calls: readonly GroupableItem[], isSealed: boolean): ToolCallRun {
const first = calls[0];
const latest = calls.at(-1);
if (!first || !latest) {
@@ -79,20 +86,21 @@ function createRun(calls: readonly ToolCallItem[], isSealed: boolean): ToolCallR
return { id: first.id, calls, latest, isSealed };
}
function createHost(run: ToolCallRun): ToolCallItem {
function createHost(run: ToolCallRun): GroupableItem {
if (run.calls.length === 1) {
return run.latest;
}
return { ...run.latest, id: run.id };
}
function isRunning(call: ToolCallItem): boolean {
function isRunning(call: GroupableItem): boolean {
if (call.kind === "thought") return call.status !== "ready";
const status = describeToolCall(call).status;
return status === "running" || status === "executing";
}
function appendRun<TGroup>(input: {
calls: readonly ToolCallItem[];
calls: readonly GroupableItem[];
isSealed: boolean;
output: StreamItem[];
groups: Map<string, TGroup>;
@@ -110,13 +118,17 @@ function appendRun<TGroup>(input: {
export function prepareGroupedHistory<TGroup>(input: {
tail: StreamItem[];
buildGroup: (run: ToolCallRun) => TGroup;
autoExpandReasoning?: boolean;
}): GroupedHistory<TGroup> {
const output: StreamItem[] = [];
const groups = new Map<string, TGroup>();
let pending: ToolCallItem[] = [];
let pending: GroupableItem[] = [];
for (const item of input.tail) {
if (isGroupableToolCall(item)) {
if (
isGroupableToolCall(item, input.autoExpandReasoning) &&
!isTurnBoundary(pending.at(-1) ?? null, item)
) {
pending.push(item);
continue;
}
@@ -128,7 +140,8 @@ export function prepareGroupedHistory<TGroup>(input: {
buildGroup: input.buildGroup,
});
pending = [];
output.push(item);
if (isGroupableToolCall(item, input.autoExpandReasoning)) pending.push(item);
else output.push(item);
}
appendRun({
@@ -151,6 +164,7 @@ export function groupLiveToolCalls<TGroup>(input: {
head: StreamItem[];
isTurnActive: boolean;
buildGroup: (run: ToolCallRun) => TGroup;
autoExpandReasoning?: boolean;
}): GroupedToolCalls<TGroup> {
const head: StreamItem[] = [];
const liveGroups = new Map<string, TGroup>();
@@ -175,7 +189,8 @@ export function groupLiveToolCalls<TGroup>(input: {
};
for (const item of input.head) {
if (isGroupableToolCall(item)) {
if (isTurnBoundary(pending.at(-1) ?? null, item)) flush(true);
if (isGroupableToolCall(item, input.autoExpandReasoning)) {
if (pending.length === 0) {
hostPlacement = "head";
}
@@ -33,13 +33,20 @@ export function useOverviewSummaryLabel(
[summary.readFileCount, "toolCallGroup.readFiles"],
[summary.searchCount, "toolCallGroup.searches"],
[summary.otherToolCount, "toolCallGroup.otherTools"],
[summary.paseoCallCount, "toolCallGroup.paseoCalls"],
] as const;
for (const [count, key] of entries) {
if (count > 0) {
parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count }));
}
}
for (const [action, count] of Object.entries(summary.paseoActions)) {
parts.push(
count === 1 ? action.toLocaleLowerCase() : `${action.toLocaleLowerCase()} ×${count}`,
);
}
// Reasoning stays in the details; it is not an action in a work summary.
// Keep a label for a group containing only reasoning.
if (parts.length === 0 && summary.thoughtCount > 0) return t("toolCallGroup.thought");
return joinSummaryParts(parts, t("toolCallGroup.and"));
}, [leadingParts, summary, t]);
}
@@ -1,17 +1,18 @@
import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
import type { ToolCallItem } from "@/types/stream";
import { describeToolCall, type ToolCallRun } from "../grouping";
import { getPaseoToolActionLabel } from "@getpaseo/protocol/paseo-tool-presentation/index";
import { isTurnTrigger } from "@/agent-stream/turn-membership";
import { describeToolCall, type GroupableItem, type ToolCallRun } from "../grouping";
const DIRECT_PASEO_TOOL_PREFIX = "paseo_";
const DIRECT_SEARCH_TOOL_SUFFIX_PATTERN = /(?:^|[_.:/])(?:web_search|llm_context)$/;
export interface OverviewSummary {
thoughtCount: number;
editedFileCount: number;
commandCount: number;
readFileCount: number;
searchCount: number;
otherToolCount: number;
paseoCallCount: number;
paseoActions: Record<string, number>;
}
export interface OverviewToolCallGroup {
@@ -21,28 +22,31 @@ export interface OverviewToolCallGroup {
isLoading: boolean;
}
function isPaseoCall(name: string, normalizedName: string): boolean {
return isPaseoToolName(name) || normalizedName.startsWith(DIRECT_PASEO_TOOL_PREFIX);
}
function isSearchCall(name: string): boolean {
return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name);
}
/** Counts tool calls by what they did, for any summary of a stretch of agent work. */
export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSummary {
export function summarizeToolCalls(calls: readonly GroupableItem[]): OverviewSummary {
const editedFiles = new Set<string>();
const readFiles = new Set<string>();
let thoughtCount = 0;
let commandCount = 0;
let searchCount = 0;
let otherToolCount = 0;
let paseoCallCount = 0;
const paseoActions: Record<string, number> = {};
for (const call of calls) {
if (call.kind === "thought") {
thoughtCount += 1;
continue;
}
if (isTurnTrigger(call)) continue;
const descriptor = describeToolCall(call);
const normalizedName = descriptor.name.trim().toLowerCase();
if (isPaseoCall(descriptor.name, normalizedName)) {
paseoCallCount += 1;
const action = getPaseoToolActionLabel(descriptor.name);
if (action) {
paseoActions[action] = (paseoActions[action] ?? 0) + 1;
} else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") {
editedFiles.add(descriptor.detail.filePath);
} else if (descriptor.detail.type === "shell") {
@@ -57,17 +61,19 @@ export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSumm
}
return {
thoughtCount,
editedFileCount: editedFiles.size,
commandCount,
readFileCount: readFiles.size,
searchCount,
otherToolCount,
paseoCallCount,
paseoActions,
};
}
export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup {
const isLoading = run.calls.some((call) => {
if (call.kind === "thought") return call.status !== "ready";
const status = describeToolCall(call).status;
return status === "running" || status === "executing";
});
@@ -255,12 +255,13 @@ describe("tool call detail-level projection", () => {
run: expect.any(Object),
isLoading: false,
summary: {
thoughtCount: 0,
editedFileCount: 1,
commandCount: 1,
readFileCount: 2,
searchCount: 0,
otherToolCount: 0,
paseoCallCount: 0,
paseoActions: {},
},
});
});
@@ -328,7 +329,7 @@ describe("tool call detail-level projection", () => {
const result = project({ level: "overview", head: calls });
expect(result.groupsByHostId.get("1")).toMatchObject({
summary: { otherToolCount: 2, paseoCallCount: 2 },
summary: { otherToolCount: 2, paseoActions: { "Listed agents": 1, "List worktrees": 1 } },
});
});
@@ -346,7 +347,11 @@ describe("tool call detail-level projection", () => {
const result = project({ level: "overview", head: calls });
expect(result.groupsByHostId.get("1")).toMatchObject({
summary: { searchCount: 3, otherToolCount: 0, paseoCallCount: 3 },
summary: {
searchCount: 3,
otherToolCount: 0,
paseoActions: { "Listed providers": 1, "List worktrees": 2 },
},
});
});
@@ -480,3 +485,56 @@ describe("tool call detail-level projection", () => {
expect(result.groupsByHostId.size).toBe(1);
});
});
it("keeps a heartbeat trigger visible outside grouped work", () => {
const trigger = toolCall(
"1",
{ type: "plain_text", text: "Check progress" },
{ name: "heartbeat" },
);
const work = toolCall("2", { type: "shell", command: "pwd" });
const result = project({ level: "overview", tail: [trigger, work] });
expect(result.tail.map((item) => item.id)).toEqual(["1", "2"]);
expect(result.groupsByHostId.has("1")).toBe(false);
});
it("does not merge tools from different canonical turns", () => {
const first = toolCall("1", { type: "shell", command: "pwd" });
first.turnId = "first";
const second = toolCall("2", { type: "shell", command: "ls" });
second.turnId = "second";
const result = project({ level: "overview", tail: [first, second] });
expect(result.tail.map((item) => item.id)).toEqual(["1", "2"]);
});
it.each([false, true])(
"handles streaming reasoning across retained history (Expand thinking %s)",
(autoExpandReasoning) => {
const call = { ...toolCall("1", { type: "shell", command: "echo ok" }), turnId: "run" };
const thought: StreamItem = {
kind: "thought",
id: "reasoning",
text: "Checking",
status: "loading",
timestamp: new Date(),
turnId: "run",
};
const tail = [call];
const result = project({
level: "overview",
tail,
head: [thought],
isTurnActive: true,
preparedHistory: prepareToolCallHistory("overview", tail, autoExpandReasoning),
});
expect(result.head).toEqual(autoExpandReasoning ? [thought] : []);
expect(result.groupsByHostId.get(call.id)?.run.calls).toEqual(
autoExpandReasoning ? [call] : [call, thought],
);
expect(result.groupsByHostId.get(call.id)?.summary).toMatchObject({
commandCount: 1,
otherToolCount: 0,
thoughtCount: autoExpandReasoning ? 0 : 1,
});
},
);
@@ -13,6 +13,7 @@ export type ToolCallDetailGroup = OverviewToolCallGroup;
export interface PreparedToolCallHistory {
mode: "overview";
autoExpandReasoning: boolean;
grouped: GroupedHistory<ToolCallDetailGroup>;
}
@@ -41,15 +42,18 @@ function visibleToolCallItems(items: StreamItem[]): StreamItem[] {
export function prepareToolCallHistory(
level: ToolCallDetailLevel,
tail: StreamItem[],
autoExpandReasoning = false,
): PreparedToolCallHistory | null {
if (level === "detailed") {
return null;
}
return {
mode: "overview",
autoExpandReasoning,
grouped: prepareGroupedHistory({
tail: visibleToolCallItems(tail),
buildGroup: buildOverviewGroup,
autoExpandReasoning,
}),
};
}
@@ -74,6 +78,7 @@ export function projectToolCallDetailLevel(input: {
}
return groupLiveToolCalls({
history: input.preparedHistory.grouped,
autoExpandReasoning: input.preparedHistory.autoExpandReasoning,
head: visibleToolCallItems(input.head),
isTurnActive: input.isTurnActive,
buildGroup: buildOverviewGroup,
+76
View File
@@ -661,6 +661,82 @@ describe("stream reducer canonical tool calls", () => {
expect(new Set(tools.map((tool) => tool.id)).size).toBe(2);
});
it.each(["live", "canonical"] as const)(
"keeps current tool identity during %s updates when an older turn reports late",
(source) => {
let state: StreamItem[] = [];
const events = [
{ callId: "background", turnId: "codex-turn-17", status: "running" as const },
{ callId: "exec-current", turnId: "codex-turn-18", status: "running" as const },
{ callId: "background", turnId: "codex-turn-17", status: "completed" as const },
{ callId: "exec-current", turnId: "codex-turn-18", status: "completed" as const },
];
for (const [index, item] of events.entries()) {
state = reduceStreamUpdate(
state,
canonicalToolTimeline({
provider: "codex",
name: "exec_command",
...item,
}),
new Date(index * 1000),
{ source },
);
}
expect(
state.filter(isAgentToolCallItem).map((item) => ({
id: item.id,
turnId: item.turnId,
status: item.payload.data.status,
})),
).toEqual([
{
id: "agent_tool_turn:codex-turn-17/background",
turnId: "codex-turn-17",
status: "completed",
},
{
id: "agent_tool_turn:codex-turn-18/exec-current",
turnId: "codex-turn-18",
status: "completed",
},
]);
},
);
it("does not move reasoning into an older turn when a late tool update finalizes it", () => {
const background = canonicalToolTimeline({
provider: "codex",
name: "exec_command",
callId: "background",
turnId: "old",
status: "running",
});
let state = reduceStreamUpdate([], background, new Date(0));
state = reduceStreamUpdate(
state,
{
type: "timeline",
provider: "codex",
turnId: "current",
item: { type: "reasoning", text: "Working on the current request" },
},
new Date(1),
);
state = reduceStreamUpdate(
state,
canonicalToolTimeline({
provider: "codex",
name: "exec_command",
callId: "background",
turnId: "old",
status: "completed",
}),
new Date(2),
);
expect(state.at(-1)).toMatchObject({ kind: "thought", turnId: "current", status: "ready" });
});
it("merges lifecycle updates for the same call occurrence", () => {
const callId = "tool-updated-within-turn";
const turnId = "autonomous-turn-1";
+42 -48
View File
@@ -91,6 +91,8 @@ export type StreamItem =
export type UserMessageImageAttachment = AttachmentMetadata;
export interface UserMessageItem {
/** Attribution added by presentation for received agent messages. */
sender?: AgentMessage["sender"];
kind: "user_message";
id: string;
clientMessageId?: string;
@@ -986,6 +988,7 @@ function appendThought(
text: string,
timestamp: Date,
timelineCursor?: TimelinePosition,
turnId?: string,
): StreamItem[] {
const { chunk, hasContent } = normalizeChunk(text);
if (!chunk) {
@@ -993,7 +996,7 @@ function appendThought(
}
const last = state[state.length - 1];
if (last && last.kind === "thought") {
if (last && last.kind === "thought" && last.turnId === turnId) {
const updated: ThoughtItem = {
...last,
...(timelineCursor ? { timelineCursor } : {}),
@@ -1511,36 +1514,30 @@ function reduceTimelineEvent(
const item = event.item;
switch (item.type) {
case "user_message":
return finalizeActiveThoughts(
appendUserMessage(
state,
item.text,
timestamp,
source,
item.messageId,
item.clientMessageId,
timelineCursor,
event.turnId,
),
return appendUserMessage(
state,
item.text,
timestamp,
source,
item.messageId,
item.clientMessageId,
timelineCursor,
event.turnId,
);
case "assistant_message":
return finalizeActiveThoughts(
appendAssistantMessage(
state,
item.text,
timestamp,
source,
item.messageId,
reservedItemIds,
timelineCursor,
),
return appendAssistantMessage(
state,
item.text,
timestamp,
source,
item.messageId,
reservedItemIds,
timelineCursor,
);
case "reasoning":
return appendThought(state, item.text, timestamp, timelineCursor);
return appendThought(state, item.text, timestamp, timelineCursor, event.turnId);
case "tool_call":
return finalizeActiveThoughts(
reduceTimelineToolCall(state, event, item, timestamp, timelineCursor),
);
return reduceTimelineToolCall(state, event, item, timestamp, timelineCursor);
case "todo": {
const items: TodoEntry[] = (item.items ?? []).map((todo) => ({
text: todo.text,
@@ -1549,9 +1546,7 @@ function reduceTimelineEvent(
status: todo.status,
activeForm: todo.activeForm,
}));
return finalizeActiveThoughts(
appendTodoList(state, event.provider, items, timestamp, timelineCursor),
);
return appendTodoList(state, event.provider, items, timestamp, timelineCursor);
}
case "error": {
const notification: NotificationItem = {
@@ -1563,7 +1558,7 @@ function reduceTimelineEvent(
level: "error",
message: item.message ?? "Unknown error",
};
return finalizeActiveThoughts(appendNotification(state, notification));
return appendNotification(state, notification);
}
case "notification": {
const notification: NotificationItem = {
@@ -1575,16 +1570,12 @@ function reduceTimelineEvent(
level: item.level,
message: item.message,
};
return finalizeActiveThoughts(appendNotification(state, notification));
return appendNotification(state, notification);
}
case "compaction":
return finalizeActiveThoughts(
reduceTimelineCompaction(state, item, timestamp, timelineCursor),
);
return reduceTimelineCompaction(state, item, timestamp, timelineCursor);
case "plugin":
return finalizeActiveThoughts(
appendPluginTimelineItem(state, item, timestamp, timelineCursor),
);
return appendPluginTimelineItem(state, item, timestamp, timelineCursor);
default:
return state;
}
@@ -1601,8 +1592,9 @@ export function reduceStreamUpdate(
): StreamItem[] {
const source = options?.source ?? "live";
switch (event.type) {
case "timeline":
return applyTimelineTurnId(
case "timeline": {
const updated = applyTimelineTurnId(
state,
reduceTimelineEvent(
state,
event,
@@ -1613,6 +1605,8 @@ export function reduceStreamUpdate(
),
event,
);
return event.item.type === "reasoning" ? updated : finalizeActiveThoughts(updated);
}
case "thread_started":
case "turn_started":
case "turn_completed":
@@ -1628,6 +1622,7 @@ export function reduceStreamUpdate(
}
function applyTimelineTurnId(
previous: StreamItem[],
items: StreamItem[],
event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
): StreamItem[] {
@@ -1637,15 +1632,14 @@ function applyTimelineTurnId(
return reconcileCanonicalUserTurnMembership(items, clientMessageId, event.turnId);
}
if (!event.turnId || items.length === 0) return items;
const index = items.length - 1;
const last = items[index];
if (!last || last.turnId === event.turnId) return items;
return [
...items.slice(0, index),
{ ...last, turnId: event.turnId } as StreamItem,
...items.slice(index + 1),
];
if (!event.turnId || items === previous) return items;
// Assign membership only to rows written by this event, before finalizing
// unrelated reasoning rows. Late lifecycle updates can replace an earlier row.
return items.map((item, index) =>
item === previous[index] || item.turnId === event.turnId
? item
: ({ ...item, turnId: event.turnId } as StreamItem),
);
}
function reconcileCanonicalUserTurnMembership(
+71
View File
@@ -1640,6 +1640,77 @@ test("preserves legacy fetchAgent id overload", async () => {
await expect(responsePromise).rejects.toThrow("legacy fetch sentinel");
});
test.each([undefined, false, true])(
"loads history with whole-turn support advertised as %s",
async (wholeTurnTimeline) => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "history-compatibility",
reconnect: { enabled: false },
transportFactory: () => transport.transport,
});
clients.push(client);
const connected = client.connect();
transport.triggerOpen({
features: wholeTurnTimeline === undefined ? {} : { wholeTurnTimeline },
});
await connected;
const requests = [
{ direction: "tail" as const },
{ direction: "before" as const, cursor: { epoch: "epoch-1", seq: 50 } },
{ direction: "after" as const, cursor: { epoch: "epoch-1", seq: 50 } },
{ direction: "before" as const, cursor: { epoch: "epoch-1", seq: 80 }, mergeWindow: true },
];
for (const [index, request] of requests.entries()) {
const requestId = `history-${index}`;
const result = client.fetchAgentTimeline("agent-1", {
...request,
requestId,
wholeTurns: true,
limit: 40,
projection: "projected",
});
// Attach immediately so the old rejection is captured by the regression.
const completed = expect(result).resolves.toMatchObject({ requestId, entries: [] });
expect(parseSentFrame(transport.sent.at(-1))).toEqual({
type: "fetch_agent_timeline_request",
agentId: "agent-1",
requestId,
...request,
limit: 40,
projection: "projected",
...(wholeTurnTimeline === true ? { wholeTurns: true } : {}),
});
transport.triggerMessage(
wrapSessionMessage({
type: "fetch_agent_timeline_response",
payload: {
requestId,
agentId: "agent-1",
agent: null,
direction: request.direction,
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 0, maxSeq: 0, nextSeq: 1 },
startCursor: null,
endCursor: null,
hasOlder: false,
hasNewer: false,
entries: [],
error: null,
},
}),
);
await completed;
}
},
);
test("honors explicit fetchAgentTimeline timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
+8
View File
@@ -686,6 +686,8 @@ export interface FetchAgentOptions {
}
type LegacyFetchAgentOptions = Omit<FetchAgentOptions, "agentId">;
export interface FetchAgentTimelineOptions {
/** Prefer whole-turn pages when supported; older hosts retain item-based paging. */
wholeTurns?: boolean;
direction?: FetchAgentTimelineDirection;
cursor?: FetchAgentTimelineCursor;
limit?: number;
@@ -3548,9 +3550,15 @@ export class DaemonClient {
agentId: string,
options: FetchAgentTimelineOptions = {},
): Promise<FetchAgentTimelinePayload> {
// COMPAT(wholeTurnTimeline): added after v0.13.2 (#8010); remove after 2027-04-27 once the daemon floor supports whole-turn pages.
// Reading history predates this capability and must keep working on older hosts.
const supportsWholeTurns = this.lastServerInfoMessage?.features?.wholeTurnTimeline === true;
const resolvedRequestId = this.createRequestId(options.requestId);
const message = SessionInboundMessageSchema.parse({
type: "fetch_agent_timeline_request",
...(supportsWholeTurns && options.wholeTurns !== undefined
? { wholeTurns: options.wholeTurns }
: {}),
agentId,
requestId: resolvedRequestId,
...(options.direction ? { direction: options.direction } : {}),
+63
View File
@@ -1770,3 +1770,66 @@ test("qualified parents preserve foreign identity without resolving an equal loc
await client.close();
expect(localParent.address).toEqual({ serverId: "srv_sdk_test", agentId: "same-parent" });
});
test("agent reload reopens through the daemon RPC then returns the observed snapshot; refresh remains a read", async () => {
const { client, ws } = await connectClient();
try {
const agent = client.agents.ref("agent_sdk");
const snapshot = createAgent();
const reloaded = agent.reload({ requestId: "reload-sdk" });
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "refresh_agent_request",
agentId: "agent_sdk",
requestId: "reload-sdk",
});
ws.message(
sessionMessage({
type: "status",
payload: {
status: "agent_refreshed",
requestId: "reload-sdk",
agentId: "agent_sdk",
timelineSize: 0,
},
}),
);
await expect
.poll(() => parseSentSessionMessage(ws.sent.at(-1)).type)
.toBe("fetch_agent_request");
const fetched = parseSentSessionMessage(ws.sent.at(-1));
ws.message(
sessionMessage({
type: "fetch_agent_response",
payload: {
requestId: fetched.requestId,
agentId: "agent_sdk",
agent: snapshot,
project: null,
error: null,
},
}),
);
expect(await reloaded).toEqual({ agent: snapshot, project: null });
expect(agent.current()).toEqual(snapshot);
const read = agent.refresh("snapshot-read");
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
type: "fetch_agent_request",
requestId: "snapshot-read",
});
ws.message(
sessionMessage({
type: "fetch_agent_response",
payload: {
requestId: "snapshot-read",
agentId: "agent_sdk",
agent: snapshot,
project: null,
error: null,
},
}),
);
expect(await read).toEqual({ agent: snapshot, project: null });
} finally {
await client.close();
}
});
+11
View File
@@ -369,6 +369,11 @@ export interface PaseoAgentHandle {
readonly timeline: PaseoAgentTimelineHandle;
current(): PaseoAgent | null;
refresh(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
/**
* Reopens the provider session with current configuration, rerunning session-open hooks.
* Interrupts a running turn and returns the refreshed snapshot.
*/
reload(options?: { requestId?: string }): Promise<PaseoAgentRefetchResult | null>;
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise<void>;
/** Sends a prompt and resolves when that turn finishes or needs attention. */
@@ -981,6 +986,12 @@ function createAgentHandleFactory(
current = result?.agent ?? null;
return result;
},
reload: async (options) => {
await daemonClient.refreshAgent(id, options?.requestId);
const result = await daemonClient.fetchAgent({ agentId: id });
current = result?.agent ?? null;
return result;
},
send: async (text, options) => {
await daemonClient.sendAgentMessage(id, text, options);
},
-1
View File
@@ -33,7 +33,6 @@
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/react": "^18.0.25",
"expo-module-scripts": "^5.0.8",
"expo-modules-core": "^2.0.4"
},
"peerDependencies": {
+1 -1
View File
@@ -6,7 +6,7 @@
"module": "esnext",
"moduleResolution": "bundler",
"typeRoots": [
"./node_modules/expo-module-scripts/ts-declarations",
"../../node_modules/expo-module-scripts/ts-declarations",
"./node_modules/@types",
"../../node_modules/@types"
]
@@ -1,4 +1,6 @@
import { describe, it, expect } from "vitest";
import { setImmediate } from "node:timers/promises";
import { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "../budgeted-highlighter.js";
import type { HighlightToken } from "../types.js";
import { highlightCode, highlightCodeInSteps, highlightLine } from "../highlighter.js";
@@ -420,3 +422,55 @@ describe("highlightLine", () => {
expect(tokens).toEqual([{ text: "hello", style: null }]);
});
});
describe("highlightCodeWithBudget", () => {
it("yields to IO while producing byte-identical output below the source budget", async () => {
const code = "const value = Math.max(42, 7);\n".repeat(3_000);
const expected = JSON.stringify(highlightCode(code, "file.ts"));
let ticks = 0;
let longestGap = 0;
let lastTick = performance.now();
const timer = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - lastTick);
lastTick = now;
ticks++;
}, 1);
try {
const actual = await highlightCodeWithBudget(code, "file.ts", {
yieldToEventLoop: setImmediate,
});
await new Promise((resolve) => setTimeout(resolve, 5));
expect(ticks).toBeGreaterThan(1);
expect(longestGap).toBeLessThan(50);
expect(JSON.stringify(actual)).toBe(expected);
} finally {
clearInterval(timer);
}
});
it("budgets UTF-8 bytes before parsing, including multibyte text", async () => {
expect(
await highlightCodeWithBudget("é".repeat(HIGHLIGHT_MAX_SOURCE_BYTES / 2 + 1), "file.ts", {
yieldToEventLoop: setImmediate,
}),
).toBeNull();
expect(
await highlightCodeWithBudget("a".repeat(HIGHLIGHT_MAX_SOURCE_BYTES + 1), "file.ts", {
yieldToEventLoop: setImmediate,
}),
).toBeNull();
});
it("stops a parser that is cancelled between real event-loop turns", async () => {
const controller = new AbortController();
const pending = highlightCodeWithBudget(
"const value = Math.max(42, 7);\n".repeat(3_000),
"file.ts",
{ yieldToEventLoop: setImmediate, signal: controller.signal },
);
await setImmediate();
controller.abort();
expect(await pending).toBeNull();
});
});
@@ -0,0 +1,49 @@
import { highlightCodeInSteps } from "./highlighter.js";
import type { HighlightToken } from "./types.js";
// Full source, rather than patch size, bounds parser and token allocation work.
// Each token consumes a source code unit or an empty line: at most the byte cap + 1 records.
export const HIGHLIGHT_MAX_SOURCE_BYTES = 768 * 1024;
const SLICE_MS = 4;
/**
* Returns null when source exceeds the budget or the request is cancelled.
* The host supplies a real event-loop yield (setImmediate on the daemon).
* Existing synchronous highlighting remains available for its existing consumers.
*/
export async function highlightCodeWithBudget(
code: string,
filename: string,
options: { yieldToEventLoop: () => Promise<void>; signal?: { readonly aborted: boolean } },
): Promise<HighlightToken[][] | null> {
if (options.signal?.aborted || exceedsSourceBudget(code)) return null;
const steps = highlightCodeInSteps(code, filename);
let sliceStarted = Date.now();
try {
for (;;) {
if (options.signal?.aborted) return null;
const step = steps.next();
if (step.done) return step.value;
if (Date.now() - sliceStarted >= SLICE_MS) {
await options.yieldToEventLoop();
sliceStarted = Date.now();
}
}
} finally {
steps.return([]);
}
}
function exceedsSourceBudget(code: string): boolean {
if (code.length > HIGHLIGHT_MAX_SOURCE_BYTES) return true;
let bytes = 0;
for (const char of code) {
const point = char.codePointAt(0)!;
if (point < 0x80) bytes++;
else if (point < 0x800) bytes += 2;
else if (point < 0x10000) bytes += 3;
else bytes += 4;
if (bytes > HIGHLIGHT_MAX_SOURCE_BYTES) return true;
}
return false;
}
+1
View File
@@ -15,3 +15,4 @@ export {
isSyntaxThemeId,
resolveSyntaxColors,
} from "./themes.js";
export { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "./budgeted-highlighter.js";
+61 -1
View File
@@ -92,7 +92,12 @@ function typeErrors(code: string): string[] {
}
describe("plugin SDK import boundaries", () => {
it("publishes process capabilities without their Windows implementation helpers", () => {
expect(Object.keys(server).sort()).toEqual(["execCommand", "spawnProcess", "terminateProcess"]);
expect(Object.keys(server).sort()).toEqual([
"defineAgentTool",
"execCommand",
"spawnProcess",
"terminateProcess",
]);
});
it("classifies every published entry", () => {
@@ -209,3 +214,58 @@ describe("plugin panel SDK boundary", () => {
).toEqual([]);
});
});
describe("plugin agent tools SDK boundary", () => {
it("lets a session hook attach typed tools without exposing their transport", () => {
expect(
typeErrors(`
import { z } from "zod";
import { defineAgentTool, type PluginServerContext, type AgentToolResult, type AgentToolContext, type AgentToolDefinition } from "@getpaseo/plugin/server";
const tool: AgentToolDefinition = defineAgentTool({
name: "create_issue",
description: "Create an issue",
input: z.object({ title: z.string(), count: z.string().transform(Number) }),
async execute(input, { agent, paseo, signal }) {
const count: number = input.count;
// @ts-expect-error input is inferred from the schema
input.missing;
return { text: agent.id + input.title, structuredContent: { count } };
},
});
export default function contribute(server: PluginServerContext) {
server.before("agent.session_open", ({ request }) => {
const title: string | null = request.title;
// @ts-expect-error labels are read-only
request.labels.x = "changed";
if (request.labels.x !== "y") return;
// @ts-expect-error MCP servers use the same typed transport config as saved servers
request.mcpServers.invalid = { type: "http", url: 42 };
return { ...request, tools: [...request.tools, tool], mcpServers: {
...request.mcpServers, company: { type: "http", url: "https://tools.example.com/mcp" },
} };
});
return () => {};
}
`),
).toEqual([]);
});
});
describe("agent session reload SDK boundary", () => {
it("exposes reload separately from snapshot refresh", () => {
expect(
typeErrors(`
import { type PluginServerContext } from "@getpaseo/plugin/server";
import { type PaseoAgentRefetchResult } from "@getpaseo/client";
async function reload(server: PluginServerContext, paseo: import("@getpaseo/client").PaseoApi) {
const agent = paseo.agents.ref("chosen-agent");
const read: PaseoAgentRefetchResult | null = await agent.refresh("read-snapshot");
const reopened: PaseoAgentRefetchResult | null = await agent.reload({ requestId: "reload-session" });
await agent.reload();
// @ts-expect-error reload options can grow without a positional signature change
await agent.reload("reload-session");
}
`),
).toEqual([]);
});
});
+39
View File
@@ -0,0 +1,39 @@
import type { PaseoApi } from "@getpaseo/client";
import type { z } from "zod";
import type { PluginHookAgent } from "./lifecycle.js";
export interface AgentToolContext {
agent: PluginHookAgent;
paseo: PaseoApi;
signal: AbortSignal;
}
export interface AgentToolResult {
text?: string;
structuredContent?: unknown;
isError?: boolean;
}
export interface AgentToolDefinition {
name: string;
title?: string;
description: string;
input: z.ZodType;
execute(input: unknown, context: AgentToolContext): AgentToolResult | Promise<AgentToolResult>;
}
export function defineAgentTool<Input extends z.ZodType>(tool: {
name: string;
title?: string;
description: string;
input: Input;
execute(
input: z.output<Input>,
context: AgentToolContext,
): AgentToolResult | Promise<AgentToolResult>;
}): AgentToolDefinition {
if (!/^[a-z][a-z0-9_]*$/.test(tool.name) || tool.name.length > 64) {
throw new Error(`Invalid agent tool name: ${tool.name}`);
}
return tool;
}
+3
View File
@@ -24,3 +24,6 @@ export type {
} from "./lifecycle.js";
export { spawnProcess, execCommand, terminateProcess } from "./process.js";
export { defineAgentTool } from "./agent-tools.js";
export type { AgentToolDefinition, AgentToolResult, AgentToolContext } from "./agent-tools.js";
+5
View File
@@ -1,3 +1,4 @@
import type { AgentToolDefinition } from "./agent-tools.js";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
@@ -36,7 +37,11 @@ export interface PluginSessionOpenRequest {
cwd: string;
reason: "create" | "resume" | "refresh" | "import";
purpose: "interactive" | "history";
readonly title: string | null;
readonly labels: Readonly<Record<string, string>>;
tools: AgentToolDefinition[];
env: Record<string, string>;
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
}
export type PluginTurnOutcome =
+3
View File
@@ -1910,6 +1910,8 @@ export const AgentTimelineCursorSchema = z.object({
});
export const FetchAgentTimelineRequestMessageSchema = z.object({
// Keep available turns whole for tail/before pages; after catch-up stays incremental.
wholeTurns: z.boolean().optional(),
type: z.literal("fetch_agent_timeline_request"),
agentId: z.string(),
requestId: z.string(),
@@ -3765,6 +3767,7 @@ export const ServerInfoStatusPayloadSchema = z
providerSubagents: z.boolean().optional(),
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire field.
projectedSubagentTimeline: z.boolean().optional(),
wholeTurnTimeline: z.boolean().optional(),
// COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04.
providerSubagentNesting: z.boolean().optional(),
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
@@ -19,6 +19,7 @@ export interface PaseoToolDetailSpec {
export interface PaseoToolEntry {
label: string;
completedLabel: string;
context?: PaseoToolContext;
detail?: PaseoToolDetailSpec;
}
@@ -123,14 +124,24 @@ function baseName(value: unknown): string | undefined {
return value.split(/[\\/]/).findLast((segment) => segment.length > 0);
}
function browser(label: string, context?: PaseoToolContext): PaseoToolEntry {
return { label, ...(context ? { context } : {}), detail: { inputOrder: BROWSER_FIELDS } };
function browser(
label: string,
completedLabel: string,
context?: PaseoToolContext,
): PaseoToolEntry {
return {
label,
completedLabel,
...(context ? { context } : {}),
detail: { inputOrder: BROWSER_FIELDS },
};
}
export const PASEO_TOOL_CATALOG = {
// Agents
create_agent: {
label: "Create agent",
completedLabel: "Created agent",
context: field("title", "initialPrompt"),
detail: {
promptField: "initialPrompt",
@@ -140,6 +151,7 @@ export const PASEO_TOOL_CATALOG = {
},
send_agent_prompt: {
label: "Send message",
completedLabel: "Sent message",
context: field("prompt"),
detail: {
promptField: "prompt",
@@ -149,42 +161,55 @@ export const PASEO_TOOL_CATALOG = {
},
get_agent_status: {
label: "Check agent",
completedLabel: "Checked agent",
context: targetAgent(),
detail: { inputOrder: ["agentId"] },
},
get_agent_activity: {
label: "Read agent activity",
completedLabel: "Read agent activity",
context: targetAgent(),
detail: { inputOrder: ["agentId", "limit"] },
},
list_agents: {
label: "List agents",
completedLabel: "Listed agents",
detail: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] },
},
update_agent: {
label: "Update agent",
completedLabel: "Updated agent",
context: fieldOrTargetAgent("name"),
detail: { inputOrder: AGENT_FIELDS },
},
set_agent_mode: {
label: "Set agent mode",
completedLabel: "Set agent mode",
context: field("modeId"),
detail: { inputOrder: ["agentId", "modeId"] },
},
cancel_agent: {
label: "Cancel agent",
completedLabel: "Canceled agent",
context: targetAgent(),
detail: { inputOrder: ["agentId"] },
},
archive_agent: {
label: "Archive agent",
completedLabel: "Archived agent",
context: targetAgent(),
detail: { inputOrder: ["agentId"] },
},
kill_agent: { label: "Kill agent", context: targetAgent(), detail: { inputOrder: ["agentId"] } },
list_pending_permissions: { label: "List permissions" },
kill_agent: {
label: "Kill agent",
completedLabel: "Killed agent",
context: targetAgent(),
detail: { inputOrder: ["agentId"] },
},
list_pending_permissions: { label: "List permissions", completedLabel: "Listed permissions" },
respond_to_permission: {
label: "Respond to permission",
completedLabel: "Responded to permission",
context: targetAgent(),
detail: { inputOrder: ["agentId", "requestId", "response"] },
},
@@ -192,32 +217,41 @@ export const PASEO_TOOL_CATALOG = {
// Workspaces and scripts
create_workspace: {
label: "Create workspace",
completedLabel: "Created workspace",
context: workspaceContext(),
detail: { inputOrder: WORKSPACE_FIELDS, outputFields: ["workspaceId", "projectId"] },
},
list_hosts: { label: "List hosts" },
list_workspaces: { label: "List workspaces" },
list_hosts: { label: "List hosts", completedLabel: "Listed hosts" },
list_workspaces: { label: "List workspaces", completedLabel: "Listed workspaces" },
rename_workspace: {
label: "Rename workspace",
completedLabel: "Renamed workspace",
context: field("title"),
detail: { inputOrder: ["title", "workspaceId"] },
},
archive_workspace: {
label: "Archive workspace",
completedLabel: "Archived workspace",
context: field("workspaceId"),
detail: {
inputOrder: ["workspaceId"],
outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"],
},
},
list_workspace_scripts: { label: "List scripts", detail: { inputOrder: ["workspaceId"] } },
list_workspace_scripts: {
label: "List scripts",
completedLabel: "Listed scripts",
detail: { inputOrder: ["workspaceId"] },
},
start_workspace_script: {
label: "Start script",
completedLabel: "Started script",
context: field("scriptName"),
detail: { inputOrder: ["workspaceId", "scriptName"] },
},
stop_workspace_script: {
label: "Stop script",
completedLabel: "Stopped script",
context: field("scriptName"),
detail: { inputOrder: ["workspaceId", "scriptName"] },
},
@@ -225,22 +259,30 @@ export const PASEO_TOOL_CATALOG = {
// Terminals
create_terminal: {
label: "Create terminal",
completedLabel: "Created terminal",
context: field("name", "command"),
detail: { inputOrder: ["cwd", "workspaceId", "name", "command"] },
},
list_terminals: { label: "List terminals", detail: { inputOrder: ["cwd", "all"] } },
list_terminals: {
label: "List terminals",
completedLabel: "Listed terminals",
detail: { inputOrder: ["cwd", "all"] },
},
capture_terminal: {
label: "Read terminal",
completedLabel: "Read terminal",
context: field("terminalId"),
detail: { inputOrder: ["terminalId", "lines"] },
},
send_terminal_keys: {
label: "Send keys",
completedLabel: "Sent keys",
context: field("keys"),
detail: { inputOrder: ["terminalId", "keys", "literal"] },
},
kill_terminal: {
label: "Kill terminal",
completedLabel: "Killed terminal",
context: field("terminalId"),
detail: { inputOrder: ["terminalId"] },
},
@@ -248,6 +290,7 @@ export const PASEO_TOOL_CATALOG = {
// Schedules and heartbeats
create_schedule: {
label: "Create schedule",
completedLabel: "Created schedule",
context: field("name", "cron"),
detail: {
promptField: "prompt",
@@ -257,6 +300,7 @@ export const PASEO_TOOL_CATALOG = {
},
create_heartbeat: {
label: "Create heartbeat",
completedLabel: "Created heartbeat",
context: field("name", "cron"),
detail: {
promptField: "prompt",
@@ -266,79 +310,93 @@ export const PASEO_TOOL_CATALOG = {
},
update_schedule: {
label: "Update schedule",
completedLabel: "Updated schedule",
context: field("name", "id"),
detail: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS },
},
list_schedules: { label: "List schedules" },
list_schedules: { label: "List schedules", completedLabel: "Listed schedules" },
inspect_schedule: {
label: "Inspect schedule",
completedLabel: "Inspected schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
schedule_logs: {
label: "Read schedule logs",
completedLabel: "Read schedule logs",
context: field("id"),
detail: { inputOrder: ["id"] },
},
run_schedule_once: {
label: "Run schedule",
completedLabel: "Ran schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
pause_schedule: {
label: "Pause schedule",
completedLabel: "Paused schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
pause_schedule: { label: "Pause schedule", context: field("id"), detail: { inputOrder: ["id"] } },
resume_schedule: {
label: "Resume schedule",
completedLabel: "Resumed schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
delete_schedule: {
label: "Delete schedule",
completedLabel: "Deleted schedule",
context: field("id"),
detail: { inputOrder: ["id"] },
},
delete_heartbeat: {
label: "Delete heartbeat",
completedLabel: "Deleted heartbeat",
context: field("id"),
detail: { inputOrder: ["id"] },
},
// Providers
list_providers: { label: "List providers" },
list_providers: { label: "List providers", completedLabel: "Listed providers" },
list_models: {
label: "List models",
completedLabel: "Listed models",
context: field("provider"),
detail: { inputOrder: ["provider"] },
},
list_profiles: { label: "List profiles" },
list_profiles: { label: "List profiles", completedLabel: "Listed profiles" },
inspect_provider: {
label: "Inspect provider",
completedLabel: "Inspected provider",
context: field("provider"),
detail: { inputOrder: ["provider", "cwd", "settings"] },
},
// Browser
browser_list_tabs: browser("List browser tabs"),
browser_new_tab: browser("Open browser tab", field("url")),
browser_close_tab: browser("Close browser tab", field("browserId")),
browser_navigate: browser("Navigate", field("url")),
browser_back: browser("Go back"),
browser_forward: browser("Go forward"),
browser_reload: browser("Reload page"),
browser_snapshot: browser("Snapshot page"),
browser_screenshot: browser("Take screenshot"),
browser_click: browser("Click element", field("ref")),
browser_hover: browser("Hover element", field("ref")),
browser_drag: browser("Drag element", field("sourceRef")),
browser_fill: browser("Fill field", field("ref")),
browser_select: browser("Select option", field("value", "ref")),
browser_type: browser("Type text", field("text")),
browser_keypress: browser("Press key", field("key")),
browser_upload: browser("Upload files", field("filePaths")),
browser_wait: browser("Wait for page", field("text", "url")),
browser_scroll: browser("Scroll page"),
browser_resize: browser("Resize browser", viewportContext()),
browser_logs: browser("Read browser logs"),
browser_evaluate: browser("Run JavaScript", field("function")),
browser_list_tabs: browser("List browser tabs", "Listed browser tabs"),
browser_new_tab: browser("Open browser tab", "Opened browser tab", field("url")),
browser_close_tab: browser("Close browser tab", "Closed browser tab", field("browserId")),
browser_navigate: browser("Navigate", "Navigated", field("url")),
browser_back: browser("Go back", "Went back"),
browser_forward: browser("Go forward", "Went forward"),
browser_reload: browser("Reload page", "Reloaded page"),
browser_snapshot: browser("Snapshot page", "Snapshotted page"),
browser_screenshot: browser("Take screenshot", "Took screenshot"),
browser_click: browser("Click element", "Clicked element", field("ref")),
browser_hover: browser("Hover element", "Hovered element", field("ref")),
browser_drag: browser("Drag element", "Dragged element", field("sourceRef")),
browser_fill: browser("Fill field", "Filled field", field("ref")),
browser_select: browser("Select option", "Selected option", field("value", "ref")),
browser_type: browser("Type text", "Typed text", field("text")),
browser_keypress: browser("Press key", "Pressed key", field("key")),
browser_upload: browser("Upload files", "Uploaded files", field("filePaths")),
browser_wait: browser("Wait for page", "Waited for page", field("text", "url")),
browser_scroll: browser("Scroll page", "Scrolled page"),
browser_resize: browser("Resize browser", "Resized browser", viewportContext()),
browser_logs: browser("Read browser logs", "Read browser logs"),
browser_evaluate: browser("Run JavaScript", "Ran JavaScript", field("function")),
} satisfies Record<string, PaseoToolEntry>;
export type PaseoToolName = keyof typeof PASEO_TOOL_CATALOG;
@@ -28,6 +28,8 @@ export const PASEO_TOOL_NAMES = Object.keys(PASEO_TOOL_CATALOG) as readonly Pase
/** Returns null for tools outside the Paseo namespace. */
export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDisplay | null {
// Canonical details own internal lifecycle rows such as worktree setup.
if (input.detail.type !== "unknown") return null;
const leafName = paseoLeafName(input.toolName);
if (!leafName) return null;
const entry = getPaseoToolEntry(leafName);
@@ -39,6 +41,13 @@ export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDi
return { displayName: entry.label, ...(summary ? { summary } : {}) };
}
/** A semantic action label for summaries, sharing the tool presentation catalog. */
export function getPaseoToolActionLabel(toolName: string): string | null {
const leafName = paseoLeafName(toolName);
if (!leafName) return null;
return getPaseoToolEntry(leafName)?.completedLabel ?? humanizeLeafName(leafName);
}
/** The agent a Paseo call acts on, so callers can resolve its title. */
export function getPaseoToolTargetAgentId(
toolName: string,
@@ -49,7 +58,11 @@ export function getPaseoToolTargetAgentId(
}
function paseoLeafName(toolName: string): string | null {
return isPaseoToolName(toolName) ? getPaseoToolLeafName(toolName) : null;
if (isPaseoToolName(toolName)) return getPaseoToolLeafName(toolName);
const normalized = toolName.trim().toLowerCase();
return normalized.startsWith("paseo_") && !normalized.startsWith("paseo__")
? normalized.slice("paseo_".length)
: null;
}
function readToolInput(detail: ToolCallDetail): Readonly<Record<string, unknown>> {
+24
View File
@@ -0,0 +1,24 @@
/** Canonical run identity wins over trigger-shaped rows steered into that run. */
export function continuesTimelineTurn(
previousTurnId: string | undefined,
nextTurnId: string | undefined,
nextIsTrigger: boolean,
): boolean {
if (previousTurnId !== undefined && nextTurnId !== undefined) {
return previousTurnId === nextTurnId;
}
// Missing IDs cannot recover historical batching or steering. Each visible
// trigger deterministically starts a turn in both paging and presentation.
return !nextIsTrigger;
}
export function isTimelineTurnTrigger(item: {
type: string;
name?: string;
agentMessage?: unknown;
}): boolean {
return (
item.type === "user_message" ||
(item.type === "tool_call" && (item.agentMessage !== undefined || item.name === "heartbeat"))
);
}
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -880,6 +880,66 @@ test("retries provider history hydration after a stream failure", async () => {
}
});
test("notification batches share the run they start or join", async () => {
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
const workdir = mkdtempSync(join(tmpdir(), "agent-notification-turns-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
logger,
});
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const batch = (prefix: string) =>
["one", "two"]
.map((id) =>
formatAgentMessage({
id: `${prefix}-${id}`,
source: { kind: "agent-notification", agentId: "worker", event: "finished" },
text: "Worker finished",
}),
)
.join("\n\n");
const consume = async (prompt: string, clientMessageId: string) => {
for await (const _event of manager.streamAgent(agent.id, prompt, { clientMessageId })) {
}
};
try {
const first = consume("human", "human");
await manager.waitForAgentRunStart(agent.id);
await expect(
manager.steerAgentRun(agent.id, batch("joined"), { clientMessageId: "joined-one" }),
).resolves.toEqual({ status: "accepted" });
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-1" });
await first;
const second = consume(batch("idle"), "idle-one");
await manager.waitForAgentRunStart(agent.id);
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-2" });
await second;
expect(
manager.fetchTimeline(agent.id, { limit: 0 }).rows.map((row) => ({
turnId: row.turnId,
id: row.item.type === "tool_call" ? row.item.callId : row.item.type,
})),
).toEqual([
{ turnId: "active-turn-1", id: "user_message" },
{ turnId: "active-turn-1", id: "paseo-agent-message:joined-one" },
{ turnId: "active-turn-1", id: "paseo-agent-message:joined-two" },
{ turnId: "active-turn-2", id: "paseo-agent-message:idle-one" },
{ turnId: "active-turn-2", id: "paseo-agent-message:idle-two" },
]);
} finally {
await manager.closeAgent(agent.id);
rmSync(workdir, { recursive: true, force: true });
}
});
test("unavailable steer interrupts once and starts one replacement turn", async () => {
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
session.steerResult = "unavailable";
@@ -11341,7 +11401,7 @@ test("listImportableSessions searches every provider result before global rankin
]);
});
test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => {
test("system-injected prompts appear as trigger tools in the live timeline", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -11370,13 +11430,16 @@ test("user_message events wrapping a paseo-system envelope are not added to the
await manager.runAgent(snapshot.id, { text: "do something" });
const timeline = manager.getTimeline(snapshot.id);
expect(timeline).toContainEqual(
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
);
const userMessages = timeline.filter((item) => item.type === "user_message");
expect(userMessages).toHaveLength(1);
expect(userMessages[0].text).toBe("plain user message");
});
test("user_message events wrapping a paseo-system envelope are not restored during history replay", async () => {
test("system-injected prompts appear as trigger tools during history replay", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-history-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -11411,6 +11474,9 @@ test("user_message events wrapping a paseo-system envelope are not restored duri
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true });
const timeline = manager.getTimeline(snapshot.id);
expect(timeline).toContainEqual(
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
);
const userMessages = timeline.filter((item) => item.type === "user_message");
expect(userMessages).toHaveLength(1);

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