From 8021d2b6c9122add8052565e19ec71fad9ed2007 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 1 Aug 2026 14:13:47 +0200 Subject: [PATCH] Keep agent timelines stable from submission through resume (#2718) * Reapply: Stop completed turns from appearing stuck (#2484) * Reapply: Keep older chat history and image previews stable (#2490) * Lock timeline layout stability with user-driven specs Two reds reproduce the reported jank: - new agent, first prompt: the row paints at top=100 then jumps to 144 through authoritative hydration, because the draft tab and the agent panel each render their own AgentStreamView for the same logical row. - long loaded timeline, submit: scrollHeight collapses 10793 -> 9111 mid-turn, because splitOrderedTail reclassifies measured rows into the virtualized segment where the virtualizer falls back to estimateStreamItemHeight. Also restores centered timeline rows: the pagination anchor wrapper introduced in #2490 was a plain block div, which broke alignSelf: center. * Measure the turn footer blink from when the footer appears Bounding the window at the prompt row conflated two things: the footer arriving a frame after the row, and the footer leaving the layout and coming back. Bounded from the footer's first frame, the blink still reproduces, so the footer-ownership race is real rather than a sampling artifact. * Rename the shadowed prompt row binding in the frame recorder * fix(desktop): allow concurrent worktree dev instances Desktop terminals can inherit another instance's profile path and debugging flags. Scope the profile to the current dev root and select an available debugging port so instances do not share global runtime state. * fix(desktop): expose development providers in dev builds Desktop-managed daemons inherited no Paseo runtime mode, so dev builds constructed the production provider registry. Mark unpackaged internal launches as development while continuing to force packaged launches to production. * fix(timeline): stabilize optimistic turn layout Keep initial history loading dormant until user intent and preserve active-turn layout across delayed transport updates. Add painted-frame coverage for optimistic timeline transitions. * fix(timeline): preserve optimistic prompts through hydration Canonical replacement relied on a transport submission record that can settle before a provider echoes the user row. Keep presentation continuity owned by the unreconciled row while destructive history replacement remains governed by active transactions. * fix(timeline): make submitted prompts canonical Submission settlement previously depended on status updates that could arrive after the optimistic row. Persist every accepted prompt in the canonical timeline and keep the footer active until RPC and canonical acknowledgement converge. * fix(timeline): stop older history pages repeating entries Backward pagination counted source sequence rows, so page boundaries inside a merged projected item could return that item repeatedly. Count projected entries and advance from their display anchors instead, with server and browser regressions covering the contract. * fix(timeline): resume agents from the latest history Use a bounded tail probe on resume so long background runs appear atomically instead of replaying every missed page. Preserve unchanged views, reconcile local/live rows, and keep skipped history reachable through backward pagination. * fix(timeline): reconcile turn state after missed updates Turn completion events can be missed while an agent timeline is unsubscribed, leaving footer activity stale even after the directory reports idle. Reconcile the single-owned turn lifecycle from authoritative resume snapshots while preserving newer live evidence. * test(timeline): cover running state across refresh Protect the hydration boundary where directory state must restore consistent tab, composer, and footer activity. Expose the running indicator semantically so the regression checks user-visible state instead of spinner implementation details. * fix(timeline): keep history loading geometry stable Reserve a fixed history-start gutter regardless of pagination state so showing or hiding the loader cannot shift timeline content. * fix(timeline): keep turn activity coherent Derive footer, tab, and composer activity from one lifecycle and submission projection. Idle directory transitions now request an authoritative resume so missed terminal events converge without treating directory delivery order as turn authority. * fix(tests): align checks with runtime boundaries * fix(desktop): stop dev app surviving its terminal Electron was detached from the desktop runner, allowing it to outlive a stopped workspace terminal with broken output pipes and retain the single-instance lock. Keep Electron owned by the runner and preserve explicit process-group cleanup for detached child trees. * fix(timeline): preserve activity through turn handoff Correlate lifecycle events and snapshots with daemon-owned turn identity, and keep optimistic submissions active until their canonical row arrives. This prevents stale updates and RPC timing from exposing a false idle state. * refactor(timeline): remove submission timing state * fix(timeline): restore prompt identity on resume * fix(timeline): exclude attachments from canonical prompt text Structured provider prompts can include hidden history attachments before the user's text. Persist only plain prompt blocks so authoritative user messages match what the composer displayed while provider input remains unchanged. * refactor(agent): remove unused pending-run message identity * fix(desktop): let Chromium allocate the debugging port * refactor(agent): remove impossible finalized-turn tracking * fix(timeline): load history after keyboard scrolling Treat observed upward displacement as user intent so keyboard and scrollbar navigation can wake dormant pagination. * test(server): preserve accepted turn stream order The manager-owned accepted turn boundary intentionally precedes coalesced output. Assert that lifecycle event explicitly while retaining the timeline-before-terminal ordering contract. * test(app): scope sidebar fixtures by identity Shared E2E daemon state may contain unrelated projects. Keep stale-route coverage tied to its named missing project and drag only the two rows seeded by the reorder spec. * fix(timeline): gate prompt revisions by client capability * fix(timeline): finalize canonical prompt delivery Treat provider identity as a single stored-row transition so legacy clients receive one deliverable canonical prompt. Release identity-pending rows before later foreground output or the current terminal while preserving stale-turn isolation. * fix(server): preserve legacy prompt pagination * fix(timeline): keep assistant block identity stable Assign canonical block IDs during live promotion so terminal finalization cannot remount image-bearing rows. * fix(timeline): apply covered revisions during resume append Run the exact canonical revision pass before overlapping resume pages append newer rows, while leaving cursor advancement to the incremental path. * fix(server): preserve queued agent transitions Project each queued live snapshot before awaiting stored metadata so later lifecycle state cannot overwrite an earlier transition. * fix(server): snapshot queued agent updates Capture each live agent payload before it enters the per-agent queue so shared nested state cannot change while an earlier storage read is pending. * docs(app): date agent turn compatibility cleanup * refactor(timeline): mint submitted prompt identity at acceptance Canonical prompt rows now keep one immutable wire identity while provider identity remains internal for rewind translation. This removes the unreleased same-sequence revision and legacy projection contract. * refactor(app): remove prompt revision reconciliation Submitted prompt rows are immutable on the wire, so resume and live reducers no longer need to apply already-covered same-sequence updates. * test(app): cover submitted prompt rewind roundtrip * docs(timeline): describe immutable submitted prompt identity Provider echoes now enrich daemon-internal identity only; wire rows keep their acceptance-time identity and rewind performs the provider translation. * test(server): validate mock rewind targets Reject unknown provider message IDs so app rewind coverage exercises wire-to-provider identity translation. * fix(server): validate rewind before interrupting * fix(server): omit rewind identity for daemon prompts * test(app): guard rewind eligibility and rejection * fix(server): preserve prompt identity across hydration * refactor(timeline): remove stale revision remnants * Revert "fix(server): preserve prompt identity across hydration" This reverts commit 6adcf7e5b0bc8601761af59b461a746cb5e395fa. * fix(timeline): settle missed submission acknowledgements --- docs/architecture.md | 10 +- docs/development.md | 28 +- docs/providers.md | 6 +- docs/timeline-sync.md | 130 +- .../app/e2e/agent-consecutive-turns.spec.ts | 853 ++++++++++ packages/app/e2e/agent-message-rewind.spec.ts | 133 ++ .../app/e2e/agent-message-submission.spec.ts | 1092 +++++++++++++ packages/app/e2e/agent-stream-ui.spec.ts | 27 +- .../app/e2e/agent-tab-image-stability.spec.ts | 85 + .../app/e2e/agent-timeline-pagination.spec.ts | 241 ++- .../app/e2e/agent-timeline-resume.spec.ts | 65 + .../app/e2e/helpers/agent-message-gate.ts | 86 + packages/app/e2e/helpers/agent-stream.ts | 44 + .../app/e2e/helpers/agent-timeline-gate.ts | 203 ++- packages/app/e2e/helpers/assistant-images.ts | 343 ++++ .../app/e2e/helpers/daemon-websocket-gate.ts | 544 ++++++- packages/app/e2e/helpers/mock-agent.ts | 17 + .../app/e2e/helpers/timeline-pagination.ts | 438 ++++- packages/app/e2e/helpers/timeline-resume.ts | 68 + packages/app/e2e/new-workspace-entry.spec.ts | 3 +- .../out-of-band-command.codex.real.spec.ts | 29 + .../app/e2e/rewind-menu.ui-contract.spec.ts | 151 ++ packages/app/e2e/sidebar-reorder.spec.ts | 7 +- .../app/e2e/viewed-agent-timelines.spec.ts | 54 +- .../history-start-pagination.test.ts | 152 +- .../agent-stream/history-start-pagination.ts | 131 +- .../history-start-settle-scheduler.test.ts | 45 + .../history-start-settle-scheduler.ts | 52 + packages/app/src/agent-stream/layout.test.ts | 10 +- packages/app/src/agent-stream/layout.ts | 4 +- packages/app/src/agent-stream/model.test.ts | 24 +- packages/app/src/agent-stream/model.ts | 22 +- .../app/src/agent-stream/strategy-native.tsx | 135 +- .../src/agent-stream/strategy-web.test.tsx | 86 +- .../app/src/agent-stream/strategy-web.tsx | 319 +++- packages/app/src/agent-stream/strategy.ts | 2 +- packages/app/src/agent-stream/view.tsx | 56 +- .../assistant-image/acquisition-cache.test.ts | 193 +++ .../src/assistant-image/acquisition-cache.ts | 159 ++ .../assistant-image/file-acquisition.test.ts | 53 + .../src/assistant-image/file-acquisition.ts | 67 + .../app/src/assistant-image/lifecycle.test.ts | 120 ++ packages/app/src/assistant-image/lifecycle.ts | 46 + .../assistant-image/load-dimensions.test.ts | 48 + .../src/assistant-image/load-dimensions.ts | 45 + .../app/src/assistant-image/retry.test.ts | 49 + packages/app/src/assistant-image/retry.ts | 31 + .../assistant-image/use-assistant-image.ts | 544 +++++++ .../app/src/attachments/gc-retention.test.ts | 15 + packages/app/src/attachments/gc-retention.ts | 22 + packages/app/src/attachments/service.test.ts | 48 +- packages/app/src/attachments/service.ts | 88 +- packages/app/src/attachments/utils.test.ts | 26 + packages/app/src/attachments/utils.ts | 7 +- .../src/client-slash-commands/index.test.ts | 1 + packages/app/src/components/message.tsx | 304 +--- .../rewind/use-rewind-agent-mutation.ts | 8 - .../app/src/components/ui/loading-spinner.tsx | 1 + packages/app/src/composer/actions.test.ts | 177 +- packages/app/src/composer/actions.ts | 79 +- .../src/composer/draft/create-flow.test.ts | 6 +- .../app/src/composer/draft/create-flow.ts | 32 +- .../app/src/composer/draft/workspace-tab.tsx | 13 +- packages/app/src/composer/index.tsx | 115 +- packages/app/src/composer/input/input.tsx | 82 +- .../app/src/composer/submission/model.test.ts | 104 ++ packages/app/src/composer/submission/model.ts | 92 ++ .../src/composer/submission/writer.test.ts | 49 + .../app/src/composer/submission/writer.ts | 72 + packages/app/src/composer/submit.ts | 2 +- packages/app/src/contexts/session-context.tsx | 195 +-- .../contexts/session-status-tracking.test.ts | 1 + .../hooks/use-agent-initialization.test.ts | 48 +- .../app/src/hooks/use-agent-initialization.ts | 7 +- .../use-agent-screen-state-machine.test.ts | 1 + .../app/src/hooks/use-archive-agent.test.ts | 1 + .../use-load-older-agent-history.test.ts | 12 +- .../src/hooks/use-load-older-agent-history.ts | 35 +- packages/app/src/panels/agent-panel.tsx | 38 +- .../src/panels/provider-subagent-panel.tsx | 20 +- .../src/runtime/directory-sync/index.test.ts | 2 +- .../app/src/runtime/directory-sync/index.ts | 4 +- packages/app/src/runtime/host-runtime.test.ts | 76 +- packages/app/src/runtime/host-runtime.ts | 52 +- .../src/runtime/replica-cache/index.test.ts | 103 +- .../app/src/runtime/replica-cache/index.ts | 27 +- .../workspace/workspace-tab-presentation.tsx | 6 +- packages/app/src/stores/create-flow-store.ts | 5 +- packages/app/src/stores/draft-store/index.ts | 4 + packages/app/src/stores/session-store.test.ts | 285 ++++ packages/app/src/stores/session-store.ts | 356 +++- .../workspace-subagents-integration.test.ts | 1 + packages/app/src/subagents/select.test.ts | 1 + .../timeline/session-stream-reducers.test.ts | 1435 ++++++++++++++--- .../src/timeline/session-stream-reducers.ts | 695 ++++---- .../src/timeline/timeline-sync-plan.test.ts | 72 +- .../app/src/timeline/timeline-sync-plan.ts | 36 +- .../app/src/timeline/turn-liveness.test.ts | 78 + packages/app/src/timeline/turn-liveness.ts | 125 ++ packages/app/src/timeline/turn-time.test.ts | 38 +- packages/app/src/timeline/turn-time.ts | 14 +- .../src/timeline/viewed-timeline-sync.test.ts | 76 +- .../app/src/timeline/viewed-timeline-sync.ts | 78 +- packages/app/src/types/stream.test.ts | 389 +++-- packages/app/src/types/stream.ts | 872 ++++++++-- .../app/src/utils/agent-attention.test.ts | 1 + .../agent-directory-reconciliation.test.ts | 1 + .../src/utils/agent-directory-sync.test.ts | 142 ++ .../app/src/utils/agent-directory-sync.ts | 13 +- .../app/src/utils/agent-snapshots.test.ts | 21 + packages/app/src/utils/agent-snapshots.ts | 20 + .../utils/assistant-image-metadata.test.ts | 18 - .../app/src/utils/assistant-image-metadata.ts | 14 - .../utils/workspace-agent-activity.test.ts | 1 + .../workspace-tabs/agent-visibility.test.ts | 1 + packages/client/src/index.ts | 4 +- .../desktop/scripts/dev-runner-config.mjs | 17 + .../scripts/dev-runner-config.test.mjs | 26 + packages/desktop/scripts/dev-runner.mjs | 33 +- .../desktop/scripts/dev-runtime-config.mjs | 45 + packages/desktop/scripts/dev-runtime.mjs | 14 + packages/desktop/scripts/dev-runtime.test.mjs | 50 + packages/desktop/scripts/dev.ps1 | 19 +- packages/desktop/scripts/dev.sh | 10 +- .../daemon/node-entrypoint-launcher.test.ts | 3 +- .../src/daemon/node-entrypoint-launcher.ts | 2 +- .../src/agent-feature-schemas.test.ts | 41 + packages/protocol/src/messages.ts | 14 + .../agent-manager-stream-coalescing.test.ts | 20 +- .../src/server/agent/agent-manager.test.ts | 431 ++++- .../server/src/server/agent/agent-manager.ts | 328 +++- .../server/agent/agent-projections.test.ts | 9 + .../src/server/agent/agent-projections.ts | 6 + .../server/src/server/agent/agent-prompt.ts | 2 +- .../src/server/agent/agent-run-state.ts | 38 +- .../agent/agent-timeline-store-types.ts | 2 + .../src/server/agent/agent-timeline-store.ts | 32 +- .../agent/create-agent-lifecycle-dispatch.ts | 4 +- .../server/agent/providers/acp-agent.test.ts | 9 + .../agent/providers/claude/agent.test.ts | 16 + .../providers/codex-app-server-agent.test.ts | 57 +- .../agent/providers/codex-app-server-agent.ts | 47 +- .../codex/test-utils/fake-app-server.ts | 5 +- .../mock-load-test-agent-rewind.test.ts | 69 + .../providers/mock-load-test-agent.test.ts | 151 ++ .../agent/providers/mock-load-test-agent.ts | 239 ++- .../server/agent/providers/omp/agent.test.ts | 1 + .../providers/omp/test-utils/omp-harness.ts | 4 + .../agent/providers/opencode-agent.test.ts | 23 +- .../server/agent/providers/pi/agent.test.ts | 6 + .../server/agent/timeline-projection.test.ts | 82 +- .../src/server/agent/timeline-projection.ts | 53 +- packages/server/src/server/bootstrap.ts | 2 +- packages/server/src/server/messages.test.ts | 16 + packages/server/src/server/session.ts | 162 +- .../src/server/session.workspaces.test.ts | 124 +- .../agent-updates-service.test.ts | 129 +- .../agent-updates/agent-updates-service.ts | 48 +- .../websocket-server.relay-reconnect.test.ts | 4 +- .../server/src/server/websocket-server.ts | 2 + .../server/src/server/wire-compat.test.ts | 68 +- 161 files changed, 13757 insertions(+), 2523 deletions(-) create mode 100644 packages/app/e2e/agent-consecutive-turns.spec.ts create mode 100644 packages/app/e2e/agent-message-rewind.spec.ts create mode 100644 packages/app/e2e/agent-message-submission.spec.ts create mode 100644 packages/app/e2e/agent-tab-image-stability.spec.ts create mode 100644 packages/app/e2e/agent-timeline-resume.spec.ts create mode 100644 packages/app/e2e/helpers/agent-message-gate.ts create mode 100644 packages/app/e2e/helpers/assistant-images.ts create mode 100644 packages/app/e2e/helpers/timeline-resume.ts create mode 100644 packages/app/e2e/out-of-band-command.codex.real.spec.ts create mode 100644 packages/app/src/agent-stream/history-start-settle-scheduler.test.ts create mode 100644 packages/app/src/agent-stream/history-start-settle-scheduler.ts create mode 100644 packages/app/src/assistant-image/acquisition-cache.test.ts create mode 100644 packages/app/src/assistant-image/acquisition-cache.ts create mode 100644 packages/app/src/assistant-image/file-acquisition.test.ts create mode 100644 packages/app/src/assistant-image/file-acquisition.ts create mode 100644 packages/app/src/assistant-image/lifecycle.test.ts create mode 100644 packages/app/src/assistant-image/lifecycle.ts create mode 100644 packages/app/src/assistant-image/load-dimensions.test.ts create mode 100644 packages/app/src/assistant-image/load-dimensions.ts create mode 100644 packages/app/src/assistant-image/retry.test.ts create mode 100644 packages/app/src/assistant-image/retry.ts create mode 100644 packages/app/src/assistant-image/use-assistant-image.ts create mode 100644 packages/app/src/attachments/gc-retention.test.ts create mode 100644 packages/app/src/attachments/gc-retention.ts create mode 100644 packages/app/src/composer/submission/model.test.ts create mode 100644 packages/app/src/composer/submission/model.ts create mode 100644 packages/app/src/composer/submission/writer.test.ts create mode 100644 packages/app/src/composer/submission/writer.ts create mode 100644 packages/app/src/timeline/turn-liveness.test.ts create mode 100644 packages/app/src/timeline/turn-liveness.ts create mode 100644 packages/desktop/scripts/dev-runner-config.mjs create mode 100644 packages/desktop/scripts/dev-runner-config.test.mjs create mode 100644 packages/desktop/scripts/dev-runtime-config.mjs create mode 100644 packages/desktop/scripts/dev-runtime.mjs create mode 100644 packages/desktop/scripts/dev-runtime.test.mjs create mode 100644 packages/server/src/server/agent/providers/mock-load-test-agent-rewind.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index c8f0afe59..6af406f97 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -229,6 +229,11 @@ New session RPCs use dotted names with `.request` and `.response` suffixes, such - `agent_permission_request` / `agent_permission_resolved` — Tool-call permission flow - `agent_deleted`, `agent_archived`, `agent_status`, `agent_list` - `checkout_status_update`, `checkout_diff_update`, and the full `checkout_*` request/response set for git operations + +Agent snapshots optionally carry the daemon-owned active turn identity, and turn lifecycle stream events +optionally carry the same `turnId`. New clients use these fields when present and normalize an old daemon's +status once at the directory boundary rather than maintaining a second activity model. + - Terminal subscribe/input/capture commands - Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`) - Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error` @@ -294,7 +299,10 @@ initializing → idle ⇄ running `ManagedAgent` is a discriminated union over those lifecycle tags. Notes: - **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers -- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items +- Timeline sequence allocation is append-only with epochs (each run starts a new epoch). The one + permitted in-place enrichment adds a provider message id to the manager-owned row for an accepted + prompt; it preserves the row's sequence, content, and timestamp. Storage uses sequence numbers for + client-side dedup; the default fetch page is 200 items. - Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics. - Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up. - Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record). That storage path is derived from `cwd`, not from workspace id. diff --git a/docs/development.md b/docs/development.md index d008f3814..c3a42b332 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,6 +19,10 @@ Root checkout dev is intentionally split across terminals: - `npm run dev:app` runs Expo on `http://localhost:8081` and connects to the dev daemon. - `npm run dev:desktop` runs its own Electron-flavored Expo server on the first free port from `8082` through `8089`. It never claims port `8081`. +Desktop dev launches its desktop-managed daemon with `PASEO_NODE_ENV=development`, +so development-only providers such as Mock Load Test are available. Packaged +desktop launches always force the daemon to production mode. + `npm run dev` is only a shorthand for `npm run dev:server`. Keep `127.0.0.1:6767` for the packaged app and production-style `~/.paseo` state. ## Nix desktop package @@ -111,10 +115,21 @@ The iOS simulator shares the Mac's loopback, so `localhost:` reaches the h ### Desktop renderer profiling -`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on -`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP. -It launches its own Electron-flavored Expo server and passes that URL to Electron. -Override the CDP port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy. +`npm run dev:desktop` starts Electron with Chromium remote debugging enabled so +renderer CPU profiles can be captured through CDP. By default it passes +`--remote-debugging-port=0`, so Chromium atomically asks the OS for an available +port and prints the selected DevTools endpoint. Set +`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when a QA workflow requires a validated, +fixed port. + +Desktop dev also scopes Electron `userData` to the current dev root. This prevents +desktop-only environment inherited by terminals opened inside Paseo from coupling +a new worktree instance to the parent desktop instance's profile or single-instance +lock. + +Electron remains in the desktop dev runner's process group. Closing or stopping the +workspace-script terminal must terminate Electron with Metro; detaching Electron +leaves an orphan holding the worktree's single-instance lock and broken output pipes. With desktop dev running, verify the real BrowserWindow, titlebar clearance, fullscreen transition, and 751-pixel settings split with: @@ -124,8 +139,9 @@ npm run verify:electron-cdp --workspace=@getpaseo/desktop ``` The verifier reads the same `EXPO_PORT` and -`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` environment names as desktop dev. Set both when -testing an isolated instance on non-default ports. +`PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` environment names as desktop dev. Set an +explicit remote-debugging port for verifier runs, and set both when testing an +isolated instance on non-default ports. When running a dedicated Electron QA instance against a non-default Expo port, set `EXPO_DEV_URL` explicitly. Desktop main defaults to `http://localhost:8081`, so diff --git a/docs/providers.md b/docs/providers.md index b5723733e..eb42ad312 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -42,9 +42,11 @@ OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event. -Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe. +`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe. -Submitted user-message rows preserve both identities: `messageId` is the provider-visible ID and the optional `clientMessageId` is the Paseo ID from `AgentRunOptions`. Attach `clientMessageId` only to the canonical row for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID. +Rewind accepts the canonical wire `messageId` and resolves it to the provider identity before calling the adapter. A submitted prompt cannot be rewound until its provider echo supplies that identity. + +Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID. Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery. diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index b881744f5..38c358874 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -5,9 +5,11 @@ Agent chat delivery has two paths: 1. **Live stream** — `agent_stream` WebSocket messages for immediacy. These may be delta-shaped lifecycle updates. 2. **Authoritative history** — `fetch_agent_timeline_request` for correctness. This always returns full projected timeline items, never lifecycle deltas. -The invariant is: +The invariants are: -> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail. +> 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 +> through backward pagination. Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced to 64 KiB, and the same bounded item is used for durable timeline rows and live stream events. @@ -25,17 +27,26 @@ Client heartbeat reports presence: Heartbeat is used for notification routing. It must not be used as a correctness gate for `agent_stream` delivery. A stale mobile focus heartbeat may affect whether the user gets notified; it must not make timeline rows disappear from the live stream. -## Catch-up is paged but complete +## Gap recovery is paged but complete Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial. Page limits are projected-item targets. A tool call lifecycle is one projected item even if it spans many source sequence numbers, and assistant/reasoning chunks are merged before counting. The response carries `seqStart`, `seqEnd`, `sourceSeqRanges`, and `collapsed` so clients can advance sequence cursors without rendering delta rows. -When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`. +When live delivery detects a sequence gap, the app fetches `direction: "after"`. If the daemon +responds with `hasNewer: true`, the app immediately fetches the next page from `endCursor`. Gap +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. -The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward. +Opening or resuming an agent fetches one bounded latest tail page. Older history remains +user-driven by scrolling upward. + +Reaching the history-start threshold loads one older page and preserves the visible content anchor. +Cursor progress does not trigger another page. The user must leave and return to the threshold unless +the anchored page still leaves the viewport at history start, as with short or compacted content; in +that case pagination continues as one loading operation until the page fills the viewport or history +is exhausted. ## Durable item anchors @@ -47,9 +58,21 @@ The daemon validates that the epoch is current and the exact source sequence sti ## Resume behavior -When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run. +Opening, reconnecting, or revisiting after selective-delivery grace fetches the latest tail page. +Focus alone does not mutate timeline state; the tail response is compared with the local +authoritative range first. -When a client resumes without a cursor, it fetches the latest tail page. +- The same epoch and `window.maxSeq` is an exact display no-op. The app advances synchronization + bookkeeping without replacing timeline arrays, preserving an upward-scrolled viewport. +- When the page overlaps or is adjacent to the local end cursor, only projected items newer than + that cursor are applied. Already-covered rows are not replayed. +- A true middle gap, epoch change, or rewind atomically replaces stale canonical history with the + latest tail. The replacement reconciles positioned live rows beyond its coverage and unresolved + local submissions; it never retains two discontiguous canonical ranges. + +The installed tail carries `hasOlder`, so history skipped by a replacement remains reachable through +ordinary backward pagination. A backward page is accepted only when it is adjacent to the current +history start; a response requested from a pre-replacement range is stale and is discarded. ## Client replica lifetime @@ -61,6 +84,24 @@ recomposition while the runtime still owns the same directory snapshot and timel Removing the host from the registry is the destructive boundary: it stops the runtime and clears the session and host-scoped setup state together. +The durable replica cache is a display cache, not a synchronization checkpoint. Its timeline record +contains only the focused `agentId` and a truncated item tail. It never persists a cursor, epoch, +older-history availability, authority status, or sync generation because those facts would describe +the complete source dataset rather than the truncated display dataset. + +Restoring that cache produces a painted timeline: the items may render immediately, but the first +daemon timeline request is still `tail`. A successful tail response atomically establishes canonical +items, range, and older-history availability. Live rows received between cache paint and that tail +response stay in the separate live head, do not advance a cursor or trigger gap recovery, and are +reconciled with the authoritative tail and subsequent catch-up. + +Every daemon-derived live item carries its timeline epoch and sequence position. Bootstrap +replacement keeps only positioned rows newer than the page it installs, while unresolved local +user presentations identify themselves by having client identity without a timeline position. This +prevents a page from duplicating rows it already covers without coupling display continuity to the +shorter-lived submission registry. Unreconciled local presentations are not persisted in the durable +replica cache. + ## Selective and legacy delivery The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`: @@ -87,14 +128,75 @@ its completion advances `seqEnd`, followed by a merged assistant message. The ap remaining page through the existing stream reducer. It must not append full projected text to a live prefix. -Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts -them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly -where the user submitted it. Other canonical rows are applied after the already-present timeline -instead of relocating visible user messages around newly fetched history. +Every path that sends a message to an agent — composer send, dictation accept-and-send, queued +send-now, and the automatic queue drain in `HostRuntime` — goes through +`dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same +product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending +footer, and permanently drops attachments because the daemon does not echo them back. -Canonical submitted user rows carry the provider's `messageId` and Paseo's optional -`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is -limited to the dated compatibility path for daemon timelines created before that field existed. +A submitted prompt is one `UserMessageItem` row. That row is the authoritative local presentation: +its stable identity, text, timestamp, images, and attachments do not change when the provider +acknowledges it. Submission lifecycle is a separate record keyed by agent, not another row shape. +The transaction registry records two independent settlement facts: canonical acknowledgement and RPC +settlement. Canonical acknowledgement retires optimistic activity immediately. When both facts are +known, whichever arrives second deletes the record. A canonical acknowledgement that arrives first +prevents a later transport error from rolling back a prompt already observed. + +The daemon's accepted response waits for the correlated run start and guarantees that the canonical +submitted row has been recorded. It publishes the accepted turn's liveness before that row, so the +client applies authoritative activity before canonical acknowledgement retires optimistic activity. +Timeline render batching does not delay lifecycle application. Directory status never settles a +submission. Overlapping sends settle independently rather than collapsing to one newest pending +message. + +Daemons advertising `server_info.features.canonicalSubmittedPrompts` guarantee that every accepted +prompt carrying a client message id is recorded and streamed as a canonical `user_message` with that +same id. This includes daemon-handled commands that do not allocate a foreground turn; their submitted +row is recorded before handler output. The app tracks submission transactions only for hosts with this +capability. Older hosts keep the shipped untracked optimistic-row behavior and roll that row back on RPC +rejection. + +Turn activity has one client-side replica. Lifecycle events can attach `turnId`, and agent snapshots can +expose `activeTurn: { turnId, startedAt } | null`. An identified terminal cannot close a different +identified turn; an unnamed legacy terminal can close the current turn. Snapshots, stream events, +cancellation requests, and every visible surface update the same per-agent record. The snapshot covers +both user-started foreground turns and autonomous provider turns; +foreground control ownership remains a separate daemon concern. Cancellation request identity is stored +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. + +The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an +anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app +does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a +second running state. Disconnect and replica removal remain destructive close boundaries. Elapsed time +comes only from turn liveness, never from submission records or whichever timeline rows happen to be +mounted. + +The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the +submission's `clientMessageId`, so the row is born with its final identity and remains immutable on +the wire. A correlated provider echo records the provider's native identity internally without +dispatching another timeline event. Rewind resolves the wire identity to that provider identity at +the provider boundary. Daemon-handled prompts follow the same identity rule. + +Content matching is limited to the dated compatibility path for daemon timelines created before +that field existed. Canonical ingestion may match only an explicit unreconciled local candidate; +the draft-create handoff is the one boundary that also permits the legacy canonical twin to have +arrived first. Generic reducers and consumers do not reimplement message identity matching. + +Ordinary bootstrap, same-epoch reset, and catch-up replacement preserve unmatched locally submitted +rows because a provider may never echo them. A known epoch change or rewind replaces history and +drops acknowledged local rows omitted by the new canonical epoch; every transaction not yet +acknowledged by the provider, and no other local row, crosses that destructive boundary. A cold +reset without an existing epoch is destructive because the client has no continuity anchor. + +Tail rows are positioned history, so an unmatched local presentation is appended after the +canonical replacement rather than ordered by timestamps from different machines. The head is a +live overlay: cursorless items stay there during continuity replacement until canonical positions +arrive, while a destructive replacement retains only active submission transactions. + +Canonical replacement owns both timeline lanes. A matching local row keeps its presentation ID and +payload while taking the canonical row's ordered position. If a live assistant head is the +canonical assistant prefix, it stays in the head lane. No row may be returned in both lanes. ## Relevant code diff --git a/packages/app/e2e/agent-consecutive-turns.spec.ts b/packages/app/e2e/agent-consecutive-turns.spec.ts new file mode 100644 index 000000000..7e291f06f --- /dev/null +++ b/packages/app/e2e/agent-consecutive-turns.spec.ts @@ -0,0 +1,853 @@ +import type { Page } from "@playwright/test"; +import { expect, test as baseTest } from "./fixtures"; +import { expectAgentIdle } from "./helpers/agent-stream"; +import { + attachImageFromMenu, + expectAttachmentPill, + expectComposerVisible, + submitMessage, +} from "./helpers/composer"; +import { clickNewChat, gotoWorkspace } from "./helpers/launcher"; +import { seedWorkspace } from "./helpers/seed-client"; +import { + openAgentRoute, + seedMockAgentWorkspace, + type MockAgentWorkspace, +} from "./helpers/mock-agent"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; + +interface FrameRect { + top: number; + right: number; + bottom: number; + left: number; + width: number; + height: number; +} + +interface ElementFrame { + mounted: boolean; + visible: boolean; + painted: boolean; + rect: FrameRect | null; + opacity: number | null; + backgroundColor: string | null; +} + +interface ContentChildFrame extends ElementFrame { + key: string; + text: string; +} + +interface TurnFrame { + at: number; + userRow: ElementFrame; + attachment: ElementFrame; + footerRow: ElementFrame; + spinner: ElementFrame; + agentTab: ElementFrame; + tabProgress: ElementFrame; + interruptControl: ElementFrame; + primaryActionCount: number; + composer: ElementFrame & { value: string | null }; + contentChildren: ContentChildFrame[]; + scroll: ElementFrame & { + scrollTop: number | null; + clientHeight: number | null; + scrollHeight: number | null; + }; +} + +interface ActivityCheckpoint { + row: boolean; + stop: boolean; + footer: boolean; + tabProgress: boolean; + elapsed: boolean; +} + +interface ActivityOracleState { + phase: "waiting" | "observing" | "complete"; + checkpoints: ActivityCheckpoint[]; + observer: MutationObserver; +} + +type RunningReleaseOrder = "turn-before-response" | "response-before-turn" | "snapshot-before-turn"; + +const FIRST_PROMPT_IMAGE = { + name: "first-prompt.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", + ), +}; + +declare global { + interface Window { + __consecutiveTurnFrames?: { active: boolean; frames: TurnFrame[] }; + __activityContinuityOracle?: ActivityOracleState; + } +} + +const test = baseTest.extend<{ streamingAgent: MockAgentWorkspace }>({ + streamingAgent: async ({ page: _page }, provide) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "consecutive-ten-second-turns-", + title: "Consecutive ten-second turns", + model: "ten-second-stream", + initialPrompt: "emit 100 coalesced agent stream updates", + }); + await agent.client.waitForFinish(agent.agentId, 15_000); + await provide(agent); + await agent.cleanup(); + }, +}); + +async function recordTurnFrames(page: Page, prompt: string): Promise { + await page.evaluate((promptText) => { + const state = { active: true, frames: [] as TurnFrame[] }; + const emptyElement = (): ElementFrame => ({ + mounted: false, + visible: false, + painted: false, + rect: null, + opacity: null, + backgroundColor: null, + }); + const rectOf = (element: Element): FrameRect => { + const rect = element.getBoundingClientRect(); + return { + top: rect.top, + right: rect.right, + bottom: rect.bottom, + left: rect.left, + width: rect.width, + height: rect.height, + }; + }; + const intersects = (first: FrameRect, second: FrameRect) => + first.bottom > second.top && + first.top < second.bottom && + first.right > second.left && + first.left < second.right; + const windowRect: FrameRect = { + top: 0, + right: innerWidth, + bottom: innerHeight, + left: 0, + width: innerWidth, + height: innerHeight, + }; + const hasColor = (color: string) => + color !== "" && color !== "none" && color !== "transparent" && color !== "rgba(0, 0, 0, 0)"; + const isVisible = (element: Element, clip?: Element | null) => { + const rect = rectOf(element); + return ( + element.isConnected && + element.checkVisibility() && + rect.width > 0 && + rect.height > 0 && + intersects(rect, windowRect) && + (!clip || intersects(rect, rectOf(clip))) + ); + }; + const isPainted = (element: Element, clip?: Element | null) => { + if (!isVisible(element, clip)) return false; + return [element, ...Array.from(element.querySelectorAll("*"))].some((candidate) => { + if (!isVisible(candidate, clip)) return false; + const style = getComputedStyle(candidate); + if (Number(style.opacity) <= 0) return false; + if (hasColor(style.backgroundColor)) return true; + const hasPaintedBorder = ["Top", "Right", "Bottom", "Left"].some( + (side) => + Number.parseFloat(style.getPropertyValue(`border-${side.toLowerCase()}-width`)) > 0 && + hasColor(style.getPropertyValue(`border-${side.toLowerCase()}-color`)), + ); + if (hasPaintedBorder) return true; + if (candidate.textContent?.trim() && hasColor(style.color)) return true; + if (candidate instanceof SVGElement) { + const fill = style.fill || candidate.getAttribute("fill") || ""; + const stroke = style.stroke || candidate.getAttribute("stroke") || ""; + return hasColor(fill) || hasColor(stroke); + } + return candidate instanceof HTMLImageElement || candidate instanceof HTMLCanvasElement; + }); + }; + const snapshot = ( + element: Element | null | undefined, + clip?: Element | null, + painted?: boolean, + ): ElementFrame => { + if (!element?.isConnected) return emptyElement(); + const style = getComputedStyle(element); + return { + mounted: true, + visible: isVisible(element, clip), + painted: painted ?? isPainted(element, clip), + rect: rectOf(element), + opacity: Number(style.opacity), + backgroundColor: style.backgroundColor, + }; + }; + const unionRect = (elements: Element[]): FrameRect | null => { + if (elements.length === 0) return null; + const rects = elements.map(rectOf); + const top = Math.min(...rects.map((rect) => rect.top)); + const right = Math.max(...rects.map((rect) => rect.right)); + const bottom = Math.max(...rects.map((rect) => rect.bottom)); + const left = Math.min(...rects.map((rect) => rect.left)); + return { top, right, bottom, left, width: right - left, height: bottom - top }; + }; + const spinnerSnapshot = ( + footer: Element | null | undefined, + clip: Element | null, + ): ElementFrame => { + const dots = Array.from(footer?.querySelectorAll("*") ?? []).filter((candidate) => + hasColor(getComputedStyle(candidate).backgroundColor), + ); + if (dots.length === 0) return emptyElement(); + const opacities = dots.map((dot) => Number(getComputedStyle(dot).opacity)); + return { + mounted: true, + visible: dots.some((dot) => isVisible(dot, clip)), + painted: dots.some((dot, index) => opacities[index] > 0 && isVisible(dot, clip)), + rect: unionRect(dots), + opacity: Math.max(...opacities), + backgroundColor: getComputedStyle(dots[0]).backgroundColor, + }; + }; + const findImageAttachment = (row: Element | undefined) => + row?.querySelector('[role="button"][aria-label="Open image attachment"]'); + const findAgentTabState = () => { + const agentTab = Array.from( + document.querySelectorAll('[data-testid^="workspace-tab-agent_"]'), + ).find((candidate) => isVisible(candidate)); + return { + agentTab, + tabProgress: agentTab?.querySelector('[role="progressbar"][aria-label="Agent running"]'), + }; + }; + const countPrimaryActions = (composerRoot: Element | null | undefined) => + Array.from(composerRoot?.querySelectorAll('[role="button"][aria-label]') ?? []).filter( + (candidate) => + /stop agent|canceling agent|interrupt agent|send message|send and interrupt|queue message/i.test( + candidate.getAttribute("aria-label") ?? "", + ) && isVisible(candidate), + ).length; + const sample = () => { + const viewport = Array.from( + document.querySelectorAll('[data-testid="agent-chat-scroll"]'), + ).find((candidate) => isVisible(candidate)); + const promptRow = Array.from( + viewport?.querySelectorAll('[data-testid="user-message"]') ?? [], + ).find((candidate) => candidate.textContent?.includes(promptText)); + const attachment = findImageAttachment(promptRow); + const footer = Array.from( + viewport?.querySelectorAll('[data-testid="turn-working-indicator"]') ?? [], + )[0]; + const spinner = spinnerSnapshot(footer, viewport ?? null); + const footerRow = footer?.parentElement; + const composer = Array.from( + document.querySelectorAll('[aria-label="Message agent..."]'), + ).find((candidate) => isVisible(candidate)); + const composerRoot = composer?.closest('[data-testid="message-input-root"]'); + const interrupt = Array.from( + composerRoot?.querySelectorAll('[role="button"][aria-label]') ?? [], + ).find((candidate) => + /stop agent|canceling agent/i.test(candidate.getAttribute("aria-label") ?? ""), + ); + const primaryActionCount = countPrimaryActions(composerRoot); + const { agentTab, tabProgress } = findAgentTabState(); + const scrollFrame = snapshot(viewport); + const contentChildren = Array.from(viewport?.firstElementChild?.children ?? []).map( + (child, index): ContentChildFrame => + Object.assign(snapshot(child, viewport), { + key: + (child.querySelector('[data-testid="turn-working-indicator"]') + ? "turn-footer" + : (child.getAttribute("data-history-row-id") ?? + child.getAttribute("data-testid"))) ?? `child-${index}`, + text: child.textContent?.trim().slice(0, 120) ?? "", + }), + ); + state.frames.push({ + at: performance.now(), + userRow: snapshot(promptRow, viewport), + attachment: snapshot(attachment, viewport), + footerRow: snapshot(footerRow, viewport, spinner.painted), + spinner, + agentTab: snapshot(agentTab), + tabProgress: snapshot(tabProgress), + interruptControl: snapshot(interrupt), + primaryActionCount, + composer: { + ...snapshot(composerRoot ?? composer), + value: + composer instanceof HTMLInputElement || composer instanceof HTMLTextAreaElement + ? composer.value + : null, + }, + contentChildren, + scroll: { + ...scrollFrame, + scrollTop: viewport instanceof HTMLElement ? viewport.scrollTop : null, + clientHeight: viewport instanceof HTMLElement ? viewport.clientHeight : null, + scrollHeight: viewport instanceof HTMLElement ? viewport.scrollHeight : null, + }, + }); + if (state.active) scheduleAfterNextPaint(); + }; + const scheduleAfterNextPaint = () => { + requestAnimationFrame(() => setTimeout(sample, 0)); + }; + window.__consecutiveTurnFrames = state; + scheduleAfterNextPaint(); + }, prompt); +} + +async function waitForRecordedFrames( + page: Page, + predicate: "user-visible" | "turn-running", + count: number, +): Promise { + await expect + .poll(() => + page.evaluate( + (input) => { + const frames = window.__consecutiveTurnFrames?.frames ?? []; + return frames.filter((frame) => { + if (input.predicate === "user-visible") return frame.userRow.visible; + return ( + frame.interruptControl.painted && + frame.footerRow.painted && + frame.spinner.painted && + frame.tabProgress.painted + ); + }).length; + }, + { predicate }, + ), + ) + .toBeGreaterThanOrEqual(count); +} + +async function recordPaintsFor(page: Page, durationMs: number): Promise { + const until = await page.evaluate((duration) => performance.now() + duration, durationMs); + await expect + .poll(() => page.evaluate(() => window.__consecutiveTurnFrames?.frames.at(-1)?.at ?? 0)) + .toBeGreaterThanOrEqual(until); +} + +async function installActivityContinuityOracle(page: Page, prompt: string): Promise { + await page.evaluate((promptText) => { + const isVisible = (element: Element | null) => Boolean(element?.checkVisibility()); + const snapshot = (): ActivityCheckpoint => { + const visibleAgentTab = Array.from( + document.querySelectorAll('[data-testid^="workspace-tab-agent_"]'), + ).find((candidate) => isVisible(candidate)); + return { + row: Array.from(document.querySelectorAll('[data-testid="user-message"]')).some( + (candidate) => candidate.textContent?.includes(promptText), + ), + stop: isVisible(document.querySelector('[role="button"][aria-label="Stop agent"]')), + footer: isVisible(document.querySelector('[data-testid="turn-working-indicator"]')), + tabProgress: isVisible( + visibleAgentTab?.querySelector('[role="progressbar"][aria-label="Agent running"]') ?? + null, + ), + elapsed: isVisible(document.querySelector('[data-testid="turn-working-elapsed"]')), + }; + }; + const state = { + phase: "waiting" as ActivityOracleState["phase"], + checkpoints: [] as ActivityCheckpoint[], + observer: null as unknown as MutationObserver, + }; + const observeCommit = () => { + const checkpoint = snapshot(); + if (state.phase === "waiting" && checkpoint.row) { + state.phase = "observing"; + } + if (state.phase !== "observing") return; + state.checkpoints.push(checkpoint); + if (checkpoint.elapsed) { + state.phase = "complete"; + state.observer.disconnect(); + } + }; + state.observer = new MutationObserver(observeCommit); + window.__activityContinuityOracle = state; + state.observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + characterData: true, + }); + }, prompt); +} + +async function waitForActivityOracleArmed(page: Page): Promise { + await expect + .poll(() => page.evaluate(() => window.__activityContinuityOracle?.phase ?? "missing")) + .not.toBe("waiting"); +} + +async function readActivityContinuityOracle(page: Page): Promise { + return page.evaluate(() => { + const state = window.__activityContinuityOracle; + if (!state) throw new Error("Activity continuity oracle was never installed"); + state.observer.disconnect(); + return state.checkpoints; + }); +} + +function expectActivityContinuity(checkpoints: ActivityCheckpoint[]): void { + const incompleteActivityCheckpoints = checkpoints.filter( + (checkpoint) => !checkpoint.stop || !checkpoint.footer || !checkpoint.tabProgress, + ); + expect(checkpoints[0]?.row, "activity oracle never armed on the optimistic row").toBe(true); + expect( + incompleteActivityCheckpoints, + `activity oracle observed row without full activity cluster:\n${JSON.stringify(incompleteActivityCheckpoints, null, 2)}\n\nall checkpoints:\n${JSON.stringify(checkpoints, null, 2)}`, + ).toEqual([]); + expect( + checkpoints.slice(0, -1).filter((checkpoint) => checkpoint.elapsed), + `activity oracle observed elapsed before its authoritative disarm checkpoint:\n${JSON.stringify(checkpoints, null, 2)}`, + ).toEqual([]); + expect(checkpoints.at(-1)?.elapsed, "activity oracle never observed authoritative elapsed").toBe( + true, + ); +} + +async function stopTurnFrameRecording(page: Page): Promise { + return page.evaluate(() => { + const state = window.__consecutiveTurnFrames; + if (!state) throw new Error("Turn frames were never recorded"); + state.active = false; + return state.frames; + }); +} + +function formatFrames(frames: TurnFrame[], centers: number | number[]): string { + const indexes = new Set(); + for (const center of Array.isArray(centers) ? centers : [centers]) { + for (let index = Math.max(0, center - 3); index <= center + 3; index += 1) { + if (index < frames.length) indexes.add(index); + } + } + return Array.from(indexes) + .sort((left, right) => left - right) + .map((index) => `frame ${index}: ${JSON.stringify(frames[index])}`) + .join("\n"); +} + +function expectRowContinuity(frames: TurnFrame[]): void { + const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow)); + const baseline = frames[first]?.userRow.rect?.top; + const disappeared = frames.findIndex( + (frame, index) => index > first && !hasPaintedLayout(frame.userRow), + ); + const reappeared = frames.findIndex( + (frame, index) => index > disappeared && disappeared >= 0 && hasPaintedLayout(frame.userRow), + ); + const shifted = frames.findIndex( + (frame, index) => + index > first && + hasPaintedLayout(frame.userRow) && + baseline !== undefined && + Math.abs((frame.userRow.rect?.top ?? baseline) - baseline) > 1, + ); + const attachmentMissing = frames.findIndex( + (frame, index) => index >= first && !hasPaintedLayout(frame.attachment), + ); + const violations = [ + ...(first < 0 ? ["submitted row never became visible"] : []), + ...(disappeared >= 0 + ? [ + `submitted row disappeared for ${Math.max(0, reappeared - disappeared)} recorded frames (${Math.max(0, (frames[reappeared]?.at ?? frames.at(-1)?.at ?? 0) - frames[disappeared].at).toFixed(1)}ms) before authoritative recovery`, + ] + : []), + ...(shifted >= 0 + ? [ + `submitted row moved from ${baseline?.toFixed(1)} to ${frames[shifted].userRow.rect?.top.toFixed(1)}`, + ] + : []), + ...(attachmentMissing >= 0 ? ["submitted image attachment left the painted layout"] : []), + ]; + let failure = Math.max(0, first); + if (attachmentMissing >= 0) failure = attachmentMissing; + if (shifted >= 0) failure = shifted; + if (disappeared >= 0) failure = disappeared; + expect( + violations, + `transition violations:\n${formatFrames(frames, [failure, Math.max(failure, reappeared)])}`, + ).toEqual([]); +} + +interface FrameViolation { + frame: number; + reason: string; +} + +function geometryChanged( + value: number | null | undefined, + expected: number | null | undefined, +): boolean { + if (value == null || expected == null) return true; + return Math.abs(value - expected) > 1; +} + +function hasPaintedLayout(element: ElementFrame): boolean { + return [element.mounted, element.visible, element.painted].every(Boolean); +} + +function violationsForChecks( + frame: number, + checks: Array<{ passes: boolean; reason: string }>, +): FrameViolation[] { + return checks.filter(({ passes }) => !passes).map(({ reason }) => ({ frame, reason })); +} + +function collectElementViolations( + frame: TurnFrame, + baseline: TurnFrame | undefined, + index: number, +): FrameViolation[] { + return violationsForChecks(index, [ + { + passes: hasPaintedLayout(frame.userRow), + reason: "submitted row left the painted layout", + }, + { + passes: !geometryChanged(frame.userRow.rect?.top, baseline?.userRow.rect?.top), + reason: "submitted row moved vertically", + }, + { + passes: [hasPaintedLayout(frame.footerRow), (frame.footerRow.rect?.height ?? 0) > 0].every( + Boolean, + ), + reason: "footer row was absent from the painted layout", + }, + { + passes: [ + geometryChanged(frame.footerRow.rect?.top, baseline?.footerRow.rect?.top), + geometryChanged(frame.footerRow.rect?.height, baseline?.footerRow.rect?.height), + ].every((hasChanged) => !hasChanged), + reason: "footer geometry moved", + }, + { + passes: hasPaintedLayout(frame.spinner), + reason: "working spinner was not painted", + }, + { + passes: hasPaintedLayout(frame.tabProgress), + reason: "selected tab running indicator was not painted", + }, + { + passes: hasPaintedLayout(frame.interruptControl), + reason: "interrupt control was not painted", + }, + { + passes: frame.primaryActionCount === 1, + reason: `expected one primary action, found ${frame.primaryActionCount}`, + }, + { + passes: [hasPaintedLayout(frame.composer), frame.composer.value === ""].every(Boolean), + reason: "composer was not painted and empty", + }, + { + passes: [ + geometryChanged(frame.composer.rect?.top, baseline?.composer.rect?.top), + geometryChanged(frame.composer.rect?.height, baseline?.composer.rect?.height), + ].every((hasChanged) => !hasChanged), + reason: "composer geometry moved", + }, + ]); +} + +function collectScrollViolations( + frame: TurnFrame, + baseline: TurnFrame | undefined, + index: number, +): FrameViolation[] { + return violationsForChecks(index, [ + { + passes: [ + frame.scroll.mounted, + frame.scroll.visible, + !geometryChanged(frame.scroll.rect?.top, baseline?.scroll.rect?.top), + !geometryChanged(frame.scroll.rect?.height, baseline?.scroll.rect?.height), + !geometryChanged(frame.scroll.clientHeight, baseline?.scroll.clientHeight), + ].every(Boolean), + reason: "scroll viewport geometry moved", + }, + { + passes: [ + geometryChanged(frame.scroll.scrollTop, baseline?.scroll.scrollTop), + geometryChanged(frame.scroll.scrollHeight, baseline?.scroll.scrollHeight), + ].every((hasChanged) => !hasChanged), + reason: "scroll content metrics moved", + }, + ]); +} + +function collectContentViolations( + frame: TurnFrame, + stableContentChildren: Map, + index: number, +): FrameViolation[] { + return Array.from(stableContentChildren).flatMap(([key, expected]) => { + const child = frame.contentChildren.find((candidate) => candidate.key === key); + return violationsForChecks(index, [ + { + passes: [ + Boolean(child?.mounted), + !geometryChanged(child?.rect?.top, expected.rect?.top), + !geometryChanged(child?.rect?.bottom, expected.rect?.bottom), + !geometryChanged(child?.rect?.height, expected.rect?.height), + ].every(Boolean), + reason: `content row ${key} geometry moved`, + }, + ]); + }); +} + +function expectAtomicIdleToRunningTransition(frames: TurnFrame[]): void { + const first = frames.findIndex((frame) => frame.userRow.visible); + const running = frames.findIndex( + (frame, index) => + index >= first && + frame.interruptControl.painted && + frame.footerRow.painted && + frame.spinner.painted && + frame.tabProgress.painted, + ); + const transition = frames.slice(first); + const baseline = transition[0]; + const violations: FrameViolation[] = []; + const stableContentChildren = new Map( + baseline?.contentChildren + .filter(({ key }) => key !== "turn-footer") + .map((child) => [child.key, child]), + ); + if (first < 0) violations.push({ frame: 0, reason: "submitted row never became visible" }); + if (running < 0) violations.push({ frame: Math.max(first, 0), reason: "turn never visibly ran" }); + for (const [offset, frame] of transition.entries()) { + const index = first + offset; + violations.push(...collectElementViolations(frame, baseline, index)); + violations.push(...collectScrollViolations(frame, baseline, index)); + violations.push(...collectContentViolations(frame, stableContentChildren, index)); + } + for (let offset = 1; offset < transition.length; offset += 1) { + const before = transition[offset - 1]; + const after = transition[offset]; + const footerEnteredLayout = [ + (before.footerRow.rect?.height ?? 0) <= 0, + (after.footerRow.rect?.height ?? 0) > 0, + ].every(Boolean); + if (footerEnteredLayout) { + violations.push({ + frame: first + offset, + reason: "footer height changed from zero to non-zero", + }); + } + } + const failure = violations[0]?.frame ?? Math.max(first, 0); + expect( + violations, + `transition violations:\n${violations.map(({ frame, reason }) => `frame ${frame}: ${reason}`).join("\n")}\n\nframe record:\n${formatFrames(frames, [failure, running])}`, + ).toEqual([]); +} + +function expectAtomicFirstPromptTransition(frames: TurnFrame[]): void { + expectRowContinuity(frames); + const first = frames.findIndex((frame) => hasPaintedLayout(frame.userRow)); + const transition = frames.slice(first); + const violations: FrameViolation[] = []; + for (const [offset, frame] of transition.entries()) { + const index = first + offset; + violations.push( + ...violationsForChecks(index, [ + { + passes: hasPaintedLayout(frame.footerRow), + reason: "footer was not painted with the first prompt", + }, + { + passes: hasPaintedLayout(frame.spinner), + reason: "working spinner was not painted with the first prompt", + }, + { + passes: hasPaintedLayout(frame.composer) && frame.composer.value === "", + reason: "composer was not painted and empty with the first prompt", + }, + { + passes: !frame.agentTab.mounted || hasPaintedLayout(frame.tabProgress), + reason: "created agent tab appeared without running state", + }, + { + passes: !frame.agentTab.mounted || hasPaintedLayout(frame.interruptControl), + reason: "created agent appeared without its interrupt control", + }, + ]), + ); + } + const failure = violations[0]?.frame ?? Math.max(first, 0); + expect( + violations, + `transition violations:\n${violations.map(({ frame, reason }) => `frame ${frame}: ${reason}`).join("\n")}\n\nframe record:\n${formatFrames(frames, failure)}`, + ).toEqual([]); +} + +async function recordDelayedRunningTransition( + page: Page, + agent: MockAgentWorkspace, + releaseOrder: RunningReleaseOrder, +): Promise<{ frames: TurnFrame[]; checkpoints: ActivityCheckpoint[] }> { + const gate = await installDaemonWebSocketGate(page); + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectAgentIdle(page); + await expect(page.getByRole("button", { name: "Copy turn" })).toHaveCount(1); + + const prompt = "Second prompt keeps streaming."; + gate.holdNextAgentStreamEvent("turn_started"); + gate.holdNextAgentUpdate(agent.agentId, "running"); + gate.holdNextServerMessage("send_agent_message_response"); + gate.setAgentStreamItemSuppressed("assistant_message", true); + await recordTurnFrames(page, prompt); + await installActivityContinuityOracle(page, prompt); + await submitMessage(page, prompt); + await Promise.all([ + gate.waitForHeldAgentStreamEvent("turn_started"), + gate.waitForHeldAgentUpdate(agent.agentId, "running"), + gate.waitForHeldServerMessage("send_agent_message_response"), + ]); + + let turnReleased = false; + let snapshotReleased = false; + let responseReleased = false; + try { + await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toHaveValue(""); + await gate.waitForAgentStreamItem("user_message"); + await waitForActivityOracleArmed(page); + const elapsedBeforeAuthoritative = await page.getByTestId("turn-working-elapsed").count(); + + if (releaseOrder === "turn-before-response") { + gate.releaseHeldAgentStreamEvent("turn_started"); + turnReleased = true; + await expect(page.getByTestId("turn-working-elapsed")).toBeVisible(); + gate.releaseHeldServerMessage("send_agent_message_response"); + responseReleased = true; + gate.releaseHeldAgentUpdate(agent.agentId, "running"); + snapshotReleased = true; + } else if (releaseOrder === "response-before-turn") { + gate.releaseHeldServerMessage("send_agent_message_response"); + responseReleased = true; + await recordPaintsFor(page, 80); + gate.releaseHeldAgentStreamEvent("turn_started"); + turnReleased = true; + await expect(page.getByTestId("turn-working-elapsed")).toBeVisible(); + gate.releaseHeldAgentUpdate(agent.agentId, "running"); + snapshotReleased = true; + } else { + gate.releaseHeldServerMessage("send_agent_message_response"); + responseReleased = true; + await recordPaintsFor(page, 80); + gate.releaseHeldAgentUpdate(agent.agentId, "running"); + snapshotReleased = true; + await expect(page.getByTestId("turn-working-elapsed")).toBeVisible(); + gate.releaseHeldAgentStreamEvent("turn_started"); + turnReleased = true; + } + + await waitForRecordedFrames(page, "turn-running", 3); + const frames = await stopTurnFrameRecording(page); + const checkpoints = await readActivityContinuityOracle(page); + expectActivityContinuity(checkpoints); + expect(elapsedBeforeAuthoritative).toBe(0); + gate.setAgentStreamItemSuppressed("assistant_message", false); + return { frames, checkpoints }; + } finally { + if (!turnReleased) gate.releaseHeldAgentStreamEvent("turn_started"); + if (!snapshotReleased) gate.releaseHeldAgentUpdate(agent.agentId, "running"); + if (!responseReleased) gate.releaseHeldServerMessage("send_agent_message_response"); + gate.setAgentStreamItemSuppressed("assistant_message", false); + } +} + +// The first prompt of a brand new agent is submitted before the agent exists, so the +// authoritative timeline arrives after the row is already on screen. That is the only +// path where hydration can move an already-visible message. +test("keeps the first prompt of a new agent in place through authoritative hydration", async ({ + page, +}) => { + const workspace = await seedWorkspace({ repoPrefix: "new-agent-first-prompt-" }); + try { + await page.addInitScript(() => { + localStorage.setItem( + "@paseo:create-agent-preferences", + JSON.stringify({ + provider: "mock", + providerPreferences: { + mock: { mode: "load-test", model: "ten-second-stream" }, + }, + }), + ); + }); + const gate = await installDaemonWebSocketGate(page); + await gotoWorkspace(page, workspace.workspaceId); + await clickNewChat(page); + await expectComposerVisible(page); + + const prompt = "Delay synthetic user message by 300ms."; + await attachImageFromMenu(page, FIRST_PROMPT_IMAGE); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + await recordTurnFrames(page, prompt); + await submitMessage(page, prompt); + + const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first(); + await expect(submittedRow).toBeVisible(); + await gate.waitForServerMessage("fetch_agent_timeline_response"); + await recordPaintsFor(page, 80); + expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page)); + } finally { + await workspace.cleanup(); + } +}); + +test("keeps follow-up activity continuous when turn starts before response", async ({ + page, + streamingAgent, +}) => { + const { frames } = await recordDelayedRunningTransition( + page, + streamingAgent, + "turn-before-response", + ); + expectAtomicIdleToRunningTransition(frames); +}); + +test("keeps follow-up activity continuous when response arrives before running state", async ({ + page, + streamingAgent, +}) => { + const { frames } = await recordDelayedRunningTransition( + page, + streamingAgent, + "response-before-turn", + ); + expectAtomicIdleToRunningTransition(frames); +}); + +test("keeps follow-up activity continuous when snapshot opens before turn event", async ({ + page, + streamingAgent, +}) => { + const { frames } = await recordDelayedRunningTransition( + page, + streamingAgent, + "snapshot-before-turn", + ); + expectAtomicIdleToRunningTransition(frames); +}); diff --git a/packages/app/e2e/agent-message-rewind.spec.ts b/packages/app/e2e/agent-message-rewind.spec.ts new file mode 100644 index 000000000..b0797786f --- /dev/null +++ b/packages/app/e2e/agent-message-rewind.spec.ts @@ -0,0 +1,133 @@ +import type { Locator } from "@playwright/test"; +import { expect, test, type Page } from "./fixtures"; +import { expectAgentIdle, expectAgentReadyToInterrupt } from "./helpers/agent-stream"; +import { + cancelAgent, + expectComposerDraft, + expectComposerVisible, + submitMessage, +} from "./helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; + +async function completeSubmittedTurn( + page: Page, + agent: Awaited>, + prompt: string, +): Promise { + await submitMessage(page, prompt); + const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(userMessage).toBeVisible(); + + const finish = await agent.client.waitForFinish(agent.agentId, 30_000); + expect(finish.status).toBe("idle"); + await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible(); + await expect(userMessage).toHaveAttribute("aria-busy", "false"); + return userMessage; +} + +async function rewindConversation(page: Page, userMessage: Locator, prompt: string): Promise { + await userMessage.getByText(prompt, { exact: true }).hover(); + await userMessage.getByRole("button", { name: "Rewind to this message", exact: true }).click(); + await page.getByRole("button", { name: "Rewind conversation", exact: true }).click(); +} + +async function expectTurnCompletesNormally( + page: Page, + agent: Awaited>, +): Promise { + const finish = await agent.client.waitForFinish(agent.agentId, 30_000); + expect(finish.status).toBe("idle"); + await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible(); + await expectAgentIdle(page); +} + +test.describe("Agent message rewind", () => { + test("rewinds a submitted prompt and restores it to the composer", async ({ page }) => { + test.setTimeout(90_000); + const prompt = "Restore this submitted prompt after rewind."; + const agent = await seedMockAgentWorkspace({ + repoPrefix: "message-rewind-roundtrip-", + title: "Message rewind roundtrip", + model: "ten-second-stream", + }); + + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + const userMessage = await completeSubmittedTurn(page, agent, prompt); + + await rewindConversation(page, userMessage, prompt); + + await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0); + await expectComposerDraft(page, prompt); + } finally { + await agent.cleanup(); + } + }); + + test("keeps a pre-acknowledgement turn running after rewind is rejected", async ({ page }) => { + test.setTimeout(90_000); + const prompt = "Delay synthetic user message by 2000ms."; + const rewindError = "Cannot rewind before the provider acknowledges the submitted prompt"; + const agent = await seedMockAgentWorkspace({ + repoPrefix: "message-rewind-pre-echo-", + title: "Message rewind pre-echo", + model: "ten-second-stream", + }); + + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await submitMessage(page, prompt); + const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(userMessage).toBeVisible(); + await expectAgentReadyToInterrupt(page); + + await rewindConversation(page, userMessage, prompt); + + await expect(page.getByText(rewindError, { exact: true })).toBeVisible(); + await expectAgentReadyToInterrupt(page); + await expectTurnCompletesNormally(page, agent); + } finally { + await agent.cleanup(); + } + }); + + test("does not offer rewind for daemon-handled prompts", async ({ page }) => { + const commandPrompt = "/mock handled-command"; + const providerPrompt = "Keep this provider-bound prompt rewindable."; + const agent = await seedMockAgentWorkspace({ + repoPrefix: "message-rewind-daemon-command-", + title: "Message rewind daemon command", + model: "ten-second-stream", + }); + + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await expectAgentIdle(page); + + await submitMessage(page, commandPrompt); + const commandMessage = page.getByTestId("user-message").filter({ hasText: commandPrompt }); + await expect(commandMessage).toBeVisible(); + await expect(page.getByText("Mock command handled", { exact: true })).toBeVisible(); + await commandMessage.getByText(commandPrompt, { exact: true }).hover(); + await expect( + commandMessage.getByRole("button", { name: "Rewind to this message", exact: true }), + ).toHaveCount(0); + + await submitMessage(page, providerPrompt); + const providerMessage = page.getByTestId("user-message").filter({ hasText: providerPrompt }); + await expect(providerMessage).toBeVisible(); + await expectAgentReadyToInterrupt(page); + await providerMessage.getByText(providerPrompt, { exact: true }).hover(); + await expect( + providerMessage.getByRole("button", { name: "Rewind to this message", exact: true }), + ).toBeVisible(); + await cancelAgent(page); + await expectAgentIdle(page); + } finally { + await agent.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/agent-message-submission.spec.ts b/packages/app/e2e/agent-message-submission.spec.ts new file mode 100644 index 000000000..fccf211fb --- /dev/null +++ b/packages/app/e2e/agent-message-submission.spec.ts @@ -0,0 +1,1092 @@ +import type { Locator, Page } from "@playwright/test"; +import { expect, test as baseTest } from "./fixtures"; +import { + awaitToolCall, + expectAgentIdle, + expectAgentReadyToInterrupt, + expectAgentSurfacesIdle, + expectRunningAgentChrome, + expectVisibleAgentSurfacesIdle, +} from "./helpers/agent-stream"; +import { gateNextAgentMessage } from "./helpers/agent-message-gate"; +import { + attachImageFromMenu, + expectComposerDraft, + expectComposerEditable, + expectAttachmentPill, + expectComposerVisible, + cancelAgent, + composerLocator, + fillComposerDraft, + sendDraftToQueue, + startRunningMockAgent, + submitMessage, +} from "./helpers/composer"; +import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { seedWorkspace } from "./helpers/seed-client"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { getServerId } from "./helpers/server-id"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { selectModel } from "./helpers/app"; +import { + scrollTimelineToNewestLoadedEdge, + scrollTimelineUntilOlderHistoryIsReachable, +} from "./helpers/timeline-pagination"; +import { + appendSettledTimelineTurns, + createSettledMockAgent, + createSmallAssistantPng, + emitSettledAssistantImage, + expectAssistantImageRendered, +} from "./helpers/assistant-images"; + +const IMAGE = { + name: "message-submission.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", + ), +}; + +interface SubmissionScenario { + existingPrompt: string; +} + +interface DraftCreateScenario { + workspaceId: string; + agentCreatedDelay: Awaited>; +} + +interface RejectionScenario { + errorMessage: string; +} + +interface UnrelatedRunningScenario { + gate: Awaited>; + agent: Awaited>; +} + +const test = baseTest.extend<{ + submissionScenario: SubmissionScenario; + draftCreateScenario: DraftCreateScenario; + rejectionScenario: RejectionScenario; + unrelatedRunningScenario: UnrelatedRunningScenario; +}>({ + submissionScenario: async ({ page }, provide, testInfo) => { + await page.setViewportSize({ width: 1280, height: 960 }); + const workspace = await seedWorkspace({ + repoPrefix: `message-submission-layout-${testInfo.workerIndex}-`, + }); + try { + const agent = await createSettledMockAgent(workspace, "Message submission layout regression"); + await appendSettledTimelineTurns(workspace.client, agent, 80); + const image = await createSmallAssistantPng(workspace, { + alt: "Existing timeline image", + fileName: "existing-timeline.png", + }); + await emitSettledAssistantImage(workspace.client, agent, image); + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.id }); + await expectComposerVisible(page); + await expectAgentIdle(page); + const oldestPrompt = "image-history-turn-0: emit 1 coalesced agent stream updates"; + await scrollTimelineUntilOlderHistoryIsReachable(page, oldestPrompt); + await scrollTimelineToNewestLoadedEdge(page); + await expectAssistantImageRendered(page, image); + await provide({ + existingPrompt: + "Emit settled assistant image Markdown: ![Existing timeline image](existing-timeline.png)", + }); + } finally { + await workspace.cleanup(); + } + }, + draftCreateScenario: async ({ page }, provide, testInfo) => { + const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page); + const workspace = await seedWorkspace({ + repoPrefix: `message-create-handoff-${testInfo.workerIndex}-`, + }); + await provide({ workspaceId: workspace.workspaceId, agentCreatedDelay }); + agentCreatedDelay.release(); + await workspace.cleanup(); + }, + rejectionScenario: async ({ page }, provide, testInfo) => { + const errorMessage = "Requested mock prompt rejection"; + const agent = await seedMockAgentWorkspace({ + repoPrefix: `message-rejection-${testInfo.workerIndex}-`, + title: "Message rejection regression", + model: "ten-second-stream", + featureValues: { mockPromptRejections: 1 }, + }); + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + await provide({ errorMessage }); + await agent.cleanup(); + }, + unrelatedRunningScenario: async ({ page }, provide, testInfo) => { + const gate = await gateNextAgentMessage(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `unrelated-running-${testInfo.workerIndex}-`, + title: "Unrelated running transition", + model: "one-minute-stream", + }); + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + await provide({ gate, agent }); + await agent.cleanup(); + }, +}); + +async function submitMessageWithImage(page: Page, prompt: string): Promise { + await attachImageFromMenu(page, IMAGE); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + await composer.fill(prompt); + await composer.press("Enter"); + const nextFrame = await composer.evaluate( + (composerElement, submittedPrompt) => + new Promise<{ + rowPresent: boolean; + workingPresent: boolean; + composerValue: string | null; + attachmentPresent: boolean; + }>((resolve) => { + requestAnimationFrame(() => { + const rows = Array.from(document.querySelectorAll('[data-testid="user-message"]')); + const composerInput = composerElement as HTMLInputElement | HTMLTextAreaElement; + resolve({ + rowPresent: rows.some((row) => row.textContent?.includes(submittedPrompt)), + workingPresent: Boolean( + document.querySelector('[data-testid="turn-working-indicator"]'), + ), + composerValue: composerInput.value, + attachmentPresent: Boolean( + document.querySelector('[data-testid="composer-image-attachment-pill"]'), + ), + }); + }); + }), + prompt, + ); + expect(nextFrame).toEqual({ + rowPresent: true, + workingPresent: true, + composerValue: "", + attachmentPresent: false, + }); + return page.getByTestId("user-message").filter({ hasText: prompt }).last(); +} + +async function submitImageOnlyMessage(page: Page): Promise { + await attachImageFromMenu(page, IMAGE); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter"); + const userMessage = page.getByTestId("user-message").last(); + await expect(userMessage).toBeVisible(); + await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible(); + return userMessage; +} + +async function expectPendingSubmission(page: Page, userMessage: Locator): Promise { + await expect(userMessage).toBeVisible(); + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toHaveValue(""); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); + await expect(userMessage.getByTestId("user-message-timestamp")).toBeAttached(); + await expect(userMessage.getByTestId("user-message-trailing-row")).toHaveCSS("opacity", "0"); + await expect(userMessage).toHaveAttribute("aria-busy", "true"); + await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible(); +} + +async function beginTimelineRowStabilityCheck( + page: Page, + timelineRow: Locator, +): Promise<() => Promise> { + await expect(timelineRow).toBeVisible(); + await timelineRow.evaluate((initialRow) => { + const state = { + active: true, + lastTop: initialRow.getBoundingClientRect().top, + largestDownwardShift: 0, + sawMissing: false, + }; + const windowState = window as unknown as Record; + windowState.__messageSubmissionTimelineRowStability = state; + const checkFrame = () => { + if (!state.active) return; + if (!initialRow.isConnected) { + state.sawMissing = true; + requestAnimationFrame(checkFrame); + return; + } + const top = initialRow.getBoundingClientRect().top; + state.largestDownwardShift = Math.max(state.largestDownwardShift, top - state.lastTop); + state.lastTop = top; + requestAnimationFrame(checkFrame); + }; + requestAnimationFrame(checkFrame); + }); + + return async () => { + const result = await page.evaluate(() => { + const windowState = window as unknown as Record; + const state = windowState.__messageSubmissionTimelineRowStability as + | { active: boolean; largestDownwardShift: number; sawMissing: boolean } + | undefined; + if (!state) throw new Error("Timeline-row stability check was not started"); + state.active = false; + delete windowState.__messageSubmissionTimelineRowStability; + return { largestDownwardShift: state.largestDownwardShift, sawMissing: state.sawMissing }; + }); + expect(result.sawMissing).toBe(false); + expect(result.largestDownwardShift).toBeLessThanOrEqual(2); + }; +} + +async function submitMessageThatWillBeRejected(page: Page, prompt: string): Promise { + await attachImageFromMenu(page, IMAGE); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + await composer.fill(prompt); + await composer.press("Enter"); +} + +async function expectRejectedSubmissionRestored( + page: Page, + input: { prompt: string; errorMessage: string }, +): Promise { + await expect(page.getByText(input.errorMessage)).toBeVisible({ timeout: 30_000 }); + await expectComposerDraft(page, input.prompt); + await expectComposerEditable(page); + await expectAttachmentPill(page, "composer-image-attachment-pill"); + await expect(page.getByRole("button", { name: "Send message" })).toBeEnabled(); + await expect(page.getByTestId("user-message").filter({ hasText: input.prompt })).toHaveCount(0); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); +} + +async function retryRestoredSubmission(page: Page, prompt: string): Promise { + await page.getByRole("textbox", { name: "Message agent..." }).first().press("Enter"); + const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(userMessage).toHaveCount(1); + await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible(); + await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0); +} + +async function queueMessage(page: Page, prompt: string): Promise { + await fillComposerDraft(page, prompt); + await sendDraftToQueue(page); +} + +async function expectQueuedSendFailuresRestored(page: Page, prompts: string[]): Promise { + await expect(page.getByRole("button", { name: "Send queued message now" })).toHaveCount( + prompts.length, + ); + for (const prompt of prompts) { + await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0); + } +} + +async function expectFailedSubmissionRestored(page: Page, prompt: string): Promise { + await expectComposerDraft(page, prompt); + await expectComposerEditable(page); + await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(0); +} + +async function expectInterruptedTurnOrderAfterReconnect( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-reconnect-${testInfo.workerIndex}-`, + title: "Submission reconnect ordering", + model: "ten-second-stream", + }); + const prompt = "Keep this prompt before its response."; + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await agent.client.sendAgentMessage(agent.agentId, "Start the turn that will be interrupted."); + await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible(); + await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible(); + await queueMessage(page, prompt); + gate.setAgentStreamSuppressed(true); + await page.getByRole("button", { name: "Send queued message now" }).click(); + const promptRow = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(promptRow).toBeVisible(); + await gate.waitForServerMessage("send_agent_message_response"); + await gate.drop(); + await agent.client.waitForFinish(agent.agentId, 30_000); + gate.setAgentStreamSuppressed(false); + gate.forceNextTimelineEpochReset(); + gate.restoreFresh(); + await gate.waitForServerMessage("fetch_agent_timeline_response", 2); + const response = page.getByText("(end of synthetic stream)", { exact: true }).last(); + await expect(promptRow).toBeVisible(); + await expect(response).toBeVisible(); + await expectRenderedBefore(promptRow, response); + } finally { + gate.restore(); + await agent.cleanup(); + } +} + +async function expectCompletedSubmissionClearsAfterMissedRunningTransition( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-missed-running-${testInfo.workerIndex}-`, + title: "Submission missed running transition", + model: "ten-second-stream", + }); + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + gate.holdNextClientRequest("send_agent_message_request"); + const userMessage = await submitImageOnlyMessage(page); + await gate.waitForHeldClientRequest(); + gate.setServerMessageSuppressed("agent_status", true); + gate.setServerMessageSuppressed("agent_update", true); + gate.releaseHeldClientRequest(); + await gate.waitForServerMessage("send_agent_message_response"); + await expect(userMessage).toHaveAttribute("aria-busy", "false"); + await gate.drop(); + await agent.client.waitForFinish(agent.agentId, 30_000); + gate.setServerMessageSuppressed("agent_status", false); + gate.setServerMessageSuppressed("agent_update", false); + gate.restoreFresh(); + await gate.waitForServerMessage("fetch_agent_timeline_response", 2); + await expect(page.getByText("(end of synthetic stream)", { exact: true }).last()).toBeVisible(); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + await expect(userMessage).toHaveAttribute("aria-busy", "false"); + } finally { + gate.restore(); + await agent.cleanup(); + } +} + +async function expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-ack-before-rpc-${testInfo.workerIndex}-`, + title: "Submission acknowledgement before RPC", + model: "ten-second-stream", + }); + const prompt = "Settle this provider-acknowledged submission."; + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + gate.setServerMessageSuppressed("agent_status", true); + gate.setServerMessageSuppressed("agent_update", true); + gate.holdNextServerMessage("send_agent_message_response"); + const userMessage = await submitMessageWithImage(page, prompt); + await gate.waitForHeldServerMessage(); + await gate.waitForAgentStreamItem("user_message"); + gate.releaseHeldServerMessage(); + await gate.drop(); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + await expect(userMessage).toHaveAttribute("aria-busy", "false"); + } finally { + gate.restore(); + await agent.cleanup(); + } +} + +async function expectLegacyAssistantStartsAfterInterruptedPrompt( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-legacy-assistant-${testInfo.workerIndex}-`, + title: "Legacy assistant interrupt boundary", + model: "ten-second-stream", + }); + const prompt = "Start the replacement answer after this prompt."; + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await agent.client.sendAgentMessage(agent.agentId, "Start the interrupted answer."); + await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible(); + await queueMessage(page, prompt); + gate.setAssistantMessageIdsStripped(true); + gate.setAgentStreamEventSuppressed("turn_canceled", true); + await page.getByRole("button", { name: "Send queued message now" }).click(); + const promptRow = page.getByTestId("user-message").filter({ hasText: prompt }); + const replacementAnswer = page.getByText("(end of synthetic stream)", { exact: true }).last(); + await expect(promptRow).toBeVisible(); + await expect(replacementAnswer).toBeVisible({ timeout: 30_000 }); + await expectRenderedBefore(promptRow, replacementAnswer); + } finally { + gate.setAssistantMessageIdsStripped(false); + gate.setAgentStreamEventSuppressed("turn_canceled", false); + await agent.cleanup(); + } +} + +async function expectStaleCanonicalPagePreservesNewerLiveOutput( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-stale-canonical-${testInfo.workerIndex}-`, + title: "Stale canonical page race", + model: "one-minute-stream", + }); + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await agent.client.sendAgentMessage(agent.agentId, "End the snapshot at a tool call."); + await awaitToolCall(page, "read"); + await page + .getByRole("button", { name: /stop|cancel/i }) + .first() + .click(); + await expectAgentIdle(page); + + gate.holdNextServerMessage("fetch_agent_timeline_response"); + gate.requestTimelineTail(agent.agentId); + await gate.waitForHeldServerMessage(); + gate.truncateHeldTimelineAfterLast("tool_call"); + expect(gate.getHeldTimelineLastItemType()).toBe("tool_call"); + + const nextPrompt = "Stream after the stale snapshot."; + await agent.client.sendAgentMessage(agent.agentId, nextPrompt); + const nextPromptRow = page.getByTestId("user-message").filter({ hasText: nextPrompt }); + const liveAssistant = nextPromptRow.locator( + 'xpath=following::*[@data-testid="assistant-message"][1]', + ); + await expect(nextPromptRow).toBeVisible(); + await expect(liveAssistant).toContainText("Cycle 1"); + gate.releaseHeldServerMessage(); + await expect(liveAssistant).toContainText("Cycle 1"); + } finally { + await agent.cleanup(); + } +} + +async function expectCanonicalOrderWinsAcrossOverlappingClients( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `submission-cross-client-order-${testInfo.workerIndex}-`, + title: "Cross-client submission order", + model: "ten-second-stream", + }); + const localPrompt = "Send this after the other client turn."; + const remotePrompt = "Commit this other client turn first."; + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + gate.holdNextClientRequest("send_agent_message_request"); + const localRow = await submitMessageWithImage(page, localPrompt); + await gate.waitForHeldClientRequest(); + + await agent.client.sendAgentMessage(agent.agentId, remotePrompt); + await agent.client.waitForFinish(agent.agentId, 30_000); + const remoteRow = page.getByTestId("user-message").filter({ hasText: remotePrompt }); + await expect(remoteRow).toBeVisible(); + + const userMessageCount = gate.getAgentStreamItemCount("user_message"); + gate.releaseHeldClientRequest(); + await gate.waitForAgentStreamItem("user_message", userMessageCount + 1); + await expect(localRow).toHaveAttribute("aria-busy", "false"); + await expect(localRow.getByRole("button", { name: "Open image attachment" })).toBeVisible(); + await expect + .poll(async () => { + const localElement = await localRow.elementHandle(); + if (!localElement) return false; + return remoteRow.evaluate( + (remoteElement, localNode) => + Boolean( + remoteElement.compareDocumentPosition(localNode) & Node.DOCUMENT_POSITION_FOLLOWING, + ), + localElement, + ); + }) + .toBe(true); + } finally { + gate.restore(); + await agent.cleanup(); + } +} + +async function expectDaemonHandledSubmissionSurvivesReload( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const agent = await seedMockAgentWorkspace({ + repoPrefix: `daemon-handled-submission-${testInfo.workerIndex}-`, + title: "Daemon-handled submission", + }); + const prompt = "/mock handled-command"; + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + await submitMessage(page, prompt); + const command = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(command).toHaveAttribute("aria-busy", "false"); + await expect(page.getByText("Mock command handled", { exact: true })).toBeVisible(); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + + await page.reload(); + await expectComposerVisible(page); + await expect(command).toBeVisible(); + await expect(command).toHaveAttribute("aria-busy", "false"); + await expect(page.getByText("Mock command handled", { exact: true })).toBeVisible(); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + } finally { + await agent.cleanup(); + } +} + +async function expectOldHostSubmissionBehavior( + page: Page, + testInfo: { workerIndex: number }, +): Promise { + const gate = await installDaemonWebSocketGate(page); + gate.setCanonicalSubmittedPromptsStripped(true); + const agent = await seedMockAgentWorkspace({ + repoPrefix: `old-host-submission-${testInfo.workerIndex}-`, + title: "Old host submission", + }); + const prompt = "/mock handled-command"; + try { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.agentId }); + await expectComposerVisible(page); + await expectAgentIdle(page); + gate.holdNextClientRequest("send_agent_message_request"); + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + await composer.fill(prompt); + await composer.press("Enter"); + const nextFrame = await composer.evaluate( + (_, submittedPrompt) => + new Promise<{ rowPresent: boolean; ariaBusy: string | null; workingPresent: boolean }>( + (resolve) => { + requestAnimationFrame(() => { + const row = Array.from( + document.querySelectorAll('[data-testid="user-message"]'), + ).find((candidate) => candidate.textContent?.includes(submittedPrompt)); + resolve({ + rowPresent: Boolean(row), + ariaBusy: row?.getAttribute("aria-busy") ?? null, + workingPresent: Boolean( + document.querySelector('[data-testid="turn-working-indicator"]'), + ), + }); + }); + }, + ), + prompt, + ); + expect(nextFrame).toEqual({ rowPresent: true, ariaBusy: "false", workingPresent: false }); + + await gate.waitForHeldClientRequest(); + gate.releaseHeldClientRequest(); + const command = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(command).toHaveAttribute("aria-busy", "false"); + await expect(page.getByText("Mock command handled", { exact: true })).toBeVisible(); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + } finally { + gate.restore(); + await agent.cleanup(); + } +} + +async function expectRenderedBefore(first: Locator, second: Locator): Promise { + const secondElement = await second.elementHandle(); + if (!secondElement) throw new Error("Expected the second timeline item to be rendered"); + expect( + await first.evaluate( + (firstElement, secondNode) => + Boolean( + firstElement.compareDocumentPosition(secondNode) & Node.DOCUMENT_POSITION_FOLLOWING, + ), + secondElement, + ), + ).toBe(true); +} + +async function openWorkspaceDraft(page: Page, workspaceId: string): Promise { + await page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId)); + await waitForWorkspaceTabsVisible(page); + await page.getByTestId("workspace-new-agent-tab-inline").click(); + await expectComposerVisible(page); +} + +async function expectCreatedAgentHandoff( + page: Page, + prompt: string, + userMessage: Locator, +): Promise { + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await expect(page.getByTestId(/^workspace-tab-agent_/).first()).toBeVisible({ timeout: 30_000 }); + await expect(userMessage).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); + await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1); + await expect(userMessage.getByRole("button", { name: "Open image attachment" })).toBeVisible(); +} + +interface DraftCreatePendingSubmission { + prompt: string; + userMessage: Locator; +} + +async function beginDraftCreateSubmission( + page: Page, + scenario: DraftCreateScenario, +): Promise { + await openWorkspaceDraft(page, scenario.workspaceId); + await selectModel(page, "one-minute-stream"); + const prompt = "Keep this row through create handoff."; + const userMessage = await submitMessageWithImage(page, prompt); + await scenario.agentCreatedDelay.waitForCreateRequest(); + await scenario.agentCreatedDelay.waitForDelayedCreatedStatus(); + await expectPendingSubmission(page, userMessage); + return { prompt, userMessage }; +} + +async function completeDraftCreateSubmission( + page: Page, + scenario: DraftCreateScenario, + pending: DraftCreatePendingSubmission, +): Promise { + scenario.agentCreatedDelay.release(); + await expectCreatedAgentHandoff(page, pending.prompt, pending.userMessage); +} + +test.describe("Agent message submission", () => { + test("settles an immediately interrupted first prompt", async ({ page }, testInfo) => { + const workspace = await seedWorkspace({ + repoPrefix: `submission-immediate-interrupt-${testInfo.workerIndex}-`, + }); + const prompt = "Withhold synthetic user message until interrupted."; + try { + await openWorkspaceDraft(page, workspace.workspaceId); + await selectModel(page, "one-minute-stream"); + + await submitMessage(page, prompt); + await expectAgentReadyToInterrupt(page); + await page.getByRole("button", { name: "Stop agent", exact: true }).click(); + + const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(submittedPrompt).toHaveCount(1); + await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0); + await expectVisibleAgentSurfacesIdle(page); + await expectComposerEditable(page); + + await page.reload(); + await expect(submittedPrompt).toHaveCount(1); + await expect(submittedPrompt).toHaveAttribute("aria-busy", "false"); + await expectVisibleAgentSurfacesIdle(page); + await expectComposerEditable(page); + } finally { + await workspace.cleanup(); + } + }); + + test("keeps one canonical prompt when the provider echoes before accepting", async ({ + page, + }, testInfo) => { + const workspace = await seedWorkspace({ + repoPrefix: `submission-echo-before-accept-${testInfo.workerIndex}-`, + }); + const prompt = "Emit synthetic user message before accepting turn."; + try { + await openWorkspaceDraft(page, workspace.workspaceId); + await selectModel(page, "one-minute-stream"); + + await submitMessage(page, prompt); + await expectAgentReadyToInterrupt(page); + await page.getByRole("button", { name: "Stop agent", exact: true }).click(); + await expectVisibleAgentSurfacesIdle(page); + + await page.reload(); + const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(submittedPrompt).toHaveCount(1); + await expect(submittedPrompt).toHaveAttribute("aria-busy", "false"); + await expectVisibleAgentSurfacesIdle(page); + } finally { + await workspace.cleanup(); + } + }); + + test("enriches a visible prompt when the provider identity arrives later", async ({ + page, + }, testInfo) => { + const workspace = await seedWorkspace({ + repoPrefix: `submission-late-provider-identity-${testInfo.workerIndex}-`, + }); + const prompt = "Delay synthetic user message by 2000ms."; + try { + await openWorkspaceDraft(page, workspace.workspaceId); + await selectModel(page, "one-minute-stream"); + + await submitMessage(page, prompt); + const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + const marker = `late-provider-identity-${Date.now()}`; + await submittedPrompt.evaluate((element, value) => { + element.dataset.lateProviderIdentity = value; + }, marker); + + await submittedPrompt.hover(); + await expect(submittedPrompt.getByTestId("rewind-menu-trigger")).toBeVisible({ + timeout: 5_000, + }); + await expect(page.locator(`[data-late-provider-identity="${marker}"]`)).toBeVisible(); + await page.getByRole("button", { name: "Stop agent", exact: true }).click(); + await expectVisibleAgentSurfacesIdle(page); + } finally { + await workspace.cleanup(); + } + }); + + test("keeps one prompt when its provider echo crosses the bounded tail", async ({ + page, + }, testInfo) => { + const workspace = await seedWorkspace({ + repoPrefix: `submission-echo-past-tail-${testInfo.workerIndex}-`, + }); + const prompt = "Emit 205 assistant messages before synthetic user message."; + try { + await openWorkspaceDraft(page, workspace.workspaceId); + await selectModel(page, "one-minute-stream"); + + await submitMessage(page, prompt); + const submittedPrompt = page.getByTestId("user-message").filter({ hasText: prompt }); + await expect(submittedPrompt).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expectVisibleAgentSurfacesIdle(page); + await page.reload(); + + await expect(submittedPrompt).toHaveCount(0); + await scrollTimelineUntilOlderHistoryIsReachable(page, prompt); + await expect(submittedPrompt).toHaveCount(1); + await expect(submittedPrompt).toHaveAttribute("aria-busy", "false"); + } finally { + await workspace.cleanup(); + } + }); + + test("keeps one Stop action while submitting into a running agent", async ({ page }) => { + const gate = await installDaemonWebSocketGate(page); + const agent = await seedMockAgentWorkspace({ + repoPrefix: "submission-running-action-", + title: "Running submission action", + model: "one-minute-stream", + }); + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await submitMessage(page, "Keep running while the next prompt is submitted."); + await expectAgentReadyToInterrupt(page); + + gate.holdNextClientRequest("send_agent_message_request"); + await fillComposerDraft(page, "Replace the running turn without duplicating its action."); + await expect( + page.getByRole("button", { name: "Send and interrupt", exact: true }), + ).toHaveCount(1); + await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Interrupt agent", exact: true })).toHaveCount( + 0, + ); + await composerLocator(page).press("Enter"); + await gate.waitForHeldClientRequest(); + + await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(1); + await expect(page.getByRole("button", { name: "Interrupt agent", exact: true })).toHaveCount( + 0, + ); + + gate.releaseHeldClientRequest(); + } finally { + gate.restore(); + await agent.cleanup(); + } + }); + + test("makes the next queued turn interruptible after cancellation settles", async ({ page }) => { + const title = "Queued turn after interrupt"; + const secondPrompt = "Run the queued turn after interruption."; + const agent = await seedMockAgentWorkspace({ + repoPrefix: "submission-queued-after-interrupt-", + title, + model: "one-minute-stream", + }); + try { + const gate = await installDaemonWebSocketGate(page); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await submitMessage(page, "Keep running until the queued turn is ready."); + await expectAgentReadyToInterrupt(page); + await queueMessage(page, secondPrompt); + await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible(); + + gate.holdNextServerMessage("cancel_agent_response"); + await page.getByRole("button", { name: "Stop agent", exact: true }).click(); + await gate.waitForHeldServerMessage(); + + await expect(page.getByTestId("user-message").filter({ hasText: secondPrompt })).toHaveCount( + 1, + ); + await expectRunningAgentChrome(page, title); + await expectAgentReadyToInterrupt(page); + + gate.holdNextClientRequest("cancel_agent_request"); + await page.getByRole("button", { name: "Stop agent", exact: true }).click(); + await gate.waitForHeldClientRequest(); + await expect( + page.getByRole("button", { name: "Canceling agent", exact: true }), + ).toBeVisible(); + + gate.releaseHeldServerMessage(); + await expect( + page.getByRole("button", { name: "Canceling agent", exact: true }), + ).toBeVisible(); + gate.releaseHeldClientRequest(); + await expectVisibleAgentSurfacesIdle(page); + } finally { + await agent.cleanup(); + } + }); + + test("shows every agent surface idle after interrupting a submitted turn", async ({ page }) => { + const title = "Interrupted submission"; + const agent = await seedMockAgentWorkspace({ + repoPrefix: "submission-interrupt-", + title, + model: "one-minute-stream", + }); + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await submitMessage(page, "Interrupt this submitted turn."); + await expectRunningAgentChrome(page, title); + + await cancelAgent(page); + + await expectAgentSurfacesIdle(page, title); + } finally { + await agent.cleanup(); + } + }); + + test("reconciles every agent surface when interrupt completion is missed", async ({ page }) => { + const gate = await installDaemonWebSocketGate(page); + const title = "Missed interrupt completion"; + const agent = await seedMockAgentWorkspace({ + repoPrefix: "submission-missed-interrupt-", + title, + model: "one-minute-stream", + }); + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + await submitMessage(page, "Interrupt without delivering the terminal event."); + await expectRunningAgentChrome(page, title); + gate.setAgentStreamEventSuppressed("turn_canceled", true); + + await cancelAgent(page); + await gate.waitForAgentStreamEvent("turn_canceled"); + await agent.client.waitForFinish(agent.agentId, 30_000); + + await expectAgentSurfacesIdle(page, title); + } finally { + gate.setAgentStreamEventSuppressed("turn_canceled", false); + await agent.cleanup(); + } + }); + + test("reconciles an interrupted submission when live acknowledgements are missed", async ({ + page, + }) => { + const gate = await installDaemonWebSocketGate(page); + const title = "Missed submission acknowledgements"; + const prompt = "Interrupt without delivering live acknowledgements."; + const agent = await seedMockAgentWorkspace({ + repoPrefix: "submission-missed-acknowledgements-", + title, + model: "one-minute-stream", + }); + try { + await openAgentRoute(page, agent); + await expectComposerVisible(page); + gate.setAgentStreamItemSuppressed("user_message", true); + gate.setAgentStreamEventSuppressed("turn_canceled", true); + + await submitMessage(page, prompt); + await gate.waitForAgentStreamItem("user_message"); + await expectRunningAgentChrome(page, title); + await cancelAgent(page); + await gate.waitForAgentStreamEvent("turn_canceled"); + await agent.client.waitForFinish(agent.agentId, 30_000); + + await expect(page.getByTestId("user-message").filter({ hasText: prompt })).toHaveCount(1); + await expectAgentSurfacesIdle(page, title); + } finally { + gate.setAgentStreamItemSuppressed("user_message", false); + gate.setAgentStreamEventSuppressed("turn_canceled", false); + await agent.cleanup(); + } + }); + + test("keeps layout stable when submitting to an agent with existing history", async ({ + page, + submissionScenario, + }) => { + const prompt = "Hold this submission."; + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + await composer.fill(prompt); + await expect(composer).toHaveValue(prompt); + await expect( + page.getByTestId("user-message").filter({ hasText: submissionScenario.existingPrompt }), + ).toBeVisible(); + const finishTimelineRowStabilityCheck = await beginTimelineRowStabilityCheck( + page, + page.getByTestId("assistant-message").last(), + ); + const assistantMessageCount = await page.getByTestId("assistant-message").count(); + const toolCallCount = await page.getByTestId("tool-call-badge").count(); + await composer.press("Enter"); + const userMessage = page.getByTestId("user-message").filter({ hasText: prompt }).last(); + await expect(userMessage).toBeVisible(); + await expect + .poll(async () => page.getByTestId("assistant-message").count()) + .toBeGreaterThan(assistantMessageCount); + await expect + .poll(async () => page.getByTestId("tool-call-badge").count()) + .toBeGreaterThan(toolCallCount); + await finishTimelineRowStabilityCheck(); + }); + + test("keeps the submitted row stable through draft create handoff", async ({ + page, + draftCreateScenario, + }) => { + test.setTimeout(120_000); + const pending = await beginDraftCreateSubmission(page, draftCreateScenario); + await completeDraftCreateSubmission(page, draftCreateScenario, pending); + }); + + test("restores a rejected submission and accepts its retry", async ({ + page, + rejectionScenario, + }) => { + const prompt = "Restore this rejected submission."; + await submitMessageThatWillBeRejected(page, prompt); + await expectRejectedSubmissionRestored(page, { prompt, ...rejectionScenario }); + await retryRestoredSubmission(page, prompt); + }); + + test("restores overlapping queued sends when their connection fails", async ({ + page, + }, testInfo) => { + test.setTimeout(120_000); + const gate = await gateNextAgentMessage(page); + const agent = await startRunningMockAgent(page, { + prefix: `overlapping-queued-send-${testInfo.workerIndex}-`, + model: "one-minute-stream", + prompt: "Keep the agent running while messages queue.", + }); + const prompts = ["Restore the first queued send.", "Restore the second queued send."]; + try { + await queueMessage(page, prompts[0]); + await queueMessage(page, prompts[1]); + await page.getByRole("button", { name: "Send queued message now" }).first().click(); + await gate.waitForRequest(1); + await page.getByRole("button", { name: "Send queued message now" }).first().click(); + await gate.waitForRequest(2); + await gate.disconnect(); + await expectQueuedSendFailuresRestored(page, prompts); + } finally { + await agent.cleanup(); + } + }); + + test("does not accept a failed submission from an unrelated running turn", async ({ + page, + unrelatedRunningScenario, + }) => { + const prompt = "Restore this unsent prompt."; + await submitMessageThatWillBeRejected(page, prompt); + await unrelatedRunningScenario.gate.waitForRequest(); + await unrelatedRunningScenario.agent.client.sendAgentMessage( + unrelatedRunningScenario.agent.agentId, + "Start an unrelated turn.", + ); + await expect( + page.getByTestId("user-message").filter({ hasText: "Start an unrelated turn." }), + ).toBeVisible(); + await unrelatedRunningScenario.gate.disconnect(); + await expectFailedSubmissionRestored(page, prompt); + }); + + test("keeps a submitted prompt before its response when canonical history arrives", async ({ + page, + }, testInfo) => { + test.setTimeout(90_000); + await expectInterruptedTurnOrderAfterReconnect(page, testInfo); + }); + + test("clears an attachment-only submission when canonical history arrives after a missed running transition", async ({ + page, + }, testInfo) => { + test.setTimeout(90_000); + await expectCompletedSubmissionClearsAfterMissedRunningTransition(page, testInfo); + }); + + test("clears a provider acknowledgement that arrives before RPC acceptance", async ({ + page, + }, testInfo) => { + test.setTimeout(90_000); + await expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission(page, testInfo); + }); + + test("keeps an old-daemon replacement answer after its interrupted prompt", async ({ + page, + }, testInfo) => { + test.setTimeout(90_000); + await expectLegacyAssistantStartsAfterInterruptedPrompt(page, testInfo); + }); + + test("preserves newer live output when a stale canonical page arrives", async ({ + page, + }, testInfo) => { + test.setTimeout(90_000); + await expectStaleCanonicalPagePreservesNewerLiveOutput(page, testInfo); + }); + + test("uses canonical order when another client turn overtakes a held submission", async ({ + page, + }, testInfo) => { + await expectCanonicalOrderWinsAcrossOverlappingClients(page, testInfo); + }); + + test("keeps a daemon-handled submitted row after authoritative reload", async ({ + page, + }, testInfo) => { + await expectDaemonHandledSubmissionSurvivesReload(page, testInfo); + }); + + test("uses untracked optimistic rows for an old host", async ({ page }, testInfo) => { + await expectOldHostSubmissionBehavior(page, testInfo); + }); +}); diff --git a/packages/app/e2e/agent-stream-ui.spec.ts b/packages/app/e2e/agent-stream-ui.spec.ts index aeeb6cff7..45f9cb9df 100644 --- a/packages/app/e2e/agent-stream-ui.spec.ts +++ b/packages/app/e2e/agent-stream-ui.spec.ts @@ -3,6 +3,7 @@ import { awaitAssistantMessage, expectAgentIdle, expectInlineWorkingIndicator, + expectRunningAgentChrome, expectTurnCopyButton, expectScrollFollowsNewContent, } from "./helpers/agent-stream"; @@ -18,11 +19,35 @@ import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-g import { selectModel } from "./helpers/app"; import { clickNewChat } from "./helpers/launcher"; import { expectComposerVisible, startRunningMockAgent } from "./helpers/composer"; -import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { + openAgentRoute, + seedMockAgentWorkspace, + seedRunningMockAgentWorkspace, +} from "./helpers/mock-agent"; const SCROLL_AWAY_MIN_SCROLLABLE_DISTANCE = 360; test.describe("Agent stream UI", () => { + test("keeps running agent chrome after page refresh", async ({ page }) => { + const title = "Running agent refresh"; + const agent = await seedRunningMockAgentWorkspace({ + repoPrefix: "stream-running-refresh-", + title, + model: "five-minute-stream", + initialPrompt: "Stay running while the page refreshes.", + }); + try { + await openAgentRoute(page, agent); + await expectRunningAgentChrome(page, title); + + await page.reload(); + + await expectRunningAgentChrome(page, title); + } finally { + await agent.cleanup(); + } + }); + test("auto-scroll sticks to bottom across token bursts", async ({ page }) => { test.setTimeout(120_000); const agent = await startRunningMockAgent(page, { diff --git a/packages/app/e2e/agent-tab-image-stability.spec.ts b/packages/app/e2e/agent-tab-image-stability.spec.ts new file mode 100644 index 000000000..6ad5d0550 --- /dev/null +++ b/packages/app/e2e/agent-tab-image-stability.spec.ts @@ -0,0 +1,85 @@ +import { test as base } from "./fixtures"; +import { + appendSettledTimelineTurns, + createNearTenMegabyteAssistantPng, + createSettledMockAgent, + createSmallAssistantPng, + emitSettledAssistantImage, + expectAssistantImageNotMounted, + expectAssistantImageRendered, + openAssistantImageTimeline, + openExistingImageAgentTabs, + remountAndRecoverAssistantImageFromHistory, + sendFollowUpAndExpectVisibleResponse, + switchAwayAndBackWithoutImageInstability, + userPagesUntilAssistantImageRenders, +} from "./helpers/assistant-images"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; + +const test = base.extend<{ imageWorkspace: SeededWorkspace }>({ + imageWorkspace: async ({ page: _page }, provide) => { + const workspace = await seedWorkspace({ repoPrefix: "agent-tab-image-stability-" }); + try { + await provide(workspace); + } finally { + await workspace.cleanup(); + } + }, +}); + +test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({ + imageWorkspace: workspace, + page, +}) => { + test.setTimeout(120_000); + const image = await createSmallAssistantPng(workspace, { + alt: "Real file image", + fileName: "assistant-preview.png", + }); + const imageAgent = await createSettledMockAgent(workspace, "Image timeline"); + const otherAgent = await createSettledMockAgent(workspace, "Other timeline"); + await emitSettledAssistantImage(workspace.client, imageAgent, image); + + await openExistingImageAgentTabs(page, { imageAgent, otherAgent }); + await expectAssistantImageRendered(page, image); + await switchAwayAndBackWithoutImageInstability(page, { image, imageAgent, otherAgent }); +}); + +test("a real assistant PNG remains reachable through pagination and remount", async ({ + imageWorkspace: workspace, + page, +}) => { + test.setTimeout(120_000); + const image = await createSmallAssistantPng(workspace, { + alt: "Paginated real file image", + fileName: "paginated-assistant-preview.png", + }); + const imageAgent = await createSettledMockAgent(workspace, "Paginated image timeline"); + await emitSettledAssistantImage(workspace.client, imageAgent, image); + await appendSettledTimelineTurns(workspace.client, imageAgent, 40); + + await openAssistantImageTimeline(page, imageAgent); + await expectAssistantImageNotMounted(page, image); + await userPagesUntilAssistantImageRenders(page, image); + await remountAndRecoverAssistantImageFromHistory(page, image); +}); + +test("a near-10 MiB real assistant PNG renders and the app remains responsive", async ({ + imageWorkspace: workspace, + page, +}) => { + test.setTimeout(180_000); + const image = await createNearTenMegabyteAssistantPng(workspace, { + alt: "Large real file image", + fileName: "large-assistant-preview.png", + }); + const imageAgent = await createSettledMockAgent(workspace, "Large image timeline"); + await emitSettledAssistantImage(workspace.client, imageAgent, image); + + await openAssistantImageTimeline(page, imageAgent); + await expectAssistantImageRendered(page, image); + await sendFollowUpAndExpectVisibleResponse(page, { + prompt: "confirm responsiveness: emit 1 coalesced agent stream updates", + response: "stress-update-0", + }); +}); diff --git a/packages/app/e2e/agent-timeline-pagination.spec.ts b/packages/app/e2e/agent-timeline-pagination.spec.ts index 30c0bc379..8cb1fdca6 100644 --- a/packages/app/e2e/agent-timeline-pagination.spec.ts +++ b/packages/app/e2e/agent-timeline-pagination.spec.ts @@ -1,27 +1,229 @@ -import { test } from "./fixtures"; +import { expect, test } from "./fixtures"; import { - expectLoadedTimelineDoesNotScroll, + expectSameOlderHistoryLoadingOperation, + expectStableHistoryStartGutter, + expectTimelineAtHistoryStart, + expectTimelinePromptCentered, expectTimelinePromptNotMounted, + expectTimelinePromptPositionPreserved, expectTimelinePromptVisible, - holdNextOlderTimelinePage, + holdBootstrapTimelinePage, + holdDaemonHydration, + holdOlderHistoryPages, makeLoadedTimelineFitViewport, openAgentTimeline, + rememberOlderHistoryLoadingOperation, + rememberTimelineViewport, + rememberTimelinePromptPosition, + reloadAgentTimelineFromPersistedReplica, scrollTimelineUntilOlderHistoryIsReachable, + scrollTimelineToOldestLoadedEdge, + scrollTimelineToNewestLoadedEdge, seedLongMockAgentTimeline, + scrollThroughOlderHistoryPages, + seedRealisticOlderTimeline, + sendLiveTurnBeforeHydration, + expectTimelineViewportAnchoredAfterPrepend, + userNavigatesTimelineToHistoryStartWithKeyboard, + userScrollsTimelineToHistoryStart, } from "./helpers/timeline-pagination"; test.describe("Agent timeline pagination", () => { - test("loads older history when the user scrolls to the top of a long agent timeline", async ({ + test("keeps a fixed history-start gutter before, during, and after pagination", async ({ + page, + }) => { + const agent = await seedLongMockAgentTimeline({ turns: 40 }); + try { + const history = await holdOlderHistoryPages(page, agent); + await openAgentTimeline(page, agent); + await expectStableHistoryStartGutter(page); + + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + await expectStableHistoryStartGutter(page); + + history.releasePage(1); + await history.expectSettledWithRequestedPages(1); + await expectStableHistoryStartGutter(page); + } finally { + await agent.cleanup(); + } + }); + + test("loads one page each time the user returns to history start", async ({ page }) => { + test.setTimeout(120_000); + const agent = await seedLongMockAgentTimeline({ turns: 80 }); + try { + const history = await holdOlderHistoryPages(page, agent); + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, agent.newestPrompt); + await expectTimelinePromptCentered(page, agent.newestPrompt); + await expectTimelinePromptNotMounted(page, agent.oldestPrompt); + + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + history.releasePage(1); + await history.expectSettledWithRequestedPages(1); + await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt); + + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(2); + } finally { + await agent.cleanup(); + } + }); + + test("loads older history when the user navigates to history start with the keyboard", async ({ + page, + }) => { + const agent = await seedLongMockAgentTimeline({ turns: 40 }); + try { + const history = await holdOlderHistoryPages(page, agent); + await openAgentTimeline(page, agent); + + await userNavigatesTimelineToHistoryStartWithKeyboard(page); + await history.expectRequestedPages(1); + } finally { + await agent.cleanup(); + } + }); + + test("does not repeat an assistant block that spans older history pages", async ({ page }) => { + test.setTimeout(120_000); + const agent = await seedRealisticOlderTimeline(); + try { + const history = await holdOlderHistoryPages(page, agent); + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, agent.newestPrompt); + + await scrollThroughOlderHistoryPages(page, 3, history); + history.expectNoRepeatedAssistantEntries(); + } finally { + await agent.cleanup(); + } + }); + + test("keeps the visible timeline position anchored while prepending a page", async ({ page }) => { + test.setTimeout(120_000); + const agent = await seedLongMockAgentTimeline({ turns: 80 }); + try { + const history = await holdOlderHistoryPages(page, agent); + await openAgentTimeline(page, agent); + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + const viewport = await rememberTimelineViewport(page); + + history.releasePage(1); + await expectTimelineViewportAnchoredAfterPrepend(page, viewport); + } finally { + await agent.cleanup(); + } + }); + + test("keeps visible history anchored when live output grows during a prepend", async ({ page, }) => { test.setTimeout(120_000); const agent = await seedLongMockAgentTimeline({ turns: 80 }); try { + const history = await holdOlderHistoryPages(page, agent); await openAgentTimeline(page, agent); - await expectTimelinePromptVisible(page, agent.newestPrompt); - await expectTimelinePromptNotMounted(page, agent.oldestPrompt); + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt); - await scrollTimelineUntilOlderHistoryIsReachable(page); + await agent.client.sendAgentMessage( + agent.agentId, + "timeline live during held older page: emit 20 coalesced agent stream updates", + ); + await agent.client.waitForFinish(agent.agentId, 15_000); + history.releasePage(1); + + await expectTimelinePromptPositionPreserved(page, position); + } finally { + await agent.cleanup(); + } + }); + + test("finishes loading an older page while live output continues", async ({ page }) => { + test.setTimeout(120_000); + const agent = await seedLongMockAgentTimeline({ turns: 40 }); + try { + const history = await holdOlderHistoryPages(page, agent); + await openAgentTimeline(page, agent); + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + + await agent.client.sendAgentMessage(agent.agentId, "keep streaming while history settles"); + await agent.client.waitForAgentUpsert( + agent.agentId, + (snapshot) => snapshot.status === "running", + ); + history.releasePage(1); + + await expect(page.getByTestId("load-older-history-spinner")).toBeHidden({ timeout: 5_000 }); + const running = await agent.client.fetchAgents({ scope: "active" }); + expect(running.entries.find((entry) => entry.agent.id === agent.agentId)?.agent.status).toBe( + "running", + ); + } finally { + await agent.cleanup(); + } + }); + + test("keeps the visible timeline anchored when the final page finishes", async ({ page }) => { + test.setTimeout(120_000); + const agent = await seedLongMockAgentTimeline({ turns: 40 }); + try { + const history = await holdOlderHistoryPages(page, agent); + await openAgentTimeline(page, agent); + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(1); + const position = await rememberTimelinePromptPosition(page, agent.initialTailOldestPrompt); + + history.releasePage(1); + + await expectTimelinePromptPositionPreserved(page, position); + await history.expectSettledWithRequestedPages(1); + } finally { + await agent.cleanup(); + } + }); + + test("continues one loading operation while older pages still leave history start exposed", async ({ + page, + }) => { + test.setTimeout(120_000); + const agent = await seedLongMockAgentTimeline({ turns: 80 }); + try { + await makeLoadedTimelineFitViewport(page); + const history = await holdOlderHistoryPages(page, agent); + await openAgentTimeline(page, agent); + await scrollTimelineToOldestLoadedEdge(page); + await history.expectRequestedPages(1); + const loading = await rememberOlderHistoryLoadingOperation(page); + + history.releasePage(1); + await history.expectRequestedPages(2); + await expectTimelineAtHistoryStart(page); + await expectSameOlderHistoryLoadingOperation(page, loading); + } finally { + await agent.cleanup(); + } + }); + + test("keeps complete loaded history reachable after reload", async ({ page }) => { + test.setTimeout(120_000); + const agent = await seedLongMockAgentTimeline({ turns: 80 }); + try { + await openAgentTimeline(page, agent); + await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt); + await expectTimelinePromptVisible(page, agent.oldestPrompt); + + const hydration = await holdDaemonHydration(page); + await reloadAgentTimelineFromPersistedReplica(page, agent); + hydration.release(); + await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt); await expectTimelinePromptVisible(page, agent.oldestPrompt); } finally { @@ -29,18 +231,27 @@ test.describe("Agent timeline pagination", () => { } }); - test("loads older history when the initial page does not fill the viewport", async ({ page }) => { + test("preserves a live row received before replica hydration", async ({ page }) => { test.setTimeout(120_000); - const agent = await seedLongMockAgentTimeline({ turns: 30 }); + const agent = await seedLongMockAgentTimeline({ turns: 80 }); try { - await makeLoadedTimelineFitViewport(page); - const olderPage = await holdNextOlderTimelinePage(page, agent); await openAgentTimeline(page, agent); - await expectTimelinePromptVisible(page, agent.newestPrompt); - await expectLoadedTimelineDoesNotScroll(page); - await olderPage.expectLoading(); - olderPage.release(); + await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt); + + const hydration = await holdBootstrapTimelinePage(page, agent); + await reloadAgentTimelineFromPersistedReplica(page, agent); + await hydration.waitForDelayedResponse(); + const livePrompt = await sendLiveTurnBeforeHydration(agent); + await expectTimelinePromptVisible(page, livePrompt); + + hydration.release(); + await hydration.waitForDelayedCatchUp(); + await expectTimelinePromptVisible(page, livePrompt); + hydration.releaseCatchUp(); + await scrollTimelineUntilOlderHistoryIsReachable(page, agent.oldestPrompt); await expectTimelinePromptVisible(page, agent.oldestPrompt); + await scrollTimelineToNewestLoadedEdge(page); + await expectTimelinePromptVisible(page, livePrompt); } finally { await agent.cleanup(); } diff --git a/packages/app/e2e/agent-timeline-resume.spec.ts b/packages/app/e2e/agent-timeline-resume.spec.ts new file mode 100644 index 000000000..e21e3a186 --- /dev/null +++ b/packages/app/e2e/agent-timeline-resume.spec.ts @@ -0,0 +1,65 @@ +import { test } from "./fixtures"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { + expectTimelinePresentationUnchanged, + expectTimelinePromptNotMounted, + expectTimelinePromptVisible, + openAgentTimeline, + rememberTimelinePresentation, + scrollTimelinePromptIntoView, + scrollTimelineUntilOlderHistoryIsReachable, + seedLongMockAgentTimeline, +} from "./helpers/timeline-pagination"; +import { + commitTimelineTurnsWhileDisconnected, + disconnectViewedTimeline, + expectOneTailCheckWithoutCatchUpPages, + rememberTimelineRequestCounts, + restoreViewedTimelineWithHeldTail, +} from "./helpers/timeline-resume"; + +test.describe("Agent timeline resume", () => { + test("a long disconnected run lands as one latest-tail update", async ({ page }) => { + test.setTimeout(120_000); + const gate = await installDaemonWebSocketGate(page); + const agent = await seedLongMockAgentTimeline({ turns: 3 }); + try { + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, agent.newestPrompt); + const requests = rememberTimelineRequestCounts(gate); + await disconnectViewedTimeline(page, gate); + const background = await commitTimelineTurnsWhileDisconnected(agent, 24); + await restoreViewedTimelineWithHeldTail(page, gate); + + await expectTimelinePromptVisible(page, background.newestPrompt); + await expectTimelinePromptNotMounted(page, background.firstPrompt); + expectOneTailCheckWithoutCatchUpPages(gate, requests); + await scrollTimelineUntilOlderHistoryIsReachable(page, background.firstPrompt); + } finally { + gate.restore(); + await agent.cleanup(); + } + }); + + test("an unchanged resume leaves a scrolled-up timeline untouched", async ({ page }) => { + test.setTimeout(120_000); + const gate = await installDaemonWebSocketGate(page); + const agent = await seedLongMockAgentTimeline({ turns: 30 }); + try { + await openAgentTimeline(page, agent); + await expectTimelinePromptVisible(page, agent.newestPrompt); + await scrollTimelinePromptIntoView(page, agent.initialTailAnchorPrompt); + const presentation = await rememberTimelinePresentation(page, agent.initialTailAnchorPrompt); + const requests = rememberTimelineRequestCounts(gate); + + await disconnectViewedTimeline(page, gate); + await restoreViewedTimelineWithHeldTail(page, gate); + + await expectTimelinePresentationUnchanged(page, presentation); + expectOneTailCheckWithoutCatchUpPages(gate, requests); + } finally { + gate.restore(); + await agent.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/helpers/agent-message-gate.ts b/packages/app/e2e/helpers/agent-message-gate.ts new file mode 100644 index 000000000..866d25770 --- /dev/null +++ b/packages/app/e2e/helpers/agent-message-gate.ts @@ -0,0 +1,86 @@ +import type { Page, WebSocketRoute } from "@playwright/test"; +import { daemonWsRoutePattern } from "./daemon-port"; + +type WebSocketMessage = string | Buffer; + +interface SendAgentMessageRequest { + type: "send_agent_message_request"; + requestId: string; + agentId: string; +} + +function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null { + if (typeof message !== "string") return null; + try { + const envelope = JSON.parse(message) as { + type?: unknown; + message?: Record; + }; + const request = envelope.type === "session" ? envelope.message : null; + if ( + request?.type !== "send_agent_message_request" || + typeof request.requestId !== "string" || + typeof request.agentId !== "string" + ) { + return null; + } + return { + type: "send_agent_message_request", + requestId: request.requestId, + agentId: request.agentId, + }; + } catch { + return null; + } +} + +export async function gateNextAgentMessage(page: Page) { + let serverSocket: WebSocketRoute | null = null; + let browserSocket: WebSocketRoute | null = null; + const heldMessages: Array = []; + const requests: SendAgentMessageRequest[] = []; + const requestWaiters = new Set<() => void>(); + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + browserSocket = ws; + const server = ws.connectToServer(); + serverSocket = server; + + ws.onMessage((message) => { + const request = readSendRequest(message); + if (request) { + heldMessages.push(message); + requests.push(request); + for (const resolve of requestWaiters) resolve(); + requestWaiters.clear(); + return; + } + server.send(message); + }); + + server.onMessage((message) => ws.send(message)); + }); + + const waitForRequest = async (count = 1): Promise => { + while (requests.length < count) { + await new Promise((resolve) => requestWaiters.add(resolve)); + } + return requests[count - 1]; + }; + + return { + waitForRequest, + accept(index = 0) { + const heldMessage = heldMessages[index]; + if (!serverSocket || !heldMessage) { + throw new Error("No held send-agent-message request to accept"); + } + serverSocket.send(heldMessage); + heldMessages[index] = null; + }, + async disconnect(): Promise { + if (!browserSocket) throw new Error("No browser daemon socket to disconnect"); + await browserSocket.close({ code: 1008, reason: "Dropped by submission test." }); + }, + }; +} diff --git a/packages/app/e2e/helpers/agent-stream.ts b/packages/app/e2e/helpers/agent-stream.ts index a6086513f..313028ab7 100644 --- a/packages/app/e2e/helpers/agent-stream.ts +++ b/packages/app/e2e/helpers/agent-stream.ts @@ -22,6 +22,50 @@ export async function expectInlineWorkingIndicator(page: Page): Promise { await expect(page.getByTestId("turn-working-indicator")).toBeVisible({ timeout: 30_000 }); } +export async function expectRunningAgentChrome(page: Page, title: string): Promise { + const tab = page.getByRole("button", { name: title, exact: true }); + + await expect(tab).toBeVisible({ timeout: 30_000 }); + await expect(tab.getByRole("progressbar", { name: "Agent running" })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByRole("button", { name: /stop agent|canceling agent/i })).toBeVisible({ + timeout: 30_000, + }); + await expectInlineWorkingIndicator(page); +} + +export async function expectAgentReadyToInterrupt(page: Page): Promise { + await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByRole("button", { name: "Canceling agent", exact: true })).toHaveCount(0); +} + +export async function expectVisibleAgentSurfacesIdle(page: Page): Promise { + const visibleAgentTab = page + .getByTestId(/^workspace-tab-agent_/) + .filter({ visible: true }) + .first(); + + await expect(visibleAgentTab).toBeVisible({ timeout: 30_000 }); + await expect(visibleAgentTab.getByRole("progressbar", { name: "Agent running" })).toHaveCount(0); + await expect(page.getByRole("button", { name: /stop agent|canceling agent/i })).toHaveCount(0); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + await expect(page.getByTestId("turn-working-elapsed")).toHaveCount(0); +} + +export async function expectAgentSurfacesIdle(page: Page, title: string): Promise { + const tab = page.getByRole("button", { name: title, exact: true }); + + await expect(tab).toBeVisible({ timeout: 30_000 }); + await expect(tab.getByRole("progressbar", { name: "Agent running" })).toHaveCount(0); + await expect(page.getByRole("button", { name: /stop agent|canceling agent/i })).toHaveCount(0); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + await expect(page.getByTestId("turn-working-elapsed")).toHaveCount(0); + await expectTurnCopyButton(page); +} + export async function expectTurnCopyButton(page: Page): Promise { await expect(page.getByRole("button", { name: "Copy turn" }).first()).toBeVisible({ timeout: 30_000, diff --git a/packages/app/e2e/helpers/agent-timeline-gate.ts b/packages/app/e2e/helpers/agent-timeline-gate.ts index 511d303db..3c7f7865a 100644 --- a/packages/app/e2e/helpers/agent-timeline-gate.ts +++ b/packages/app/e2e/helpers/agent-timeline-gate.ts @@ -15,6 +15,22 @@ export interface AgentTimelineResponseGate { waitForDelayedResponse(): Promise; } +export interface OlderTimelinePagesGate { + getRequestCount(): number; + getRepeatedAssistantEntryCount(): number; + releasePage(pageNumber: number): void; + waitForRequestCount(count: number): Promise; +} + +export interface DaemonHydrationGate { + release(): void; +} + +export interface BootstrapTimelineGate extends AgentTimelineResponseGate { + releaseCatchUp(): void; + waitForDelayedCatchUp(): Promise; +} + function parseWebSocketJson(message: WebSocketMessage): unknown { const rawMessage = typeof message === "string" ? message : message.toString("utf8"); try { @@ -45,6 +61,32 @@ function getPayload(message: Record): Record | : null; } +export async function holdDaemonHydration(page: Page): Promise { + let released = false; + const delayedForwards: Array<() => void> = []; + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => server.send(message)); + server.onMessage((message) => { + if (released) { + ws.send(message); + return; + } + delayedForwards.push(() => ws.send(message)); + }); + }); + + return { + release() { + released = true; + for (const forward of delayedForwards.splice(0)) { + forward(); + } + }, + }; +} + export async function delayCreatedAgentInitialTailResponse( page: Page, ): Promise { @@ -127,6 +169,165 @@ export async function delayCreatedAgentInitialTailResponse( export async function delayAgentOlderTimelineResponse( page: Page, agentId: string, +): Promise { + return delayAgentTimelineResponse(page, agentId, "before"); +} + +export async function holdAgentOlderTimelinePages( + page: Page, + agentId: string, +): Promise { + let requestCount = 0; + let responseCount = 0; + let repeatedAssistantEntryCount = 0; + const assistantEntryKeys = new Set(); + const releasedPages = new Set(); + const delayedForwards = new Map void>>(); + const requestWaiters = new Map void>>(); + + const resolveRequestWaiters = () => { + for (const [count, resolvers] of requestWaiters) { + if (requestCount < count) continue; + requestWaiters.delete(count); + for (const resolve of resolvers) resolve(); + } + }; + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + if ( + sessionMessage?.type === "fetch_agent_timeline_request" && + sessionMessage.agentId === agentId && + sessionMessage.direction === "before" + ) { + requestCount += 1; + resolveRequestWaiters(); + } + server.send(message); + }); + server.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + const payload = sessionMessage ? getPayload(sessionMessage) : null; + if ( + sessionMessage?.type === "fetch_agent_timeline_response" && + payload?.agentId === agentId && + payload.direction === "before" + ) { + responseCount += 1; + const entries = Array.isArray(payload.entries) ? payload.entries : []; + for (const entry of entries) { + if (!entry || typeof entry !== "object") continue; + const projectedEntry = entry as { + seqStart?: unknown; + seqEnd?: unknown; + item?: { type?: unknown }; + }; + if ( + typeof projectedEntry.seqStart !== "number" || + typeof projectedEntry.seqEnd !== "number" || + typeof projectedEntry.item?.type !== "string" + ) { + continue; + } + if (projectedEntry.item.type !== "assistant_message") continue; + const key = `${projectedEntry.seqStart}:${projectedEntry.seqEnd}:${projectedEntry.item.type}`; + if (assistantEntryKeys.has(key)) repeatedAssistantEntryCount += 1; + else assistantEntryKeys.add(key); + } + const pageNumber = responseCount; + if (releasedPages.has(pageNumber)) { + ws.send(message); + return; + } + const forwards = delayedForwards.get(pageNumber) ?? []; + forwards.push(() => ws.send(message)); + delayedForwards.set(pageNumber, forwards); + return; + } + ws.send(message); + }); + }); + + return { + getRequestCount: () => requestCount, + getRepeatedAssistantEntryCount: () => repeatedAssistantEntryCount, + releasePage(pageNumber) { + releasedPages.add(pageNumber); + for (const forward of delayedForwards.get(pageNumber) ?? []) forward(); + delayedForwards.delete(pageNumber); + }, + waitForRequestCount(count) { + if (requestCount >= count) return Promise.resolve(); + return new Promise((resolve) => { + const resolvers = requestWaiters.get(count) ?? []; + resolvers.push(resolve); + requestWaiters.set(count, resolvers); + }); + }, + }; +} + +export async function delayAgentBootstrapTailResponse( + page: Page, + agentId: string, +): Promise { + let tailReleased = false; + let catchUpReleased = false; + const delayedTailForwards: Array<() => void> = []; + const delayedCatchUpForwards: Array<() => void> = []; + let resolveDelayedTail: (() => void) | null = null; + let resolveDelayedCatchUp: (() => void) | null = null; + const delayedTail = new Promise((resolve) => { + resolveDelayedTail = resolve; + }); + const delayedCatchUp = new Promise((resolve) => { + resolveDelayedCatchUp = resolve; + }); + + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { + const server = ws.connectToServer(); + ws.onMessage((message) => server.send(message)); + server.onMessage((message) => { + const sessionMessage = getSessionMessage(message); + const payload = sessionMessage ? getPayload(sessionMessage) : null; + const isTimelineResponse = + sessionMessage?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId; + if (isTimelineResponse && payload.direction === "tail") { + resolveDelayedTail?.(); + if (tailReleased) ws.send(message); + else delayedTailForwards.push(() => ws.send(message)); + return; + } + if (isTimelineResponse && payload.direction === "after") { + resolveDelayedCatchUp?.(); + if (catchUpReleased) ws.send(message); + else delayedCatchUpForwards.push(() => ws.send(message)); + return; + } + ws.send(message); + }); + }); + + return { + release() { + tailReleased = true; + for (const forward of delayedTailForwards.splice(0)) forward(); + }, + releaseCatchUp() { + catchUpReleased = true; + for (const forward of delayedCatchUpForwards.splice(0)) forward(); + }, + waitForDelayedResponse: () => delayedTail, + waitForDelayedCatchUp: () => delayedCatchUp, + }; +} + +async function delayAgentTimelineResponse( + page: Page, + agentId: string, + direction: "before" | "tail", ): Promise { let releaseRequested = false; let delayedResponseSeen = false; @@ -148,7 +349,7 @@ export async function delayAgentOlderTimelineResponse( !delayedResponseSeen && sessionMessage?.type === "fetch_agent_timeline_response" && payload?.agentId === agentId && - payload.direction === "before" + payload.direction === direction ) { delayedResponseSeen = true; resolveDelayedResponse?.(); diff --git a/packages/app/e2e/helpers/assistant-images.ts b/packages/app/e2e/helpers/assistant-images.ts new file mode 100644 index 000000000..00016f0c0 --- /dev/null +++ b/packages/app/e2e/helpers/assistant-images.ts @@ -0,0 +1,343 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; +import { deflateSync } from "node:zlib"; +import { expect, type Page } from "@playwright/test"; +import type { ArchiveTabAgent } from "./archive-tab"; +import { openWorkspaceWithAgents } from "./archive-tab"; +import { submitMessage } from "./composer"; +import type { SeedDaemonClient, SeededWorkspace } from "./seed-client"; +import { openAgentRoute } from "./mock-agent"; +import { rememberTimelineViewport, userScrollsTimelineToHistoryStart } from "./timeline-pagination"; + +const IMAGE_PREVIEW_ERROR = "Unable to load image preview."; +const SMALL_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", + "base64", +); +const TEN_MEBIBYTES = 10 * 1024 * 1024; + +export interface AssistantImageFixture { + alt: string; + height: number; + relativePath: string; + size: number; + width: number; +} + +export async function createSmallAssistantPng( + workspace: SeededWorkspace, + input: { alt: string; fileName: string }, +): Promise { + await writeFile(path.join(workspace.repoPath, input.fileName), SMALL_PNG); + return { + alt: input.alt, + height: 1, + relativePath: input.fileName, + size: SMALL_PNG.byteLength, + width: 1, + }; +} + +export async function createNearTenMegabyteAssistantPng( + workspace: SeededWorkspace, + input: { alt: string; fileName: string }, +): Promise { + const width = 2_048; + const height = 1_280; + const bytes = encodeDeterministicPng(width, height); + if (bytes.byteLength < TEN_MEBIBYTES * 0.95 || bytes.byteLength > TEN_MEBIBYTES * 1.05) { + throw new Error(`Expected a PNG near 10 MiB, encoded ${bytes.byteLength} bytes`); + } + await writeFile(path.join(workspace.repoPath, input.fileName), bytes); + return { + alt: input.alt, + height, + relativePath: input.fileName, + size: bytes.byteLength, + width, + }; +} + +export async function createSettledMockAgent( + workspace: SeededWorkspace, + title: string, +): Promise { + const agent = await workspace.client.createAgent({ + provider: "mock", + model: "ten-second-stream", + modeId: "load-test", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title, + }); + await workspace.client.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "idle", + 30_000, + ); + return { + id: agent.id, + title, + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + }; +} + +export async function emitSettledAssistantImage( + client: SeedDaemonClient, + agent: ArchiveTabAgent, + image: AssistantImageFixture, +): Promise { + await client.sendAgentMessage( + agent.id, + `Emit settled assistant image Markdown: ![${image.alt}](${image.relativePath})`, + ); + const result = await client.waitForFinish(agent.id, 30_000); + if (result.status !== "idle" || result.final?.lastError) { + throw new Error( + `Assistant image agent did not settle: ${result.final?.lastError ?? result.status}`, + ); + } +} + +export async function appendSettledTimelineTurns( + client: SeedDaemonClient, + agent: ArchiveTabAgent, + count: number, +): Promise { + for (let index = 0; index < count; index += 1) { + await client.sendAgentMessage( + agent.id, + `image-history-turn-${index}: emit 1 coalesced agent stream updates`, + ); + const result = await client.waitForFinish(agent.id, 30_000); + if (result.status !== "idle" || result.final?.lastError) { + throw new Error( + `Assistant image history turn did not settle: ${result.final?.lastError ?? result.status}`, + ); + } + } +} + +export async function expectAssistantImageRendered( + page: Page, + image: AssistantImageFixture, +): Promise { + const rendered = page.getByRole("img", { name: image.alt }).first(); + await expect(rendered).toBeVisible({ timeout: 30_000 }); + await expect + .poll(async () => + rendered.evaluate((element) => { + const imageElement = + element instanceof HTMLImageElement ? element : element.querySelector("img"); + return imageElement?.complete + ? { height: imageElement.naturalHeight, width: imageElement.naturalWidth } + : null; + }), + ) + .toEqual({ height: image.height, width: image.width }); +} + +export async function switchAwayAndBackWithoutImageInstability( + page: Page, + input: { + image: AssistantImageFixture; + imageAgent: ArchiveTabAgent; + otherAgent: ArchiveTabAgent; + }, +): Promise { + await beginVisibleImageStabilityObservation(page, input.image.alt, input.imageAgent.id); + await selectSettledAgentTab(page, input.otherAgent); + await selectSettledAgentTab(page, input.imageAgent); + await expectAssistantImageRendered(page, input.image); + await expectNoVisibleImageInstability(page); +} + +export async function openExistingImageAgentTabs( + page: Page, + input: { imageAgent: ArchiveTabAgent; otherAgent: ArchiveTabAgent }, +): Promise { + await openWorkspaceWithAgents(page, [input.otherAgent, input.imageAgent]); +} + +export async function openAssistantImageTimeline( + page: Page, + agent: ArchiveTabAgent, +): Promise { + await openAgentRoute(page, { workspaceId: agent.workspaceId, agentId: agent.id }); +} + +export async function expectAssistantImageNotMounted( + page: Page, + image: AssistantImageFixture, +): Promise { + await expect(page.getByRole("img", { name: image.alt })).toHaveCount(0); +} + +export async function userPagesUntilAssistantImageRenders( + page: Page, + image: AssistantImageFixture, +): Promise { + const rendered = page.getByRole("img", { name: image.alt }); + for (let attempt = 0; attempt < 10; attempt += 1) { + if ((await rendered.count()) > 0) { + await expectAssistantImageRendered(page, image); + return; + } + const previous = await rememberTimelineViewport(page); + await userScrollsTimelineToHistoryStart(page); + await expect + .poll(async () => (await rememberTimelineViewport(page)).scrollHeight) + .toBeGreaterThan(previous.scrollHeight); + } + await expectAssistantImageRendered(page, image); +} + +export async function remountAndRecoverAssistantImageFromHistory( + page: Page, + image: AssistantImageFixture, +): Promise { + await page.reload(); + await userPagesUntilAssistantImageRenders(page, image); +} + +export async function sendFollowUpAndExpectVisibleResponse( + page: Page, + input: { prompt: string; response: string }, +): Promise { + await submitMessage(page, input.prompt); + await expect(page.getByText(input.prompt, { exact: true })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(input.response, { exact: true })).toBeVisible({ timeout: 30_000 }); +} + +async function selectSettledAgentTab(page: Page, agent: ArchiveTabAgent): Promise { + const tab = page.getByRole("button", { name: agent.title, exact: true }); + await tab.click(); + await expect(page).toHaveTitle(agent.title); + await expect(tab).toHaveAttribute("aria-selected", "true"); + await expect(page.locator('[data-testid="agent-chat-scroll"]:visible').first()).toBeVisible({ + timeout: 30_000, + }); +} + +async function beginVisibleImageStabilityObservation( + page: Page, + alt: string, + imageAgentId: string, +): Promise { + await page.evaluate( + ({ accessibleName, errorText, tabTestId }) => { + const record = { + errorSeen: false, + missingSeen: false, + inspect: () => undefined, + observer: null as MutationObserver | null, + }; + const isVisible = (element: Element) => { + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + record.inspect = () => { + record.errorSeen ||= Array.from(document.querySelectorAll("*")).some( + (element) => + element.children.length === 0 && + element.textContent?.trim() === errorText && + isVisible(element), + ); + const imageTab = document.querySelector(`[data-testid="${tabTestId}"]`); + if (imageTab?.getAttribute("aria-selected") !== "true") return; + const imageVisible = Array.from(document.querySelectorAll('[role="img"]')).some( + (element) => element.getAttribute("aria-label") === accessibleName && isVisible(element), + ); + record.missingSeen ||= !imageVisible; + }; + record.observer = new MutationObserver(record.inspect); + record.observer.observe(document.body, { + attributes: true, + childList: true, + subtree: true, + characterData: true, + }); + record.inspect(); + ( + window as unknown as { + __paseoAssistantImageObservation?: typeof record; + } + ).__paseoAssistantImageObservation = record; + }, + { + accessibleName: alt, + errorText: IMAGE_PREVIEW_ERROR, + tabTestId: `workspace-tab-agent_${imageAgentId}`, + }, + ); +} + +async function expectNoVisibleImageInstability(page: Page): Promise { + const observation = await page.evaluate(() => { + const owner = window as unknown as { + __paseoAssistantImageObservation?: { + errorSeen: boolean; + missingSeen: boolean; + inspect(): void; + observer: MutationObserver | null; + }; + }; + const record = owner.__paseoAssistantImageObservation; + if (!record) throw new Error("Assistant image stability observation was not started"); + record.inspect(); + record.observer?.disconnect(); + delete owner.__paseoAssistantImageObservation; + return { errorSeen: record.errorSeen, missingSeen: record.missingSeen }; + }); + expect(observation).toEqual({ errorSeen: false, missingSeen: false }); +} + +function encodeDeterministicPng(width: number, height: number): Buffer { + const stride = width * 4 + 1; + const scanlines = Buffer.allocUnsafe(stride * height); + let state = 0x9e3779b9; + for (let row = 0; row < height; row += 1) { + const rowOffset = row * stride; + scanlines[rowOffset] = 0; + for (let offset = 1; offset < stride; offset += 1) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + scanlines[rowOffset + offset] = state & 0xff; + } + } + + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 6; + return Buffer.concat([ + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), + pngChunk("IHDR", header), + pngChunk("IDAT", deflateSync(scanlines, { level: 0 })), + pngChunk("IEND", Buffer.alloc(0)), + ]); +} + +function pngChunk(type: string, data: Buffer): Buffer { + const typeBytes = Buffer.from(type, "ascii"); + const chunk = Buffer.allocUnsafe(12 + data.byteLength); + chunk.writeUInt32BE(data.byteLength, 0); + typeBytes.copy(chunk, 4); + data.copy(chunk, 8); + chunk.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 8 + data.byteLength); + return chunk; +} + +function crc32(bytes: Buffer): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} diff --git a/packages/app/e2e/helpers/daemon-websocket-gate.ts b/packages/app/e2e/helpers/daemon-websocket-gate.ts index ec0d37ea1..82067b47e 100644 --- a/packages/app/e2e/helpers/daemon-websocket-gate.ts +++ b/packages/app/e2e/helpers/daemon-websocket-gate.ts @@ -14,8 +14,10 @@ export interface DirectoryRequestStartCounts { interface ClientRequest { type?: unknown; + direction?: unknown; subscribe?: unknown; page?: { cursor?: unknown }; + payload?: unknown; mode?: unknown; path?: unknown; } @@ -33,6 +35,31 @@ interface ServerMessage { }; } +interface HeldAgentUpdate { + agentId: string; + status: string; +} + +interface HeldServerMessage { + browser: WebSocketRoute; + message: string | Buffer; + key: string; + agentStreamFollowers: Array; + blockedAgentId?: string; +} + +function readSessionMessage(message: string | Buffer): ClientRequest | null { + try { + const envelope = JSON.parse(typeof message === "string" ? message : message.toString()) as { + type?: unknown; + message?: ClientRequest; + }; + return envelope.message ?? envelope; + } catch { + return null; + } +} + function readClientRequest(message: string | Buffer): ClientRequest | null { if (typeof message !== "string") return null; try { @@ -46,18 +73,6 @@ function readClientRequest(message: string | Buffer): ClientRequest | null { } } -function readServerMessage(message: string | Buffer): ServerMessage | null { - try { - const envelope = JSON.parse(typeof message === "string" ? message : message.toString()) as { - type?: unknown; - message?: ServerMessage; - }; - return envelope.type === "session" ? (envelope.message ?? null) : envelope; - } catch { - return null; - } -} - function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCounts | null { if (request.page?.cursor) return null; if (request.type === "fetch_agents_request") return "agents"; @@ -65,15 +80,170 @@ function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCo return null; } +function stripAssistantMessageId( + message: string | Buffer, + enabled: boolean, + messageType: unknown, +): string | Buffer { + if (!enabled || messageType !== "agent_stream" || typeof message !== "string") return message; + const envelope = JSON.parse(message) as { + message?: { payload?: { event?: { type?: unknown; item?: Record } } }; + payload?: { event?: { type?: unknown; item?: Record } }; + }; + const event = (envelope.message?.payload ?? envelope.payload)?.event; + if (event?.type !== "timeline" || event.item?.type !== "assistant_message") return message; + delete event.item.messageId; + return JSON.stringify(envelope); +} + +function stripCanonicalSubmittedPrompts( + message: string | Buffer, + enabled: boolean, + messageType: unknown, +): string | Buffer { + if (!enabled || messageType !== "status" || typeof message !== "string") return message; + const envelope = JSON.parse(message) as { + message?: { payload?: { status?: unknown; features?: Record } }; + payload?: { status?: unknown; features?: Record }; + }; + const payload = envelope.message?.payload ?? envelope.payload; + if (payload?.status !== "server_info" || !payload.features) return message; + delete payload.features.canonicalSubmittedPrompts; + return JSON.stringify(envelope); +} + +function forceTimelineReset(message: string | Buffer, enabled: boolean): string | Buffer { + if (!enabled || typeof message !== "string") return message; + const envelope = JSON.parse(message) as { + message?: { payload?: Record }; + payload?: Record; + }; + const payload = envelope.message?.payload ?? envelope.payload; + if (!payload) return message; + payload.epoch = `playwright-reset-${Date.now()}`; + payload.reset = true; + return JSON.stringify(envelope); +} + +function readAgentStreamEventType(message: ClientRequest | null): string | null { + if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") { + return null; + } + const event = (message.payload as { event?: { type?: unknown } }).event; + return typeof event?.type === "string" ? event.type : null; +} + +function readAgentStreamAgentId(message: ClientRequest | null): string | null { + if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") { + return null; + } + const agentId = (message.payload as { agentId?: unknown }).agentId; + return typeof agentId === "string" ? agentId : null; +} + +function readAgentStreamItemType(message: ClientRequest | null): string | null { + if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") { + return null; + } + const event = (message.payload as { event?: { type?: unknown; item?: { type?: unknown } } }) + .event; + return event?.type === "timeline" && typeof event.item?.type === "string" + ? event.item.type + : null; +} + +function shouldSuppressServerMessage(input: { + message: ClientRequest | null; + messageTypes: ReadonlySet; + agentStreamEventTypes: ReadonlySet; + agentStreamItemTypes: ReadonlySet; + suppressAgentStream: boolean; +}): boolean { + const messageType = typeof input.message?.type === "string" ? input.message.type : null; + if (messageType && input.messageTypes.has(messageType)) return true; + if (input.suppressAgentStream && messageType === "agent_stream") return true; + const itemType = readAgentStreamItemType(input.message); + if (itemType && input.agentStreamItemTypes.has(itemType)) return true; + const eventType = readAgentStreamEventType(input.message); + return Boolean(eventType && input.agentStreamEventTypes.has(eventType)); +} + +function serverMessageKey(type: string): string { + return `message:${type}`; +} + +function agentStreamEventKey(type: string): string { + return `agent-stream-event:${type}`; +} + +function agentUpdateKey(agentId: string, status: string): string { + return `agent-update:${agentId}:${status}`; +} + +function matchesAgentUpdate(message: ClientRequest | null, agentUpdate: HeldAgentUpdate): boolean { + if (message?.type !== "agent_update") return false; + const payload = message.payload; + if ( + !payload || + typeof payload !== "object" || + (payload as { kind?: unknown }).kind !== "upsert" + ) { + return false; + } + const agent = (payload as { agent?: { id?: unknown; status?: unknown } }).agent; + return agent?.id === agentUpdate.agentId && agent.status === agentUpdate.status; +} + +function recordClientRequest( + request: ClientRequest | null, + clientRequestCounts: Map, + timelineRequestCounts: Map, + directoryStarts: DirectoryRequestStartCounts, +): void { + if (typeof request?.type !== "string") return; + clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1); + if (request.type === "fetch_agent_timeline_request" && typeof request.direction === "string") { + timelineRequestCounts.set( + request.direction, + (timelineRequestCounts.get(request.direction) ?? 0) + 1, + ); + } + const directory = directoryForRequest(request); + if (!directory) return; + const subscription = request.subscribe === undefined ? "unsubscribed" : "subscribed"; + directoryStarts[subscription][directory] += 1; + directoryStarts.total[directory] += 1; +} + export async function installDaemonWebSocketGate(page: Page) { let acceptingConnections = true; + let reconnectWithFreshClient = false; + let suppressAgentStream = false; + let forceTimelineEpochReset = false; + let stripAssistantMessageIds = false; + let stripCanonicalSubmittedPromptsFeature = false; + let heldClientRequestType: string | null = null; + let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null; + let resolveHeldClientRequest: (() => void) | null = null; + const pendingServerMessageHolds = new Map boolean>(); + const heldServerMessages: HeldServerMessage[] = []; + const heldServerMessageWaiters = new Set<() => void>(); + const suppressedServerMessageTypes = new Set(); + const suppressedAgentStreamEventTypes = new Set(); + const suppressedAgentStreamItemTypes = new Set(); const activeSockets = new Set(); + let latestServer: WebSocketRoute | null = null; const directoryStarts: DirectoryRequestStartCounts = { subscribed: { agents: 0, workspaces: 0 }, unsubscribed: { agents: 0, workspaces: 0 }, total: { agents: 0, workspaces: 0 }, }; const clientRequestCounts = new Map(); + const timelineRequestCounts = new Map(); + const serverMessageCounts = new Map(); + const agentStreamEventCounts = new Map(); + const agentStreamItemCounts = new Map(); + const serverMessageWaiters = new Set<() => void>(); const subscribedFilePaths = new Set(); const fileSubscriptionWaiters = new Map void>(); const observedFileUpdates = new Set(); @@ -100,6 +270,93 @@ export async function installDaemonWebSocketGate(page: Page) { fileUpdateWaiters.delete(key); }; + const recordServerMessage = (message: ClientRequest | null): void => { + const messageType = typeof message?.type === "string" ? message.type : null; + const itemType = readAgentStreamItemType(message); + const eventType = readAgentStreamEventType(message); + if (messageType) + serverMessageCounts.set(messageType, (serverMessageCounts.get(messageType) ?? 0) + 1); + if (itemType) + agentStreamItemCounts.set(itemType, (agentStreamItemCounts.get(itemType) ?? 0) + 1); + if (eventType) + agentStreamEventCounts.set(eventType, (agentStreamEventCounts.get(eventType) ?? 0) + 1); + if (!messageType && !itemType && !eventType) return; + for (const resolve of serverMessageWaiters) resolve(); + serverMessageWaiters.clear(); + }; + + const observeFileMessage = (message: ServerMessage | null): void => { + if ( + message?.type === "fs.file.subscribe.response" && + typeof message.payload?.initial?.path === "string" + ) { + const path = message.payload.initial.path; + subscribedFilePaths.add(path); + fileSubscriptionWaiters.get(path)?.(); + fileSubscriptionWaiters.delete(path); + } + if (message) recordFileUpdate(message); + }; + + const holdServerMessage = (input: { + browser: WebSocketRoute; + message: string | Buffer; + parsed: ClientRequest | null; + }): boolean => { + const agentId = readAgentStreamAgentId(input.parsed); + const blocked = agentId + ? heldServerMessages.find((held) => held.blockedAgentId === agentId) + : undefined; + const suppressed = shouldSuppressServerMessage({ + message: input.parsed, + messageTypes: suppressedServerMessageTypes, + agentStreamEventTypes: suppressedAgentStreamEventTypes, + agentStreamItemTypes: suppressedAgentStreamItemTypes, + suppressAgentStream, + }); + if (blocked) { + if (!suppressed) blocked.agentStreamFollowers.push(input.message); + return true; + } + const matchedHold = Array.from(pendingServerMessageHolds).find(([, matches]) => + matches(input.parsed), + ); + if (!matchedHold) return suppressed; + const [key] = matchedHold; + pendingServerMessageHolds.delete(key); + heldServerMessages.push({ + browser: input.browser, + message: input.message, + key, + agentStreamFollowers: [], + blockedAgentId: + readAgentStreamEventType(input.parsed) === "turn_started" + ? (agentId ?? undefined) + : undefined, + }); + for (const resolve of heldServerMessageWaiters) resolve(); + heldServerMessageWaiters.clear(); + return true; + }; + + const holdReadyFileUpdate = ( + browser: WebSocketRoute, + message: string | Buffer, + fileMessage: ServerMessage | null, + ): boolean => { + if ( + fileMessage?.type !== "fs.file.update" || + fileMessage.payload?.version?.status !== "ready" || + fileMessage.payload.version.path !== readyFileUpdatePathToHold || + heldReadyFileUpdate + ) { + return false; + } + heldReadyFileUpdate = () => browser.send(message); + resolveHeldReadyFileUpdate?.(); + return true; + }; + await page.routeWebSocket(daemonWsRoutePattern(), (ws) => { if (!acceptingConnections) { void ws.close({ code: 1008, reason: "Blocked by reconnect test." }); @@ -108,19 +365,28 @@ export async function installDaemonWebSocketGate(page: Page) { activeSockets.add(ws); const server = ws.connectToServer(); + latestServer = server; ws.onMessage((message) => { if (!acceptingConnections) return; - const request = readClientRequest(message); - if (typeof request?.type === "string") { - clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1); - const directory = directoryForRequest(request); - if (directory) { - const subscription = request.subscribe === undefined ? "unsubscribed" : "subscribed"; - directoryStarts[subscription][directory] += 1; - directoryStarts.total[directory] += 1; + if (reconnectWithFreshClient && typeof message === "string") { + const hello = readClientRequest(message); + if (hello?.type === "hello") { + const parsed = JSON.parse(message) as { clientId?: string }; + parsed.clientId = `${parsed.clientId ?? "playwright"}-fresh-${Date.now()}`; + reconnectWithFreshClient = false; + server.send(JSON.stringify(parsed)); + return; } } + const request = readClientRequest(message); + recordClientRequest(request, clientRequestCounts, timelineRequestCounts, directoryStarts); + if (request?.type === heldClientRequestType) { + heldClientRequest = { server, message }; + resolveHeldClientRequest?.(); + resolveHeldClientRequest = null; + return; + } if ( request?.type === "file_explorer_request" && request.mode === "file" && @@ -139,29 +405,29 @@ export async function installDaemonWebSocketGate(page: Page) { server.onMessage((message) => { if (!acceptingConnections) return; - const serverMessage = readServerMessage(message); - if ( - serverMessage?.type === "fs.file.subscribe.response" && - typeof serverMessage.payload?.initial?.path === "string" - ) { - const path = serverMessage.payload.initial.path; - subscribedFilePaths.add(path); - fileSubscriptionWaiters.get(path)?.(); - fileSubscriptionWaiters.delete(path); - } - if (serverMessage) recordFileUpdate(serverMessage); - if ( - serverMessage?.type === "fs.file.update" && - serverMessage.payload?.version?.status === "ready" && - serverMessage.payload.version.path === readyFileUpdatePathToHold && - !heldReadyFileUpdate - ) { - heldReadyFileUpdate = () => ws.send(message); - resolveHeldReadyFileUpdate?.(); + const serverMessage = readSessionMessage(message); + const fileMessage = serverMessage as ServerMessage | null; + observeFileMessage(fileMessage); + let outboundMessage = stripAssistantMessageId( + message, + stripAssistantMessageIds, + serverMessage?.type, + ); + outboundMessage = stripCanonicalSubmittedPrompts( + outboundMessage, + stripCanonicalSubmittedPromptsFeature, + serverMessage?.type, + ); + const shouldForceTimelineReset = + forceTimelineEpochReset && serverMessage?.type === "fetch_agent_timeline_response"; + outboundMessage = forceTimelineReset(outboundMessage, shouldForceTimelineReset); + if (shouldForceTimelineReset) forceTimelineEpochReset = false; + recordServerMessage(serverMessage); + if (holdServerMessage({ browser: ws, message: outboundMessage, parsed: serverMessage })) return; - } + if (holdReadyFileUpdate(ws, outboundMessage, fileMessage)) return; try { - ws.send(message); + ws.send(outboundMessage); } catch { activeSockets.delete(ws); } @@ -225,6 +491,181 @@ export async function installDaemonWebSocketGate(page: Page) { restore(): void { acceptingConnections = true; }, + restoreFresh(): void { + reconnectWithFreshClient = true; + acceptingConnections = true; + }, + holdNextClientRequest(type: string): void { + heldClientRequestType = type; + heldClientRequest = null; + }, + waitForHeldClientRequest(): Promise { + if (heldClientRequest) return Promise.resolve(); + return new Promise((resolve) => { + resolveHeldClientRequest = resolve; + }); + }, + releaseHeldClientRequest(): void { + if (!heldClientRequest) throw new Error("No held client request to release"); + heldClientRequest.server.send(heldClientRequest.message); + heldClientRequest = null; + heldClientRequestType = null; + }, + holdNextServerMessage(type: string): void { + pendingServerMessageHolds.set(serverMessageKey(type), (message) => message?.type === type); + }, + holdNextAgentUpdate(agentId: string, status: string): void { + const heldAgentUpdate = { agentId, status }; + pendingServerMessageHolds.set(agentUpdateKey(agentId, status), (message) => + matchesAgentUpdate(message, heldAgentUpdate), + ); + }, + holdNextAgentStreamEvent(type: string): void { + pendingServerMessageHolds.set( + agentStreamEventKey(type), + (message) => readAgentStreamEventType(message) === type, + ); + }, + async waitForHeldServerMessage(type?: string): Promise { + const key = type ? serverMessageKey(type) : null; + while (!heldServerMessages.some((message) => key === null || message.key === key)) { + await new Promise((resolve) => heldServerMessageWaiters.add(resolve)); + } + }, + releaseHeldServerMessage(type?: string): void { + const key = type ? serverMessageKey(type) : null; + const index = heldServerMessages.findIndex((message) => key === null || message.key === key); + const [heldServerMessage] = index >= 0 ? heldServerMessages.splice(index, 1) : []; + if (!heldServerMessage) throw new Error("No held server message to release"); + heldServerMessage.browser.send(heldServerMessage.message); + for (const follower of heldServerMessage.agentStreamFollowers) { + heldServerMessage.browser.send(follower); + } + }, + async waitForHeldAgentUpdate(agentId: string, status: string): Promise { + const key = agentUpdateKey(agentId, status); + while (!heldServerMessages.some((message) => message.key === key)) { + await new Promise((resolve) => heldServerMessageWaiters.add(resolve)); + } + }, + releaseHeldAgentUpdate(agentId: string, status: string): void { + const key = agentUpdateKey(agentId, status); + const index = heldServerMessages.findIndex((message) => message.key === key); + const [heldServerMessage] = index >= 0 ? heldServerMessages.splice(index, 1) : []; + if (!heldServerMessage) throw new Error("No held agent update to release"); + heldServerMessage.browser.send(heldServerMessage.message); + for (const follower of heldServerMessage.agentStreamFollowers) { + heldServerMessage.browser.send(follower); + } + }, + async waitForHeldAgentStreamEvent(type: string): Promise { + const key = agentStreamEventKey(type); + while (!heldServerMessages.some((message) => message.key === key)) { + await new Promise((resolve) => heldServerMessageWaiters.add(resolve)); + } + }, + releaseHeldAgentStreamEvent(type: string): void { + const key = agentStreamEventKey(type); + const index = heldServerMessages.findIndex((message) => message.key === key); + const [heldServerMessage] = index >= 0 ? heldServerMessages.splice(index, 1) : []; + if (!heldServerMessage) throw new Error("No held agent stream event to release"); + heldServerMessage.browser.send(heldServerMessage.message); + for (const follower of heldServerMessage.agentStreamFollowers) { + heldServerMessage.browser.send(follower); + } + }, + requestTimelineTail(agentId: string): void { + if (!latestServer) throw new Error("No daemon WebSocket is connected"); + latestServer.send( + JSON.stringify({ + type: "session", + message: { + type: "fetch_agent_timeline_request", + agentId, + requestId: `playwright-timeline-${Date.now()}`, + direction: "tail", + limit: 0, + projection: "projected", + }, + }), + ); + }, + getHeldTimelineLastItemType(): string | null { + const heldServerMessage = heldServerMessages[0]; + if (!heldServerMessage) throw new Error("No held server message to inspect"); + const response = readSessionMessage(heldServerMessage.message); + const payload = response?.payload; + if (!payload || typeof payload !== "object") return null; + const entries = (payload as { entries?: unknown }).entries; + if (!Array.isArray(entries)) return null; + const last = entries.at(-1) as { item?: { type?: unknown } } | undefined; + return typeof last?.item?.type === "string" ? last.item.type : null; + }, + truncateHeldTimelineAfterLast(itemType: string): void { + const heldServerMessage = heldServerMessages[0]; + if (!heldServerMessage || typeof heldServerMessage.message !== "string") { + throw new Error("No held text server message to truncate"); + } + const envelope = JSON.parse(heldServerMessage.message) as { + message?: { payload?: Record }; + payload?: Record; + }; + const payload = envelope.message?.payload ?? envelope.payload; + if (!payload) throw new Error("Held message has no payload"); + const entries = payload.entries; + if (!Array.isArray(entries)) throw new Error("Held message is not a timeline response"); + const index = entries.findLastIndex( + (entry) => + typeof entry === "object" && + entry !== null && + (entry as { item?: { type?: unknown } }).item?.type === itemType, + ); + if (index < 0) throw new Error(`Timeline response has no ${itemType} item`); + const retained = entries.slice(0, index + 1) as Array<{ seqEnd?: unknown }>; + const lastSeq = retained.at(-1)?.seqEnd; + if (typeof lastSeq !== "number") throw new Error("Timeline entry has no sequence end"); + payload.entries = retained; + payload.endCursor = { epoch: payload.epoch, seq: lastSeq }; + payload.hasNewer = false; + if (payload.window && typeof payload.window === "object") { + (payload.window as Record).maxSeq = lastSeq; + (payload.window as Record).nextSeq = lastSeq + 1; + } + heldServerMessage.message = JSON.stringify(envelope); + }, + setServerMessageSuppressed(type: string, suppressed: boolean): void { + if (suppressed) { + suppressedServerMessageTypes.add(type); + } else { + suppressedServerMessageTypes.delete(type); + } + }, + setAgentStreamEventSuppressed(type: string, suppressed: boolean): void { + if (suppressed) { + suppressedAgentStreamEventTypes.add(type); + } else { + suppressedAgentStreamEventTypes.delete(type); + } + }, + setAgentStreamItemSuppressed(type: string, suppressed: boolean): void { + if (suppressed) { + suppressedAgentStreamItemTypes.add(type); + } else { + suppressedAgentStreamItemTypes.delete(type); + } + }, + setAssistantMessageIdsStripped(stripped: boolean): void { + stripAssistantMessageIds = stripped; + }, + setCanonicalSubmittedPromptsStripped(stripped: boolean): void { + stripCanonicalSubmittedPromptsFeature = stripped; + }, + setAgentStreamSuppressed(suppressed: boolean): void { + suppressAgentStream = suppressed; + }, + forceNextTimelineEpochReset(): void { + forceTimelineEpochReset = true; + }, getDirectoryRequestStartCounts(): DirectoryRequestStartCounts { return { subscribed: { ...directoryStarts.subscribed }, @@ -235,5 +676,26 @@ export async function installDaemonWebSocketGate(page: Page) { getClientRequestCount(type: string): number { return clientRequestCounts.get(type) ?? 0; }, + getTimelineRequestCount(direction: "tail" | "before" | "after"): number { + return timelineRequestCounts.get(direction) ?? 0; + }, + getAgentStreamItemCount(type: string): number { + return agentStreamItemCounts.get(type) ?? 0; + }, + async waitForServerMessage(type: string, count = 1): Promise { + while ((serverMessageCounts.get(type) ?? 0) < count) { + await new Promise((resolve) => serverMessageWaiters.add(resolve)); + } + }, + async waitForAgentStreamItem(type: string, count = 1): Promise { + while ((agentStreamItemCounts.get(type) ?? 0) < count) { + await new Promise((resolve) => serverMessageWaiters.add(resolve)); + } + }, + async waitForAgentStreamEvent(type: string, count = 1): Promise { + while ((agentStreamEventCounts.get(type) ?? 0) < count) { + await new Promise((resolve) => serverMessageWaiters.add(resolve)); + } + }, }; } diff --git a/packages/app/e2e/helpers/mock-agent.ts b/packages/app/e2e/helpers/mock-agent.ts index 687891136..82dad5169 100644 --- a/packages/app/e2e/helpers/mock-agent.ts +++ b/packages/app/e2e/helpers/mock-agent.ts @@ -53,6 +53,23 @@ export async function seedMockAgentWorkspace( } } +export async function seedRunningMockAgentWorkspace( + options: MockAgentOptions, +): Promise { + const agent = await seedMockAgentWorkspace(options); + try { + await agent.client.waitForAgentUpsert( + agent.agentId, + (snapshot) => snapshot.status === "running", + 15_000, + ); + return agent; + } catch (error) { + await agent.cleanup(); + throw error; + } +} + export function buildAgentRoute(workspaceId: string, agentId: string): string { return buildHostAgentDetailRoute(getServerId(), agentId, workspaceId); } diff --git a/packages/app/e2e/helpers/timeline-pagination.ts b/packages/app/e2e/helpers/timeline-pagination.ts index bf3023309..2d4320de5 100644 --- a/packages/app/e2e/helpers/timeline-pagination.ts +++ b/packages/app/e2e/helpers/timeline-pagination.ts @@ -1,20 +1,64 @@ import { expect, type Page } from "@playwright/test"; import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; import { + delayAgentBootstrapTailResponse, delayAgentOlderTimelineResponse, + holdDaemonHydration, + holdAgentOlderTimelinePages, type AgentTimelineResponseGate, + type BootstrapTimelineGate, + type OlderTimelinePagesGate, } from "./agent-timeline-gate"; +export { holdDaemonHydration }; + interface LongTimelineAgentOptions { turns: number; } -interface LongTimelineAgent extends MockAgentWorkspace { +export interface LongTimelineAgent extends MockAgentWorkspace { + firstOlderPagePrompt: string; + initialTailAnchorPrompt: string; + initialTailOldestPrompt: string; oldestPrompt: string; newestPrompt: string; } +interface RealisticOlderTimelineAgent extends MockAgentWorkspace { + newestPrompt: string; +} + const PROMPT_PREFIX = "timeline-pagination-turn"; +const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration"; +const HISTORY_START_THRESHOLD_PX = 96; + +interface TimelineViewportSnapshot { + scrollHeight: number; + scrollTop: number; +} + +export interface TimelinePresentationSnapshot { + marker: string; + position: TimelinePromptPositionSnapshot; + viewport: TimelineViewportSnapshot; +} + +interface TimelinePromptPositionSnapshot { + prompt: string; + top: number; +} + +interface OlderHistoryLoadingOperation { + animationStartTime: number | null; + marker: string; +} + +interface OlderHistoryPages { + expectRequestedPages(count: number): Promise; + expectSettledWithRequestedPages(count: number): Promise; + expectNoRepeatedAssistantEntries(): void; + releasePage(pageNumber: number): void; +} function promptForTurn(index: number): string { return `${PROMPT_PREFIX}-${index}: emit 1 coalesced agent stream updates`; @@ -36,12 +80,88 @@ export async function seedLongMockAgentTimeline( return { ...agent, + firstOlderPagePrompt: promptForTurn(Math.max(0, options.turns - 40)), + initialTailAnchorPrompt: promptForTurn(Math.max(0, options.turns - 5)), + initialTailOldestPrompt: promptForTurn(Math.max(0, options.turns - 20)), oldestPrompt: promptForTurn(0), newestPrompt: promptForTurn(options.turns - 1), }; } -export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): Promise { +export async function seedRealisticOlderTimeline(): Promise { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "timeline-realistic-older-history-", + title: "Realistic older timeline pagination regression", + model: "ten-second-stream", + }); + + const olderTurnCount = 40; + for (let index = 0; index < olderTurnCount; 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); + + const newerTurnCount = 20; + for (let index = 0; index < newerTurnCount; index += 1) { + await agent.client.sendAgentMessage(agent.agentId, promptForTurn(index)); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + + return { + ...agent, + newestPrompt: promptForTurn(newerTurnCount - 1), + }; +} + +export async function scrollThroughOlderHistoryPages( + page: Page, + pageCount: number, + history: OlderHistoryPages, +): Promise { + for (let pageNumber = 0; pageNumber < pageCount; pageNumber += 1) { + await userScrollsTimelineToHistoryStart(page); + await history.expectRequestedPages(pageNumber + 1); + history.releasePage(pageNumber + 1); + await history.expectSettledWithRequestedPages(pageNumber + 1); + } +} + +export async function rememberTimelinePromptPosition( + page: Page, + prompt: string, +): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const item = timeline.getByText(prompt, { exact: true }); + await expect(item).toBeVisible(); + const box = await item.boundingBox(); + if (!box) { + throw new Error(`Expected a rendered timeline item for ${prompt}`); + } + return { prompt, top: box.y }; +} + +export async function expectTimelinePromptPositionPreserved( + page: Page, + before: TimelinePromptPositionSnapshot, +): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const item = timeline.getByText(before.prompt, { exact: true }); + await expect(item).toBeVisible(); + await expect + .poll(async () => { + const box = await item.boundingBox(); + return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY; + }) + .toBeLessThanOrEqual(2); +} + +export async function openAgentTimeline(page: Page, agent: MockAgentWorkspace): Promise { await page.goto(buildAgentRoute(agent.workspaceId, agent.agentId)); await page.waitForURL( (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), @@ -50,15 +170,37 @@ export async function openAgentTimeline(page: Page, agent: LongTimelineAgent): P } export async function expectTimelinePromptVisible(page: Page, prompt: string): Promise { - await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 }); + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + await expect(timeline.getByText(prompt, { exact: true })).toBeVisible({ timeout: 30_000 }); +} + +export async function expectTimelinePromptCentered(page: Page, prompt: string): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const message = timeline.getByTestId("user-message").filter({ hasText: prompt }); + await expect(message).toBeVisible(); + await expect + .poll(async () => { + const [timelineBox, messageBox] = await Promise.all([ + timeline.boundingBox(), + message.boundingBox(), + ]); + if (!timelineBox || !messageBox) { + return Number.POSITIVE_INFINITY; + } + const timelineCenter = timelineBox.x + timelineBox.width / 2; + const messageCenter = messageBox.x + messageBox.width / 2; + return Math.abs(timelineCenter - messageCenter); + }) + .toBeLessThanOrEqual(2); } export async function expectTimelinePromptNotMounted(page: Page, prompt: string): Promise { - await expect(page.getByText(prompt, { exact: true })).toHaveCount(0); + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + await expect(timeline.getByText(prompt, { exact: true })).toHaveCount(0); } export async function makeLoadedTimelineFitViewport(page: Page): Promise { - await page.setViewportSize({ width: 1280, height: 8_000 }); + await page.setViewportSize({ width: 1280, height: 20_000 }); } export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise { @@ -75,6 +217,33 @@ export async function expectLoadedTimelineDoesNotScroll(page: Page): Promise { + await expect + .poll(() => + page.evaluate((agentId) => { + const raw = localStorage.getItem("@paseo:replica-cache"); + if (!raw) return false; + const cache = JSON.parse(raw) as { + hosts?: Array<{ + timeline?: { + agentId?: string; + items?: unknown[]; + } | null; + }>; + }; + const timeline = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline; + return timeline?.items?.length === 50; + }, agent.agentId), + ) + .toBe(true); + + await page.reload(); + await expectTimelinePromptVisible(page, agent.newestPrompt); +} + export async function holdNextOlderTimelinePage( page: Page, agent: LongTimelineAgent, @@ -90,6 +259,154 @@ export async function holdNextOlderTimelinePage( }; } +export async function holdOlderHistoryPages( + page: Page, + agent: MockAgentWorkspace, +): Promise { + const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(page, agent.agentId); + return { + async expectRequestedPages(count) { + await gate.waitForRequestCount(count); + expect(gate.getRequestCount()).toBe(count); + }, + async expectSettledWithRequestedPages(count) { + await waitForTimelineGeometryToSettle(page); + expect(gate.getRequestCount()).toBe(count); + }, + expectNoRepeatedAssistantEntries() { + expect(gate.getRepeatedAssistantEntryCount()).toBe(0); + }, + releasePage(pageNumber) { + gate.releasePage(pageNumber); + }, + }; +} + +export async function rememberTimelineViewport(page: Page): Promise { + return readTimelineViewport(page); +} + +export async function expectStableHistoryStartGutter(page: Page): Promise { + const gutter = page.getByTestId("older-history-slot"); + await expect(gutter).toBeAttached(); + await expect + .poll(() => gutter.evaluate((element) => element.getBoundingClientRect().height)) + .toBe(32); +} + +export async function scrollTimelinePromptIntoView(page: Page, prompt: string): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const row = timeline.getByTestId("user-message").filter({ hasText: prompt }); + await row.scrollIntoViewIfNeeded(); + await expect(row).toBeVisible(); + await expect + .poll(async () => (await readTimelineViewport(page)).scrollTop) + .toBeGreaterThan(HISTORY_START_THRESHOLD_PX); +} + +export async function rememberTimelinePresentation( + page: Page, + prompt: string, +): Promise { + const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const row = timeline.getByTestId("user-message").filter({ hasText: prompt }); + await expect(row).toBeVisible(); + const marker = `timeline-presentation-${Date.now()}`; + await row.evaluate((element, value) => { + element.dataset.timelinePresentation = value; + }, marker); + return { + marker, + position: await rememberTimelinePromptPosition(page, prompt), + viewport: await rememberTimelineViewport(page), + }; +} + +export async function expectTimelinePresentationUnchanged( + page: Page, + before: TimelinePresentationSnapshot, +): Promise { + await expect(page.locator(`[data-timeline-presentation="${before.marker}"]`)).toBeVisible(); + await expectTimelinePromptPositionPreserved(page, before.position); + await expect.poll(async () => rememberTimelineViewport(page)).toEqual(before.viewport); +} + +export async function expectTimelineViewportAnchoredAfterPrepend( + page: Page, + before: TimelineViewportSnapshot, +): Promise { + await expect + .poll(async () => (await readTimelineViewport(page)).scrollHeight) + .toBeGreaterThan(before.scrollHeight); + await waitForTimelineGeometryToSettle(page); + const after = await readTimelineViewport(page); + const contentGrowth = after.scrollHeight - before.scrollHeight; + const scrollAdjustment = after.scrollTop - before.scrollTop; + expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2); +} + +export async function rememberOlderHistoryLoadingOperation( + page: Page, +): Promise { + const slot = page.getByTestId("load-older-history-spinner"); + await expect(slot).toBeVisible(); + const marker = `older-history-loading-${Date.now()}`; + await slot.evaluate((element, operationMarker) => { + const candidates = [element, ...Array.from(element.querySelectorAll("*"))]; + const animated = candidates.find( + (candidate) => getComputedStyle(candidate).animationName !== "none", + ); + if (!(animated instanceof HTMLElement)) { + throw new Error("Expected the older-history loader to contain an animated element"); + } + animated.dataset.olderHistoryLoadingOperation = operationMarker; + }, marker); + const animated = page.locator(`[data-older-history-loading-operation="${marker}"]`); + await expect + .poll(async () => { + const startTime = await animated.evaluate((element) => element.getAnimations()[0]?.startTime); + return typeof startTime === "number" ? startTime : null; + }) + .not.toBeNull(); + const animationStartTime = await animated.evaluate((element) => { + const startTime = element.getAnimations()[0]?.startTime; + return typeof startTime === "number" ? startTime : null; + }); + return { animationStartTime, marker }; +} + +export async function expectSameOlderHistoryLoadingOperation( + page: Page, + operation: OlderHistoryLoadingOperation, +): Promise { + const animated = page.locator(`[data-older-history-loading-operation="${operation.marker}"]`); + await expect(animated).toBeVisible(); + const animationStartTime = await animated.evaluate((element) => { + const startTime = element.getAnimations()[0]?.startTime; + return typeof startTime === "number" ? startTime : null; + }); + expect(animationStartTime).toBe(operation.animationStartTime); +} + +export async function expectTimelineAtHistoryStart(page: Page): Promise { + await expect + .poll(async () => (await readTimelineViewport(page)).scrollTop) + .toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX); +} + +export async function holdBootstrapTimelinePage( + page: Page, + agent: LongTimelineAgent, +): Promise { + return delayAgentBootstrapTailResponse(page, agent.agentId); +} + +export async function sendLiveTurnBeforeHydration(agent: LongTimelineAgent): Promise { + await agent.client.sendAgentMessage(agent.agentId, LIVE_BEFORE_HYDRATION_PROMPT); + await agent.client.waitForFinish(agent.agentId, 15_000); + return LIVE_BEFORE_HYDRATION_PROMPT; +} + export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise { const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); await scroll.hover(); @@ -109,31 +426,104 @@ export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise { +export async function userScrollsTimelineToHistoryStart(page: Page): Promise { const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); - const previousHeight = await scroll.evaluate((element) => { + await scroll.hover(); + for (let step = 0; step < 60; step += 1) { + if ((await readTimelineViewport(page)).scrollTop <= HISTORY_START_THRESHOLD_PX) { + break; + } + await page.mouse.wheel(0, -1_000); + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }), + ); + } + await expect + .poll(async () => (await readTimelineViewport(page)).scrollTop) + .toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX); +} + +export async function userNavigatesTimelineToHistoryStartWithKeyboard(page: Page): Promise { + const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + await scroll.focus(); + await page.keyboard.press("Home"); + await expect + .poll(async () => (await readTimelineViewport(page)).scrollTop) + .toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX); +} + +export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise { + const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + await scroll.evaluate((element) => { if (!(element instanceof HTMLElement)) { throw new Error("Agent chat scroll element is not an HTMLElement"); } - return element.scrollHeight; + element.scrollTop = element.scrollHeight; + element.dispatchEvent(new Event("scroll", { bubbles: true })); }); +} - await page.evaluate( - () => - new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); +export async function scrollTimelineUntilOlderHistoryIsReachable( + page: Page, + oldestPrompt: string, +): Promise { + const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + const prompt = scroll.getByText(oldestPrompt, { exact: true }); + for (let attempt = 0; attempt < 10; attempt += 1) { + if ((await prompt.count()) > 0) { + await expect(prompt).toBeVisible(); + return; + } + const previousHeight = await readTimelineViewport(page); + await userScrollsTimelineToHistoryStart(page); + await expect + .poll(async () => (await readTimelineViewport(page)).scrollHeight) + .toBeGreaterThan(previousHeight.scrollHeight); + await waitForTimelineGeometryToSettle(page); + } + await expect(prompt).toBeVisible(); +} + +async function readTimelineViewport(page: Page): Promise { + const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + return scroll.evaluate((element) => { + if (!(element instanceof HTMLElement)) { + throw new Error("Agent chat scroll element is not an HTMLElement"); + } + return { scrollHeight: element.scrollHeight, scrollTop: element.scrollTop }; + }); +} + +async function waitForTimelineGeometryToSettle(page: Page): Promise { + const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first(); + await scroll.evaluate( + (element) => + new Promise((resolve, reject) => { + if (!(element instanceof HTMLElement)) { + reject(new Error("Agent chat scroll element is not an HTMLElement")); + return; + } + const startedAt = performance.now(); + let stableFrames = 0; + let previous = `${element.scrollTop}:${element.scrollHeight}`; + const sample = () => { + const current = `${element.scrollTop}:${element.scrollHeight}`; + stableFrames = current === previous ? stableFrames + 1 : 0; + previous = current; + if (stableFrames >= 4) { + resolve(); + return; + } + if (performance.now() - startedAt > 5_000) { + reject(new Error("Timeline geometry did not settle")); + return; + } + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); }), ); - await scrollTimelineToOldestLoadedEdge(page); - await expect - .poll(async () => - scroll.evaluate((element) => { - if (!(element instanceof HTMLElement)) { - throw new Error("Agent chat scroll element is not an HTMLElement"); - } - return element.scrollHeight; - }), - ) - .toBeGreaterThan(previousHeight); - await scrollTimelineToOldestLoadedEdge(page); } diff --git a/packages/app/e2e/helpers/timeline-resume.ts b/packages/app/e2e/helpers/timeline-resume.ts new file mode 100644 index 000000000..c4a44713c --- /dev/null +++ b/packages/app/e2e/helpers/timeline-resume.ts @@ -0,0 +1,68 @@ +import { expect, type Page } from "@playwright/test"; +import type { installDaemonWebSocketGate } from "./daemon-websocket-gate"; +import type { LongTimelineAgent } from "./timeline-pagination"; +import { expectReconnectingToastGone, expectReconnectingToastVisible } from "./workspace-ui"; + +type DaemonWebSocketGate = Awaited>; + +export interface TimelineRequestCounts { + after: number; + tail: number; +} + +export interface BackgroundTimelineTurns { + firstPrompt: string; + newestPrompt: string; +} + +function backgroundPrompt(index: number): string { + return `timeline-resume-background-${index}: emit 1 coalesced agent stream updates`; +} + +export async function commitTimelineTurnsWhileDisconnected( + agent: LongTimelineAgent, + count: number, +): Promise { + for (let index = 0; index < count; index += 1) { + await agent.client.sendAgentMessage(agent.agentId, backgroundPrompt(index)); + await agent.client.waitForFinish(agent.agentId, 15_000); + } + return { + firstPrompt: backgroundPrompt(0), + newestPrompt: backgroundPrompt(count - 1), + }; +} + +export function rememberTimelineRequestCounts(gate: DaemonWebSocketGate): TimelineRequestCounts { + return { + after: gate.getTimelineRequestCount("after"), + tail: gate.getTimelineRequestCount("tail"), + }; +} + +export function expectOneTailCheckWithoutCatchUpPages( + gate: DaemonWebSocketGate, + before: TimelineRequestCounts, +): void { + expect(gate.getTimelineRequestCount("tail") - before.tail).toBe(1); + expect(gate.getTimelineRequestCount("after") - before.after).toBe(0); +} + +export async function disconnectViewedTimeline( + page: Page, + gate: DaemonWebSocketGate, +): Promise { + await gate.drop(); + await expectReconnectingToastVisible(page); +} + +export async function restoreViewedTimelineWithHeldTail( + page: Page, + gate: DaemonWebSocketGate, +): Promise { + gate.holdNextServerMessage("fetch_agent_timeline_response"); + gate.restore(); + await gate.waitForHeldServerMessage(); + gate.releaseHeldServerMessage(); + await expectReconnectingToastGone(page); +} diff --git a/packages/app/e2e/new-workspace-entry.spec.ts b/packages/app/e2e/new-workspace-entry.spec.ts index bc4c64229..fae10ce82 100644 --- a/packages/app/e2e/new-workspace-entry.spec.ts +++ b/packages/app/e2e/new-workspace-entry.spec.ts @@ -15,7 +15,7 @@ import { seedSavedSettingsHosts } from "./helpers/settings"; import { getServerId } from "./helpers/server-id"; import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { clickArchiveWorkspaceMenuItem, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar"; -import { waitForNoProjectsInSidebar, waitForSidebarHydration } from "./helpers/workspace-ui"; +import { waitForSidebarHydration } from "./helpers/workspace-ui"; // Model B entry points into the New Workspace screen. The surviving entries are // the global button (universal) and each project's per-row New workspace icon @@ -112,7 +112,6 @@ test.describe("New workspace entry points", () => { test("a stale project route keeps the New Workspace controls enabled", async ({ page }) => { await gotoAppShell(page); - await waitForNoProjectsInSidebar(page); await openMissingProjectNewWorkspaceComposer(page, { serverId: getServerId(), projectId: "missing-project", diff --git a/packages/app/e2e/out-of-band-command.codex.real.spec.ts b/packages/app/e2e/out-of-band-command.codex.real.spec.ts new file mode 100644 index 000000000..cbec6733c --- /dev/null +++ b/packages/app/e2e/out-of-band-command.codex.real.spec.ts @@ -0,0 +1,29 @@ +import { mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { expect, test } from "./fixtures"; +import { submitMessage } from "./helpers/composer"; +import { cleanupRewindFlow, launchAgent, type AgentHandle } from "./helpers/rewind-flow"; + +test.describe("Codex out-of-band commands", () => { + test.setTimeout(300_000); + + test("settles the submitted row when a goal command completes without a turn", async ({ + page, + }) => { + const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-command-"))); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ page, provider: "codex", cwd, mode: "full-access" }); + await submitMessage(page, "/goal clear"); + const command = page.getByTestId("user-message").filter({ hasText: "/goal clear" }); + + await expect(command).toBeVisible(); + await expect(command).toHaveAttribute("aria-busy", "false", { timeout: 30_000 }); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); +}); diff --git a/packages/app/e2e/rewind-menu.ui-contract.spec.ts b/packages/app/e2e/rewind-menu.ui-contract.spec.ts index 158f40ae1..af5d75a0f 100644 --- a/packages/app/e2e/rewind-menu.ui-contract.spec.ts +++ b/packages/app/e2e/rewind-menu.ui-contract.spec.ts @@ -1,6 +1,8 @@ import type { Locator } from "@playwright/test"; import { expect, test, type Page } from "./fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent"; +import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { scrollChatAwayFromBottom } from "./helpers/agent-bottom-anchor"; import { composerLocator, expectComposerDraft, @@ -23,7 +25,152 @@ async function expectUserMessageVisible(page: Page, text: string): Promise await expect(userMessage(page, text)).toBeVisible(); } +async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise { + await expect + .poll(() => + page.evaluate((messageText) => { + const raw = localStorage.getItem("@paseo:replica-cache"); + if (!raw) return false; + const cache = JSON.parse(raw) as { + hosts?: Array<{ timeline?: { items?: Array> } | null }>; + }; + for (const host of cache.hosts ?? []) { + for (const item of host.timeline?.items ?? []) { + if (item.kind === "user_message" && item.text === messageText && item.messageId) { + return true; + } + } + } + return false; + }, prompt), + ) + .toBe(true); + + await page.evaluate((messageText) => { + const key = "@paseo:replica-cache"; + const raw = localStorage.getItem(key); + if (!raw) throw new Error("Replica cache was not persisted"); + const cache = JSON.parse(raw) as { + hosts?: Array<{ timeline?: { items?: Array> } | null }>; + }; + const cachedMessage = cache.hosts + ?.flatMap((host) => host.timeline?.items ?? []) + .find((item) => item.kind === "user_message" && item.text === messageText); + if (!cachedMessage) throw new Error("Cached user message was not found"); + delete cachedMessage.messageId; + localStorage.setItem(key, JSON.stringify(cache)); + }, prompt); +} + +async function waitForCurrentSubmissionExcludedFromCache( + page: Page, + prompt: string, +): Promise { + await expect + .poll(() => + page.evaluate((messageText) => { + const raw = localStorage.getItem("@paseo:replica-cache"); + if (!raw) return false; + const cache = JSON.parse(raw) as { + hosts?: Array<{ timeline?: { items?: Array> } | null }>; + }; + return !cache.hosts + ?.flatMap((host) => host.timeline?.items ?? []) + .some( + (item) => + item.kind === "user_message" && + item.text === messageText && + typeof item.clientMessageId === "string" && + item.messageId === undefined, + ); + }, prompt), + ) + .toBe(true); +} + +async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise { + await expect + .poll(() => + page.evaluate((messageText) => { + const raw = localStorage.getItem("@paseo:replica-cache"); + if (!raw) return false; + const cache = JSON.parse(raw) as { + hosts?: Array<{ timeline?: { items?: Array> } | null }>; + }; + return cache.hosts + ?.flatMap((host) => host.timeline?.items ?? []) + .some( + (item) => + item.kind === "user_message" && + item.text === messageText && + item.messageId === undefined, + ); + }, prompt), + ) + .toBe(true); +} + +async function expectPendingSubmissionNotRestoredAfterReload(page: Page): Promise { + const prompt = "Keep this cached submission pending."; + const gate = await installDaemonWebSocketGate(page); + const session = await seedMockAgentWorkspace({ + repoPrefix: "rewind-current-cache-e2e-", + title: "Current cache submission e2e", + }); + + try { + await openAgentRoute(page, session); + await expectComposerVisible(page); + gate.holdNextClientRequest("send_agent_message_request"); + await submitMessage(page, prompt); + await gate.waitForHeldClientRequest(); + await waitForCurrentSubmissionExcludedFromCache(page, prompt); + await gate.drop(); + await page.reload(); + + await expect(userMessage(page, prompt)).toHaveCount(0); + } finally { + gate.restore(); + await session.cleanup(); + } +} + test.describe("Rewind sheet", () => { + test("does not restore a local-only submission from the display cache", async ({ page }) => { + await expectPendingSubmissionNotRestoredAfterReload(page); + }); + + test("does not invent rewind identity for an ID-less cached message", async ({ page }) => { + const prompt = "Restore this rewind identity from the legacy cache."; + const gate = await installDaemonWebSocketGate(page); + const session = await seedMockAgentWorkspace({ + repoPrefix: "rewind-cache-upgrade-e2e-", + title: "Rewind cache upgrade e2e", + initialPrompt: prompt, + }); + let heldTimelineRequest = false; + + try { + await openAgentRoute(page, session); + await expectUserMessageVisible(page, prompt); + await rewriteCachedMessageAsLegacyRow(page, prompt); + gate.holdNextClientRequest("fetch_agent_timeline_request"); + await page.reload(); + await gate.waitForHeldClientRequest(); + heldTimelineRequest = true; + + const restoredMessage = userMessage(page, prompt); + await expect(restoredMessage).toBeVisible(); + await restoredMessage.hover(); + await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0); + await waitForCachedMessageWithoutProviderId(page, prompt); + } finally { + if (heldTimelineRequest) gate.releaseHeldClientRequest(); + gate.restore(); + await session.cleanup(); + } + }); + test("rewinds from a user message sheet option", async ({ page }) => { const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn."; const secondPrompt = "Prepare deleted rewind turn assistant content."; @@ -45,6 +192,10 @@ test.describe("Rewind sheet", () => { await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible(); await expectUserMessageCount(page, 2); + await scrollChatAwayFromBottom(page, { + deltaY: -900, + minDistanceFromBottom: 300, + }); await userMessage(page, firstPrompt).hover(); await page.getByTestId("rewind-menu-trigger").first().click(); const rewindSheet = page.getByTestId("rewind-menu-content"); diff --git a/packages/app/e2e/sidebar-reorder.spec.ts b/packages/app/e2e/sidebar-reorder.spec.ts index 0979694f0..b8019537c 100644 --- a/packages/app/e2e/sidebar-reorder.spec.ts +++ b/packages/app/e2e/sidebar-reorder.spec.ts @@ -1,6 +1,7 @@ import type { Locator } from "@playwright/test"; import { expect, test } from "./fixtures"; import { gotoAppShell } from "./helpers/app"; +import { projectEquivalenceViewKey } from "./helpers/project-view-key"; import { getServerId } from "./helpers/server-id"; import { seedWorkspace } from "./helpers/seed-client"; import { waitForSidebarHydration } from "./helpers/workspace-ui"; @@ -56,7 +57,11 @@ test("projects and workspaces reorder with an immediate mouse drag", async ({ pa await gotoAppShell(page); await waitForSidebarHydration(page); - await quickDragFirstRowAfterSecond(page.locator('[data-testid^="sidebar-project-row-"]')); + const firstProjectTestId = `sidebar-project-row-${projectEquivalenceViewKey(firstProject.projectKey)}`; + const secondProjectTestId = `sidebar-project-row-${projectEquivalenceViewKey(secondProject.projectKey)}`; + await quickDragFirstRowAfterSecond( + page.locator(`[data-testid="${firstProjectTestId}"], [data-testid="${secondProjectTestId}"]`), + ); const firstWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${firstProject.workspaceId}`; const secondWorkspaceTestId = `sidebar-workspace-row-${getServerId()}:${secondWorkspace.workspace.id}`; await quickDragFirstRowAfterSecond( diff --git a/packages/app/e2e/viewed-agent-timelines.spec.ts b/packages/app/e2e/viewed-agent-timelines.spec.ts index a16723aec..35a4e24e1 100644 --- a/packages/app/e2e/viewed-agent-timelines.spec.ts +++ b/packages/app/e2e/viewed-agent-timelines.spec.ts @@ -6,6 +6,11 @@ import { getServerId } from "./helpers/server-id"; import { observeTimelineSubscriptions } from "./helpers/timeline-delivery"; import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { + expectAgentIdle, + expectInlineWorkingIndicator, + expectTurnCopyButton, +} from "./helpers/agent-stream"; import { expectReconnectingToastGone, expectReconnectingToastVisible, @@ -19,19 +24,21 @@ interface ViewedTimelineScenario { cleanup(): Promise; } -async function seedViewedTimelineScenario(): Promise { +async function seedViewedTimelineScenario( + options: { firstAgentModel?: string } = {}, +): Promise { const workspace = await seedWorkspace({ repoPrefix: "viewed-timelines-" }); - const createAgent = (title: string) => + const createAgent = (title: string, model = "ten-second-stream") => workspace.client.createAgent({ provider: "mock", cwd: workspace.repoPath, workspaceId: workspace.workspaceId, title, modeId: "load-test", - model: "ten-second-stream", + model, }); const [firstAgent, secondAgent] = await Promise.all([ - createAgent("First viewed chat"), + createAgent("First viewed chat", options.firstAgentModel), createAgent("Second viewed chat"), ]); return { @@ -71,7 +78,46 @@ async function commitMessage(scenario: ViewedTimelineScenario, agentId: string, expect(finish.status).toBe("idle"); } +async function startVisibleTurn( + page: Page, + scenario: ViewedTimelineScenario, + prompt: string, +): Promise { + await scenario.client.sendAgentMessage(scenario.firstAgentId, prompt); + await expect(page.getByText(prompt, { exact: true })).toBeVisible(); + await expectInlineWorkingIndicator(page); +} + +async function expectAgentConsistentlyIdle(page: Page, title: string): Promise { + const tab = page.getByRole("button", { name: title, exact: true }); + await expect(tab.locator('[data-status-bucket="running"]')).toHaveCount(0); + await expectAgentIdle(page); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); + await expectTurnCopyButton(page); +} + test.describe("Viewed agent timelines", () => { + test("a turn that finishes while unsubscribed reopens with consistently idle chrome", async ({ + page, + }) => { + test.setTimeout(150_000); + const subscriptions = observeTimelineSubscriptions(page); + const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" }); + try { + await openAgent(page, scenario, scenario.firstAgentId); + await startVisibleTurn(page, scenario, "Finish after this chat becomes unsubscribed."); + await selectAgent(page, "Second viewed chat"); + await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 }); + const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 90_000); + expect(finish.status).toBe("idle"); + + await selectAgent(page, "First viewed chat"); + await expectAgentConsistentlyIdle(page, "First viewed chat"); + } finally { + await scenario.cleanup(); + } + }); + test("an unsubscribed hidden chat catches up when shown", async ({ page }) => { test.setTimeout(90_000); const subscriptions = observeTimelineSubscriptions(page); diff --git a/packages/app/src/agent-stream/history-start-pagination.test.ts b/packages/app/src/agent-stream/history-start-pagination.test.ts index 2801f479f..475dbc771 100644 --- a/packages/app/src/agent-stream/history-start-pagination.test.ts +++ b/packages/app/src/agent-stream/history-start-pagination.test.ts @@ -1,11 +1,19 @@ import { describe, expect, it } from "vitest"; import { + abandonHistoryStartPaginationRequest, createHistoryStartPaginationState, evaluateHistoryStartPagination, + isHistoryStartLoadingOperation, rearmHistoryStartPagination, + settleHistoryStartPagination, + type HistoryStartPaginationInput, } from "./history-start-pagination"; -const visibleHistoryStart = { +function createArmedHistoryStartPaginationState() { + return rearmHistoryStartPagination(createHistoryStartPaginationState()); +} + +const visibleHistoryStart: HistoryStartPaginationInput = { distanceFromHistoryStart: 0, hasOlderHistory: true, isLoadingOlderHistory: false, @@ -14,51 +22,151 @@ const visibleHistoryStart = { }; describe("history start pagination", () => { - it("loads once for each authoritative history cursor", () => { - const initial = createHistoryStartPaginationState(); - const first = evaluateHistoryStartPagination(initial, visibleHistoryStart); - const duplicate = evaluateHistoryStartPagination(first.state, visibleHistoryStart); - const nextPage = evaluateHistoryStartPagination(first.state, { + it("waits for user scroll intent before loading", () => { + const dormant = createHistoryStartPaginationState(); + const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart); + + expect(evaluated).toEqual({ state: { status: "dormant" }, shouldLoad: false }); + }); + + it("waits for anchored page geometry before authorizing another page", () => { + const first = evaluateHistoryStartPagination( + createArmedHistoryStartPaginationState(), + visibleHistoryStart, + ); + const inFlight = evaluateHistoryStartPagination(first.state, { + ...visibleHistoryStart, + isLoadingOlderHistory: true, + }); + const pageApplied = evaluateHistoryStartPagination(inFlight.state, { + ...visibleHistoryStart, + isLoadingOlderHistory: true, + progressKey: "epoch-1:10", + }); + + expect([first.shouldLoad, inFlight.shouldLoad, pageApplied.shouldLoad]).toEqual([ + true, + false, + false, + ]); + expect(pageApplied.state).toEqual({ status: "settling", loadedProgressKey: "epoch-1:10" }); + }); + + it("loads one page each time anchored geometry leaves and returns to history start", () => { + const first = evaluateHistoryStartPagination( + createArmedHistoryStartPaginationState(), + visibleHistoryStart, + ); + const pageApplied = evaluateHistoryStartPagination(first.state, { + ...visibleHistoryStart, + progressKey: "epoch-1:10", + }); + const settledAway = settleHistoryStartPagination(pageApplied.state, { + ...visibleHistoryStart, + distanceFromHistoryStart: 300, + progressKey: "epoch-1:10", + }); + const returned = evaluateHistoryStartPagination(settledAway.state, { ...visibleHistoryStart, progressKey: "epoch-1:10", }); - expect([first.shouldLoad, duplicate.shouldLoad, nextPage.shouldLoad]).toEqual([ - true, - false, - true, - ]); + expect([ + first.shouldLoad, + pageApplied.shouldLoad, + settledAway.shouldLoad, + returned.shouldLoad, + ]).toEqual([true, false, false, true]); }); - it("allows the same revision again after the user leaves the history edge", () => { + it("continues the same loading operation when anchored geometry remains at history start", () => { const first = evaluateHistoryStartPagination( - createHistoryStartPaginationState(), + createArmedHistoryStartPaginationState(), visibleHistoryStart, ); - const away = evaluateHistoryStartPagination(first.state, { + const pageApplied = evaluateHistoryStartPagination(first.state, { ...visibleHistoryStart, - distanceFromHistoryStart: 200, + progressKey: "epoch-1:10", + }); + const continued = settleHistoryStartPagination(pageApplied.state, { + ...visibleHistoryStart, + progressKey: "epoch-1:10", }); - const returned = evaluateHistoryStartPagination(away.state, visibleHistoryStart); - expect([first.shouldLoad, away.shouldLoad, returned.shouldLoad]).toEqual([true, false, true]); + expect(continued.shouldLoad).toBe(true); + expect(isHistoryStartLoadingOperation(first.state)).toBe(true); + expect(isHistoryStartLoadingOperation(pageApplied.state)).toBe(true); + expect(isHistoryStartLoadingOperation(continued.state)).toBe(true); }); - it("re-arms the same cursor when the user makes another upward edge gesture", () => { + it("latches a request that finishes without cursor progress", () => { const first = evaluateHistoryStartPagination( - createHistoryStartPaginationState(), + createArmedHistoryStartPaginationState(), visibleHistoryStart, ); + const inFlight = evaluateHistoryStartPagination(first.state, { + ...visibleHistoryStart, + isLoadingOlderHistory: true, + }); + const finished = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart); + const duplicate = evaluateHistoryStartPagination(finished.state, visibleHistoryStart); + const away = evaluateHistoryStartPagination(duplicate.state, { + ...visibleHistoryStart, + distanceFromHistoryStart: 300, + }); + + expect(finished.state).toEqual({ status: "latched" }); + expect([finished.shouldLoad, duplicate.shouldLoad, away.shouldLoad]).toEqual([ + false, + false, + false, + ]); + expect(away.state).toEqual({ status: "ready" }); + }); + + it("allows another user attempt after a request finishes without progress", () => { + const first = evaluateHistoryStartPagination( + createArmedHistoryStartPaginationState(), + visibleHistoryStart, + ); + const inFlight = evaluateHistoryStartPagination(first.state, { + ...visibleHistoryStart, + isLoadingOlderHistory: true, + }); + const failed = evaluateHistoryStartPagination(inFlight.state, visibleHistoryStart); const retried = evaluateHistoryStartPagination( - rearmHistoryStartPagination(first.state), + rearmHistoryStartPagination(failed.state), visibleHistoryStart, ); - expect([first.shouldLoad, retried.shouldLoad]).toEqual([true, true]); + expect(failed.state).toEqual({ status: "latched" }); + expect(retried.shouldLoad).toBe(true); + }); + + it("latches an attempt that becomes invalid before entering flight", () => { + const requested = evaluateHistoryStartPagination( + createArmedHistoryStartPaginationState(), + visibleHistoryStart, + ); + + expect(abandonHistoryStartPaginationRequest(requested.state, "epoch-1:20")).toEqual({ + status: "latched", + }); + }); + + it("does not mistake repeated edge observations for a finished request", () => { + const first = evaluateHistoryStartPagination( + createArmedHistoryStartPaginationState(), + visibleHistoryStart, + ); + const repeated = evaluateHistoryStartPagination(first.state, visibleHistoryStart); + + expect(repeated.state).toBe(first.state); + expect(repeated.shouldLoad).toBe(false); }); it("waits while history loading is unavailable or already active", () => { - const state = createHistoryStartPaginationState(); + const state = createArmedHistoryStartPaginationState(); expect([ evaluateHistoryStartPagination(state, { ...visibleHistoryStart, isReady: false }).shouldLoad, diff --git a/packages/app/src/agent-stream/history-start-pagination.ts b/packages/app/src/agent-stream/history-start-pagination.ts index 180ea5cf0..46914ae3e 100644 --- a/packages/app/src/agent-stream/history-start-pagination.ts +++ b/packages/app/src/agent-stream/history-start-pagination.ts @@ -1,33 +1,95 @@ export const HISTORY_START_THRESHOLD_PX = 96; -export interface HistoryStartPaginationState { - requestedProgressKey: string | null; +export type HistoryStartPaginationState = + | { status: "dormant" } + | { status: "ready" } + | { status: "loading"; requestedProgressKey: string; requestObserved: boolean } + | { status: "settling"; loadedProgressKey: string } + | { status: "latched" }; + +export interface HistoryStartPaginationInput { + distanceFromHistoryStart: number; + hasOlderHistory: boolean; + isLoadingOlderHistory: boolean; + isReady: boolean; + progressKey: string | null; +} + +export interface HistoryStartPaginationTransition { + state: HistoryStartPaginationState; + shouldLoad: boolean; } export function createHistoryStartPaginationState(): HistoryStartPaginationState { - return { requestedProgressKey: null }; + return { status: "dormant" }; +} + +export function isHistoryStartLoadingOperation(state: HistoryStartPaginationState): boolean { + return state.status === "loading" || state.status === "settling"; } export function rearmHistoryStartPagination( - _state: HistoryStartPaginationState, + state: HistoryStartPaginationState, ): HistoryStartPaginationState { - return createHistoryStartPaginationState(); + return state.status === "dormant" || state.status === "latched" ? { status: "ready" } : state; +} + +export function abandonHistoryStartPaginationRequest( + state: HistoryStartPaginationState, + requestedProgressKey: string, +): HistoryStartPaginationState { + if ( + state.status !== "loading" || + state.requestObserved || + state.requestedProgressKey !== requestedProgressKey + ) { + return state; + } + return { status: "latched" }; } export function evaluateHistoryStartPagination( state: HistoryStartPaginationState, - input: { - distanceFromHistoryStart: number; - hasOlderHistory: boolean; - isLoadingOlderHistory: boolean; - isReady: boolean; - progressKey: string | null; - }, -): { state: HistoryStartPaginationState; shouldLoad: boolean } { - if (input.distanceFromHistoryStart > HISTORY_START_THRESHOLD_PX) { - return { state: createHistoryStartPaginationState(), shouldLoad: false }; + input: HistoryStartPaginationInput, +): HistoryStartPaginationTransition { + if (state.status === "dormant") { + return { state, shouldLoad: false }; + } + if (state.status === "loading") { + if (input.progressKey !== null && input.progressKey !== state.requestedProgressKey) { + return { + state: { status: "settling", loadedProgressKey: input.progressKey }, + shouldLoad: false, + }; + } + if (input.isLoadingOlderHistory && !state.requestObserved) { + return { state: { ...state, requestObserved: true }, shouldLoad: false }; + } + if (!input.isLoadingOlderHistory && !input.hasOlderHistory) { + return { state: { status: "latched" }, shouldLoad: false }; + } + if ( + state.requestObserved && + !input.isLoadingOlderHistory && + input.progressKey === state.requestedProgressKey + ) { + return { state: { status: "latched" }, shouldLoad: false }; + } + return { state, shouldLoad: false }; + } + + if (state.status === "settling") { + return { state, shouldLoad: false }; + } + + const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX; + if (!isAtHistoryStart) { + return state.status === "ready" + ? { state, shouldLoad: false } + : { state: { status: "ready" }, shouldLoad: false }; } if ( + state.status === "latched" || !input.isReady || !input.hasOlderHistory || input.isLoadingOlderHistory || @@ -35,11 +97,42 @@ export function evaluateHistoryStartPagination( ) { return { state, shouldLoad: false }; } - if (state.requestedProgressKey === input.progressKey) { - return { state, shouldLoad: false }; - } return { - state: { requestedProgressKey: input.progressKey }, + state: { + status: "loading", + requestedProgressKey: input.progressKey, + requestObserved: false, + }, + shouldLoad: true, + }; +} + +export function settleHistoryStartPagination( + state: HistoryStartPaginationState, + input: HistoryStartPaginationInput, +): HistoryStartPaginationTransition { + if (state.status !== "settling") { + return { state, shouldLoad: false }; + } + const isAtHistoryStart = input.distanceFromHistoryStart <= HISTORY_START_THRESHOLD_PX; + if ( + !isAtHistoryStart || + !input.isReady || + !input.hasOlderHistory || + input.isLoadingOlderHistory || + input.progressKey === null + ) { + return { + state: isAtHistoryStart ? { status: "latched" } : { status: "ready" }, + shouldLoad: false, + }; + } + return { + state: { + status: "loading", + requestedProgressKey: input.progressKey, + requestObserved: false, + }, shouldLoad: true, }; } diff --git a/packages/app/src/agent-stream/history-start-settle-scheduler.test.ts b/packages/app/src/agent-stream/history-start-settle-scheduler.test.ts new file mode 100644 index 000000000..2a8894299 --- /dev/null +++ b/packages/app/src/agent-stream/history-start-settle-scheduler.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { createHistoryStartSettleScheduler } from "./history-start-settle-scheduler"; + +describe("history start settle scheduler", () => { + it("does not restart its countdown when geometry keeps changing", () => { + const frames = new Map void>(); + let nextFrameId = 1; + const onFrame = vi.fn(); + const onSettle = vi.fn(); + const scheduler = createHistoryStartSettleScheduler({ + settleFrames: 2, + requestFrame(callback) { + const id = nextFrameId; + nextFrameId += 1; + frames.set(id, callback); + return id; + }, + cancelFrame: (id) => frames.delete(id), + isSettling: () => true, + isLoading: () => false, + onFrame, + onSettle, + }); + const runNextFrame = () => { + const next = frames.entries().next().value as [number, () => void] | undefined; + if (!next) { + throw new Error("Expected a scheduled settlement frame"); + } + frames.delete(next[0]); + next[1](); + }; + + scheduler.schedule(); + scheduler.schedule(); + runNextFrame(); + scheduler.schedule(); + runNextFrame(); + scheduler.schedule(); + runNextFrame(); + + expect(onSettle).toHaveBeenCalledTimes(1); + expect(onFrame).toHaveBeenCalledTimes(3); + expect(frames.size).toBe(0); + }); +}); diff --git a/packages/app/src/agent-stream/history-start-settle-scheduler.ts b/packages/app/src/agent-stream/history-start-settle-scheduler.ts new file mode 100644 index 000000000..3049ab8ed --- /dev/null +++ b/packages/app/src/agent-stream/history-start-settle-scheduler.ts @@ -0,0 +1,52 @@ +export interface HistoryStartSettleScheduler { + schedule(): void; + cancel(): void; +} + +export function createHistoryStartSettleScheduler(input: { + settleFrames: number; + requestFrame: (callback: () => void) => number; + cancelFrame: (id: number) => void; + isSettling: () => boolean; + isLoading: () => boolean; + onFrame?: () => void; + onSettle: () => void; +}): HistoryStartSettleScheduler { + let frameId: number | null = null; + let remainingFrames = 0; + + const tick = () => { + input.onFrame?.(); + if (!input.isSettling()) { + frameId = null; + remainingFrames = 0; + return; + } + if (input.isLoading() || remainingFrames > 0) { + if (!input.isLoading()) { + remainingFrames -= 1; + } + frameId = input.requestFrame(tick); + return; + } + frameId = null; + input.onSettle(); + }; + + return { + schedule() { + if (frameId !== null) { + return; + } + remainingFrames = input.settleFrames; + frameId = input.requestFrame(tick); + }, + cancel() { + if (frameId !== null) { + input.cancelFrame(frameId); + } + frameId = null; + remainingFrames = 0; + }, + }; +} diff --git a/packages/app/src/agent-stream/layout.test.ts b/packages/app/src/agent-stream/layout.test.ts index b996543be..2fce5c437 100644 --- a/packages/app/src/agent-stream/layout.test.ts +++ b/packages/app/src/agent-stream/layout.test.ts @@ -82,7 +82,7 @@ function strategyFor(platform: "web" | "android"): StreamStrategy { function layoutFor(input: { platform: "web" | "android"; - agentStatus?: string; + isTurnActive?: boolean; tail: StreamItem[]; head?: StreamItem[]; timingIds?: string[]; @@ -90,7 +90,7 @@ function layoutFor(input: { const strategy = strategyFor(input.platform); return layoutStream({ strategy, - agentStatus: input.agentStatus ?? "idle", + isTurnActive: input.isTurnActive ?? false, history: orderTailForStreamRenderStrategy({ strategy, streamItems: input.tail, @@ -151,14 +151,14 @@ describe("layoutStream", () => { const thirdBlock = assistantMessage("turn:block:2", 4, { groupId: "turn", index: 2 }); const splitLayout = layoutFor({ platform, - agentStatus: "running", + isTurnActive: true, tail: [userMessage("u1", 1), firstBlock], head: [secondBlock, thirdBlock], timingIds: [firstBlock.id, secondBlock.id, thirdBlock.id], }); const unsplitLayout = layoutFor({ platform, - agentStatus: "running", + isTurnActive: true, tail: [userMessage("u1", 1), firstBlock, secondBlock, thirdBlock], timingIds: [firstBlock.id, secondBlock.id, thirdBlock.id], }); @@ -439,7 +439,7 @@ describe("layoutStream", () => { const tool = toolCall("tool-1", 3); const layout = layoutFor({ platform, - agentStatus: "running", + isTurnActive: true, tail: [userMessage("u1", 1), assistant, tool], timingIds: [assistant.id], }); diff --git a/packages/app/src/agent-stream/layout.ts b/packages/app/src/agent-stream/layout.ts index f276db382..9249f8159 100644 --- a/packages/app/src/agent-stream/layout.ts +++ b/packages/app/src/agent-stream/layout.ts @@ -36,7 +36,7 @@ export interface StreamLayout { export interface StreamLayoutInput { strategy: StreamStrategy; - agentStatus: string; + isTurnActive: boolean; history: StreamItem[]; liveHead: StreamItem[]; timingByAssistantId: Map; @@ -117,7 +117,7 @@ function findLatestAssistantInTurn(input: { } function resolveAuxiliaryTurnFooter(input: StreamLayoutInput): TurnFooterHost | null { - if (input.agentStatus === "running") { + if (input.isTurnActive) { return null; } diff --git a/packages/app/src/agent-stream/model.test.ts b/packages/app/src/agent-stream/model.test.ts index 806a501d6..c55c51dda 100644 --- a/packages/app/src/agent-stream/model.test.ts +++ b/packages/app/src/agent-stream/model.test.ts @@ -35,7 +35,8 @@ describe("buildAgentStreamRenderModel", () => { const head = [assistantMessage("live-a", 121)]; const model = buildAgentStreamRenderModel({ - agentStatus: "running", + isTurnActive: true, + activeTurnStartedAt: tail.at(-2)?.timestamp ?? null, tail, head, platform: "web", @@ -53,7 +54,8 @@ describe("buildAgentStreamRenderModel", () => { const head = [assistantMessage("live-a", 3)]; const model = buildAgentStreamRenderModel({ - agentStatus: "running", + isTurnActive: true, + activeTurnStartedAt: tail[0]?.timestamp ?? null, tail, head, platform: "web", @@ -71,14 +73,16 @@ describe("buildAgentStreamRenderModel", () => { const secondHead = [assistantMessage("live-b", 4)]; const first = buildAgentStreamRenderModel({ - agentStatus: "running", + isTurnActive: true, + activeTurnStartedAt: tail[0]?.timestamp ?? null, tail, head: firstHead, platform: "native", isMobileBreakpoint: false, }); const second = buildAgentStreamRenderModel({ - agentStatus: "running", + isTurnActive: true, + activeTurnStartedAt: tail[0]?.timestamp ?? null, tail, head: secondHead, platform: "native", @@ -95,7 +99,8 @@ describe("buildAgentStreamRenderModel", () => { const head = [assistantMessage("live-a", 4)]; const model = buildAgentStreamRenderModel({ - agentStatus: "running", + isTurnActive: true, + activeTurnStartedAt: tail[0]?.timestamp ?? null, tail, head, platform: "web", @@ -111,7 +116,8 @@ describe("buildAgentStreamRenderModel", () => { const head = [assistantMessage("live-a", 4)]; const model = buildAgentStreamRenderModel({ - agentStatus: "idle", + isTurnActive: false, + activeTurnStartedAt: null, tail, head, platform: "web", @@ -130,7 +136,8 @@ describe("buildAgentStreamRenderModel", () => { const tail = [userMessage("u1", 1), assistantMessage("a1", 4)]; const model = buildAgentStreamRenderModel({ - agentStatus: "idle", + isTurnActive: false, + activeTurnStartedAt: null, tail, head: [], platform: "native", @@ -149,7 +156,8 @@ describe("buildAgentStreamRenderModel", () => { const tail = [userMessage("u1", 1), userMessage("u2", 4)]; const model = buildAgentStreamRenderModel({ - agentStatus: "idle", + isTurnActive: false, + activeTurnStartedAt: null, tail, head: [], platform: "web", diff --git a/packages/app/src/agent-stream/model.ts b/packages/app/src/agent-stream/model.ts index fe92e63fc..f7a385497 100644 --- a/packages/app/src/agent-stream/model.ts +++ b/packages/app/src/agent-stream/model.ts @@ -35,7 +35,8 @@ export interface AgentStreamRenderModel { } export interface BuildAgentStreamRenderModelInput { - agentStatus: string; + isTurnActive: boolean; + activeTurnStartedAt: Date | null; tail: StreamItem[]; head: StreamItem[]; platform: "web" | "native"; @@ -131,7 +132,8 @@ function splitOrderedTail(params: { } function getTurnTiming(params: { - agentStatus: string; + isTurnActive: boolean; + activeTurnStartedAt: Date | null; tail: StreamItem[]; head: StreamItem[]; }): StreamTurnTiming { @@ -140,17 +142,18 @@ function getTurnTiming(params: { cachedByHead = new WeakMap(); turnTimingCache.set(params.tail, cachedByHead); } - let cachedByStatus = cachedByHead.get(params.head); - if (!cachedByStatus) { - cachedByStatus = new Map(); - cachedByHead.set(params.head, cachedByStatus); + let cachedByActivity = cachedByHead.get(params.head); + if (!cachedByActivity) { + cachedByActivity = new Map(); + cachedByHead.set(params.head, cachedByActivity); } - const cached = cachedByStatus.get(params.agentStatus); + const activityKey = `${params.isTurnActive}:${params.activeTurnStartedAt?.getTime() ?? "none"}`; + const cached = cachedByActivity.get(activityKey); if (cached) { return cached; } const timing = deriveStreamTurnTiming(params); - cachedByStatus.set(params.agentStatus, timing); + cachedByActivity.set(activityKey, timing); return timing; } @@ -188,7 +191,8 @@ export function buildAgentStreamRenderModel( isMobileBreakpoint: input.isMobileBreakpoint, }); const turnTiming = getTurnTiming({ - agentStatus: input.agentStatus, + isTurnActive: input.isTurnActive, + activeTurnStartedAt: input.activeTurnStartedAt, tail: input.tail, head: input.head, }); diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 2a38fa26d..788e4a38a 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -32,10 +32,19 @@ import { resolveBottomAnchorTransportBehavior, } from "./strategy"; import { + abandonHistoryStartPaginationRequest, createHistoryStartPaginationState, evaluateHistoryStartPagination, + isHistoryStartLoadingOperation, rearmHistoryStartPagination, + settleHistoryStartPagination, + type HistoryStartPaginationInput, + type HistoryStartPaginationTransition, } from "./history-start-pagination"; +import { + createHistoryStartSettleScheduler, + type HistoryStartSettleScheduler, +} from "./history-start-settle-scheduler"; const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({ minIndexForVisible: 0, @@ -49,10 +58,11 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ const historyStartSlotStyle: ViewStyle = { alignItems: "center", justifyContent: "center", - minHeight: 32, - paddingTop: 4, - paddingBottom: 8, + height: 32, + flexShrink: 0, }; +const HISTORY_START_SETTLE_FRAMES = 2; + interface HistoryRowDisplayVariants { regular?: StreamItem; compact?: StreamItem; @@ -116,7 +126,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false); const nativeViewportSettlingFrameIdRef = useRef(null); const historyStartReadyRef = useRef(false); - const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState()); + const [historyStartPaginationState, setHistoryStartPaginationState] = useState( + createHistoryStartPaginationState, + ); + const historyStartPaginationStateRef = useRef(historyStartPaginationState); + const historyStartSettleSchedulerRef = useRef(null); const historyItems = useMemo(() => { if (segments.historyVirtualized.length === 0) { @@ -145,22 +159,74 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat ), [displayStateHistoryRows, historyRowRevision?.contentById], ); - const evaluateHistoryStart = useStableEvent(() => { + const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput => { const metrics = streamViewportMetricsRef.current; const hasMeasuredViewport = metrics.viewportMeasuredForKey === metrics.containerKey && metrics.contentMeasuredForKey === metrics.containerKey; - const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, { + return { distanceFromHistoryStart: metrics.contentHeight - metrics.viewportHeight - metrics.offsetY, hasOlderHistory, isLoadingOlderHistory, isReady: historyStartReadyRef.current && hasMeasuredViewport, progressKey: olderHistoryProgressKey, - }); - historyStartPaginationStateRef.current = result.state; - if (result.shouldLoad) { - onNearHistoryStart(); + }; + }); + const applyHistoryStartPaginationTransition = useStableEvent( + (transition: HistoryStartPaginationTransition) => { + const previousState = historyStartPaginationStateRef.current; + historyStartPaginationStateRef.current = transition.state; + if (transition.state !== previousState) { + setHistoryStartPaginationState(transition.state); + } + if (transition.shouldLoad) { + const requestedProgressKey = olderHistoryProgressKey; + if (requestedProgressKey === null) { + return; + } + void (async () => { + const started = await onNearHistoryStart(); + if (started === true) { + return; + } + applyHistoryStartPaginationTransition({ + state: abandonHistoryStartPaginationRequest( + historyStartPaginationStateRef.current, + requestedProgressKey, + ), + shouldLoad: false, + }); + })(); + } + }, + ); + const evaluateHistoryStart = useStableEvent(() => { + const transition = evaluateHistoryStartPagination( + historyStartPaginationStateRef.current, + getHistoryStartPaginationInput(), + ); + applyHistoryStartPaginationTransition(transition); + }); + const scheduleHistoryStartSettle = useStableEvent(() => { + let scheduler = historyStartSettleSchedulerRef.current; + if (!scheduler) { + scheduler = createHistoryStartSettleScheduler({ + settleFrames: HISTORY_START_SETTLE_FRAMES, + requestFrame: requestAnimationFrame, + cancelFrame: cancelAnimationFrame, + isSettling: () => historyStartPaginationStateRef.current.status === "settling", + isLoading: () => getHistoryStartPaginationInput().isLoadingOlderHistory, + onSettle: () => { + const transition = settleHistoryStartPagination( + historyStartPaginationStateRef.current, + getHistoryStartPaginationInput(), + ); + applyHistoryStartPaginationTransition(transition); + }, + }); + historyStartSettleSchedulerRef.current = scheduler; } + scheduler.schedule(); }); const clearNativeViewportSettling = useCallback(() => { @@ -261,7 +327,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat clearNativeViewportSettling(); setIsNativeViewportSettling(false); historyStartReadyRef.current = false; - historyStartPaginationStateRef.current = createHistoryStartPaginationState(); + const initialHistoryStartState = createHistoryStartPaginationState(); + historyStartPaginationStateRef.current = initialHistoryStartState; + setHistoryStartPaginationState(initialHistoryStartState); const frame = requestAnimationFrame(() => { historyStartReadyRef.current = true; evaluateHistoryStart(); @@ -269,6 +337,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat return () => { cancelAnimationFrame(frame); clearPendingUserScrollEnd(); + historyStartSettleSchedulerRef.current?.cancel(); + historyStartSettleSchedulerRef.current = null; }; }, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]); @@ -369,16 +439,16 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); const handleScrollBeginDrag = useStableEvent((event: NativeSyntheticEvent) => { - if (!isLoadingOlderHistory) { - historyStartPaginationStateRef.current = rearmHistoryStartPagination( - historyStartPaginationStateRef.current, - ); - } clearPendingUserScrollEnd(); isUserScrollActiveRef.current = true; scrollKeyboardDismiss.onScrollBeginDrag(event); bottomAnchorController.beginUserScroll(); - evaluateHistoryStart(); + const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current); + if (rearmed !== historyStartPaginationStateRef.current) { + historyStartPaginationStateRef.current = rearmed; + setHistoryStartPaginationState(rearmed); + evaluateHistoryStart(); + } }); // Defer drag end so momentum can take ownership, but capture the terminal @@ -454,11 +524,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentHeight: nextContentHeight, }); evaluateHistoryStart(); + if (historyStartPaginationStateRef.current.status === "settling") { + scheduleHistoryStartSettle(); + } }); useEffect(() => { evaluateHistoryStart(); - }, [evaluateHistoryStart, hasOlderHistory, isLoadingOlderHistory, olderHistoryProgressKey]); + if (historyStartPaginationStateRef.current.status === "settling") { + scheduleHistoryStartSettle(); + } + }, [ + evaluateHistoryStart, + hasOlderHistory, + isLoadingOlderHistory, + olderHistoryProgressKey, + scheduleHistoryStartSettle, + ]); const renderItem = useStableEvent( ({ item, index }: ListRenderItemInfo): ReactElement | null => { @@ -499,20 +581,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat ]); const historyFooterContent = useMemo(() => { - if (!hasOlderHistory && !isLoadingOlderHistory) { - return null; - } + const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState); return ( - - {isLoadingOlderHistory ? ( - + + {isLoadingOperation ? ( + + + ) : null} ); - }, [hasOlderHistory, isLoadingOlderHistory]); + }, [historyStartPaginationState]); // RN's FlatList strictMode keeps its internal renderItem wrapper stable when // data or the live header changes, preserving the row identities above. diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index c125293cf..c9035fc68 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -130,7 +130,7 @@ describe("createWebStreamStrategy", () => { routeBottomAnchorRequest: null, isAuthoritativeHistoryReady: true, onNearBottomChange: vi.fn(), - onNearHistoryStart: vi.fn(), + onNearHistoryStart: vi.fn().mockReturnValue(true), isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -174,7 +174,7 @@ describe("createWebStreamStrategy", () => { routeBottomAnchorRequest: null, isAuthoritativeHistoryReady: true, onNearBottomChange: vi.fn(), - onNearHistoryStart: vi.fn(), + onNearHistoryStart: vi.fn().mockReturnValue(true), isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -230,7 +230,7 @@ describe("createWebStreamStrategy", () => { routeBottomAnchorRequest: null, isAuthoritativeHistoryReady: true, onNearBottomChange: vi.fn(), - onNearHistoryStart: vi.fn(), + onNearHistoryStart: vi.fn().mockReturnValue(true), isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -285,76 +285,6 @@ describe("createWebStreamStrategy", () => { expect(scrollTo).not.toHaveBeenCalled(); }); - it("fires near-history-start when the user scrolls near the top", async () => { - const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); - const viewportRef = React.createRef(); - const onNearHistoryStart = vi.fn(); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - act(() => { - root?.render( - <> - {strategy.render({ - agentId: "agent", - segments: { - historyVirtualized: [], - historyMounted: [userMessage(1), userMessage(2)], - liveHead: [], - }, - boundary: { - hasVirtualizedHistory: false, - hasMountedHistory: true, - hasLiveHead: false, - }, - renderers: createRenderers(vi.fn()), - listEmptyComponent: null, - viewportRef, - routeBottomAnchorRequest: null, - isAuthoritativeHistoryReady: true, - onNearBottomChange: vi.fn(), - onNearHistoryStart, - isLoadingOlderHistory: false, - hasOlderHistory: true, - olderHistoryProgressKey: "epoch-1:20", - scrollEnabled: true, - listStyle: null, - baseListContentContainerStyle: null, - forwardListContentContainerStyle: null, - })} - , - ); - }); - - const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]'); - if (!(scrollContainer instanceof HTMLElement)) { - throw new Error("Expected agent chat scroll container"); - } - Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 }); - Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 }); - Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 }); - - await act(async () => { - await new Promise((resolve) => requestAnimationFrame(resolve)); - }); - - expect(onNearHistoryStart).not.toHaveBeenCalled(); - - act(() => { - scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); - scrollContainer?.dispatchEvent(new Event("scroll")); - }); - - expect(onNearHistoryStart).toHaveBeenCalledTimes(1); - - act(() => { - scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -1 })); - }); - - expect(onNearHistoryStart).toHaveBeenCalledTimes(2); - }); - it("waits for bottom anchoring before evaluating a delayed initial tail", async () => { HTMLElement.prototype.scrollTo = vi.fn(function ( this: HTMLElement, @@ -366,7 +296,7 @@ describe("createWebStreamStrategy", () => { }); const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); const viewportRef = React.createRef(); - const onNearHistoryStart = vi.fn(); + const onNearHistoryStart = vi.fn().mockReturnValue(true); const renderInput = { agentId: "agent", boundary: { @@ -473,7 +403,7 @@ describe("createWebStreamStrategy", () => { viewportRef, routeBottomAnchorRequest, onNearBottomChange: vi.fn(), - onNearHistoryStart: vi.fn(), + onNearHistoryStart: vi.fn().mockReturnValue(true), isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -581,7 +511,7 @@ describe("createWebStreamStrategy", () => { viewportRef, routeBottomAnchorRequest, onNearBottomChange: vi.fn(), - onNearHistoryStart: vi.fn(), + onNearHistoryStart: vi.fn().mockReturnValue(true), isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -678,7 +608,7 @@ describe("createWebStreamStrategy", () => { viewportRef, routeBottomAnchorRequest, onNearBottomChange: vi.fn(), - onNearHistoryStart: vi.fn(), + onNearHistoryStart: vi.fn().mockReturnValue(true), isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, @@ -777,7 +707,7 @@ describe("createWebStreamStrategy", () => { viewportRef, routeBottomAnchorRequest: null, onNearBottomChange: vi.fn(), - onNearHistoryStart: vi.fn(), + onNearHistoryStart: vi.fn().mockReturnValue(true), isLoadingOlderHistory: false, hasOlderHistory: false, olderHistoryProgressKey: null, diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 7df586cef..5faf2112d 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -17,15 +17,30 @@ import { estimateStreamItemHeight } from "./web-virtualization"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import { createStreamStrategy } from "./strategy"; import { + abandonHistoryStartPaginationRequest, createHistoryStartPaginationState, evaluateHistoryStartPagination, + isHistoryStartLoadingOperation, rearmHistoryStartPagination, + settleHistoryStartPagination, + type HistoryStartPaginationInput, + type HistoryStartPaginationTransition, } from "./history-start-pagination"; +import { + createHistoryStartSettleScheduler, + type HistoryStartSettleScheduler, +} from "./history-start-settle-scheduler"; interface CreateWebStreamStrategyInput { isMobileBreakpoint: boolean; } +interface HistoryStartPrependAnchor { + progressKey: string; + rowId: string; + viewportOffset: number; +} + type ScrollBehaviorLike = "auto" | "smooth"; const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200; @@ -33,19 +48,34 @@ const USER_SCROLL_DELTA_EPSILON = 1; const BOTTOM_OVERSCROLL_TOLERANCE_PX = 2; const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1; +const HISTORY_START_SETTLE_FRAMES = 2; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); +function findHistoryRowElement(contentNode: HTMLElement, rowId: string): HTMLElement | null { + for (const element of contentNode.querySelectorAll("[data-history-row-id]")) { + if (element.dataset.historyRowId === rowId) { + return element; + } + } + return null; +} + const historyStartSlotStyle: CSSProperties = { display: "flex", alignItems: "center", justifyContent: "center", - minHeight: 32, - paddingTop: 4, - paddingBottom: 8, + height: 32, + flexShrink: 0, +}; + +const mountedHistoryRowStyle: CSSProperties = { + display: "flex", + flexDirection: "column", + width: "100%", }; function isScrollContainerNearBottom( @@ -140,13 +170,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }; const lastKnownScrollTopRef = useRef(0); const pendingUserScrollUpIntentRef = useRef(false); - const isPointerScrollActiveRef = useRef(false); const lastTouchClientYRef = useRef(null); const pendingAutoScrollFrameRef = useRef(null); const pendingAutoScrollTimeoutRef = useRef(null); const pendingVirtualRowMeasureFramesRef = useRef(new Map()); const historyStartReadyRef = useRef(false); - const historyStartPaginationStateRef = useRef(createHistoryStartPaginationState()); + const [historyStartPaginationState, setHistoryStartPaginationState] = useState( + createHistoryStartPaginationState, + ); + const historyStartPaginationStateRef = useRef(historyStartPaginationState); + const historyStartPrependAnchorRef = useRef(null); + const historyStartPrependAnchorActiveRef = useRef(false); + const historyStartSettleSchedulerRef = useRef(null); const shouldUseVirtualizer = segments.historyVirtualized.length > 0; const { renderHistoryVirtualizedRow, @@ -176,6 +211,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }); useEffect(() => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { + if (historyStartPrependAnchorActiveRef.current) { + return false; + } const viewportHeight = instance.scrollRect?.height ?? 0; const scrollOffset = instance.scrollOffset ?? 0; const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); @@ -187,25 +225,162 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }, [rowVirtualizer]); const virtualRows = rowVirtualizer.getVirtualItems(); const virtualTotalSize = rowVirtualizer.getTotalSize(); - const evaluateHistoryStart = useStableEvent(() => { + const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => { const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { - return; + return null; } const bottomAnchorSettled = !followOutputRef.current || isScrollContainerNearBottom(scrollContainer); - const result = evaluateHistoryStartPagination(historyStartPaginationStateRef.current, { + return { distanceFromHistoryStart: scrollContainer.scrollTop, hasOlderHistory, isLoadingOlderHistory, isReady: historyStartReadyRef.current && bottomAnchorSettled, progressKey: olderHistoryProgressKey, - }); - historyStartPaginationStateRef.current = result.state; - if (result.shouldLoad) { - onNearHistoryStart(); - } + }; }); + const applyHistoryStartPaginationTransition = useStableEvent( + (transition: HistoryStartPaginationTransition) => { + const previousState = historyStartPaginationStateRef.current; + historyStartPaginationStateRef.current = transition.state; + if (transition.state !== previousState) { + setHistoryStartPaginationState(transition.state); + } + if (!isHistoryStartLoadingOperation(transition.state)) { + historyStartPrependAnchorRef.current = null; + historyStartPrependAnchorActiveRef.current = false; + } + if (!transition.shouldLoad || olderHistoryProgressKey === null) { + return; + } + const scrollContainer = scrollContainerRef.current; + const contentNode = contentRef.current; + const anchorRow = segments.historyMounted.at(-1) ?? segments.historyVirtualized.at(-1); + const anchorElement = + contentNode && anchorRow ? findHistoryRowElement(contentNode, anchorRow.id) : null; + if (scrollContainer && anchorRow && anchorElement) { + historyStartPrependAnchorRef.current = { + progressKey: olderHistoryProgressKey, + rowId: anchorRow.id, + viewportOffset: + anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top, + }; + } else { + historyStartPrependAnchorRef.current = null; + } + historyStartPrependAnchorActiveRef.current = false; + const requestedProgressKey = olderHistoryProgressKey; + void (async () => { + const started = await onNearHistoryStart(); + if (started === true) { + return; + } + applyHistoryStartPaginationTransition({ + state: abandonHistoryStartPaginationRequest( + historyStartPaginationStateRef.current, + requestedProgressKey, + ), + shouldLoad: false, + }); + })(); + }, + ); + const evaluateHistoryStart = useStableEvent(() => { + const input = getHistoryStartPaginationInput(); + if (!input) { + return; + } + const transition = evaluateHistoryStartPagination( + historyStartPaginationStateRef.current, + input, + ); + applyHistoryStartPaginationTransition(transition); + }); + const rearmHistoryStartFromUserIntent = useStableEvent(() => { + const rearmed = rearmHistoryStartPagination(historyStartPaginationStateRef.current); + if (rearmed === historyStartPaginationStateRef.current) { + return; + } + historyStartPaginationStateRef.current = rearmed; + setHistoryStartPaginationState(rearmed); + evaluateHistoryStart(); + }); + const applyHistoryStartPrependAnchor = useStableEvent(() => { + const scrollContainer = scrollContainerRef.current; + const contentNode = contentRef.current; + const anchor = historyStartPrependAnchorRef.current; + if ( + !scrollContainer || + !contentNode || + !anchor || + !historyStartPrependAnchorActiveRef.current + ) { + return; + } + const anchorElement = findHistoryRowElement(contentNode, anchor.rowId); + if (!anchorElement) { + return; + } + const viewportOffset = + anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top; + scrollContainer.scrollTop += viewportOffset - anchor.viewportOffset; + lastKnownScrollTopRef.current = scrollContainer.scrollTop; + }); + const scheduleHistoryStartPrependSettle = useStableEvent(() => { + let scheduler = historyStartSettleSchedulerRef.current; + if (!scheduler) { + scheduler = createHistoryStartSettleScheduler({ + settleFrames: HISTORY_START_SETTLE_FRAMES, + requestFrame: (callback) => window.requestAnimationFrame(callback), + cancelFrame: (frame) => window.cancelAnimationFrame(frame), + isSettling: () => historyStartPaginationStateRef.current.status === "settling", + isLoading: () => { + const input = getHistoryStartPaginationInput(); + return ( + !input || + input.isLoadingOlderHistory || + pendingVirtualRowMeasureFramesRef.current.size > 0 + ); + }, + onFrame: applyHistoryStartPrependAnchor, + onSettle: () => { + const input = getHistoryStartPaginationInput(); + if (!input) { + return; + } + historyStartPrependAnchorActiveRef.current = false; + const transition = settleHistoryStartPagination( + historyStartPaginationStateRef.current, + input, + ); + historyStartPrependAnchorRef.current = null; + applyHistoryStartPaginationTransition(transition); + }, + }); + historyStartSettleSchedulerRef.current = scheduler; + } + scheduler.schedule(); + }); + + useLayoutEffect(() => { + const anchor = historyStartPrependAnchorRef.current; + if (!anchor || anchor.progressKey === olderHistoryProgressKey) { + return; + } + historyStartPrependAnchorActiveRef.current = true; + evaluateHistoryStart(); + applyHistoryStartPrependAnchor(); + scheduleHistoryStartPrependSettle(); + }, [ + applyHistoryStartPrependAnchor, + evaluateHistoryStart, + olderHistoryProgressKey, + scheduleHistoryStartPrependSettle, + segments.historyMounted, + segments.historyVirtualized, + virtualTotalSize, + ]); const measureVirtualizedRowElement = useCallback( (node: HTMLDivElement | null) => { @@ -313,29 +488,40 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const scrolledDown = currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON; - if (!followOutputRef.current && isAtBottom && scrolledDown) { + if (scrolledUp) { + cancelPendingStickToBottom(); + if (followOutputRef.current) { + setFollowOutput(false); + } + pendingUserScrollUpIntentRef.current = false; + rearmHistoryStartFromUserIntent(); + } else if (!followOutputRef.current && isAtBottom && scrolledDown) { setFollowOutput(true); pendingUserScrollUpIntentRef.current = false; } else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) { - if (scrolledUp || !isAtBottom) { + if (!isAtBottom) { cancelPendingStickToBottom(); setFollowOutput(false); } pendingUserScrollUpIntentRef.current = false; - } else if (followOutputRef.current && isPointerScrollActiveRef.current) { - if (scrolledUp) { - cancelPendingStickToBottom(); - setFollowOutput(false); - } } lastKnownScrollTopRef.current = currentScrollTop; updateScrollMetrics(); evaluateHistoryStart(); - }, [cancelPendingStickToBottom, evaluateHistoryStart, updateScrollMetrics]); + }, [ + cancelPendingStickToBottom, + evaluateHistoryStart, + rearmHistoryStartFromUserIntent, + updateScrollMetrics, + ]); useEffect(() => { - historyStartPaginationStateRef.current = createHistoryStartPaginationState(); + const initialHistoryStartState = createHistoryStartPaginationState(); + historyStartPaginationStateRef.current = initialHistoryStartState; + setHistoryStartPaginationState(initialHistoryStartState); + historyStartPrependAnchorRef.current = null; + historyStartPrependAnchorActiveRef.current = false; const frame = window.requestAnimationFrame(() => { historyStartReadyRef.current = true; evaluateHistoryStart(); @@ -343,6 +529,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return () => { window.cancelAnimationFrame(frame); historyStartReadyRef.current = false; + historyStartSettleSchedulerRef.current?.cancel(); + historyStartSettleSchedulerRef.current = null; }; }, [evaluateHistoryStart, props.agentId]); @@ -379,33 +567,36 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scheduleStickToBottom, ]); - useEffect(() => { + // Following output is a layout invariant: rows, footer, and bottom offset must + // reach the browser in the same paint. + useLayoutEffect(() => { if (!followOutputRef.current) { return; } - scheduleStickToBottom(); + cancelPendingStickToBottom(); + scrollMessagesToBottom("auto"); }, [ - scheduleStickToBottom, + cancelPendingStickToBottom, + renderLiveAuxiliary, + scrollMessagesToBottom, segments.historyMounted, segments.historyVirtualized, segments.liveHead, + virtualTotalSize, ]); - useEffect(() => { - if (!followOutputRef.current || !shouldUseVirtualizer) { - return; - } - scheduleStickToBottom(); - }, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize]); - useEffect(() => { updateScrollMetrics(); evaluateHistoryStart(); + if (historyStartPaginationStateRef.current.status === "settling") { + scheduleHistoryStartPrependSettle(); + } }, [ evaluateHistoryStart, hasOlderHistory, isLoadingOlderHistory, olderHistoryProgressKey, + scheduleHistoryStartPrependSettle, segments.historyMounted.length, segments.historyVirtualized.length, segments.liveHead.length, @@ -423,6 +614,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool updateScrollMetrics(); evaluateHistoryStart(); const observer = new ResizeObserver(() => { + if (historyStartPrependAnchorActiveRef.current) { + applyHistoryStartPrependAnchor(); + } + if (historyStartPaginationStateRef.current.status === "settling") { + scheduleHistoryStartPrependSettle(); + } updateScrollMetrics(); evaluateHistoryStart(); if (!followOutputRef.current) { @@ -437,7 +634,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return () => { observer.disconnect(); }; - }, [evaluateHistoryStart, scheduleStickToBottom, updateScrollMetrics]); + }, [ + applyHistoryStartPrependAnchor, + evaluateHistoryStart, + scheduleHistoryStartPrependSettle, + scheduleStickToBottom, + updateScrollMetrics, + ]); useEffect(() => { const scrollContainer = scrollContainerRef.current; @@ -447,22 +650,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const handleWheel = (event: WheelEvent) => { if (event.deltaY < 0) { - if (!isLoadingOlderHistory) { - historyStartPaginationStateRef.current = rearmHistoryStartPagination( - historyStartPaginationStateRef.current, - ); - } pendingUserScrollUpIntentRef.current = true; cancelPendingStickToBottom(); - evaluateHistoryStart(); + rearmHistoryStartFromUserIntent(); } }; - const handlePointerDown = () => { - isPointerScrollActiveRef.current = true; - }; - const handlePointerUp = () => { - isPointerScrollActiveRef.current = false; - }; const handleTouchStart = (event: TouchEvent) => { const touch = event.touches[0]; if (!touch) { @@ -477,14 +669,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool } const previousTouchY = lastTouchClientYRef.current; if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { - if (!isLoadingOlderHistory) { - historyStartPaginationStateRef.current = rearmHistoryStartPagination( - historyStartPaginationStateRef.current, - ); - } pendingUserScrollUpIntentRef.current = true; cancelPendingStickToBottom(); - evaluateHistoryStart(); + rearmHistoryStartFromUserIntent(); } lastTouchClientYRef.current = touch.clientY; }; @@ -494,9 +681,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true }); scrollContainer.addEventListener("wheel", handleWheel, { passive: true }); - scrollContainer.addEventListener("pointerdown", handlePointerDown, { passive: true }); - scrollContainer.addEventListener("pointerup", handlePointerUp, { passive: true }); - scrollContainer.addEventListener("pointercancel", handlePointerUp, { passive: true }); scrollContainer.addEventListener("touchstart", handleTouchStart, { passive: true }); scrollContainer.addEventListener("touchmove", handleTouchMove, { passive: true }); scrollContainer.addEventListener("touchend", handleTouchEnd, { passive: true }); @@ -505,15 +689,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return () => { scrollContainer.removeEventListener("scroll", handleDomScroll); scrollContainer.removeEventListener("wheel", handleWheel); - scrollContainer.removeEventListener("pointerdown", handlePointerDown); - scrollContainer.removeEventListener("pointerup", handlePointerUp); - scrollContainer.removeEventListener("pointercancel", handlePointerUp); scrollContainer.removeEventListener("touchstart", handleTouchStart); scrollContainer.removeEventListener("touchmove", handleTouchMove); scrollContainer.removeEventListener("touchend", handleTouchEnd); scrollContainer.removeEventListener("touchcancel", handleTouchEnd); }; - }, [cancelPendingStickToBottom, evaluateHistoryStart, handleDomScroll, isLoadingOlderHistory]); + }, [cancelPendingStickToBottom, handleDomScroll, rearmHistoryStartFromUserIntent]); useEffect(() => { const handle: StreamViewportHandle = { @@ -580,9 +761,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool ); const mountedHistoryRows = useMemo(() => { return segments.historyMounted.map((item, index) => ( - +
{renderHistoryMountedRow(item, index, segments.historyMounted)} - +
)); }, [renderHistoryMountedRow, segments.historyMounted]); const liveHeadRows = useMemo(() => { @@ -595,20 +776,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool return renderLiveAuxiliary(); }, [renderLiveAuxiliary]); const historyStartSlot = useMemo(() => { - if (!hasOlderHistory && !isLoadingOlderHistory) { - return null; - } + const isLoadingOperation = isHistoryStartLoadingOperation(historyStartPaginationState); return ( -
- {isLoadingOlderHistory ? ( - +
+ {isLoadingOperation ? ( +
+ +
) : null}
); - }, [hasOlderHistory, isLoadingOlderHistory]); + }, [historyStartPaginationState]); const shouldRenderEmpty = !boundary.hasMountedHistory && !boundary.hasVirtualizedHistory && @@ -635,6 +813,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 9a467c578..15836f623 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -69,7 +69,7 @@ export interface StreamRenderInput { routeBottomAnchorRequest: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady: boolean; onNearBottomChange: (value: boolean) => void; - onNearHistoryStart: () => void; + onNearHistoryStart: () => boolean | Promise; isLoadingOlderHistory: boolean; hasOlderHistory: boolean; olderHistoryProgressKey: string | null; diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 5af665d40..8639afbbc 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -41,6 +41,8 @@ import { } from "@/components/message"; import { PlanCard } from "@/components/plan-card"; import type { StreamItem } from "@/types/stream"; +import type { PendingMessageSubmission } from "@/composer/submission/model"; +import type { TurnPresentation } from "@/timeline/turn-liveness"; import type { PendingPermission } from "@/types/shared"; import type { AgentCapabilityFlags, @@ -76,6 +78,7 @@ import { type BottomAnchorLocalRequest, type BottomAnchorRouteRequest, } from "./bottom-anchor-controller"; +import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache"; import { AssistantFileLinkResolverProvider, normalizeInlinePathTarget, @@ -239,6 +242,8 @@ export interface AgentStreamViewProps { streamItems: StreamItem[]; streamHead?: StreamItem[]; pendingPermissions: Map; + pendingMessageSubmissions?: readonly PendingMessageSubmission[]; + turnPresentation: TurnPresentation; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady?: boolean; toast?: ToastApi | null; @@ -248,7 +253,7 @@ export interface AgentStreamViewProps { hasOlder: boolean; isLoadingOlder: boolean; progressKey: string | null; - onLoadOlder: () => void; + onLoadOlder: () => boolean | Promise; }; } @@ -265,6 +270,7 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [ ]; const EMPTY_STREAM_HEAD: StreamItem[] = []; +const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = []; const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200; function buildChatHistoryAttachment(input: { @@ -327,6 +333,8 @@ const AgentStreamViewComponent = forwardRef settings.autoExpandReasoning); const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel); const viewportRef = useRef(null); + const pendingClientMessageIds = useMemo( + () => new Set(pendingMessageSubmissions.map((submission) => submission.clientMessageId)), + [pendingMessageSubmissions], + ); const isMobile = useIsCompactFormFactor(); const streamRenderStrategy = useMemo( () => @@ -554,6 +566,7 @@ const AgentStreamViewComponent = forwardRef prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems), @@ -566,12 +579,12 @@ const AgentStreamViewComponent = forwardRef { return buildAgentStreamRenderModel({ - agentStatus: context.status, + isTurnActive, + activeTurnStartedAt: turnPresentation.startedAt, tail: projectedToolCalls.tail, head: projectedToolCalls.head, platform: isWeb ? "web" : "native", isMobileBreakpoint: isMobile, }); - }, [context.status, isMobile, projectedToolCalls.head, projectedToolCalls.tail]); + }, [ + isMobile, + isTurnActive, + projectedToolCalls.head, + projectedToolCalls.tail, + turnPresentation.startedAt, + ]); const streamLayout = useMemo( () => layoutStream({ strategy: streamRenderStrategy, - agentStatus: context.status, + isTurnActive, history: baseRenderModel.history, liveHead: baseRenderModel.segments.liveHead, timingByAssistantId: baseRenderModel.turnTiming.byAssistantId, }), [ - context.status, baseRenderModel.history, baseRenderModel.segments.liveHead, baseRenderModel.turnTiming.byAssistantId, + isTurnActive, streamRenderStrategy, ], ); @@ -656,7 +676,7 @@ const AgentStreamViewComponent = forwardRef ); }, - [context.capabilities, agentId, client, resolvedServerId], + [context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId], ); const renderAssistantMessageItem = useCallback( @@ -682,6 +706,7 @@ const AgentStreamViewComponent = forwardRef ); }, - [client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot], + [agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot], ); const renderThoughtItem = useCallback( @@ -878,7 +903,6 @@ const AgentStreamViewComponent = forwardRef renderPendingPermissionsNode({ @@ -889,9 +913,9 @@ const AgentStreamViewComponent = forwardRef - showRunningTurnFooter || bottomTurnFooterHost ? ( + isTurnActive || bottomTurnFooterHost ? ( { + it("evicts a rejected acquisition so the next request can retry", async () => { + const cache = createAssistantImageAcquisitionCache({ capacity: 2 }); + let attempts = 0; + + await expect( + cache.acquire("image", async () => { + attempts += 1; + throw new Error("first attempt failed"); + }), + ).rejects.toThrow("first attempt failed"); + const recovered = await cache.acquire("image", async () => { + attempts += 1; + return "recovered"; + }); + + expect({ attempts, recovered, size: cache.size() }).toEqual({ + attempts: 2, + recovered: "recovered", + size: 1, + }); + }); + + it("bounds successful acquisitions and evicts the least recently used entry", async () => { + const cache = createAssistantImageAcquisitionCache({ capacity: 2 }); + const located: string[] = []; + const locate = async (key: string) => { + located.push(key); + return key; + }; + + await cache.acquire("a", async () => await locate("a")); + await cache.acquire("b", async () => await locate("b")); + await cache.acquire("a", async () => await locate("a-again")); + await cache.acquire("c", async () => await locate("c")); + await cache.acquire("b", async () => await locate("b-again")); + + expect({ located, size: cache.size() }).toEqual({ + located: ["a", "b", "c", "b-again"], + size: 2, + }); + }); + + it("reuses an acquired image when the current locator is unavailable", async () => { + const cache = createAssistantImageAcquisitionCache({ capacity: 2 }); + let unavailableCalls = 0; + + await cache.acquire("message:image", async () => "persisted attachment"); + const cached = await cache.acquire("message:image", async () => { + unavailableCalls += 1; + throw new Error("daemon disconnected"); + }); + + expect({ cached, unavailableCalls }).toEqual({ + cached: "persisted attachment", + unavailableCalls: 0, + }); + expect(cache.peek("message:image")).toBe("persisted attachment"); + }); + + it("scopes file acquisitions to the rendered message occurrence", () => { + const first = createAssistantImageFileAcquisitionKey({ + serverId: "server", + occurrenceKey: "message-1:image-1", + cwd: "/workspace", + path: "screenshot.png", + }); + const remount = createAssistantImageFileAcquisitionKey({ + serverId: "server", + occurrenceKey: "message-1:image-1", + cwd: "/workspace", + path: "screenshot.png", + }); + const laterMessage = createAssistantImageFileAcquisitionKey({ + serverId: "server", + occurrenceKey: "message-2:image-1", + cwd: "/workspace", + path: "screenshot.png", + }); + + expect(remount).toBe(first); + expect(laterMessage).not.toBe(first); + }); + + it("scopes persisted file previews to the rendered message occurrence", () => { + const first = createAssistantImageFilePreviewAttachmentId({ + serverId: "server-1", + occurrenceKey: "message-1:image-1", + mimeType: "image/png", + path: "/workspace/screenshot.png", + size: 512, + modifiedAt: "2026-07-27T12:00:00.000Z", + contentLength: 512, + }); + const second = createAssistantImageFilePreviewAttachmentId({ + serverId: "server-1", + occurrenceKey: "message-2:image-1", + mimeType: "image/png", + path: "/workspace/screenshot.png", + size: 512, + modifiedAt: "2026-07-27T12:00:00.000Z", + contentLength: 512, + }); + + expect(second).not.toBe(first); + }); + + it("scopes message occurrences to their agent", () => { + const first = createAssistantImageOccurrenceKey({ agentId: "agent-1", itemId: "message-1" }); + const second = createAssistantImageOccurrenceKey({ agentId: "agent-2", itemId: "message-1" }); + + expect(second).not.toBe(first); + }); + + it("retains successful values until their cache entry is evicted", async () => { + const retained: string[] = []; + const released: string[] = []; + const cache = createAssistantImageAcquisitionCache({ + capacity: 1, + onRetain(value) { + retained.push(value); + return () => released.push(value); + }, + }); + + await cache.acquire("first", async () => "attachment-1"); + expect({ retained, released }).toEqual({ retained: ["attachment-1"], released: [] }); + + await cache.acquire("second", async () => "attachment-2"); + expect({ retained, released }).toEqual({ + retained: ["attachment-1", "attachment-2"], + released: ["attachment-1"], + }); + }); + + it("does not evict a value while an active consumer retains it", async () => { + const released: string[] = []; + const cache = createAssistantImageAcquisitionCache({ + capacity: 1, + onRetain: (value) => () => released.push(value), + }); + + const first = cache.acquireRetained("first", async () => "attachment-1"); + await first.promise; + const second = cache.acquireRetained("second", async () => "attachment-2"); + await second.promise; + + expect({ released, size: cache.size() }).toEqual({ released: [], size: 2 }); + + first.release(); + expect({ released, size: cache.size() }).toEqual({ + released: ["attachment-1"], + size: 1, + }); + second.release(); + }); + + it("protects every actively consumed attachment from garbage collection past capacity", async () => { + const cache = createAssistantImageAcquisitionCache<{ id: string }>({ + capacity: 1, + onRetain: (attachment) => retainAttachmentForGarbageCollection(attachment.id), + }); + + const first = cache.acquireRetained("first", async () => ({ id: "mounted-image-1" })); + await first.promise; + const second = cache.acquireRetained("second", async () => ({ id: "mounted-image-2" })); + await second.promise; + + expect(collectRetainedAttachmentIds()).toEqual(new Set(["mounted-image-1", "mounted-image-2"])); + + first.release(); + expect(collectRetainedAttachmentIds()).toEqual(new Set(["mounted-image-2"])); + second.release(); + await expect( + cache.acquire("cleanup", async () => { + throw new Error("cleanup"); + }), + ).rejects.toThrow("cleanup"); + expect(collectRetainedAttachmentIds()).toEqual(new Set()); + }); +}); diff --git a/packages/app/src/assistant-image/acquisition-cache.ts b/packages/app/src/assistant-image/acquisition-cache.ts new file mode 100644 index 000000000..f76f594b8 --- /dev/null +++ b/packages/app/src/assistant-image/acquisition-cache.ts @@ -0,0 +1,159 @@ +import { createPreviewAttachmentId } from "@/attachments/utils"; + +export interface AssistantImageAcquisitionCache { + acquire(key: string, locate: () => Promise): Promise; + acquireRetained( + key: string, + locate: () => Promise, + ): { promise: Promise; value?: T; release: () => void }; + peek(key: string): T | undefined; + size(): number; +} + +export function createAssistantImageOccurrenceKey(input: { + agentId: string; + itemId: string; +}): string { + return `${input.agentId}:${input.itemId}`; +} + +export function createAssistantImageFilePreviewAttachmentId(input: { + serverId?: string; + occurrenceKey: string; + mimeType: string; + path: string; + size: number; + modifiedAt?: string | null; + contentLength: number; +}): string { + return createPreviewAttachmentId({ + mimeType: input.mimeType, + path: input.path, + size: input.size, + modifiedAt: input.modifiedAt, + contentLength: input.contentLength, + contentKey: `${input.serverId ?? "unknown-server"}:${input.occurrenceKey}`, + }); +} + +export function createAssistantImageFileAcquisitionKey(input: { + serverId?: string; + occurrenceKey: string; + cwd: string; + path: string; +}): string { + return `file:${input.serverId ?? "unknown-server"}:${input.occurrenceKey}:${input.cwd}:${input.path}`; +} + +export function createAssistantImageAcquisitionCache(input: { + capacity: number; + onRetain?: (value: T) => () => void; +}): AssistantImageAcquisitionCache { + if (!Number.isInteger(input.capacity) || input.capacity < 1) { + throw new Error("Assistant image acquisition cache capacity must be a positive integer."); + } + interface CacheEntry { + pending: Promise; + resolved: boolean; + value?: T; + release: (() => void) | null; + activeConsumers: number; + } + const entries = new Map(); + + const evict = (key: string, entry: CacheEntry) => { + if (entries.get(key) === entry) { + entries.delete(key); + } + entry.release?.(); + entry.release = null; + }; + + const enforceCapacity = () => { + while (entries.size > input.capacity) { + let evicted = false; + for (const [key, entry] of entries) { + if (entry.activeConsumers > 0) { + continue; + } + evict(key, entry); + evicted = true; + break; + } + if (!evicted) { + return; + } + } + }; + + const acquireEntry = (key: string, locate: () => Promise, retain: boolean): CacheEntry => { + const cached = entries.get(key); + if (cached) { + entries.delete(key); + entries.set(key, cached); + if (retain) { + cached.activeConsumers += 1; + } + return cached; + } + const pending = locate(); + const entry: CacheEntry = { + pending, + resolved: false, + release: null, + activeConsumers: retain ? 1 : 0, + }; + entries.set(key, entry); + enforceCapacity(); + void (async () => { + try { + const value = await pending; + const release = input.onRetain?.(value) ?? null; + if (entries.get(key) === entry) { + entry.value = value; + entry.resolved = true; + entry.release = release; + } else { + release?.(); + } + } catch { + evict(key, entry); + } + })(); + return entry; + }; + + return { + acquire(key, locate) { + return acquireEntry(key, locate, false).pending; + }, + acquireRetained(key, locate) { + const entry = acquireEntry(key, locate, true); + let released = false; + return { + promise: entry.pending, + ...(entry.resolved ? { value: entry.value } : {}), + release() { + if (released) { + return; + } + released = true; + entry.activeConsumers = Math.max(0, entry.activeConsumers - 1); + enforceCapacity(); + }, + }; + }, + peek(key) { + const entry = entries.get(key); + if (!entry?.resolved) { + return undefined; + } + entries.delete(key); + entries.set(key, entry); + return entry.value; + }, + size() { + return entries.size; + }, + }; +} diff --git a/packages/app/src/assistant-image/file-acquisition.test.ts b/packages/app/src/assistant-image/file-acquisition.test.ts new file mode 100644 index 000000000..552609e75 --- /dev/null +++ b/packages/app/src/assistant-image/file-acquisition.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import type { AttachmentMetadata } from "@/attachments/types"; +import { + createAssistantImageFileAcquisition, + type AssistantImageFileAcquisitionPort, +} from "./file-acquisition"; + +class MemoryFileAcquisitionPort implements AssistantImageFileAcquisitionPort { + readonly reads: Array<{ cwd: string; path: string }> = []; + + async readFile(cwd: string, path: string) { + this.reads.push({ cwd, path }); + return { + kind: "image" as const, + path, + mime: "image/png", + size: 4, + modifiedAt: "1", + bytes: new Uint8Array([1, 2, 3, 4]), + }; + } + + async persist(input: { id: string; mimeType: string; fileName: string | null }) { + return { + id: input.id, + mimeType: input.mimeType, + storageType: "web-indexeddb" as const, + storageKey: input.id, + fileName: input.fileName, + byteSize: 4, + createdAt: 1, + } satisfies AttachmentMetadata; + } +} + +describe("assistant image file acquisition", () => { + it("recreates the same acquisition with a live port after reconnect", async () => { + const common = { + resolution: { kind: "file_rpc" as const, cwd: "/workspace", path: "reconnect.png" }, + serverId: "server", + occurrenceKey: "agent:message:reconnect-image", + unavailableMessage: "Image unavailable", + }; + const disconnected = createAssistantImageFileAcquisition({ ...common, port: null }); + const connectedPort = new MemoryFileAcquisitionPort(); + const connected = createAssistantImageFileAcquisition({ ...common, port: connectedPort }); + + expect(disconnected?.key).toBe(connected?.key); + await expect(disconnected?.locate()).rejects.toThrow("Image unavailable"); + await expect(connected?.locate()).resolves.toMatchObject({ mimeType: "image/png" }); + expect(connectedPort.reads).toEqual([{ cwd: "/workspace", path: "reconnect.png" }]); + }); +}); diff --git a/packages/app/src/assistant-image/file-acquisition.ts b/packages/app/src/assistant-image/file-acquisition.ts new file mode 100644 index 000000000..f7a80e853 --- /dev/null +++ b/packages/app/src/assistant-image/file-acquisition.ts @@ -0,0 +1,67 @@ +import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; +import type { AttachmentMetadata } from "@/attachments/types"; +import { getFileNameFromPath } from "@/attachments/utils"; +import type { AssistantImageSourceResolution } from "@/utils/assistant-image-source"; +import { + createAssistantImageFileAcquisitionKey, + createAssistantImageFilePreviewAttachmentId, +} from "./acquisition-cache"; + +export interface AssistantImageFileAcquisitionPort { + readFile(cwd: string, path: string): Promise; + persist(input: { + id: string; + bytes: Uint8Array; + mimeType: string; + fileName: string | null; + }): Promise; +} + +export interface AssistantImageAcquisition { + key: string; + locate: () => Promise; +} + +export function createAssistantImageFileAcquisition(input: { + port: AssistantImageFileAcquisitionPort | null; + resolution: AssistantImageSourceResolution | null; + serverId?: string; + occurrenceKey: string; + unavailableMessage: string; +}): AssistantImageAcquisition | null { + if (input.resolution?.kind !== "file_rpc") { + return null; + } + const { port, resolution } = input; + return { + key: createAssistantImageFileAcquisitionKey({ + serverId: input.serverId, + occurrenceKey: input.occurrenceKey, + cwd: resolution.cwd, + path: resolution.path, + }), + locate: async () => { + if (!port) { + throw new Error(input.unavailableMessage); + } + const file = await port.readFile(resolution.cwd, resolution.path); + if (file.kind !== "image") { + throw new Error(input.unavailableMessage); + } + return await port.persist({ + id: createAssistantImageFilePreviewAttachmentId({ + serverId: input.serverId, + occurrenceKey: input.occurrenceKey, + mimeType: file.mime, + path: file.path || resolution.path, + size: file.size, + modifiedAt: file.modifiedAt, + contentLength: file.bytes.byteLength, + }), + bytes: file.bytes, + mimeType: file.mime, + fileName: getFileNameFromPath(file.path || resolution.path), + }); + }, + }; +} diff --git a/packages/app/src/assistant-image/lifecycle.test.ts b/packages/app/src/assistant-image/lifecycle.test.ts new file mode 100644 index 000000000..ed7729dd3 --- /dev/null +++ b/packages/app/src/assistant-image/lifecycle.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { createAssistantImageLifecycle, transitionAssistantImageLifecycle } from "./lifecycle"; + +describe("assistant image lifecycle", () => { + it("keeps preview URL recreation in the public loading state", () => { + const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), { + type: "preview_created", + uri: "blob:first", + aspectRatio: null, + }); + const loaded = transitionAssistantImageLifecycle(loading, { + type: "image_loaded", + uri: "blob:first", + aspectRatio: 1.5, + }); + + const recreating = transitionAssistantImageLifecycle(loaded, { + type: "preview_released", + }); + + expect(recreating).toEqual({ status: "loading", uri: null, aspectRatio: null }); + }); + + it("publishes loaded only after the recreated URL loads", () => { + const recreating = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), { + type: "preview_created", + uri: "blob:recreated", + aspectRatio: 1.5, + }); + + expect(recreating).toEqual({ + status: "loading", + uri: "blob:recreated", + aspectRatio: 1.5, + }); + + const recreated = transitionAssistantImageLifecycle(recreating, { + type: "image_loaded", + uri: "blob:recreated", + aspectRatio: 0.75, + }); + + expect(recreated).toEqual({ + status: "loaded", + uri: "blob:recreated", + aspectRatio: 0.75, + }); + }); + + it("does not reset a loaded image when the same preview is reported again", () => { + const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), { + type: "preview_created", + uri: "blob:current", + aspectRatio: null, + }); + const loaded = transitionAssistantImageLifecycle(loading, { + type: "image_loaded", + uri: "blob:current", + aspectRatio: 1.5, + }); + + const repeated = transitionAssistantImageLifecycle(loaded, { + type: "preview_created", + uri: "blob:current", + aspectRatio: 1.5, + }); + + expect(repeated).toBe(loaded); + }); + + it("publishes failed for a terminal image failure on the current URI", () => { + const loading = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), { + type: "preview_created", + uri: "blob:current", + aspectRatio: null, + }); + const failed = transitionAssistantImageLifecycle(loading, { + type: "failed", + uri: "blob:current", + message: "Unable to load image preview.", + }); + + expect(failed).toEqual({ + status: "failed", + message: "Unable to load image preview.", + }); + }); + + it("ignores a stale load callback from a replaced URI", () => { + const current = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), { + type: "preview_created", + uri: "blob:current", + aspectRatio: 1.25, + }); + + const afterStaleLoad = transitionAssistantImageLifecycle(current, { + type: "image_loaded", + uri: "blob:released", + aspectRatio: 2, + }); + + expect(afterStaleLoad).toEqual(current); + }); + + it("ignores a stale error callback from a replaced URI", () => { + const current = transitionAssistantImageLifecycle(createAssistantImageLifecycle(), { + type: "preview_created", + uri: "blob:current", + aspectRatio: null, + }); + + const afterStaleError = transitionAssistantImageLifecycle(current, { + type: "failed", + uri: "blob:released", + message: "Image unavailable", + }); + + expect(afterStaleError).toEqual(current); + }); +}); diff --git a/packages/app/src/assistant-image/lifecycle.ts b/packages/app/src/assistant-image/lifecycle.ts new file mode 100644 index 000000000..bf0db18c9 --- /dev/null +++ b/packages/app/src/assistant-image/lifecycle.ts @@ -0,0 +1,46 @@ +export type AssistantImageLifecycle = + | { status: "loading"; uri: string | null; aspectRatio: number | null } + | { status: "loaded"; uri: string; aspectRatio: number } + | { status: "failed"; message: string }; + +export type AssistantImageLifecycleEvent = + | { type: "preview_created"; uri: string; aspectRatio: number | null } + | { type: "preview_released" } + | { type: "image_loaded"; uri: string; aspectRatio: number } + | { type: "failed"; uri: string; message: string }; + +export function createAssistantImageLifecycle(): AssistantImageLifecycle { + return { status: "loading", uri: null, aspectRatio: null }; +} + +export function transitionAssistantImageLifecycle( + state: AssistantImageLifecycle, + event: AssistantImageLifecycleEvent, +): AssistantImageLifecycle { + if (event.type === "image_loaded") { + if (state.status !== "loading" || state.uri !== event.uri) { + return state; + } + return { + status: "loaded", + uri: event.uri, + aspectRatio: event.aspectRatio, + }; + } + if (event.type === "failed") { + if (state.status === "failed" || state.uri !== event.uri) { + return state; + } + return { status: "failed", message: event.message }; + } + if (event.type === "preview_created") { + if (state.status === "loaded" && state.uri === event.uri) { + return state; + } + return { status: "loading", uri: event.uri, aspectRatio: event.aspectRatio }; + } + if (event.type === "preview_released") { + return { status: "loading", uri: null, aspectRatio: null }; + } + return state; +} diff --git a/packages/app/src/assistant-image/load-dimensions.test.ts b/packages/app/src/assistant-image/load-dimensions.test.ts new file mode 100644 index 000000000..a60f9b1b6 --- /dev/null +++ b/packages/app/src/assistant-image/load-dimensions.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + type AssistantImageRenderedDimensionsReader, + resolveAssistantImageLoadDimensions, +} from "./load-dimensions"; + +class MemoryRenderedDimensionsReader implements AssistantImageRenderedDimensionsReader { + constructor(private readonly dimensions: { width: number; height: number } | null) {} + + read(): { width: number; height: number } | null { + return this.dimensions; + } +} + +describe("assistant image load dimensions", () => { + it("prefers native source dimensions", () => { + const dimensions = resolveAssistantImageLoadDimensions({ + source: { width: 640, height: 320 }, + target: { naturalWidth: 800, naturalHeight: 400 }, + renderedImage: null, + renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }), + }); + + expect(dimensions).toEqual({ width: 640, height: 320 }); + }); + + it("uses browser event dimensions when native dimensions are absent", () => { + const dimensions = resolveAssistantImageLoadDimensions({ + source: { width: 0, height: 0 }, + target: { naturalWidth: 800, naturalHeight: 400 }, + renderedImage: null, + renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }), + }); + + expect(dimensions).toEqual({ width: 800, height: 400 }); + }); + + it("falls back to the rendered image adapter", () => { + const renderedImage = { id: "rendered-image" }; + const dimensions = resolveAssistantImageLoadDimensions({ + target: null, + renderedImage, + renderedDimensions: new MemoryRenderedDimensionsReader({ width: 900, height: 600 }), + }); + + expect(dimensions).toEqual({ width: 900, height: 600 }); + }); +}); diff --git a/packages/app/src/assistant-image/load-dimensions.ts b/packages/app/src/assistant-image/load-dimensions.ts new file mode 100644 index 000000000..069814ced --- /dev/null +++ b/packages/app/src/assistant-image/load-dimensions.ts @@ -0,0 +1,45 @@ +export interface AssistantImageDimensions { + width: number; + height: number; +} + +interface AssistantImageDimensionCandidate { + width?: unknown; + height?: unknown; +} + +export interface AssistantImageRenderedDimensionsReader { + read(renderedImage: unknown): AssistantImageDimensions | null; +} + +function readPositiveDimensions( + candidate: AssistantImageDimensionCandidate | null | undefined, +): AssistantImageDimensions | null { + if ( + typeof candidate?.width !== "number" || + typeof candidate.height !== "number" || + candidate.width <= 0 || + candidate.height <= 0 + ) { + return null; + } + return { width: candidate.width, height: candidate.height }; +} + +export function resolveAssistantImageLoadDimensions(input: { + source?: AssistantImageDimensionCandidate | null; + target?: { naturalWidth?: unknown; naturalHeight?: unknown } | null; + renderedImage: unknown; + renderedDimensions: AssistantImageRenderedDimensionsReader; +}): AssistantImageDimensions | null { + const source = readPositiveDimensions(input.source); + if (source) { + return source; + } + + const target = readPositiveDimensions({ + width: input.target?.naturalWidth, + height: input.target?.naturalHeight, + }); + return target ?? input.renderedDimensions.read(input.renderedImage); +} diff --git a/packages/app/src/assistant-image/retry.test.ts b/packages/app/src/assistant-image/retry.test.ts new file mode 100644 index 000000000..9777663be --- /dev/null +++ b/packages/app/src/assistant-image/retry.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { runAssistantImageOperationWithRetry } from "./retry"; + +describe("assistant image retry", () => { + it("recovers from transient failures while the image remains mounted", async () => { + const waits: number[] = []; + let attempts = 0; + + const result = await runAssistantImageOperationWithRetry({ + operation: async () => { + attempts += 1; + if (attempts < 3) { + throw new Error("transient"); + } + return "loaded"; + }, + delaysMs: [10, 20, 30], + wait: async (delayMs) => { + waits.push(delayMs); + }, + }); + + expect({ attempts, waits, result }).toEqual({ + attempts: 3, + waits: [10, 20], + result: "loaded", + }); + }); + + it("stops retrying after the image is released", async () => { + let stopped = false; + let attempts = 0; + + await expect( + runAssistantImageOperationWithRetry({ + operation: async () => { + attempts += 1; + throw new Error("transient"); + }, + delaysMs: [10, 20], + shouldStop: () => stopped, + wait: async () => { + stopped = true; + }, + }), + ).rejects.toThrow("transient"); + expect(attempts).toBe(1); + }); +}); diff --git a/packages/app/src/assistant-image/retry.ts b/packages/app/src/assistant-image/retry.ts new file mode 100644 index 000000000..4a2cf5c58 --- /dev/null +++ b/packages/app/src/assistant-image/retry.ts @@ -0,0 +1,31 @@ +export const ASSISTANT_IMAGE_RETRY_DELAYS_MS = [100, 400, 1_200] as const; + +export async function runAssistantImageOperationWithRetry(input: { + operation: () => Promise; + delaysMs?: readonly number[]; + shouldStop?: () => boolean; + wait?: (delayMs: number) => Promise; +}): Promise { + const delays = input.delaysMs ?? ASSISTANT_IMAGE_RETRY_DELAYS_MS; + const shouldStop = input.shouldStop ?? (() => false); + const wait = + input.wait ?? + (async (delayMs: number) => { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + }); + + for (let attempt = 0; ; attempt += 1) { + try { + return await input.operation(); + } catch (error) { + const delay = delays[attempt]; + if (delay === undefined || shouldStop()) { + throw error; + } + await wait(delay); + if (shouldStop()) { + throw error; + } + } + } +} diff --git a/packages/app/src/assistant-image/use-assistant-image.ts b/packages/app/src/assistant-image/use-assistant-image.ts new file mode 100644 index 000000000..0c0818e8f --- /dev/null +++ b/packages/app/src/assistant-image/use-assistant-image.ts @@ -0,0 +1,544 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; +import type { ImageLoadEvent } from "react-native"; +import { useTranslation } from "react-i18next"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { AttachmentMetadata } from "@/attachments/types"; +import { isWeb } from "@/constants/platform"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { retainAttachmentForGarbageCollection } from "@/attachments/gc-retention"; +import { + persistAttachmentFromBytes, + persistAttachmentFromDataUrl, + releaseAttachmentPreviewUrl, + resolveAttachmentPreviewUrl, +} from "@/attachments/service"; +import { createPreviewAttachmentId, parseImageDataUrl } from "@/attachments/utils"; +import { + getAssistantImageMetadata, + setAssistantImageMetadata, +} from "@/utils/assistant-image-metadata"; +import { resolveAssistantImageSource } from "@/utils/assistant-image-source"; +import { createAssistantImageAcquisitionCache } from "./acquisition-cache"; +import { + createAssistantImageFileAcquisition, + type AssistantImageAcquisition, + type AssistantImageFileAcquisitionPort, +} from "./file-acquisition"; +import { + createAssistantImageLifecycle, + transitionAssistantImageLifecycle, + type AssistantImageLifecycle, + type AssistantImageLifecycleEvent, +} from "./lifecycle"; +import { + type AssistantImageRenderedDimensionsReader, + resolveAssistantImageLoadDimensions, +} from "./load-dimensions"; +import { runAssistantImageOperationWithRetry } from "./retry"; + +interface AssistantImageRenderBinding { + uri: string; + onRef: (instance: unknown) => void; + onLoad: (event: ImageLoadEvent) => void; + onError: () => void; +} + +const renderedDimensions: AssistantImageRenderedDimensionsReader = { + read(renderedImage) { + if (!isWeb || !(renderedImage instanceof HTMLElement)) { + return null; + } + const image = renderedImage.querySelector("img"); + return image && image.naturalWidth > 0 && image.naturalHeight > 0 + ? { width: image.naturalWidth, height: image.naturalHeight } + : null; + }, +}; + +export type AssistantImageResult = + | { + status: "loading"; + binding: AssistantImageRenderBinding | null; + aspectRatio: number | null; + } + | { + status: "loaded"; + binding: AssistantImageRenderBinding; + aspectRatio: number; + } + | { status: "failed"; message: string }; + +interface UseAssistantImageInput { + source: string; + occurrenceKey: string; + client?: DaemonClient | null; + workspaceRoot?: string; + serverId?: string; +} + +type PreviewUrlState = + | { status: "waiting" } + | { status: "loading" } + | { status: "loaded"; uri: string } + | { status: "failed"; error: unknown }; + +type AttachmentAcquisitionState = + | { status: "waiting" } + | { status: "loading" } + | { status: "loaded"; attachment: AttachmentMetadata } + | { status: "failed"; error: unknown }; + +interface DataImage { + mimeType: string; + base64: string; + cacheKey: string; +} + +const attachmentAcquisitionCache = createAssistantImageAcquisitionCache({ + capacity: 500, + onRetain: (attachment) => retainAttachmentForGarbageCollection(attachment.id), +}); + +interface CachedPreviewUrl { + attachment: AttachmentMetadata; + uri: string; +} + +const previewUrlCache = createAssistantImageAcquisitionCache({ + capacity: 500, + onRetain: + ({ attachment, uri }) => + () => { + void releaseAttachmentPreviewUrl({ attachment, url: uri }); + }, +}); + +const LOADED_IMAGE_CACHE_CAPACITY = 500; +const loadedImageCache = new Map(); + +function getLoadedImageAspectRatio(uri: string): number | null { + const aspectRatio = loadedImageCache.get(uri); + if (aspectRatio === undefined) { + return null; + } + loadedImageCache.delete(uri); + loadedImageCache.set(uri, aspectRatio); + return aspectRatio; +} + +function rememberLoadedImage(uri: string, aspectRatio: number): void { + loadedImageCache.delete(uri); + loadedImageCache.set(uri, aspectRatio); + if (loadedImageCache.size <= LOADED_IMAGE_CACHE_CAPACITY) { + return; + } + const leastRecentlyUsedUri = loadedImageCache.keys().next().value; + if (leastRecentlyUsedUri !== undefined) { + loadedImageCache.delete(leastRecentlyUsedUri); + } +} + +function useAttachmentAcquisition( + acquisition: AssistantImageAcquisition | null, +): AttachmentAcquisitionState { + const acquisitionKey = acquisition?.key ?? null; + const [entry, setEntry] = useState<{ + key: string | null; + state: AttachmentAcquisitionState; + }>(() => { + const cached = acquisitionKey ? attachmentAcquisitionCache.peek(acquisitionKey) : undefined; + return { + key: acquisitionKey, + state: cached ? { status: "loaded", attachment: cached } : { status: "waiting" }, + }; + }); + + useEffect(() => { + let disposed = false; + let releaseCurrent: (() => void) | null = null; + if (!acquisition || !acquisitionKey) { + setEntry({ key: null, state: { status: "waiting" } }); + return; + } + + const acquireCurrent = () => { + releaseCurrent?.(); + const retained = attachmentAcquisitionCache.acquireRetained( + acquisitionKey, + acquisition.locate, + ); + releaseCurrent = retained.release; + return retained; + }; + const initial = acquireCurrent(); + if (initial.value) { + setEntry({ key: acquisitionKey, state: { status: "loaded", attachment: initial.value } }); + return () => { + disposed = true; + releaseCurrent?.(); + }; + } + + setEntry({ key: acquisitionKey, state: { status: "loading" } }); + void (async () => { + let firstAttempt: ReturnType | null = initial; + try { + const attachment = await runAssistantImageOperationWithRetry({ + operation: async () => { + const retained = firstAttempt ?? acquireCurrent(); + firstAttempt = null; + return await retained.promise; + }, + shouldStop: () => disposed, + }); + if (!disposed) { + setEntry({ key: acquisitionKey, state: { status: "loaded", attachment } }); + } + } catch (error) { + if (!disposed) { + setEntry({ key: acquisitionKey, state: { status: "failed", error } }); + } + } + })(); + return () => { + disposed = true; + releaseCurrent?.(); + }; + }, [acquisition, acquisitionKey]); + + if (!acquisitionKey) { + return { status: "waiting" }; + } + const cached = attachmentAcquisitionCache.peek(acquisitionKey); + if (cached) { + return { status: "loaded", attachment: cached }; + } + return entry.key === acquisitionKey ? entry.state : { status: "waiting" }; +} + +function createDataImageAcquisition(input: { + source: string; + dataImage: DataImage | null; +}): AssistantImageAcquisition | null { + if (!input.dataImage) { + return null; + } + const { dataImage, source } = input; + return { + key: dataImage.cacheKey, + locate: async () => + await persistAttachmentFromDataUrl({ + id: createPreviewAttachmentId({ + mimeType: dataImage.mimeType, + contentLength: dataImage.base64.length, + contentKey: dataImage.cacheKey, + }), + dataUrl: source, + mimeType: dataImage.mimeType, + }), + }; +} + +function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): PreviewUrlState { + const id = attachment?.id; + const storageType = attachment?.storageType; + const storageKey = attachment?.storageKey; + const mimeType = attachment?.mimeType; + const previewKey = + id && storageType && storageKey && mimeType + ? `${id}:${storageType}:${storageKey}:${mimeType}` + : null; + const [entry, setEntry] = useState<{ key: string | null; state: PreviewUrlState }>(() => { + return { + key: previewKey, + state: { status: "waiting" }, + }; + }); + const getCurrentAttachment = useStableEvent(() => attachment ?? null); + + useLayoutEffect(() => { + let disposed = false; + let releaseCurrent: (() => void) | null = null; + const current = getCurrentAttachment(); + + if (!current || !previewKey) { + setEntry({ key: null, state: { status: "waiting" } }); + return; + } + + const acquireCurrent = () => { + releaseCurrent?.(); + const retained = previewUrlCache.acquireRetained(previewKey, async () => ({ + attachment: current, + uri: await resolveAttachmentPreviewUrl(current), + })); + releaseCurrent = retained.release; + return retained; + }; + const initial = acquireCurrent(); + if (initial.value) { + setEntry({ key: previewKey, state: { status: "loaded", uri: initial.value.uri } }); + return () => { + disposed = true; + releaseCurrent?.(); + }; + } + + setEntry({ key: previewKey, state: { status: "loading" } }); + void (async () => { + let firstAttempt: ReturnType | null = initial; + try { + const preview = await runAssistantImageOperationWithRetry({ + operation: async () => { + const retained = firstAttempt ?? acquireCurrent(); + firstAttempt = null; + return await retained.promise; + }, + shouldStop: () => disposed, + }); + if (!disposed) { + setEntry({ key: previewKey, state: { status: "loaded", uri: preview.uri } }); + } + } catch (error) { + if (!disposed) { + setEntry({ key: previewKey, state: { status: "failed", error } }); + } + } + })(); + + return () => { + disposed = true; + releaseCurrent?.(); + }; + }, [getCurrentAttachment, previewKey]); + + if (!previewKey) { + return { status: "waiting" }; + } + return entry.key === previewKey ? entry.state : { status: "waiting" }; +} + +function lifecycleReducer( + state: AssistantImageLifecycle, + event: AssistantImageLifecycleEvent, +): AssistantImageLifecycle { + return transitionAssistantImageLifecycle(state, event); +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +function getAcquisitionFailure(input: { + hasResolution: boolean; + isFileSource: boolean; + isDataImage: boolean; + fileAttachment: AttachmentAcquisitionState; + dataImageAttachment: AttachmentAcquisitionState; + preview: PreviewUrlState; + hasDirectUri: boolean; + fallbackMessage: string; +}): AssistantImageResult | null { + if (!input.hasResolution) { + return { status: "failed", message: input.fallbackMessage }; + } + if (input.isFileSource && input.fileAttachment.status === "failed") { + return { + status: "failed", + message: errorMessage(input.fileAttachment.error, input.fallbackMessage), + }; + } + if (input.isDataImage && input.dataImageAttachment.status === "failed") { + return { + status: "failed", + message: errorMessage(input.dataImageAttachment.error, input.fallbackMessage), + }; + } + if (!input.hasDirectUri && input.preview.status === "failed") { + return { + status: "failed", + message: errorMessage(input.preview.error, input.fallbackMessage), + }; + } + return null; +} + +export function useAssistantImage({ + source, + occurrenceKey, + client, + workspaceRoot, + serverId, +}: UseAssistantImageInput): AssistantImageResult { + const { t } = useTranslation(); + const resolution = useMemo( + () => resolveAssistantImageSource({ source, workspaceRoot }), + [source, workspaceRoot], + ); + const dataImage = useMemo(() => parseImageDataUrl(source), [source]); + const fileAcquisition = useMemo(() => { + const port: AssistantImageFileAcquisitionPort | null = client + ? { + readFile: async (cwd, path) => await client.readFile(cwd, path), + persist: persistAttachmentFromBytes, + } + : null; + return createAssistantImageFileAcquisition({ + port, + resolution, + serverId, + occurrenceKey, + unavailableMessage: t("message.attachments.imagePreviewUnavailable"), + }); + }, [client, occurrenceKey, resolution, serverId, t]); + const dataImageAcquisition = useMemo( + () => createDataImageAcquisition({ source, dataImage }), + [dataImage, source], + ); + const fileAttachment = useAttachmentAcquisition(fileAcquisition); + const dataImageAttachment = useAttachmentAcquisition(dataImageAcquisition); + const filePreview = usePreviewUrl( + fileAttachment.status === "loaded" ? fileAttachment.attachment : null, + ); + const dataImagePreview = usePreviewUrl( + dataImageAttachment.status === "loaded" ? dataImageAttachment.attachment : null, + ); + const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null; + const preview = dataImage ? dataImagePreview : filePreview; + const previewUri = preview.status === "loaded" ? preview.uri : null; + const uri = directUri ?? previewUri; + const cachedMetadata = useMemo( + () => getAssistantImageMetadata({ source, workspaceRoot, serverId }), + [serverId, source, workspaceRoot], + ); + const [lifecycle, dispatchLifecycle] = useReducer( + lifecycleReducer, + uri, + (initialUri): AssistantImageLifecycle => { + if (initialUri) { + const aspectRatio = getLoadedImageAspectRatio(initialUri); + if (aspectRatio !== null) { + return { status: "loaded", uri: initialUri, aspectRatio }; + } + } + return createAssistantImageLifecycle(); + }, + ); + const dispatch = useCallback((event: AssistantImageLifecycleEvent) => { + if (event.type === "image_loaded") { + rememberLoadedImage(event.uri, event.aspectRatio); + } else if (event.type === "failed" && event.uri) { + loadedImageCache.delete(event.uri); + } + dispatchLifecycle(event); + }, []); + const renderedImageRef = useRef(null); + const handleImageRef = useCallback((instance: unknown) => { + renderedImageRef.current = instance; + }, []); + + useEffect(() => { + if (!uri) { + dispatch({ type: "preview_released" }); + return; + } + + dispatch({ + type: "preview_created", + uri, + aspectRatio: cachedMetadata?.aspectRatio ?? null, + }); + }, [cachedMetadata, dispatch, uri]); + + const handleImageError = useCallback(() => { + if (uri) { + dispatch({ + type: "failed", + uri, + message: t("message.attachments.imageUnavailable"), + }); + } + }, [dispatch, t, uri]); + const handleImageLoad = useCallback( + (event: ImageLoadEvent) => { + if (!uri) { + return; + } + const nativeEvent = event.nativeEvent as ImageLoadEvent["nativeEvent"] & { + target?: { naturalWidth?: unknown; naturalHeight?: unknown }; + }; + const dimensions = resolveAssistantImageLoadDimensions({ + source: nativeEvent.source, + target: nativeEvent.target, + renderedImage: renderedImageRef.current, + renderedDimensions, + }); + const metadata = dimensions + ? setAssistantImageMetadata({ source, workspaceRoot, serverId }, dimensions) + : null; + const aspectRatio = metadata?.aspectRatio ?? cachedMetadata?.aspectRatio ?? null; + if (!aspectRatio) { + dispatch({ + type: "failed", + uri, + message: t("message.attachments.imageUnavailable"), + }); + return; + } + dispatch({ type: "image_loaded", uri, aspectRatio }); + }, + [cachedMetadata, dispatch, serverId, source, t, uri, workspaceRoot], + ); + + const acquisitionFailure = getAcquisitionFailure({ + hasResolution: resolution !== null, + isFileSource: resolution?.kind === "file_rpc", + isDataImage: dataImage !== null, + fileAttachment, + dataImageAttachment, + preview, + hasDirectUri: directUri !== null, + fallbackMessage: t("message.attachments.imagePreviewLoadFailed"), + }); + if (acquisitionFailure) { + return acquisitionFailure; + } + const hasCurrentLifecycleUri = lifecycle.status !== "failed" && lifecycle.uri === uri; + let binding: AssistantImageRenderBinding | null = null; + if (hasCurrentLifecycleUri && lifecycle.uri) { + binding = { + uri: lifecycle.uri, + onRef: handleImageRef, + onLoad: handleImageLoad, + onError: handleImageError, + }; + } + if (lifecycle.status === "loaded" && lifecycle.uri === uri) { + return { + status: "loaded", + binding: { + uri: lifecycle.uri, + onRef: handleImageRef, + onLoad: handleImageLoad, + onError: handleImageError, + }, + aspectRatio: lifecycle.aspectRatio, + }; + } + if (lifecycle.status === "failed") { + return lifecycle; + } + return { + status: "loading", + binding, + aspectRatio: hasCurrentLifecycleUri ? lifecycle.aspectRatio : null, + }; +} diff --git a/packages/app/src/attachments/gc-retention.test.ts b/packages/app/src/attachments/gc-retention.test.ts new file mode 100644 index 000000000..4b3992681 --- /dev/null +++ b/packages/app/src/attachments/gc-retention.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { collectRetainedAttachmentIds, retainAttachmentForGarbageCollection } from "./gc-retention"; + +describe("attachment garbage-collection retention", () => { + it("keeps an attachment referenced until its final owner releases it", () => { + const releaseFirst = retainAttachmentForGarbageCollection("preview-1"); + const releaseSecond = retainAttachmentForGarbageCollection("preview-1"); + + expect(collectRetainedAttachmentIds()).toContain("preview-1"); + releaseFirst(); + expect(collectRetainedAttachmentIds()).toContain("preview-1"); + releaseSecond(); + expect(collectRetainedAttachmentIds()).not.toContain("preview-1"); + }); +}); diff --git a/packages/app/src/attachments/gc-retention.ts b/packages/app/src/attachments/gc-retention.ts new file mode 100644 index 000000000..4473350d9 --- /dev/null +++ b/packages/app/src/attachments/gc-retention.ts @@ -0,0 +1,22 @@ +const retentionCounts = new Map(); + +export function retainAttachmentForGarbageCollection(attachmentId: string): () => void { + retentionCounts.set(attachmentId, (retentionCounts.get(attachmentId) ?? 0) + 1); + let released = false; + return () => { + if (released) { + return; + } + released = true; + const nextCount = (retentionCounts.get(attachmentId) ?? 1) - 1; + if (nextCount <= 0) { + retentionCounts.delete(attachmentId); + return; + } + retentionCounts.set(attachmentId, nextCount); + }; +} + +export function collectRetainedAttachmentIds(): ReadonlySet { + return new Set(retentionCounts.keys()); +} diff --git a/packages/app/src/attachments/service.test.ts b/packages/app/src/attachments/service.test.ts index 080204ed0..05e13034f 100644 --- a/packages/app/src/attachments/service.test.ts +++ b/packages/app/src/attachments/service.test.ts @@ -1,7 +1,11 @@ import { afterEach, describe, expect, it } from "vitest"; import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types"; import { __setAttachmentStoreForTests } from "./store"; -import { encodeAttachmentsForSend, persistAttachmentFromBytes } from "./service"; +import { + encodeAttachmentsForSend, + garbageCollectAttachments, + persistAttachmentFromBytes, +} from "./service"; function createAttachment(input: Partial = {}): AttachmentMetadata { return { @@ -94,4 +98,46 @@ describe("attachment service", () => { { data: "att_send:base64", mimeType: "image/jpeg" }, ]); }); + + it("does not collect an attachment persisted while garbage collection is starting", async () => { + let releaseSave: () => void = () => undefined; + let reportSaveStarted: () => void = () => undefined; + const saveStarted = new Promise((resolve) => { + reportSaveStarted = resolve; + }); + const saveGate = new Promise((resolve) => { + releaseSave = resolve; + }); + const garbageCollections: string[][] = []; + const store: AttachmentStore = { + ...createRecordingStore(), + async save(input) { + reportSaveStarted(); + await saveGate; + return createAttachment({ id: input.id }); + }, + async garbageCollect({ referencedIds }) { + garbageCollections.push([...referencedIds]); + }, + }; + __setAttachmentStoreForTests(store); + + const persist = persistAttachmentFromBytes({ + id: "assistant-preview", + bytes: new Uint8Array([1, 2, 3]), + mimeType: "image/png", + }); + await saveStarted; + const collect = garbageCollectAttachments({ referencedIds: new Set() }); + + try { + await Promise.resolve(); + expect(garbageCollections).toEqual([]); + } finally { + releaseSave(); + await Promise.all([persist, collect]); + } + + expect(garbageCollections).toEqual([["assistant-preview"]]); + }); }); diff --git a/packages/app/src/attachments/service.ts b/packages/app/src/attachments/service.ts index 15cff1ee2..d878b1986 100644 --- a/packages/app/src/attachments/service.ts +++ b/packages/app/src/attachments/service.ts @@ -1,5 +1,40 @@ -import type { AttachmentMetadata } from "@/attachments/types"; +import { collectRetainedAttachmentIds } from "@/attachments/gc-retention"; import { getAttachmentStore } from "@/attachments/store"; +import type { AttachmentMetadata, SaveAttachmentInput } from "@/attachments/types"; + +const activePersistence = new Set>(); +const persistedDuringGarbageCollection = new Set(); +let pendingGarbageCollections = 0; +let garbageCollectionTail: Promise = Promise.resolve(); +let persistenceBarrier: Promise | null = null; +let releasePersistenceBarrier: (() => void) | null = null; + +async function waitForPersistenceBarrier(): Promise { + const barrier = persistenceBarrier; + if (!barrier) { + return; + } + await barrier; + await waitForPersistenceBarrier(); +} + +async function persistAttachment(input: SaveAttachmentInput): Promise { + await waitForPersistenceBarrier(); + const pending = (async () => { + const store = await getAttachmentStore(); + const attachment = await store.save(input); + if (pendingGarbageCollections > 0) { + persistedDuringGarbageCollection.add(attachment.id); + } + return attachment; + })(); + activePersistence.add(pending); + try { + return await pending; + } finally { + activePersistence.delete(pending); + } +} export async function persistAttachmentFromBlob(input: { blob: Blob; @@ -7,8 +42,7 @@ export async function persistAttachmentFromBlob(input: { fileName?: string | null; id?: string; }): Promise { - const store = await getAttachmentStore(); - return await store.save({ + return await persistAttachment({ id: input.id, mimeType: input.mimeType, fileName: input.fileName, @@ -22,8 +56,7 @@ export async function persistAttachmentFromDataUrl(input: { fileName?: string | null; id?: string; }): Promise { - const store = await getAttachmentStore(); - return await store.save({ + return await persistAttachment({ id: input.id, mimeType: input.mimeType, fileName: input.fileName, @@ -37,8 +70,7 @@ export async function persistAttachmentFromBytes(input: { fileName?: string | null; id?: string; }): Promise { - const store = await getAttachmentStore(); - return await store.save({ + return await persistAttachment({ id: input.id, mimeType: input.mimeType, fileName: input.fileName, @@ -52,8 +84,7 @@ export async function persistAttachmentFromFileUri(input: { fileName?: string | null; id?: string; }): Promise { - const store = await getAttachmentStore(); - return await store.save({ + return await persistAttachment({ id: input.id, mimeType: input.mimeType, fileName: input.fileName, @@ -133,6 +164,41 @@ export async function deleteAttachments( export async function garbageCollectAttachments(input: { referencedIds: ReadonlySet; }): Promise { - const store = await getAttachmentStore(); - await store.garbageCollect({ referencedIds: input.referencedIds }); + pendingGarbageCollections += 1; + if (!persistenceBarrier) { + persistenceBarrier = new Promise((resolve) => { + releasePersistenceBarrier = resolve; + }); + } + + const previousGarbageCollection = garbageCollectionTail; + const currentGarbageCollection = (async () => { + await previousGarbageCollection; + while (activePersistence.size > 0) { + await Promise.allSettled(activePersistence); + } + const referencedIds = new Set(input.referencedIds); + for (const id of collectRetainedAttachmentIds()) { + referencedIds.add(id); + } + for (const id of persistedDuringGarbageCollection) { + referencedIds.add(id); + } + const store = await getAttachmentStore(); + await store.garbageCollect({ referencedIds }); + })(); + garbageCollectionTail = currentGarbageCollection.catch(() => undefined); + + try { + await currentGarbageCollection; + } finally { + pendingGarbageCollections -= 1; + if (pendingGarbageCollections === 0) { + persistedDuringGarbageCollection.clear(); + const release = releasePersistenceBarrier; + releasePersistenceBarrier = null; + persistenceBarrier = null; + release?.(); + } + } } diff --git a/packages/app/src/attachments/utils.test.ts b/packages/app/src/attachments/utils.test.ts index 319f247d2..d6fce078f 100644 --- a/packages/app/src/attachments/utils.test.ts +++ b/packages/app/src/attachments/utils.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { createImageSourceCacheKey, + createPreviewAttachmentId, fileUriToPath, localFileSourceToPath, parseDataUrl, @@ -86,4 +87,29 @@ describe("parseImageDataUrl", () => { it("ignores SVG data URLs", () => { expect(parseImageDataUrl("data:image/svg+xml;base64,PHN2ZyAvPg==")).toBeNull(); }); + + it("distinguishes image data that differs only in the middle", () => { + const prefix = "a".repeat(64); + const suffix = "z".repeat(64); + const first = `data:image/png;base64,${prefix}${"b".repeat(256)}${suffix}`; + const second = `data:image/png;base64,${prefix}${"c".repeat(256)}${suffix}`; + + expect(createImageSourceCacheKey(first)).not.toBe(createImageSourceCacheKey(second)); + }); + + it("gives equal-length preview content distinct attachment identities", () => { + expect( + createPreviewAttachmentId({ + mimeType: "image/png", + contentLength: 512, + contentKey: "first-content", + }), + ).not.toBe( + createPreviewAttachmentId({ + mimeType: "image/png", + contentLength: 512, + contentKey: "second-content", + }), + ); + }); }); diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts index bb1903f9f..5fa033435 100644 --- a/packages/app/src/attachments/utils.ts +++ b/packages/app/src/attachments/utils.ts @@ -56,7 +56,7 @@ export function parseImageDataUrl( if (!isRasterImageMimeType(parsed.mimeType)) { return null; } - const fingerprint = `${parsed.mimeType}\0${parsed.base64.length}\0${parsed.base64.slice(0, 64)}\0${parsed.base64.slice(-64)}`; + const fingerprint = `${parsed.mimeType}\0${parsed.base64}`; return { ...parsed, cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(fingerprint)}`, @@ -87,12 +87,15 @@ export function createPreviewAttachmentId(input: { size?: number | null; modifiedAt?: string | null; contentLength?: number | null; + contentKey?: string | null; }): string { const path = input.path?.trim() ?? ""; const size = Number.isFinite(input.size) ? String(input.size) : ""; const modifiedAt = input.modifiedAt?.trim() ?? ""; const contentLength = Number.isFinite(input.contentLength) ? String(input.contentLength) : ""; - const hash = hashString(`${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`); + const contentKey = input.contentKey?.trim() ?? ""; + const identity = `${input.mimeType}\0${path}\0${size}\0${modifiedAt}\0${contentLength}`; + const hash = hashString(contentKey ? `${identity}\0${contentKey}` : identity); return `preview_${size || contentLength || "unknown"}_${hash}`; } diff --git a/packages/app/src/client-slash-commands/index.test.ts b/packages/app/src/client-slash-commands/index.test.ts index 3fd4e4b7c..768602a3d 100644 --- a/packages/app/src/client-slash-commands/index.test.ts +++ b/packages/app/src/client-slash-commands/index.test.ts @@ -53,6 +53,7 @@ function createAgent(overrides: Partial = {}): Agent { parentAgentId: null, labels: {}, ...overrides, + activeTurn: overrides.activeTurn ?? null, }; } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 8abe67928..8a85a8645 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -29,7 +29,6 @@ import { } from "react"; import type { ComponentType, ReactNode } from "react"; import { MarkdownIt, type ASTNode, type RenderRules } from "react-native-markdown-display"; -import { useQuery } from "@tanstack/react-query"; import MaskedView from "@react-native-masked-view/masked-view"; import { Circle, @@ -75,19 +74,7 @@ import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; -import { - getAssistantImageLoadStateFromMetadata, - getAssistantImageMetadata, - setAssistantImageMetadata, - type AssistantImageLoadState, -} from "@/utils/assistant-image-metadata"; import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate"; -import { resolveAssistantImageSource } from "@/utils/assistant-image-source"; -import { - createPreviewAttachmentId, - getFileNameFromPath, - parseImageDataUrl, -} from "@/attachments/utils"; import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content"; import { PlanCard } from "./plan-card"; import { useToolCallSheet } from "./tool-call-sheet"; @@ -102,8 +89,7 @@ import { useAssistantLinkPress, } from "@/assistant-file-links"; import { getCompactionMarkerLabel } from "./message-compaction-label"; -import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; -import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service"; +import { useAssistantImage } from "@/assistant-image/use-assistant-image"; import { AttachmentFrame, AttachmentLabel, @@ -132,6 +118,7 @@ interface UserMessageProps { client?: DaemonClient | null; isFirstInGroup?: boolean; isLastInGroup?: boolean; + isPending?: boolean; disableOuterSpacing?: boolean; } @@ -430,6 +417,7 @@ export const UserMessage = memo(function UserMessage({ client, isFirstInGroup = true, isLastInGroup = true, + isPending = false, disableOuterSpacing, }: UserMessageProps) { const isCompact = useIsCompactFormFactor(); @@ -441,7 +429,7 @@ export const UserMessage = memo(function UserMessage({ const hasText = message.trim().length > 0; const hasImages = images.length > 0; const hasAttachments = attachments.length > 0; - const showTrailingRow = hasText && (isCompact || isNative || isHovered); + const showTrailingRow = !isPending && hasText && (isCompact || isNative || isHovered); const formattedTimestamp = useMemo( () => formatMessageTimestamp(new Date(timestamp)), [timestamp], @@ -494,7 +482,7 @@ export const UserMessage = memo(function UserMessage({ ); return ( - + {hasText ? ( - - {formattedTimestamp} - {capabilities ? ( + + + {formattedTimestamp} + + {capabilities && messageId ? ( ({ imageSurface: { width: "100%", overflow: "hidden", + position: "relative", }, image: { width: "100%", height: "100%", }, + imageLoadingOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + alignItems: "center", + justifyContent: "center", + }, imageState: { alignItems: "center", justifyContent: "center", @@ -777,125 +782,9 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ const ASSISTANT_IMAGE_MIN_HEIGHT = 160; -const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedImage({ - uri, - alt, - containerStyle, - source, - workspaceRoot, - serverId, -}: { - uri: string; - alt?: string; - containerStyle?: StyleProp; - source: string; - workspaceRoot?: string; - serverId?: string; -}) { - const cachedMetadata = useMemo( - () => getAssistantImageMetadata({ source, workspaceRoot, serverId }), - [serverId, source, workspaceRoot], - ); - const [loadState, setLoadState] = useState(() => - getAssistantImageLoadStateFromMetadata(cachedMetadata), - ); - - useEffect(() => { - if (cachedMetadata) { - setLoadState(getAssistantImageLoadStateFromMetadata(cachedMetadata)); - return () => {}; - } - - setLoadState({ status: "loading" }); - let cancelled = false; - - Image.getSize( - uri, - (width, height) => { - if (cancelled) { - return; - } - if (width > 0 && height > 0) { - const metadata = setAssistantImageMetadata( - { source, workspaceRoot, serverId }, - { width, height }, - ); - setLoadState({ - status: "ready", - aspectRatio: metadata?.aspectRatio ?? width / height, - }); - } - }, - () => { - if (cancelled) { - return; - } - setLoadState({ status: "error" }); - }, - ); - - return () => { - cancelled = true; - }; - }, [cachedMetadata, serverId, source, uri, workspaceRoot]); - - const handleImageError = useCallback(() => { - setLoadState({ status: "error" }); - }, []); - const { t } = useTranslation(); - const surfaceStyle = useMemo>( - () => [ - assistantMessageStylesheet.imageSurface, - loadState.status === "ready" - ? { aspectRatio: loadState.aspectRatio } - : { height: ASSISTANT_IMAGE_MIN_HEIGHT }, - ], - [loadState], - ); - const frameStyle = useMemo>( - () => [assistantMessageStylesheet.imageFrame, containerStyle], - [containerStyle], - ); - const stateSurfaceStyle = useMemo>( - () => [surfaceStyle, assistantMessageStylesheet.imageState], - [surfaceStyle], - ); - const imageSource = useMemo(() => ({ uri }), [uri]); - - if (loadState.status !== "ready") { - return ( - - - {loadState.status === "loading" ? ( - - ) : null} - {loadState.status === "error" ? ( - - {t("message.attachments.imageUnavailable")} - - ) : null} - - - ); - } - - return ( - - - - - - ); -}); - function AssistantMarkdownImage({ source, + occurrenceKey, alt, hasLeadingContent, client, @@ -903,18 +792,13 @@ function AssistantMarkdownImage({ serverId, }: { source: string; + occurrenceKey: string; alt?: string; hasLeadingContent: boolean; client?: DaemonClient | null; workspaceRoot?: string; serverId?: string; }) { - const { t } = useTranslation(); - const resolution = useMemo( - () => resolveAssistantImageSource({ source, workspaceRoot }), - [source, workspaceRoot], - ); - const dataImage = useMemo(() => parseImageDataUrl(source), [source]); const containerStyle = useMemo>( () => ({ marginTop: hasLeadingContent ? 16 : 0, @@ -922,64 +806,31 @@ function AssistantMarkdownImage({ }), [hasLeadingContent], ); - - const query = useQuery({ - queryKey: [ - "assistantMarkdownImage", - serverId ?? "unknown-server", - resolution?.kind === "file_rpc" ? resolution.cwd : null, - resolution?.kind === "file_rpc" ? resolution.path : null, - ], - enabled: Boolean(client && resolution?.kind === "file_rpc"), - staleTime: 30_000, - queryFn: async () => { - if (!client || !resolution || resolution.kind !== "file_rpc") { - return null; - } - - const file = await client.readFile(resolution.cwd, resolution.path); - if (file.kind !== "image") { - throw new Error(t("message.attachments.imagePreviewUnavailable")); - } - - return await persistAttachmentFromBytes({ - id: createPreviewAttachmentId({ - mimeType: file.mime, - path: file.path || resolution.path, - size: file.size, - modifiedAt: file.modifiedAt, - contentLength: file.bytes.byteLength, - }), - bytes: file.bytes, - mimeType: file.mime, - fileName: getFileNameFromPath(file.path || resolution.path), - }); - }, + const image = useAssistantImage({ + source, + occurrenceKey, + client, + workspaceRoot, + serverId, }); - const dataImageQuery = useQuery({ - queryKey: ["assistantMarkdownDataImage", dataImage?.cacheKey ?? null], - enabled: dataImage !== null, - staleTime: 30_000, - queryFn: async () => { - if (!dataImage) { - return null; - } - - return await persistAttachmentFromDataUrl({ - id: createPreviewAttachmentId({ - mimeType: dataImage.mimeType, - contentLength: dataImage.base64.length, - }), - dataUrl: source, - mimeType: dataImage.mimeType, - }); - }, - }); - - const fileAssetUri = useAttachmentPreviewUrl(query.data); - const dataImageAssetUri = useAttachmentPreviewUrl(dataImageQuery.data); - const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null; - const resolvedUri = directUri ?? dataImageAssetUri ?? fileAssetUri ?? null; + const binding = image.status === "failed" ? null : image.binding; + const aspectRatio = image.status === "failed" ? null : image.aspectRatio; + const imageUri = binding?.uri ?? ""; + const imageSource = useMemo(() => ({ uri: imageUri }), [imageUri]); + const frameStyle = useMemo>( + () => [assistantMessageStylesheet.imageFrame, containerStyle], + [containerStyle], + ); + const imageSizeStyle = useMemo(() => { + if (aspectRatio) { + return { aspectRatio }; + } + return { height: ASSISTANT_IMAGE_MIN_HEIGHT }; + }, [aspectRatio]); + const surfaceStyle = useMemo>( + () => [assistantMessageStylesheet.imageSurface, imageSizeStyle], + [imageSizeStyle], + ); const stateFrameStyle = useMemo>( () => [ @@ -991,20 +842,15 @@ function AssistantMarkdownImage({ [containerStyle], ); - if (resolvedUri) { + if (image.status === "failed") { return ( - + + {image.message} + ); } - if (query.isLoading || dataImageQuery.isLoading) { + if (!binding) { return ( @@ -1012,29 +858,27 @@ function AssistantMarkdownImage({ ); } - const errorText = resolveAssistantImageErrorText( - query.error, - dataImageQuery.error, - t("message.attachments.imagePreviewLoadFailed"), - ); - return ( - - {errorText} + + + + {image.status === "loading" ? ( + + + + ) : null} + ); } -function resolveAssistantImageErrorText( - fileError: unknown, - dataError: unknown, - fallbackText: string, -): string { - if (fileError instanceof Error) return fileError.message; - if (dataError instanceof Error) return dataError.message; - return fallbackText; -} - function getInlineCodeAutoLinkUrl( markdownParser: ReturnType, content: string, @@ -1575,6 +1419,7 @@ function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProp } export const AssistantMessage = memo(function AssistantMessage({ + occurrenceKey, message, timestamp: _timestamp, workspaceRoot, @@ -1909,6 +1754,7 @@ export const AssistantMessage = memo(function AssistantMessage({ splitMarkdownBlocks(message), [message]); const keyedBlocks = useMemo( diff --git a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts index 037f7c733..f1483808c 100644 --- a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts +++ b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts @@ -7,7 +7,6 @@ import type { RewindMode } from "./use-rewind-capabilities"; import { useRewindComposerRestore } from "./composer-restore"; import { useSessionStore } from "@/stores/session-store"; import { shouldRestoreComposerForRewindMode } from "./rewind-mode"; -import { clearOptimisticUserMessages } from "@/types/stream"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; interface UseRewindAgentMutationInput { @@ -36,13 +35,6 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): { } await input.client.rewindAgent(input.agentId, input.messageId, mode); if (mode !== "files") { - if (input.serverId) { - const session = useSessionStore.getState().sessions[input.serverId]; - useSessionStore.getState().setAgentStreamState(input.serverId, input.agentId, { - tail: clearOptimisticUserMessages(session?.agentStreamTail.get(input.agentId) ?? []), - head: clearOptimisticUserMessages(session?.agentStreamHead.get(input.agentId) ?? []), - }); - } const cursor = input.serverId ? useSessionStore .getState() diff --git a/packages/app/src/components/ui/loading-spinner.tsx b/packages/app/src/components/ui/loading-spinner.tsx index f73fd037b..abcbc64e8 100644 --- a/packages/app/src/components/ui/loading-spinner.tsx +++ b/packages/app/src/components/ui/loading-spinner.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { ActivityIndicator, type ActivityIndicatorProps } from "react-native"; interface LoadingSpinnerProps { diff --git a/packages/app/src/composer/actions.test.ts b/packages/app/src/composer/actions.test.ts index 178fe9eb6..d1aff546a 100644 --- a/packages/app/src/composer/actions.test.ts +++ b/packages/app/src/composer/actions.test.ts @@ -6,7 +6,17 @@ import type { UserComposerAttachment, WorkspaceComposerAttachment, } from "@/attachments/types"; -import type { StreamItem } from "@/types/stream"; +import { + appendSubmittedUserMessage, + removeSubmittedUserMessage, + type StreamItem, +} from "@/types/stream"; +import { + acceptMessageSubmission, + beginMessageSubmission, + rejectMessageSubmission, + type MessageSubmissionRecord, +} from "@/composer/submission/model"; import { cancelComposerAgent, dispatchComposerAgentMessage, @@ -20,7 +30,7 @@ import { sendQueuedComposerMessageNow, toggleGithubAttachment, toggleGithubAttachmentFromPicker, - type AgentStreamWriter, + type MessageSubmissionWriter, type AttachmentPersister, type ComposerCancelClient, type ComposerSendClient, @@ -168,14 +178,16 @@ interface FakeSendCall { } function createFakeSendClient( - options: { rejection?: Error } = {}, + options: { rejection?: Error; beforeRejection?: (call: FakeSendCall) => void } = {}, ): ComposerSendClient & { calls: FakeSendCall[] } { const calls: FakeSendCall[] = []; return { calls, sendAgentMessage: async (agentId, text, opts) => { - calls.push({ agentId, text, options: opts }); + const call = { agentId, text, options: opts }; + calls.push(call); if (options.rejection) { + options.beforeRejection?.(call); throw options.rejection; } }, @@ -183,7 +195,7 @@ function createFakeSendClient( }; } -interface FakeStream extends AgentStreamWriter { +interface FakeStream extends MessageSubmissionWriter { head: Map; tail: Map; } @@ -192,18 +204,69 @@ function createFakeStream(initialHead: Map = new Map()): F const fake: FakeStream = { head: new Map(initialHead), tail: new Map(), - getTail: (agentId) => fake.tail.get(agentId), - getHead: (agentId) => fake.head.get(agentId), - setHead: (updater) => { - fake.head = updater(fake.head); + begin: (agentId, message) => { + const current = readSubmission(fake, agentId); + const stream = appendSubmittedUserMessage({ + tail: current.tail, + head: current.head, + message, + }); + writeSubmission(fake, agentId, { + ...stream, + submissions: beginMessageSubmission(current.submissions, { + clientMessageId: message.clientMessageId!, + }), + }); }, - setTail: (updater) => { - fake.tail = updater(fake.tail); + accept: (agentId, clientMessageId) => { + const current = readSubmission(fake, agentId); + writeSubmission(fake, agentId, { + ...current, + submissions: acceptMessageSubmission(current.submissions, clientMessageId), + }); + }, + reject: (agentId, clientMessageId) => { + const current = readSubmission(fake, agentId); + const result = rejectMessageSubmission(current.submissions, clientMessageId); + const stream = + result.outcome === "rejected" + ? removeSubmittedUserMessage({ + tail: current.tail, + head: current.head, + clientMessageId, + }) + : current; + writeSubmission(fake, agentId, { ...stream, submissions: result.submissions }); + return result.outcome; }, }; return fake; } +const submissionsByFakeStream = new WeakMap>(); + +interface FakeSubmissionState { + tail: StreamItem[]; + head: StreamItem[]; + submissions: MessageSubmissionRecord[]; +} + +function readSubmission(fake: FakeStream, agentId: string): FakeSubmissionState { + return { + tail: fake.tail.get(agentId) ?? [], + head: fake.head.get(agentId) ?? [], + submissions: submissionsByFakeStream.get(fake)?.get(agentId) ?? [], + }; +} + +function writeSubmission(fake: FakeStream, agentId: string, state: FakeSubmissionState): void { + fake.tail = new Map(fake.tail).set(agentId, state.tail); + fake.head = new Map(fake.head).set(agentId, state.head); + const submissions = submissionsByFakeStream.get(fake) ?? new Map(); + submissions.set(agentId, state.submissions); + submissionsByFakeStream.set(fake, submissions); +} + function createFakeQueue( initial: Map = new Map(), ): QueueWriter & { state: Map } { @@ -227,7 +290,6 @@ describe("cancelComposerAgent", () => { isAgentRunning: boolean; isCancellingAgent: boolean; isConnected: boolean; - onCancelFailed: (error: unknown) => void; } { const canceledIds: string[] = []; return { @@ -241,53 +303,45 @@ describe("cancelComposerAgent", () => { isAgentRunning: true, isCancellingAgent: false, isConnected: true, - onCancelFailed: () => undefined, }; } - it("issues a cancel and reports true when the agent is running, connected, and not already canceling", () => { + it("returns the cancel request when the agent is running, connected, and not already canceling", async () => { const input = baseInput(); const result = cancelComposerAgent(input); - expect(result).toBe(true); + expect(result).not.toBeNull(); + await result; expect(input.client.canceledIds).toEqual(["agent"]); }); - it("reports a rejected cancel so the composer can leave its canceling state", async () => { + it("returns a rejected cancel request to the composer", async () => { const cancellationError = new Error("Provider rejected the interrupt"); - const failures: unknown[] = []; const input = baseInput(); input.client.cancelAgent = async () => { throw cancellationError; }; - const result = cancelComposerAgent({ - ...input, - onCancelFailed: (error: unknown) => failures.push(error), - }); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(result).toBe(true); - expect(failures).toEqual([cancellationError]); + await expect(cancelComposerAgent(input)).rejects.toBe(cancellationError); }); it("does nothing when the agent is not running", () => { const input = baseInput(); const result = cancelComposerAgent({ ...input, isAgentRunning: false }); - expect(result).toBe(false); + expect(result).toBeNull(); expect(input.client.canceledIds).toEqual([]); }); it("does nothing when the agent is already being canceled", () => { const input = baseInput(); const result = cancelComposerAgent({ ...input, isCancellingAgent: true }); - expect(result).toBe(false); + expect(result).toBeNull(); expect(input.client.canceledIds).toEqual([]); }); it("does nothing when disconnected or the client is null", () => { const input = baseInput(); - expect(cancelComposerAgent({ ...input, isConnected: false })).toBe(false); - expect(cancelComposerAgent({ ...input, client: null })).toBe(false); + expect(cancelComposerAgent({ ...input, isConnected: false })).toBeNull(); + expect(cancelComposerAgent({ ...input, client: null })).toBeNull(); expect(input.client.canceledIds).toEqual([]); }); }); @@ -337,7 +391,7 @@ describe("pickAndPersistImages", () => { }); describe("dispatchComposerAgentMessage", () => { - it("removes the optimistic prompt when the host rejects it", async () => { + it("removes the submitted prompt when the host rejects it", async () => { const rejection = new Error("Host rejected prompt"); const client = createFakeSendClient({ rejection }); const stream = createFakeStream(); @@ -349,14 +403,54 @@ describe("dispatchComposerAgentMessage", () => { text: "rejected prompt", attachments: [], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }), ).rejects.toBe(rejection); - expect(stream.head.get("agent")).toBeUndefined(); + expect(stream.head.get("agent")).toEqual([]); expect(stream.tail.get("agent") ?? []).toEqual([]); }); + it("rolls back an already-running force send when its RPC fails", async () => { + const stream = createFakeStream(); + const transportError = new Error("Force send failed while the prior turn was running"); + const client = createFakeSendClient({ rejection: transportError }); + + await expect( + dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "force send", + attachments: [], + encodeImages: passthroughEncodeImages, + submission: stream, + }), + ).rejects.toBe(transportError); + + expect(stream.tail.get("agent") ?? []).toEqual([]); + }); + + it("does not swallow a transport error when submission state is missing", async () => { + const transportError = new Error("Connection lost with unknown submission state"); + const client = createFakeSendClient({ rejection: transportError }); + const submission: MessageSubmissionWriter = { + begin: () => {}, + accept: () => {}, + reject: () => "unknown", + }; + + await expect( + dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "unknown state", + attachments: [], + encodeImages: passthroughEncodeImages, + submission, + }), + ).rejects.toBe(transportError); + }); + it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => { const client = createFakeSendClient(); const stream = createFakeStream(); @@ -371,7 +465,7 @@ describe("dispatchComposerAgentMessage", () => { { kind: "github_pr", item: prItem }, ], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(client.calls).toHaveLength(1); @@ -393,7 +487,7 @@ describe("dispatchComposerAgentMessage", () => { }, ]); - expect(stream.head.get("agent")).toBeUndefined(); + expect(stream.head.get("agent")).toEqual([]); const tail = stream.tail.get("agent"); expect(tail).toHaveLength(1); const userMessage = tail?.[0] as Extract; @@ -402,7 +496,8 @@ describe("dispatchComposerAgentMessage", () => { expect(userMessage.images).toEqual([image]); expect(userMessage.attachments).toEqual(call.options.attachments); expect(userMessage.id).toBe(call.options.messageId); - expect(userMessage.optimistic).toBe(true); + expect(userMessage.clientMessageId).toBe(call.options.messageId); + expect(userMessage.messageId).toBeUndefined(); }); it("can send legacy GitHub attachment payloads for old daemons", async () => { @@ -416,7 +511,7 @@ describe("dispatchComposerAgentMessage", () => { attachments: [{ kind: "forge_change_request", item: prItem }], attachmentSubmitFormat: "legacy-github", encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(client.calls[0].options.attachments).toEqual([ @@ -449,11 +544,11 @@ describe("dispatchComposerAgentMessage", () => { text: "next message", attachments: [], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(stream.head.get("agent")).toHaveLength(2); - expect(stream.tail.get("agent")).toBeUndefined(); + expect(stream.tail.get("agent")).toEqual([]); }); it("submits empty wire arrays when no attachments are provided", async () => { @@ -466,7 +561,7 @@ describe("dispatchComposerAgentMessage", () => { text: "plain message", attachments: [], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(client.calls[0]?.options).toMatchObject({ @@ -486,7 +581,7 @@ describe("dispatchComposerAgentMessage", () => { text: "review this", attachments: [review], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(client.calls[0]?.options.attachments).toEqual([review.attachment]); @@ -504,7 +599,7 @@ describe("dispatchComposerAgentMessage", () => { text: "inspect element", attachments: [browserElement], encodeImages: passthroughEncodeImages, - stream, + submission: stream, }); expect(client.calls[0]?.options.attachments).toEqual([ diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 44fc31747..7b454f130 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -12,13 +12,8 @@ import { splitComposerAttachmentsForSubmit, type ComposerAttachmentSubmitFormat, } from "@/composer/attachments/submit"; -import { - appendOptimisticUserMessageToStream, - buildOptimisticUserMessage, - generateMessageId, - type StreamItem, - type UserMessageItem, -} from "@/types/stream"; +import { createUserMessage, generateMessageId, type UserMessageItem } from "@/types/stream"; +import type { MessageSubmissionRejectionOutcome } from "@/composer/submission/model"; import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker"; import { i18n } from "@/i18n/i18next"; @@ -70,11 +65,10 @@ export interface ComposerCancelClient { cancelAgent: (agentId: string) => Promise | void; } -export interface AgentStreamWriter { - getTail: (agentId: string) => StreamItem[] | undefined; - getHead: (agentId: string) => StreamItem[] | undefined; - setHead: (updater: (prev: Map) => Map) => void; - setTail: (updater: (prev: Map) => Map) => void; +export interface MessageSubmissionWriter { + begin: (agentId: string, message: UserMessageItem) => void; + accept: (agentId: string, clientMessageId: string) => void; + reject: (agentId: string, clientMessageId: string) => MessageSubmissionRejectionOutcome; } export interface QueueWriter { @@ -145,19 +139,16 @@ export interface CancelComposerAgentInput { isAgentRunning: boolean; isCancellingAgent: boolean; isConnected: boolean; - onCancelFailed: (error: unknown) => void; } -export function cancelComposerAgent(input: CancelComposerAgentInput): boolean { - if (!input.isAgentRunning || input.isCancellingAgent) return false; - if (!input.isConnected || !input.client) return false; +export function cancelComposerAgent(input: CancelComposerAgentInput): Promise | null { + if (!input.isAgentRunning || input.isCancellingAgent) return null; + if (!input.isConnected || !input.client) return null; try { - void Promise.resolve(input.client.cancelAgent(input.agentId)).catch(input.onCancelFailed); + return Promise.resolve(input.client.cancelAgent(input.agentId)); } catch (error) { - input.onCancelFailed(error); - return false; + return Promise.reject(error); } - return true; } export interface DispatchComposerAgentMessageInput { @@ -169,7 +160,7 @@ export interface DispatchComposerAgentMessageInput { encodeImages: ( images: AttachmentMetadata[], ) => Promise | undefined>; - stream: AgentStreamWriter; + submission: MessageSubmissionWriter; } export async function dispatchComposerAgentMessage( @@ -178,60 +169,30 @@ export async function dispatchComposerAgentMessage( const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, { format: input.attachmentSubmitFormat, }); - const messageId = generateMessageId(); - const userMessage = buildOptimisticUserMessage({ - id: messageId, + const clientMessageId = generateMessageId(); + const userMessage = createUserMessage({ + clientMessageId, text: input.text, timestamp: new Date(), images: wirePayload.images, attachments: wirePayload.attachments, }); - const rollbackOptimisticMessage = appendUserMessageToStream( - input.agentId, - userMessage, - input.stream, - ); + input.submission.begin(input.agentId, userMessage); try { const imagesData = await input.encodeImages(wirePayload.images); await input.client.sendAgentMessage(input.agentId, input.text, { - messageId, + messageId: clientMessageId, images: imagesData ?? [], attachments: wirePayload.attachments, }); + input.submission.accept(input.agentId, clientMessageId); } catch (error) { - rollbackOptimisticMessage(); + const outcome = input.submission.reject(input.agentId, clientMessageId); + if (outcome === "accepted") return; throw error; } } -function appendUserMessageToStream( - agentId: string, - userMessage: UserMessageItem, - stream: AgentStreamWriter, -): () => void { - const result = appendOptimisticUserMessageToStream({ - tail: stream.getTail(agentId) ?? [], - head: stream.getHead(agentId) ?? [], - message: userMessage, - placement: "active-head", - }); - const write = result.changedHead ? stream.setHead : stream.setTail; - const items = result.changedHead ? result.head : result.tail; - write((prev) => new Map(prev).set(agentId, items)); - - return () => { - write((prev) => { - const current = prev.get(agentId); - if (!current) return prev; - const nextItems = current.filter( - (item) => item.id !== userMessage.id || item.kind !== "user_message" || !item.optimistic, - ); - if (nextItems.length === current.length) return prev; - return new Map(prev).set(agentId, nextItems); - }); - }; -} - export interface QueueComposerMessageInput { agentId: string; text: string; diff --git a/packages/app/src/composer/draft/create-flow.test.ts b/packages/app/src/composer/draft/create-flow.test.ts index 41ea2a4d9..471d1fc8b 100644 --- a/packages/app/src/composer/draft/create-flow.test.ts +++ b/packages/app/src/composer/draft/create-flow.test.ts @@ -13,7 +13,7 @@ describe("useDraftAgentCreateFlow", () => { useCreateFlowStore.setState({ pendingByDraftId: {} }); }); - it("renders a prepared new-workspace create attempt as optimistic chat before continuing it", async () => { + it("renders a prepared new-workspace submission before continuing it", async () => { const image: UserMessageImageAttachment = { id: "image-1", mimeType: "image/png", @@ -60,13 +60,13 @@ describe("useDraftAgentCreateFlow", () => { expect(result.current.isSubmitting).toBe(true); expect(result.current.draftAgent).toEqual({ currentAttempt: attempt }); - expect(result.current.optimisticStreamItems).toEqual([ + expect(result.current.submittedStreamItems).toEqual([ { kind: "user_message", id: "msg-prepared", + clientMessageId: "msg-prepared", text: "build this", timestamp: attempt.timestamp, - optimistic: true, images: [image], attachments: [attachment], }, diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index d4fddc412..89a237e78 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -6,14 +6,16 @@ import { splitComposerAttachmentsForSubmit, } from "@/composer/attachments/submit"; import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer"; import { useSessionStore } from "@/stores/session-store"; import { - buildOptimisticUserMessage, + createUserMessage, generateMessageId, type StreamItem, type UserMessageImageAttachment, } from "@/types/stream"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { PendingMessageSubmission } from "@/composer/submission/model"; const EMPTY_STREAM_ITEMS: StreamItem[] = []; @@ -126,14 +128,10 @@ export function useDraftAgentCreateFlow({ const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId); const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle); const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear); - const handoffCreatedAgentUserMessage = useSessionStore( - (state) => state.handoffCreatedAgentUserMessage, - ); - const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; const isSubmitting = machine.tag === "creating"; - const optimisticStreamItems = useMemo(() => { + const submittedStreamItems = useMemo(() => { if (machine.tag !== "creating") { return EMPTY_STREAM_ITEMS; } @@ -147,8 +145,8 @@ export function useDraftAgentCreateFlow({ } return [ - buildOptimisticUserMessage({ - id: machine.attempt.clientMessageId, + createUserMessage({ + clientMessageId: machine.attempt.clientMessageId, text: machine.attempt.text, timestamp: machine.attempt.timestamp, images: machine.attempt.images, @@ -156,6 +154,14 @@ export function useDraftAgentCreateFlow({ }), ]; }, [machine]); + const pendingMessageSubmissions = useMemo(() => { + if (machine.tag !== "creating") return []; + return [ + { + clientMessageId: machine.attempt.clientMessageId, + }, + ]; + }, [machine]); const draftAgent = useMemo(() => { if (machine.tag !== "creating") { @@ -192,11 +198,11 @@ export function useDraftAgentCreateFlow({ if (createResult.agentId) { updatePendingAgentId({ draftId, agentId: createResult.agentId }); - handoffCreatedAgentUserMessage( + handoffCreatedAgentMessageSubmission( pendingServerId, createResult.agentId, - buildOptimisticUserMessage({ - id: attempt.clientMessageId, + createUserMessage({ + clientMessageId: attempt.clientMessageId, text: attempt.text, timestamp: attempt.timestamp, images: attempt.images, @@ -218,7 +224,6 @@ export function useDraftAgentCreateFlow({ } }, [ - handoffCreatedAgentUserMessage, clearPendingCreateAttempt, createRequest, draftId, @@ -326,7 +331,8 @@ export function useDraftAgentCreateFlow({ machine, formErrorMessage, isSubmitting, - optimisticStreamItems, + submittedStreamItems, + pendingMessageSubmissions, draftAgent, handleCreateFromInput, continueCreateFromAttempt, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 53614e53f..e8c992b4e 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -15,6 +15,7 @@ import { composerWorkspaceAttachment } from "@/composer/attachments/workspace"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow"; +import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import { buildDraftStoreKey } from "@/stores/draft-keys"; @@ -243,6 +244,7 @@ function buildDraftAgentSnapshot(input: { id: tabId, provider, status: "running", + activeTurn: null, createdAt: now, updatedAt: now, lastUserMessageAt: now, @@ -467,7 +469,8 @@ export function WorkspaceDraftAgentTab({ const { formErrorMessage, isSubmitting, - optimisticStreamItems, + submittedStreamItems, + pendingMessageSubmissions, draftAgent, handleCreateFromInput, continueCreateFromAttempt, @@ -529,6 +532,10 @@ export function WorkspaceDraftAgentTab({ onCreated(result); }, }); + const turnPresentation = useMemo( + () => resolveTurnPresentation(TURN_LIVENESS_IDLE, pendingMessageSubmissions.length > 0), + [pendingMessageSubmissions], + ); useAgentControlCommandCenterActions({ sourceId: `draft:${serverId}:${tabId}`, enabled: isPaneFocused && !isSubmitting, @@ -654,7 +661,9 @@ export function WorkspaceDraftAgentTab({ agentId={tabId} serverId={serverId} context={draftAgent} - streamItems={optimisticStreamItems} + streamItems={submittedStreamItems} + pendingMessageSubmissions={pendingMessageSubmissions} + turnPresentation={turnPresentation} pendingPermissions={EMPTY_PENDING_PERMISSIONS} onOpenWorkspaceFile={onOpenWorkspaceFile} /> diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 35232cd02..0b9a94b75 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -41,7 +41,7 @@ import { } from "@/composer/agent-controls"; import { ContextWindowMeter } from "@/components/context-window-meter"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; -import { useSessionStore } from "@/stores/session-store"; +import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; import { useFilePicker } from "@/hooks/use-file-picker"; import { useFileDrop } from "@/components/file-drop/use-file-drop"; import type { DroppedItem } from "@/components/file-drop/types"; @@ -64,7 +64,6 @@ import { sendQueuedComposerMessageNow, toggleGithubAttachmentFromPicker, uploadFileAttachments, - type AgentStreamWriter, type QueueWriter, type QueuedComposerMessage, } from "@/composer/actions"; @@ -91,6 +90,7 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { submitAgentInput } from "@/composer/submit"; +import { createMessageSubmissionWriter } from "@/composer/submission/writer"; import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope"; import { useAppSettings } from "@/hooks/use-settings"; import { isWeb, isNative } from "@/constants/platform"; @@ -910,22 +910,6 @@ function ComposerCancelButton({ ); } -interface ComposerCancelButtonSlotProps extends ComposerCancelButtonProps { - isAgentRunning: boolean; - hasSendableContent: boolean; - isProcessing: boolean; -} - -function ComposerCancelButtonSlot({ - isAgentRunning, - hasSendableContent, - isProcessing, - ...rest -}: ComposerCancelButtonSlotProps) { - if (!isAgentRunning || hasSendableContent || isProcessing) return null; - return ; -} - interface ComposerVoiceModeButtonProps { buttonIconSize: number; handleToggleRealtimeVoice: () => void; @@ -943,9 +927,7 @@ interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps { hasAgent: boolean; isAgentRunning: boolean; hasSendableContent: boolean; - isProcessing: boolean; isCompact: boolean; - cancelButton: ReactElement; } function ComposerRightControlsSlot({ @@ -953,20 +935,16 @@ function ComposerRightControlsSlot({ hasAgent, isAgentRunning, hasSendableContent, - isProcessing, isCompact, - cancelButton, ...voiceProps }: ComposerRightControlsSlotProps) { const hideVoiceForCompactInput = isCompact && hasSendableContent; const showVoiceModeButton = !isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput; - const shouldShowCancelButton = isAgentRunning && !hasSendableContent && !isProcessing; - if (!showVoiceModeButton && !shouldShowCancelButton) return null; + if (!showVoiceModeButton) return null; return ( - {showVoiceModeButton ? : null} - {cancelButton} + ); } @@ -1079,8 +1057,6 @@ export function Composer({ const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY; const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); - const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); - const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); const isCompactFormFactor = useIsCompactFormFactor(); const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); @@ -1125,7 +1101,6 @@ export function Composer({ const [cursorIndex, setCursorIndex] = useState(0); const [isProcessing, setIsProcessing] = useState(false); const [isUploadingFile, setIsUploadingFile] = useState(false); - const [isCancellingAgent, setIsCancellingAgent] = useState(false); const [sendError, setSendError] = useState(null); const [isMessageInputFocused, setIsMessageInputFocused] = useState(false); const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false); @@ -1283,12 +1258,6 @@ export function Composer({ if (!client) { throw new Error(t("workspace.terminal.hostDisconnected")); } - const stream: AgentStreamWriter = { - getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id), - getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id), - setHead: (updater) => setAgentStreamHead(serverId, updater), - setTail: (updater) => setAgentStreamTail(serverId, updater), - }; await dispatchComposerAgentMessage({ client, agentId: targetAgentId, @@ -1298,25 +1267,25 @@ export function Composer({ supportsForgeAttachments: supportsForgeSearch, }), encodeImages, - stream, + submission: createMessageSubmissionWriter(serverId), }); onAttentionPromptSend?.(); }; - }, [ - client, - onAttentionPromptSend, - serverId, - setAgentStreamTail, - setAgentStreamHead, - supportsForgeSearch, - t, - ]); + }, [client, onAttentionPromptSend, serverId, supportsForgeSearch, t]); useEffect(() => { onSubmitMessageRef.current = onSubmitMessage; }, [onSubmitMessage]); - const isAgentRunning = agentState.status === "running"; + const hasActiveTurn = useSessionStore( + (state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isActive, + ); + const isCancellingAgent = useSessionStore( + (state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isCancelling, + ); + const beginAgentCancellation = useSessionStore((state) => state.beginAgentCancellation); + const settleAgentCancellation = useSessionStore((state) => state.settleAgentCancellation); + const isAgentRunning = hasActiveTurn; const hasAgent = agentState.status !== null; const queueWriter = useMemo( @@ -1550,31 +1519,37 @@ export function Composer({ [openAttachment], ); - useEffect(() => { - if (!isAgentRunning || !isConnected) { - setIsCancellingAgent(false); - } - }, [isAgentRunning, isConnected]); - const handleCancelAgent = useCallback(() => { - const didCancel = cancelComposerAgent({ + const targetAgentId = agentIdRef.current; + const cancellation = cancelComposerAgent({ client, - agentId: agentIdRef.current, + agentId: targetAgentId, isAgentRunning, isCancellingAgent, isConnected, - onCancelFailed: (error) => { - setIsCancellingAgent(false); + }); + if (!cancellation) return; + const requestId = beginAgentCancellation(serverId, targetAgentId); + void cancellation + .catch((error) => { const message = resolveErrorMessage(error); if (message && message.trim().length > 0) { toastErrorRef.current(message); } - }, - }); - if (!didCancel) return; - setIsCancellingAgent(true); + }) + .finally(() => { + settleAgentCancellation(serverId, targetAgentId, requestId); + }); messageInputRef.current?.focus(); - }, [client, isAgentRunning, isCancellingAgent, isConnected]); + }, [ + beginAgentCancellation, + client, + isAgentRunning, + isCancellingAgent, + isConnected, + serverId, + settleAgentCancellation, + ]); const focusMessageInputForKeyboardAction = useCallback(() => { focusMessageInputWithPlatformStrategy(messageInputRef); @@ -1708,12 +1683,9 @@ export function Composer({ [isCompactLayout, voiceButtonDisabled], ); - const cancelButton = useMemo( + const activeActionContent = useMemo( () => ( - ), [ buttonIconSize, - cancelButton, handleToggleRealtimeVoice, hasAgent, hasSendableContent, isAgentRunning, isConnected, isCompactLayout, - isProcessing, isVoiceModeForAgent, isVoiceSwitching, realtimeVoiceButtonStyle, @@ -1779,8 +1744,7 @@ export function Composer({ agentState.contextWindowUsedTokens, ); - const contextWindowPending = - agentState.status === "initializing" || agentState.status === "running"; + const contextWindowPending = agentState.status === "initializing" || isAgentRunning; const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize; const contextWindowMeter = useMemo( @@ -2102,6 +2066,7 @@ export function Composer({ leftContent={leftContent} beforeVoiceContent={beforeVoiceContent} rightContent={rightContent} + activeActionContent={activeActionContent} voiceServerId={serverId} voiceAgentId={agentId} isAgentRunning={isAgentRunning} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index a624af981..4601915f9 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -121,8 +121,10 @@ export interface MessageInputProps { leftContent?: React.ReactNode; /** Content to render on the right side before the voice button (e.g., context window meter) */ beforeVoiceContent?: React.ReactNode; - /** Content to render on the right side after voice button (e.g., realtime button, cancel button) */ + /** Auxiliary content to render on the right side after the voice button. */ rightContent?: React.ReactNode; + /** Primary action to render when the agent is active and the composer has no sendable content. */ + activeActionContent?: React.ReactNode; voiceServerId?: string; voiceAgentId?: string; /** When true and there's sendable content, calls onQueue instead of onSubmit */ @@ -779,6 +781,40 @@ function SendButtonTooltip({ ); } +type PrimaryActionKind = "send" | "active" | "none"; + +function hasSendableComposerContent(input: { + value: string; + attachments: readonly ComposerAttachment[]; + hasExternalContent: boolean; +}): boolean { + return input.value.trim().length > 0 || input.attachments.length > 0 || input.hasExternalContent; +} + +function resolvePrimaryActionKind(input: { + hasSendableContent: boolean; + allowEmptySubmit: boolean; + isAgentRunning: boolean; + isSubmitLoading: boolean; +}): PrimaryActionKind { + if (input.hasSendableContent || input.allowEmptySubmit) return "send"; + if (input.isAgentRunning) return "active"; + if (input.isSubmitLoading) return "send"; + return "none"; +} + +function PrimaryAction({ + kind, + activeActionContent, + ...sendButtonProps +}: { + kind: PrimaryActionKind; + activeActionContent: React.ReactNode; +} & React.ComponentProps) { + if (kind === "active") return activeActionContent; + if (kind === "send") return ; + return null; +} interface ToggleRealtimeVoiceContext { voice: | { @@ -928,30 +964,6 @@ function computeShouldShowDictationOverlay( return isDictating || isDictationProcessing || dictationStatus === "failed"; } -interface SendableContentInput { - value: string; - attachments: ComposerAttachment[]; - hasExternalContent: boolean; - allowEmptySubmit: boolean; - isSubmitLoading: boolean; -} - -interface SendableContentOutput { - hasAttachments: boolean; - hasRealContent: boolean; - hasSendableContent: boolean; - shouldShowSendButton: boolean; -} - -function computeSendableContent(input: SendableContentInput): SendableContentOutput { - const hasAttachments = input.attachments.length > 0; - const hasRealContent = input.value.trim().length > 0 || hasAttachments; - const hasSendableContent = hasRealContent || input.hasExternalContent; - const shouldShowSendButton = - hasSendableContent || input.allowEmptySubmit || input.isSubmitLoading; - return { hasAttachments, hasRealContent, hasSendableContent, shouldShowSendButton }; -} - function computeIsDictationStartEnabled( isReadyForDictation: boolean | undefined, isConnected: boolean, @@ -1047,6 +1059,7 @@ interface ResolvedMessageInputProps { leftContent: React.ReactNode; beforeVoiceContent: React.ReactNode; rightContent: React.ReactNode; + activeActionContent: React.ReactNode; voiceServerId: string | undefined; voiceAgentId: string | undefined; isAgentRunning: boolean; @@ -1089,6 +1102,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu leftContent: props.leftContent, beforeVoiceContent: props.beforeVoiceContent, rightContent: props.rightContent, + activeActionContent: props.activeActionContent, voiceServerId: props.voiceServerId, voiceAgentId: props.voiceAgentId, isAgentRunning: props.isAgentRunning ?? false, @@ -1139,6 +1153,7 @@ export const MessageInput = forwardRef( leftContent, beforeVoiceContent, rightContent, + activeActionContent, voiceServerId, voiceAgentId, isAgentRunning, @@ -1553,11 +1568,14 @@ export const MessageInput = forwardRef( }); } - const { shouldShowSendButton } = computeSendableContent({ - value, - attachments, - hasExternalContent, + const primaryActionKind = resolvePrimaryActionKind({ + hasSendableContent: hasSendableComposerContent({ + value, + attachments, + hasExternalContent, + }), allowEmptySubmit, + isAgentRunning, isSubmitLoading, }); const { canPressLoadingButton, isSendButtonDisabled, defaultActionQueues } = @@ -1756,8 +1774,10 @@ export const MessageInput = forwardRef( dictationToggleKeys={dictationToggleKeys} /> {rightContent} - { + it("tracks every in-flight submission independently", () => { + const first = beginMessageSubmission([], { clientMessageId: "client-1" }); + const both = beginMessageSubmission(first, { clientMessageId: "client-2" }); + + expect(getActiveMessageSubmissions(both).map((item) => item.clientMessageId)).toEqual([ + "client-1", + "client-2", + ]); + expect(getSendingClientMessageIds(both)).toEqual(["client-1", "client-2"]); + }); + + it("settles only the accepted transaction on RPC acceptance", () => { + const both = beginMessageSubmission( + beginMessageSubmission([], { clientMessageId: "client-1" }), + { clientMessageId: "client-2" }, + ); + const acknowledged = observeMessageSubmissionCanonical(both, ["client-1"]); + + expect(acceptMessageSubmission(acknowledged, "client-1")).toEqual([ + { + clientMessageId: "client-2", + providerAcknowledged: false, + rpcSettled: false, + }, + ]); + }); + + it("keeps activity until an accepted RPC's canonical row is observed", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1" }); + const accepted = acceptMessageSubmission(sending, "client-1"); + + expect(accepted).toEqual([ + { + clientMessageId: "client-1", + providerAcknowledged: false, + rpcSettled: true, + }, + ]); + expect(getActiveMessageSubmissions(accepted).map((item) => item.clientMessageId)).toEqual([ + "client-1", + ]); + expect(observeMessageSubmissionCanonical(accepted, ["client-1"])).toEqual([]); + }); + + it("records provider acknowledgement without settling another transaction", () => { + const both = beginMessageSubmission( + beginMessageSubmission([], { clientMessageId: "client-1" }), + { clientMessageId: "client-2" }, + ); + const observed = observeMessageSubmissionCanonical(both, ["client-1"]); + + expect(observed).toEqual([ + { + clientMessageId: "client-1", + providerAcknowledged: true, + rpcSettled: false, + }, + { + clientMessageId: "client-2", + providerAcknowledged: false, + rpcSettled: false, + }, + ]); + expect(getSendingClientMessageIds(observed)).toEqual(["client-2"]); + }); + + it("does not roll back a provider-acknowledged prompt on a later transport error", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1" }); + const observed = observeMessageSubmissionCanonical(sending, ["client-1"]); + + expect(rejectMessageSubmission(observed, "client-1")).toEqual({ + outcome: "accepted", + submissions: [], + }); + }); + + it("rejects an unacknowledged transaction", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1" }); + + expect(rejectMessageSubmission(sending, "client-1")).toEqual({ + outcome: "rejected", + submissions: [], + }); + }); + + it("does not create duplicate transaction identity", () => { + const sending = beginMessageSubmission([], { clientMessageId: "client-1" }); + + expect(() => beginMessageSubmission(sending, { clientMessageId: "client-1" })).toThrow( + "Message submission already exists", + ); + }); +}); diff --git a/packages/app/src/composer/submission/model.ts b/packages/app/src/composer/submission/model.ts new file mode 100644 index 000000000..26febae04 --- /dev/null +++ b/packages/app/src/composer/submission/model.ts @@ -0,0 +1,92 @@ +export interface PendingMessageSubmission { + clientMessageId: string; +} + +export type MessageSubmissionRecord = PendingMessageSubmission & { + providerAcknowledged: boolean; + rpcSettled: boolean; +}; + +const EMPTY_MESSAGE_SUBMISSIONS: readonly MessageSubmissionRecord[] = []; + +export function getActiveMessageSubmissions( + submissions: readonly MessageSubmissionRecord[] | null | undefined, +): readonly PendingMessageSubmission[] { + return (submissions ?? EMPTY_MESSAGE_SUBMISSIONS).filter( + (submission) => !submission.providerAcknowledged, + ); +} + +export function getSendingClientMessageIds( + submissions: readonly MessageSubmissionRecord[] | null | undefined, +): string[] { + return (submissions ?? []) + .filter((submission) => !submission.providerAcknowledged) + .map((submission) => submission.clientMessageId); +} + +export type MessageSubmissionRejectionOutcome = "rejected" | "accepted" | "unknown"; + +export interface MessageSubmissionRejectionResult { + submissions: MessageSubmissionRecord[]; + outcome: MessageSubmissionRejectionOutcome; +} + +export function beginMessageSubmission( + submissions: readonly MessageSubmissionRecord[], + input: PendingMessageSubmission, +): MessageSubmissionRecord[] { + if (submissions.some((submission) => submission.clientMessageId === input.clientMessageId)) { + throw new Error(`Message submission already exists: ${input.clientMessageId}`); + } + return [...submissions, { ...input, providerAcknowledged: false, rpcSettled: false }]; +} + +export function acceptMessageSubmission( + submissions: readonly MessageSubmissionRecord[], + clientMessageId: string, +): MessageSubmissionRecord[] { + const index = submissions.findIndex( + (submission) => submission.clientMessageId === clientMessageId, + ); + if (index < 0) return submissions as MessageSubmissionRecord[]; + const submission = submissions[index]; + if (submission.providerAcknowledged) { + return submissions.filter((_, submissionIndex) => submissionIndex !== index); + } + if (submission.rpcSettled) return submissions as MessageSubmissionRecord[]; + return submissions.map((item, submissionIndex) => + submissionIndex === index ? { ...item, rpcSettled: true } : item, + ); +} + +export function observeMessageSubmissionCanonical( + submissions: readonly MessageSubmissionRecord[], + clientMessageIds: readonly string[], +): MessageSubmissionRecord[] { + if (clientMessageIds.length === 0) return submissions as MessageSubmissionRecord[]; + const observed = new Set(clientMessageIds); + let changed = false; + const next = submissions.flatMap((submission): MessageSubmissionRecord[] => { + if (submission.providerAcknowledged || !observed.has(submission.clientMessageId)) { + return [submission]; + } + changed = true; + return submission.rpcSettled ? [] : [{ ...submission, providerAcknowledged: true }]; + }); + return changed ? next : (submissions as MessageSubmissionRecord[]); +} + +export function rejectMessageSubmission( + submissions: readonly MessageSubmissionRecord[], + clientMessageId: string, +): MessageSubmissionRejectionResult { + const submission = submissions.find((item) => item.clientMessageId === clientMessageId); + if (!submission) { + return { outcome: "unknown", submissions: submissions as MessageSubmissionRecord[] }; + } + return { + outcome: submission.providerAcknowledged ? "accepted" : "rejected", + submissions: submissions.filter((item) => item.clientMessageId !== clientMessageId), + }; +} diff --git a/packages/app/src/composer/submission/writer.test.ts b/packages/app/src/composer/submission/writer.test.ts new file mode 100644 index 000000000..3ee174d55 --- /dev/null +++ b/packages/app/src/composer/submission/writer.test.ts @@ -0,0 +1,49 @@ +import { afterEach, expect, test } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useSessionStore } from "@/stores/session-store"; +import type { UserMessageItem } from "@/types/stream"; +import { createMessageSubmissionWriter } from "./writer"; + +const CANONICAL_ONLY_SERVER_ID = "canonical-only"; + +function submittedMessage(clientMessageId: string): UserMessageItem { + return { + kind: "user_message", + id: clientMessageId, + clientMessageId, + text: "hello", + timestamp: new Date("2026-07-31T10:00:00.000Z"), + }; +} + +function initializeSession(serverId: string, features: Record): void { + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "0.2.6", + features, + }); +} + +function submissionIds(serverId: string): string[] { + return ( + useSessionStore.getState().sessions[serverId]?.messageSubmissions.get("agent-1") ?? [] + ).map((submission) => submission.clientMessageId); +} + +afterEach(() => { + useSessionStore.getState().clearSession(CANONICAL_ONLY_SERVER_ID); +}); + +test("tracks submissions when canonical prompts are supported", () => { + initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: true }); + + createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID).begin( + "agent-1", + submittedMessage("canonical-only-message"), + ); + + expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]); +}); diff --git a/packages/app/src/composer/submission/writer.ts b/packages/app/src/composer/submission/writer.ts new file mode 100644 index 000000000..f8b40034b --- /dev/null +++ b/packages/app/src/composer/submission/writer.ts @@ -0,0 +1,72 @@ +import type { MessageSubmissionWriter } from "@/composer/actions"; +import { useSessionStore } from "@/stores/session-store"; +import { + appendSubmittedUserMessage, + removeSubmittedUserMessage, + type UserMessageItem, +} from "@/types/stream"; + +function appendUntrackedSubmission(serverId: string, agentId: string, message: UserMessageItem) { + const session = useSessionStore.getState().sessions[serverId]; + if (!session) return; + const tail = session.agentStreamTail.get(agentId) ?? []; + const head = session.agentStreamHead.get(agentId) ?? []; + useSessionStore + .getState() + .setAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message })); +} + +function removeUntrackedSubmission( + serverId: string, + agentId: string, + clientMessageId: string, +): void { + const session = useSessionStore.getState().sessions[serverId]; + if (!session) return; + const tail = session.agentStreamTail.get(agentId) ?? []; + const head = session.agentStreamHead.get(agentId) ?? []; + useSessionStore + .getState() + .setAgentStreamState( + serverId, + agentId, + removeSubmittedUserMessage({ tail, head, clientMessageId }), + ); +} + +function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { + return { + begin: (agentId, message) => appendUntrackedSubmission(serverId, agentId, message), + accept: () => undefined, + reject: (agentId, clientMessageId) => { + removeUntrackedSubmission(serverId, agentId, clientMessageId); + return "rejected"; + }, + }; +} + +export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { + const supportsTrackedMessageSubmissions = + useSessionStore.getState().sessions[serverId]?.serverInfo?.features + ?.canonicalSubmittedPrompts === true; + if (!supportsTrackedMessageSubmissions) { + // COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6. + return createUntrackedMessageSubmissionWriter(serverId); + } + return { + begin: (agentId, message) => + useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message), + accept: (agentId, clientMessageId) => + useSessionStore.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId), + reject: (agentId, clientMessageId) => + useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId), + }; +} + +export function handoffCreatedAgentMessageSubmission( + serverId: string, + agentId: string, + message: UserMessageItem, +): boolean { + return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message); +} diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index fb892d985..c0c39171c 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -51,7 +51,7 @@ export async function submitAgentInput( return "queued"; } - // Clear immediately so optimistic stream updates and composer state stay in sync. + // Clear immediately so the submitted timeline row and composer state stay in sync. if (shouldClearOnSubmit) { input.setUserInput(""); input.setAttachments([]); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 14d36f183..22e90575a 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -14,12 +14,13 @@ import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { generateMessageId, type StreamItem } from "@/types/stream"; import { createSessionAgentStreamReducerQueue, + deriveAgentStreamTurnLiveness, processTimelineResponse, type ProcessTimelineResponseOutput, type TimelineReducerSideEffect, } from "@/timeline/session-stream-reducers"; import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { isTimelineCatchUpComplete } from "@/timeline/timeline-sync-plan"; +import { isTimelineResumeSnapshotAuthoritative } from "@/timeline/timeline-sync-plan"; import { createViewedTimelineSync, type TimelineDeliveryMode, @@ -40,7 +41,12 @@ import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context"; import type { AudioPlaybackSource } from "@/voice/audio-engine-types"; -import { useSessionStore, type MessageEntry, type SessionState } from "@/stores/session-store"; +import { + selectAgentTimelineState, + useSessionStore, + type MessageEntry, + type SessionState, +} from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { sendOsNotification } from "@/utils/os-notifications"; import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility"; @@ -53,6 +59,7 @@ import { } from "@/utils/agent-initialization"; import { encodeImages } from "@/utils/encode-images"; import { derivePendingPermissionKey } from "@/utils/agent-snapshots"; +import { getSendingClientMessageIds } from "@/composer/submission/model"; import type { AttachmentMetadata } from "@/attachments/types"; import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; import { useToast } from "@/contexts/toast-context"; @@ -191,13 +198,6 @@ type WorkspaceSetupProgressPayload = Extract< type SessionStoreActions = ReturnType; type SetInitializingAgents = SessionStoreActions["setInitializingAgents"]; -type SetAgentStreamTail = SessionStoreActions["setAgentStreamTail"]; -type SetAgentStreamHead = SessionStoreActions["setAgentStreamHead"]; -type ClearAgentStreamHead = SessionStoreActions["clearAgentStreamHead"]; -type SetAgentTimelineCursor = SessionStoreActions["setAgentTimelineCursor"]; -type MarkAgentHistorySynchronized = SessionStoreActions["markAgentHistorySynchronized"]; -type SetAgentAuthoritativeHistoryApplied = - SessionStoreActions["setAgentAuthoritativeHistoryApplied"]; function clearAgentInitializingFlag( setInitializingAgents: SetInitializingAgents, @@ -230,75 +230,6 @@ function handleTimelineError(input: { } } -function applyTimelineStreamPatches(input: { - result: ProcessTimelineResponseOutput; - agentId: string; - serverId: string; - currentTail: StreamItem[]; - currentHead: StreamItem[]; - setAgentStreamTail: SetAgentStreamTail; - setAgentStreamHead: SetAgentStreamHead; - clearAgentStreamHead: ClearAgentStreamHead; - setAgentTimelineCursor: SetAgentTimelineCursor; -}): void { - const { - result, - agentId, - serverId, - currentTail, - currentHead, - setAgentStreamTail, - setAgentStreamHead, - clearAgentStreamHead, - setAgentTimelineCursor, - } = input; - - if (result.tail !== currentTail) { - setAgentStreamTail(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, result.tail); - return next; - }); - } - - if (result.head !== currentHead) { - if (result.head.length === 0) { - clearAgentStreamHead(serverId, agentId); - } else { - setAgentStreamHead(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, result.head); - return next; - }); - } - } - - if (result.cursorChanged) { - setAgentTimelineCursor(serverId, (prev) => { - const current = prev.get(agentId); - if (!result.cursor) { - if (!current) { - return prev; - } - const next = new Map(prev); - next.delete(agentId); - return next; - } - if ( - current && - current.epoch === result.cursor.epoch && - current.startSeq === result.cursor.startSeq && - current.endSeq === result.cursor.endSeq - ) { - return prev; - } - const next = new Map(prev); - next.set(agentId, result.cursor); - return next; - }); - } -} - function executeTimelineSideEffects(input: { sideEffects: TimelineReducerSideEffect[]; agentId: string; @@ -319,8 +250,6 @@ function finalizeTimelineApplication(input: { serverId: string; shouldMarkAuthoritativeHistoryApplied: boolean; setInitializingAgents: SetInitializingAgents; - setAgentAuthoritativeHistoryApplied: SetAgentAuthoritativeHistoryApplied; - markAgentHistorySynchronized: MarkAgentHistorySynchronized; }): void { const { result, @@ -329,17 +258,13 @@ function finalizeTimelineApplication(input: { serverId, shouldMarkAuthoritativeHistoryApplied, setInitializingAgents, - setAgentAuthoritativeHistoryApplied, - markAgentHistorySynchronized, } = input; if (result.clearInitializing) { clearAgentInitializingFlag(setInitializingAgents, serverId, agentId); } if (shouldMarkAuthoritativeHistoryApplied) { - setAgentAuthoritativeHistoryApplied(serverId, agentId, true); useCreateFlowStore.getState().clearByAgent({ serverId, agentId }); - markAgentHistorySynchronized(serverId, agentId); const session = useSessionStore.getState().sessions[serverId]; const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); if (agent && agent.status !== "running") { @@ -423,16 +348,17 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); const setAgentStreamState = useSessionStore((state) => state.setAgentStreamState); + const applyAgentTurnLiveness = useSessionStore((state) => state.applyAgentTurnLiveness); + const clearAgentTurnLiveness = useSessionStore((state) => state.clearAgentTurnLiveness); const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead); const setAgentTimelineCursor = useSessionStore((state) => state.setAgentTimelineCursor); - const setAgentTimelineHasOlder = useSessionStore((state) => state.setAgentTimelineHasOlder); const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration); const markAgentHistorySynchronized = useSessionStore( (state) => state.markAgentHistorySynchronized, ); - const setAgentAuthoritativeHistoryApplied = useSessionStore( - (state) => state.setAgentAuthoritativeHistoryApplied, + const applyAgentTimelineResponseState = useSessionStore( + (state) => state.applyAgentTimelineResponseState, ); const setAgents = useSessionStore((state) => state.setAgents); const setWorkspaces = useSessionStore((state) => state.setWorkspaces); @@ -626,6 +552,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } }, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]); + useEffect( + () => + client.subscribeConnectionStatus((connection) => { + if (connection.status === "connected") return; + clearAgentTurnLiveness(serverId); + }), + [clearAgentTurnLiveness, client, serverId], + ); + const applyWorkspaceSetupProgress = useCallback( (payload: WorkspaceSetupProgressPayload) => { upsertWorkspaceSetupProgress({ serverId, payload }); @@ -642,31 +577,25 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ) => { const agentId = payload.agentId; const initKey = getInitKey(serverId, agentId); - const catchUpComplete = isTimelineCatchUpComplete({ + const shouldMarkAuthoritativeHistoryApplied = isTimelineResumeSnapshotAuthoritative({ direction: payload.direction, hasNewer: payload.hasNewer, error: payload.error, }); - const shouldMarkAuthoritativeHistoryApplied = - payload.direction === "tail" || (payload.direction === "after" && catchUpComplete); // Read current store state const session = useSessionStore.getState().sessions[serverId]; const isInitializing = session?.initializingAgents.get(agentId) === true; const activeInitDeferred = getInitDeferred(initKey); const hasActiveInitDeferred = Boolean(activeInitDeferred); - const currentCursor = session?.agentTimelineCursor.get(agentId); - const currentTail = session?.agentStreamTail.get(agentId) ?? []; + const timeline = selectAgentTimelineState(session, agentId); + const currentCursor = + timeline.status === "synced" ? (timeline.range ?? undefined) : undefined; + const currentTail = timeline.status === "cold" ? [] : timeline.items; const currentHead = session?.agentStreamHead.get(agentId) ?? []; - - setAgentTimelineHasOlder(serverId, (prev) => { - if (prev.get(agentId) === payload.hasOlder) { - return prev; - } - const next = new Map(prev); - next.set(agentId, payload.hasOlder); - return next; - }); + const sendingClientMessageIds = getSendingClientMessageIds( + session?.messageSubmissions.get(agentId), + ); // Call pure reducer const result = processTimelineResponse({ @@ -677,6 +606,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider isInitializing, hasActiveInitDeferred, initRequestDirection: activeInitDeferred?.requestDirection ?? "tail", + sendingClientMessageIds, }); if (result.error) { @@ -690,17 +620,23 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider return; } - applyTimelineStreamPatches({ - result, - agentId, - serverId, - currentTail, - currentHead, - setAgentStreamTail, - setAgentStreamHead, - clearAgentStreamHead, - setAgentTimelineCursor, - }); + if (result.commit === "discard") { + if (result.acknowledgedClientMessageIds.length > 0) { + setAgentStreamState(serverId, agentId, { + acknowledgedClientMessageIds: result.acknowledgedClientMessageIds, + }); + } + markAgentHistorySynchronized(serverId, agentId); + } else { + applyAgentTimelineResponseState(serverId, agentId, { + items: result.tail, + head: result.head, + range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null), + older: result.older, + synchronized: shouldMarkAuthoritativeHistoryApplied, + acknowledgedClientMessageIds: result.acknowledgedClientMessageIds, + }); + } executeTimelineSideEffects({ sideEffects: result.sideEffects, @@ -715,20 +651,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider serverId, shouldMarkAuthoritativeHistoryApplied, setInitializingAgents, - setAgentAuthoritativeHistoryApplied, - markAgentHistorySynchronized, }); }, [ - clearAgentStreamHead, + applyAgentTimelineResponseState, markAgentHistorySynchronized, recoverTimelineGap, serverId, - setAgentAuthoritativeHistoryApplied, - setAgentStreamHead, - setAgentStreamTail, - setAgentTimelineCursor, - setAgentTimelineHasOlder, + setAgentStreamState, setInitializingAgents, ], ); @@ -741,16 +671,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const sync = createViewedTimelineSync({ initialDeliveryMode, setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds), - readCursor: (agentId) => - useSessionStore.getState().sessions[serverId]?.agentTimelineCursor.get(agentId), - hasAuthoritativeHistory: (agentId) => - useSessionStore - .getState() - .sessions[serverId]?.agentAuthoritativeHistoryApplied.get(agentId) === true, fetchPage: async (agentId, request) => { const session = useSessionStore.getState().sessions[serverId]; const initKey = getInitKey(serverId, agentId); - const shouldInitialize = session?.agentAuthoritativeHistoryApplied.get(agentId) !== true; + const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced"; if (shouldInitialize) { if (!getInitDeferred(initKey)) { const deferred = createInitDeferred(initKey, request.direction ?? "tail"); @@ -802,13 +726,20 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider viewedTimelineSyncRef.current?.setConnected(isConnected); }, [isConnected]); + useEffect( + () => + getHostRuntimeStore().subscribeAgentStoppedRunning(serverId, (agentId) => { + viewedTimelineSyncRef.current?.reconcileAgent(agentId); + }), + [serverId], + ); + // Daemon message handlers - directly update Zustand store useEffect(() => { const agentStreamReducerQueue = createSessionAgentStreamReducerQueue({ serverId, setAgentStreamState, setAgentTimelineCursor, - setAgents, recoverTimelineGap, }); @@ -825,7 +756,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ) { voiceRuntime?.onTurnEvent(serverId, agentId, event.type); } - + const turnLiveness = deriveAgentStreamTurnLiveness([ + { event: streamEvent, seq, epoch, timestamp: parsedTimestamp }, + ]); + if (turnLiveness.length > 0) { + applyAgentTurnLiveness(serverId, agentId, turnLiveness); + } agentStreamReducerQueue.enqueue(agentId, { event: streamEvent, seq, @@ -1170,6 +1106,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setAgentStreamTail, setAgentStreamHead, setAgentStreamState, + applyAgentTurnLiveness, clearAgentStreamHead, setAgentTimelineCursor, setInitializingAgents, diff --git a/packages/app/src/contexts/session-status-tracking.test.ts b/packages/app/src/contexts/session-status-tracking.test.ts index c5235df86..346127b1f 100644 --- a/packages/app/src/contexts/session-status-tracking.test.ts +++ b/packages/app/src/contexts/session-status-tracking.test.ts @@ -8,6 +8,7 @@ function createAgent(status: Agent["status"]): Agent { id: "agent-1", provider: "codex", status, + activeTurn: null, createdAt: new Date(0), updatedAt: new Date(0), lastUserMessageAt: null, diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index 52fb58e1e..a4a918de5 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -49,7 +49,7 @@ afterEach(() => { }); describe("ensureAgentIsInitialized", () => { - it("requests bounded projected catch-up after the current cursor when authoritative history is loaded", () => { + it("requests a bounded projected tail when authoritative history is loaded", () => { const client = new FakeDaemonClient(); const runtime = new FakeTimelineRuntime(); useSessionStore.getState().initializeSession(serverId, client as never); @@ -74,14 +74,13 @@ describe("ensureAgentIsInitialized", () => { serverId, agentId, request: { - direction: "after", - cursor: { epoch: "epoch-1", seq: 42 }, + direction: "tail", limit: TIMELINE_FETCH_PAGE_SIZE, projection: "projected", }, }, ]); - expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("after"); + expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail"); }); it("requests a bounded projected tail when no authoritative cursor is available", () => { @@ -111,6 +110,47 @@ describe("ensureAgentIsInitialized", () => { expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail"); }); + it("requests a bounded projected tail after restoring painted replica items", () => { + const client = new FakeDaemonClient(); + const runtime = new FakeTimelineRuntime(); + useSessionStore.getState().restoreSessionReplica(serverId, { + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + timeline: { + agentId, + items: [ + { + kind: "assistant_message", + id: "painted-item", + text: "Painted before hydration", + timestamp: new Date("2026-07-27T10:00:00.000Z"), + }, + ], + }, + }); + + void ensureAgentIsInitialized({ + serverId, + agentId, + client: client as never, + runtime, + setAgentInitializing: bindSetAgentInitializing(), + }); + + expect(runtime.requests).toEqual([ + { + serverId, + agentId, + request: { + direction: "tail", + limit: TIMELINE_FETCH_PAGE_SIZE, + projection: "projected", + }, + }, + ]); + }); + it("times out initialization after 65 seconds", async () => { vi.useFakeTimers(); const client = new FakeDaemonClient(); diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index ae80d352e..c44621c0f 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -11,7 +11,7 @@ import { refreshInitTimeout, } from "@/utils/agent-initialization"; import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime"; -import { planInitialAgentTimelineSync, planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; +import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { i18n } from "@/i18n/i18next"; export type SetAgentInitializing = (agentId: string, initializing: boolean) => void; @@ -51,10 +51,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): return existing.promise; } - const session = useSessionStore.getState().sessions[serverId]; - const cursor = session?.agentTimelineCursor.get(agentId); - const hasAuthoritativeHistory = session?.agentAuthoritativeHistoryApplied.get(agentId) === true; - const timelineRequest = planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory }); + const timelineRequest = planTimelineTailFetch(); const deferred = createInitDeferred(key, timelineRequest.direction); refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing }); diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index 141132942..88cce1e35 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -17,6 +17,7 @@ function createAgent(id: string): Agent { id, provider: "claude", status: "running", + activeTurn: { turnId: "turn-1", startedAt: now }, createdAt: now, updatedAt: now, lastUserMessageAt: now, diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index aad9fc4e2..cd4d433f7 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -41,6 +41,7 @@ function makeAgent(overrides: Partial = {}): Agent { labels: {}, archivedAt: null, ...overrides, + activeTurn: overrides.activeTurn ?? null, }; } diff --git a/packages/app/src/hooks/use-load-older-agent-history.test.ts b/packages/app/src/hooks/use-load-older-agent-history.test.ts index 28efe17fc..292b54ab8 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.test.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.test.ts @@ -84,7 +84,7 @@ describe("loadOlderAgentHistory", () => { const client = createClient(); const inFlight = createInFlight(); - await loadOlderAgentHistory(agentId, { + const started = await loadOlderAgentHistory(agentId, { client, cursor: undefined, hasOlder: true, @@ -94,13 +94,14 @@ describe("loadOlderAgentHistory", () => { expect(client.calls).toEqual([]); expect(inFlight.values).toEqual([false]); + expect(started).toBe(false); }); it("no-ops when the daemon says no older history exists", async () => { const client = createClient(); const inFlight = createInFlight(); - await loadOlderAgentHistory(agentId, { + const started = await loadOlderAgentHistory(agentId, { client, cursor: someCursor, hasOlder: false, @@ -110,13 +111,14 @@ describe("loadOlderAgentHistory", () => { expect(client.calls).toEqual([]); expect(inFlight.values).toEqual([false]); + expect(started).toBe(false); }); it("no-ops when a request is already in flight", async () => { const client = createClient(); const inFlight = createInFlight(true); - await loadOlderAgentHistory(agentId, { + const started = await loadOlderAgentHistory(agentId, { client, cursor: someCursor, hasOlder: true, @@ -126,13 +128,14 @@ describe("loadOlderAgentHistory", () => { expect(client.calls).toEqual([]); expect(inFlight.values).toEqual([true]); + expect(started).toBe(true); }); it("requests the page before the current start cursor and clears in-flight on success", async () => { const client = createClient(); const inFlight = createInFlight(); - await loadOlderAgentHistory(agentId, { + const started = await loadOlderAgentHistory(agentId, { client, cursor: someCursor, hasOlder: true, @@ -152,6 +155,7 @@ describe("loadOlderAgentHistory", () => { }, ]); expect(inFlight.values).toEqual([false, true, false]); + expect(started).toBe(true); }); it("shows a panel toast, warns, and clears in-flight on failure", async () => { diff --git a/packages/app/src/hooks/use-load-older-agent-history.ts b/packages/app/src/hooks/use-load-older-agent-history.ts index 1bf8b9557..2bc3f5c9d 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.ts @@ -2,7 +2,11 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import type { ToastApi } from "@/components/toast-host"; import { i18n } from "@/i18n/i18next"; -import { useSessionStore, type AgentTimelineCursorState } from "@/stores/session-store"; +import { + selectAgentTimelineState, + useSessionStore, + type AgentTimelineCursorState, +} from "@/stores/session-store"; import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; @@ -36,11 +40,14 @@ export interface LoadOlderAgentHistoryDeps { export async function loadOlderAgentHistory( agentId: string, deps: LoadOlderAgentHistoryDeps, -): Promise { +): Promise { const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } = deps; - if (!client || !cursor || !hasOlder || isLoadingOlder) { - return; + if (isLoadingOlder) { + return true; + } + if (!client || !cursor || !hasOlder) { + return false; } setInFlight(true); @@ -58,6 +65,7 @@ export async function loadOlderAgentHistory( } finally { setInFlight(false); } + return true; } export function useLoadOlderAgentHistory({ @@ -70,15 +78,17 @@ export function useLoadOlderAgentHistory({ toast?: ToastApi | null; }) { const { t } = useTranslation(); - const hasOlder = - useSessionStore((state) => state.sessions[serverId]?.agentTimelineHasOlder.get(agentId)) === - true; + const hasOlder = useSessionStore((state) => { + const timeline = selectAgentTimelineState(state.sessions[serverId], agentId); + return timeline.status === "synced" && timeline.older === "available"; + }); const isLoadingOlder = useSessionStore((state) => state.sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId), ) === true; const progressKey = useSessionStore((state) => { - const cursor = state.sessions[serverId]?.agentTimelineCursor.get(agentId); + const timeline = selectAgentTimelineState(state.sessions[serverId], agentId); + const cursor = timeline.status === "synced" ? timeline.range : null; return cursor ? `${cursor.epoch}:${cursor.startSeq}` : null; }); const setOlderFetchInFlight = useSessionStore( @@ -99,17 +109,18 @@ export function useLoadOlderAgentHistory({ [agentId, serverId, setOlderFetchInFlight], ); - const loadOlder = useCallback(() => { + const loadOlder = useCallback(async (): Promise => { const session = useSessionStore.getState().sessions[serverId]; - void loadOlderAgentHistory(agentId, { + const timeline = selectAgentTimelineState(session, agentId); + return await loadOlderAgentHistory(agentId, { client: session?.client ? { fetchAgentTimeline: (timelineAgentId, request) => getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request), } : null, - cursor: session?.agentTimelineCursor.get(agentId), - hasOlder: session?.agentTimelineHasOlder.get(agentId) === true, + cursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined, + hasOlder: timeline.status === "synced" && timeline.older === "available", isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true, setInFlight, toast, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 240f300db..62373fb24 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -25,6 +25,7 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { SidebarCallout } from "@/components/sidebar-callout"; import { Composer } from "@/composer"; +import { getActiveMessageSubmissions } from "@/composer/submission/model"; import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore"; import { getProviderIcon } from "@/components/provider-icons"; import { @@ -71,7 +72,12 @@ import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; import { usePanelStore } from "@/stores/panel-store"; -import { type Agent, useSessionStore } from "@/stores/session-store"; +import { + selectAgentTimelineState, + selectAgentTurnPresentation, + type Agent, + useSessionStore, +} from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import type { Theme } from "@/styles/theme"; @@ -316,6 +322,7 @@ function useAgentPanelDescriptor( pendingPermissionCount: agent?.pendingPermissions.length ?? 0, requiresAttention: agent?.requiresAttention ?? false, attentionReason: agent?.attentionReason ?? null, + isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive, }; }), ); @@ -331,7 +338,7 @@ function useAgentPanelDescriptor( icon, statusBucket: descriptorState.status ? deriveSidebarStateBucket({ - status: descriptorState.status, + status: descriptorState.isTurnActive ? "running" : descriptorState.status, pendingPermissionCount: descriptorState.pendingPermissionCount, requiresAttention: descriptorState.requiresAttention, attentionReason: descriptorState.attentionReason, @@ -442,6 +449,7 @@ export function useDraftPanelDescriptor( } const EMPTY_STREAM_ITEMS: StreamItem[] = []; +const EMPTY_MESSAGE_SUBMISSIONS = [] as const; const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSION_LIST: PendingPermission[] = []; @@ -790,11 +798,12 @@ function ChatAgentContent({ const historySyncGeneration = useSessionStore( (state) => state.sessions[serverId]?.historySyncGeneration ?? 0, ); - const hasAppliedAuthoritativeHistory = useSessionStore((state) => + const replicaTimelineStatus = useSessionStore((state) => agentId - ? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(agentId) === true - : false, + ? selectAgentTimelineState(state.sessions[serverId], agentId).status + : ("cold" as const), ); + const hasAppliedAuthoritativeHistory = replicaTimelineStatus === "synced"; const agentHistorySyncGeneration = useSessionStore((state) => agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1, ); @@ -830,7 +839,8 @@ function ChatAgentContent({ kind: "idle", }); - const hasHydratedHistoryBefore = hasAppliedAuthoritativeHistory; + const hasHydratedHistoryBefore = + hasAppliedAuthoritativeHistory || replicaTimelineStatus === "painted"; const attentionController = useAgentAttentionClear({ agentId, @@ -1288,6 +1298,20 @@ const AgentStreamSection = memo(function AgentStreamSection({ const streamItemsRaw = useSessionStore((state) => agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, ); + const pendingMessageSubmissions = useSessionStore( + useShallow((state) => + agentId + ? getActiveMessageSubmissions(state.sessions[serverId]?.messageSubmissions.get(agentId)) + : EMPTY_MESSAGE_SUBMISSIONS, + ), + ); + const turnPresentation = useSessionStore( + useShallow((state) => + agentId + ? selectAgentTurnPresentation(state.sessions[serverId], agentId) + : { isActive: false, isCancelling: false, startedAt: null, turnId: null }, + ), + ); const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; const pendingPermissionList = useStoreWithEqualityFn( useSessionStore, @@ -1327,6 +1351,8 @@ const AgentStreamSection = memo(function AgentStreamSection({ routeBottomAnchorRequest={routeBottomAnchorRequest} isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory} toast={toast} + pendingMessageSubmissions={pendingMessageSubmissions} + turnPresentation={turnPresentation} onOpenWorkspaceFile={onOpenWorkspaceFile} /> ); diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index f33f1beab..cf09cfe40 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -20,6 +20,7 @@ import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; +import type { TurnPresentation } from "@/timeline/turn-liveness"; const EMPTY_PERMISSIONS = new Map(); const EMPTY_STREAM_ITEMS: StreamItem[] = []; @@ -108,10 +109,12 @@ function ProviderSubagentPanel() { .catch(() => undefined); }, [client, serverId, supported, target.parentAgentId, target.subagentId]); - const loadOlder = useCallback(() => { - if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) return; + const loadOlder = useCallback((): boolean => { + if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) { + return false; + } const firstSeq = timeline.rows.size ? Math.min(...timeline.rows.keys()) : null; - if (firstSeq === null) return; + if (firstSeq === null) return false; setIsLoadingOlder(true); void client .fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, { @@ -125,6 +128,7 @@ function ProviderSubagentPanel() { }) .catch(() => undefined) .finally(() => setIsLoadingOlder(false)); + return true; }, [ client, isLoadingOlder, @@ -159,6 +163,15 @@ function ProviderSubagentPanel() { }), [isLoadingOlder, loadOlder, progressKey, timeline?.hasOlder], ); + const turnPresentation = useMemo( + () => ({ + isActive: descriptor?.status === "running", + isCancelling: false, + startedAt: null, + turnId: null, + }), + [descriptor?.status], + ); if (serverInfo && !supported) { return ( @@ -176,6 +189,7 @@ function ProviderSubagentPanel() { context={streamContext} streamItems={timeline?.tail ?? EMPTY_STREAM_ITEMS} streamHead={timeline?.head ?? EMPTY_STREAM_ITEMS} + turnPresentation={turnPresentation} pendingPermissions={EMPTY_PERMISSIONS} isAuthoritativeHistoryReady onOpenWorkspaceFile={openFileInWorkspace} diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 94eabb0c8..fc9368fae 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -92,7 +92,7 @@ function createDirectory(serverId: string): { serverIds.add(serverId); const client = new FakeDirectoryClient(); const directory = new DirectorySync(serverId, { - drainQueuedAgentMessage: () => undefined, + onAgentStoppedRunning: () => undefined, markAgentLoading: () => undefined, markAgentReady: () => undefined, markAgentError: () => undefined, diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 734b89a3c..bfb2f93a6 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -81,13 +81,13 @@ export class DirectorySync { constructor( private readonly serverId: string, private readonly callbacks: { - drainQueuedAgentMessage: (agentId: string) => void; + onAgentStoppedRunning: (agentId: string) => void; markAgentLoading: () => void; markAgentReady: () => void; markAgentError: (error: string) => void; }, ) { - this.agents = new AgentDirectoryReplica(serverId, callbacks.drainQueuedAgentMessage); + this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning); this.workspaces = new WorkspaceDirectoryReplica(serverId); } diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 0f4946a4b..74cdf8531 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -80,7 +80,9 @@ class FakeDaemonClient { this.setConnectionState({ status: "disconnected", reason: "client_closed" }); } - async sendAgentMessage(...args: Parameters): Promise { + async sendAgentMessage( + ...args: Parameters + ): ReturnType { this.sentAgentMessages.push(args); for (const waiter of this.sentMessageWaiters) waiter(); const response = this.sendAgentMessageResponses.shift(); @@ -2259,6 +2261,72 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); + it("submits an automatically drained message through the submission producer", async () => { + const host = makeHost({ serverId: "srv_drain_submission" }); + const fakeClient = new FakeDaemonClient(); + const send = new Deferred(); + fakeClient.sendAgentMessageResponses.push(send.promise); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async () => ({ + client: fakeClient as unknown as DaemonClient, + serverId: host.serverId, + hostname: null, + }), + getClientId: async () => "cid_drain_submission", + }, + }); + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + sessionStore.updateSessionServerInfo(host.serverId, { + serverId: host.serverId, + hostname: null, + version: null, + features: { canonicalSubmittedPrompts: true }, + }); + sessionStore.setQueuedMessages( + host.serverId, + new Map([ + [ + "agent", + [ + { + id: "queued-with-attachment", + text: "read this file", + attachments: [ + { + kind: "workspace_file" as const, + path: "src/main.ts", + selection: { kind: "whole_file" as const }, + }, + ], + }, + ], + ], + ]), + ); + + store.drainQueuedAgentMessage(host.serverId, "agent"); + await fakeClient.waitForSentMessages(1); + + // The row and the pending submission must exist while the RPC is still in flight — + // the user sees their message and the working footer immediately, exactly as when + // they press send. + const session = useSessionStore.getState().sessions[host.serverId]; + const tail = session?.agentStreamTail.get("agent") ?? []; + expect(tail).toHaveLength(1); + expect(tail[0]).toMatchObject({ + kind: "user_message", + text: "read this file", + attachments: [{ type: "text", title: "main.ts", text: "Workspace file: src/main.ts" }], + }); + expect(session?.messageSubmissions.get("agent")).toBeDefined(); + + send.resolve(); + useSessionStore.getState().clearSession(host.serverId); + }); + it("restores an automatically drained message when sending fails", async () => { const host = makeHost({ serverId: "srv_failed_queue_drain" }); const fakeClient = new FakeDaemonClient(); @@ -2629,6 +2697,12 @@ describe("HostRuntimeStore", () => { }).agent; const staleAgent: Agent = { ...stale, + activeTurn: stale.activeTurn + ? { + turnId: stale.activeTurn.turnId, + startedAt: stale.activeTurn.startedAt ? new Date(stale.activeTurn.startedAt) : null, + } + : null, serverId: host.serverId, createdAt: new Date(stale.createdAt), updatedAt: new Date(stale.updatedAt), diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 144632817..6d05aa0e7 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -49,11 +49,9 @@ import { } from "@/data/push-router"; import { mountBrowserAutomationDaemonClientHandler } from "@/browser-automation/handler"; import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules"; -import { sendQueuedComposerMessageNow } from "@/composer/actions"; -import { - resolveComposerAttachmentSubmitFormat, - splitComposerAttachmentsForSubmit, -} from "@/composer/attachments/submit"; +import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions"; +import { createMessageSubmissionWriter } from "@/composer/submission/writer"; +import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit"; import { encodeImages } from "@/utils/encode-images"; import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync"; import { ReplicaCache } from "@/runtime/replica-cache"; @@ -1342,6 +1340,7 @@ interface AgentDirectoryRefreshInput { export class HostRuntimeStore { private controllers = new Map(); private serverListeners = new Map void>>(); + private agentStoppedRunningListeners = new Map void>>(); private globalListeners = new Set<() => void>(); private hostListListeners = new Set<() => void>(); private version = 0; @@ -1586,11 +1585,12 @@ export class HostRuntimeStore { rekeyMap(this.controllers, oldServerId, newServerId); rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId); rekeyMap(this.directoryBootstrapInFlight, oldServerId, newServerId); + rekeyMap(this.agentStoppedRunningListeners, oldServerId, newServerId); this.replicaCache.reconcileServerId(oldServerId, newServerId); this.directorySyncByServer.get(oldServerId)?.dispose(); this.directorySyncByServer.delete(oldServerId); const directory = new DirectorySync(newServerId, { - drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId), + onAgentStoppedRunning: (agentId) => this.onAgentStoppedRunning(newServerId, agentId), markAgentLoading: () => controller.markAgentDirectorySyncLoading(), markAgentReady: () => controller.markAgentDirectorySyncReady(), markAgentError: (error) => controller.markAgentDirectorySyncError(error), @@ -1927,8 +1927,7 @@ export class HostRuntimeStore { this.directorySyncByServer.set( host.serverId, new DirectorySync(host.serverId, { - drainQueuedAgentMessage: (agentId) => - this.drainQueuedAgentMessage(host.serverId, agentId), + onAgentStoppedRunning: (agentId) => this.onAgentStoppedRunning(host.serverId, agentId), markAgentLoading: () => controller.markAgentDirectorySyncLoading(), markAgentReady: () => controller.markAgentDirectorySyncReady(), markAgentError: (error) => controller.markAgentDirectorySyncError(error), @@ -2071,14 +2070,16 @@ export class HostRuntimeStore { submitMessage: async ({ text, attachments }) => { const supportsForgeAttachments = useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true; - const wirePayload = splitComposerAttachmentsForSubmit(attachments, { - format: resolveComposerAttachmentSubmitFormat({ supportsForgeAttachments }), - }); - const images = await encodeImages(wirePayload.images); - await client.sendAgentMessage(agentId, text, { - messageId: next.id, - ...(images && images.length > 0 ? { images } : {}), - attachments: wirePayload.attachments, + await dispatchComposerAgentMessage({ + client, + agentId, + text, + attachments, + attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({ + supportsForgeAttachments, + }), + encodeImages, + submission: createMessageSubmissionWriter(serverId), }); }, }) @@ -2125,6 +2126,18 @@ export class HostRuntimeStore { }; } + subscribeAgentStoppedRunning(serverId: string, listener: (agentId: string) => void): () => void { + const listeners = this.agentStoppedRunningListeners.get(serverId) ?? new Set(); + listeners.add(listener); + this.agentStoppedRunningListeners.set(serverId, listeners); + return () => { + const current = this.agentStoppedRunningListeners.get(serverId); + if (!current) return; + current.delete(listener); + if (current.size === 0) this.agentStoppedRunningListeners.delete(serverId); + }; + } + subscribeAll(listener: () => void): () => void { this.globalListeners.add(listener); return () => { @@ -2233,6 +2246,13 @@ export class HostRuntimeStore { listener(); } } + + private onAgentStoppedRunning(serverId: string, agentId: string): void { + this.drainQueuedAgentMessage(serverId, agentId); + for (const listener of this.agentStoppedRunningListeners.get(serverId) ?? []) { + listener(agentId); + } + } } let singletonHostRuntimeStore: HostRuntimeStore | null = null; diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index cf8f9978d..c9b259290 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -4,9 +4,10 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { normalizeProjectDescriptor, normalizeWorkspaceDescriptor, + selectAgentTimelineState, useSessionStore, } from "@/stores/session-store"; -import type { StreamItem } from "@/types/stream"; +import { createUserMessage, type StreamItem } from "@/types/stream"; import { ReplicaCache, type ReplicaCacheStorage } from "."; const SERVER_ID = "cached-host"; @@ -173,23 +174,26 @@ describe("ReplicaCache", () => { await reader.restore(); const session = useSessionStore.getState().sessions[SERVER_ID]; - expect(session?.client).toBeNull(); - expect(session?.hasHydratedAgents).toBe(false); - expect(session?.hasHydratedWorkspaces).toBe(false); - expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-1"]); - expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-1"]); - expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-1"]); - expect(session?.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date); - expect(session?.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date); - expect(session?.workspaces.get("workspace-1")?.worktreeSlug).toBe("owned-worktree"); - expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]); - expect(session?.agentAuthoritativeHistoryApplied.get("agent-1")).toBe(true); - expect(session?.agentTimelineCursor.get("agent-1")).toEqual({ - epoch: "epoch-1", - startSeq: 1, - endSeq: 12, + expect(session).toBeDefined(); + if (!session) throw new Error("Expected restored session"); + expect(session.client).toBeNull(); + expect(session.hasHydratedAgents).toBe(false); + expect(session.hasHydratedWorkspaces).toBe(false); + expect(Array.from(session.agents.keys())).toEqual(["agent-1"]); + expect(Array.from(session.workspaces.keys())).toEqual(["workspace-1"]); + expect(Array.from(session.projects.keys())).toEqual(["project-1"]); + expect(session.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date); + expect(session.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date); + expect(session.workspaces.get("workspace-1")?.worktreeSlug).toBe("owned-worktree"); + expect(session.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]); + expect(session.agentAuthoritativeHistoryApplied).toEqual(new Map()); + expect(session.agentTimelineCursor).toEqual(new Map()); + expect(session.agentTimelineHasOlder).toEqual(new Map()); + expect(session.agentHistorySyncGeneration).toEqual(new Map()); + expect(selectAgentTimelineState(session, "agent-1")).toEqual({ + status: "painted", + items: [message("message-1", "Cached")], }); - expect(session?.agentTimelineHasOlder.get("agent-1")).toBe(true); }); it("persists only the focused agent view with a short timeline tail", async () => { @@ -233,6 +237,43 @@ describe("ReplicaCache", () => { expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-2"]); expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]); expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50)); + + const persisted = JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null") as { + version: number; + hosts: Array<{ timeline: Record | null }>; + }; + expect(persisted.version).toBe(3); + expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual(["agentId", "items"]); + }); + + it("persists reconciled rows without caching unreconciled local presentations", async () => { + const storage = new MemoryStorage(); + const cache = new ReplicaCache(storage); + cache.setHosts([SERVER_ID]); + seedSession(); + const unreconciled = createUserMessage({ + clientMessageId: "client-pending", + text: "Pending", + timestamp: new Date("2026-07-18T08:01:00.000Z"), + }); + const reconciled = createUserMessage({ + clientMessageId: "client-sent", + messageId: "provider-sent", + timelineCursor: { epoch: "epoch-1", seq: 11 }, + text: "Sent", + timestamp: new Date("2026-07-18T08:01:30.000Z"), + }); + useSessionStore + .getState() + .setAgentStreamTail(SERVER_ID, new Map([["agent-1", [unreconciled, reconciled]]])); + + await cache.flush(); + useSessionStore.getState().clearSession(SERVER_ID); + await cache.restore(); + + expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamTail.get("agent-1")).toEqual([ + reconciled, + ]); }); it("evicts the least recently written host when the cache exceeds its byte budget", async () => { @@ -260,14 +301,38 @@ describe("ReplicaCache", () => { expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]); }); - it("drops malformed or unknown cache versions", async () => { + it("rejects version 1 cache data and overwrites it on flush", async () => { const storage = new MemoryStorage(); - storage.values.set("@paseo:replica-cache", JSON.stringify({ version: 999, hosts: [] })); + storage.values.set( + "@paseo:replica-cache", + JSON.stringify({ + version: 1, + hosts: [ + { + serverId: SERVER_ID, + agents: [], + workspaces: [], + emptyProjects: [], + timeline: { + agentId: "agent-1", + items: [], + cursor: { epoch: "poisoned", startSeq: 1, endSeq: 100 }, + hasOlder: false, + }, + }, + ], + }), + ); const cache = new ReplicaCache(storage); cache.setHosts([SERVER_ID]); await cache.restore(); + await cache.flush(); expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined(); + expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({ + version: 3, + hosts: [], + }); }); }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 4b4a25439..67c45a547 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -11,6 +11,7 @@ import { import { normalizeProjectDescriptor, normalizeWorkspaceDescriptor, + selectAgentTimelineState, useSessionStore, type Agent, type SessionReplica, @@ -18,11 +19,11 @@ import { type ProjectDescriptor, type WorkspaceDescriptor, } from "@/stores/session-store"; -import type { StreamItem } from "@/types/stream"; +import { isUnreconciledLocalUserMessage, type StreamItem } from "@/types/stream"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; const STORAGE_KEY = "@paseo:replica-cache"; -const CACHE_VERSION = 1; +const CACHE_VERSION = 3; const PERSIST_DELAY_MS = 750; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 1024 * 1024; @@ -37,14 +38,6 @@ const StoredAgentSchema = z.object({ const StoredTimelineSchema = z.object({ agentId: z.string(), items: z.unknown(), - cursor: z - .object({ - epoch: z.string(), - startSeq: z.number().int().nonnegative(), - endSeq: z.number().int().nonnegative(), - }) - .nullable(), - hasOlder: z.boolean(), }); const StoredHostSchema = z.object({ @@ -142,8 +135,6 @@ function deserializeTimeline(stored: StoredHost["timeline"]): SessionReplica["ti return { agentId: stored.agentId, items: decoded, - cursor: stored.cursor, - hasOlder: stored.hasOlder, }; } @@ -403,14 +394,20 @@ export class ReplicaCache { (workspace) => workspace.workspaceDirectory === focusedAgent.cwd, )) : undefined; - const items = focusedAgentId ? session.agentStreamTail.get(focusedAgentId) : undefined; + const timelineState = focusedAgentId + ? selectAgentTimelineState(session, focusedAgentId) + : { status: "cold" as const }; + const items = + timelineState.status === "cold" + ? undefined + : timelineState.items.filter( + (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), + ); const timeline = focusedAgent && items ? { agentId: focusedAgent.id, items: encodeDates(items.slice(-MAX_TIMELINE_ITEMS)), - cursor: session.agentTimelineCursor.get(focusedAgent.id) ?? null, - hasOlder: session.agentTimelineHasOlder.get(focusedAgent.id) ?? false, } : null; const stored: StoredHost = { diff --git a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx index dea7fe017..44dfc3fd6 100644 --- a/packages/app/src/screens/workspace/workspace-tab-presentation.tsx +++ b/packages/app/src/screens/workspace/workspace-tab-presentation.tsx @@ -162,7 +162,11 @@ export function WorkspaceTabIcon({ if (shouldShowLoader) { return ( - + ); diff --git a/packages/app/src/stores/create-flow-store.ts b/packages/app/src/stores/create-flow-store.ts index 5ca6b9532..2056e42c0 100644 --- a/packages/app/src/stores/create-flow-store.ts +++ b/packages/app/src/stores/create-flow-store.ts @@ -109,7 +109,10 @@ export const useCreateFlowStore = create((set) => ({ set((state) => { const next = Object.fromEntries( Object.entries(state.pendingByDraftId).filter( - ([, pending]) => pending.serverId !== serverId || pending.agentId !== agentId, + ([, pending]) => + pending.lifecycle !== "sent" || + pending.serverId !== serverId || + pending.agentId !== agentId, ), ); if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) { diff --git a/packages/app/src/stores/draft-store/index.ts b/packages/app/src/stores/draft-store/index.ts index c558d2fa0..528517041 100644 --- a/packages/app/src/stores/draft-store/index.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -8,6 +8,7 @@ import { persistAttachmentFromDataUrl, persistAttachmentFromFileUri, } from "@/attachments/service"; +import { collectRetainedAttachmentIds } from "@/attachments/gc-retention"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useSessionStore, type SessionState } from "@/stores/session-store"; import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; @@ -138,6 +139,9 @@ async function runAttachmentGc(): Promise { for (const id of useDraftStore.getState().collectActiveAttachmentIds()) { referencedIds.add(id); } + for (const id of collectRetainedAttachmentIds()) { + referencedIds.add(id); + } const pendingByDraftId = useCreateFlowStore.getState().pendingByDraftId; for (const pendingCreate of Object.values(pendingByDraftId)) { diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 8ffed6bff..4243c7b02 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -5,9 +5,12 @@ import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; import { normalizeWorkspaceDescriptor, + selectAgentTurnPresentation, + selectAgentTimelineState, useSessionStore, type WorkspaceDescriptor, } from "./session-store"; +import type { StreamItem } from "../types/stream"; import { patchWorkspaceScripts } from "../contexts/session-workspace-scripts"; function createWorkspace( @@ -52,6 +55,288 @@ function getTestSessionReferences() { }; } +function submittedMessage(clientMessageId: string): Extract { + return { + kind: "user_message", + id: clientMessageId, + clientMessageId, + text: clientMessageId, + timestamp: new Date("2026-07-31T10:00:00.000Z"), + }; +} + +function hasSubmittedMessage(items: readonly StreamItem[] | undefined, clientMessageId: string) { + return items?.some( + (item) => item.kind === "user_message" && item.clientMessageId === clientMessageId, + ); +} + +function permutations(values: readonly T[]): T[][] { + if (values.length === 0) return [[]]; + return values.flatMap((value, index) => + permutations([...values.slice(0, index), ...values.slice(index + 1)]).map((suffix) => + [value].concat(suffix), + ), + ); +} + +describe("agent timeline state", () => { + it("commits canonical items, range, and older availability as one synced state", () => { + initializeTestSession(); + const items: StreamItem[] = [ + { + kind: "assistant_message", + id: "canonical-row", + text: "canonical", + timestamp: new Date("2026-07-27T10:00:00.000Z"), + }, + ]; + + useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", { + items, + head: [], + range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 }, + older: "available", + synchronized: true, + acknowledgedClientMessageIds: [], + }); + + expect( + selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"), + ).toEqual({ + status: "synced", + items, + range: { epoch: "epoch-1", startSeq: 51, endSeq: 100 }, + older: "available", + }); + }); + + it("represents an empty authoritative timeline without inventing a range", () => { + initializeTestSession(); + useSessionStore.getState().applyAgentTimelineResponseState("test-server", "agent-1", { + items: [], + head: [], + range: null, + older: "none", + synchronized: true, + acknowledgedClientMessageIds: [], + }); + + expect( + selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"), + ).toEqual({ status: "synced", items: [], range: null, older: "none" }); + }); + + it("preserves older availability when an applied response leaves it unchanged", () => { + initializeTestSession(); + const store = useSessionStore.getState(); + store.applyAgentTimelineResponseState("test-server", "agent-1", { + items: [], + head: [], + range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 }, + older: "available", + synchronized: true, + acknowledgedClientMessageIds: [], + }); + + store.applyAgentTimelineResponseState("test-server", "agent-1", { + items: [], + head: [], + range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 }, + older: "unchanged", + synchronized: false, + acknowledgedClientMessageIds: [], + }); + + expect( + selectAgentTimelineState(useSessionStore.getState().sessions["test-server"], "agent-1"), + ).toEqual({ + status: "synced", + items: [], + range: { epoch: "epoch-1", startSeq: 200, endSeq: 240 }, + older: "available", + }); + }); + + it("stores turn liveness transitions without duplicating their policy", () => { + initializeTestSession(); + const store = useSessionStore.getState(); + store.applyAgentTurnLiveness("test-server", "agent-1", { + type: "snapshot", + activeTurn: { turnId: "turn-1", startedAt: null }, + }); + expect(store.getSession("test-server")?.agentTurnLiveness.get("agent-1")).toEqual({ + phase: "open", + turnId: "turn-1", + startedAt: null, + cancellationRequestId: null, + }); + + store.applyAgentTurnLiveness("test-server", "agent-1", { + type: "snapshot", + activeTurn: null, + }); + expect(store.getSession("test-server")?.agentTurnLiveness.has("agent-1")).toBe(false); + }); +}); + +describe("message submission ordering", () => { + const agentId = "agent-1"; + const clientMessageId = "client-1"; + const startedAt = new Date("2026-07-31T10:00:01.000Z"); + const steps = [ + "accept", + "canonical-row", + "stream-open", + "snapshot-idle", + "snapshot-open", + "stream-close", + ] as const; + + function applyStep(step: (typeof steps)[number]): void { + const store = useSessionStore.getState(); + if (step === "accept") { + store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId); + return; + } + if (step === "canonical-row") { + store.setAgentStreamState("test-server", agentId, { + acknowledgedClientMessageIds: [clientMessageId], + }); + return; + } + if (step === "stream-open") { + store.applyAgentTurnLiveness("test-server", agentId, { + type: "stream_open", + turn: { turnId: "turn-1", startedAt }, + }); + return; + } + if (step === "snapshot-open") { + store.applyAgentTurnLiveness("test-server", agentId, { + type: "snapshot", + activeTurn: { turnId: "turn-1", startedAt }, + }); + return; + } + if (step === "snapshot-idle") { + store.applyAgentTurnLiveness("test-server", agentId, { + type: "snapshot", + activeTurn: null, + }); + return; + } + store.applyAgentTurnLiveness("test-server", agentId, { + type: "stream_close", + turnId: "turn-1", + }); + } + + it("keeps every ordering active until its canonical row and settles at quiescence", () => { + const continuityViolations: string[] = []; + const settlementViolations: string[] = []; + + for (const ordering of permutations(steps)) { + useSessionStore.getState().clearSession("test-server"); + initializeTestSession(); + useSessionStore + .getState() + .beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId)); + let canonicalObserved = false; + + for (const step of ordering) { + applyStep(step); + canonicalObserved ||= step === "canonical-row"; + const presentation = selectAgentTurnPresentation( + useSessionStore.getState().sessions["test-server"], + agentId, + ); + if (!canonicalObserved && !presentation.isActive) { + continuityViolations.push(`${ordering.join(" → ")}: inactive after ${step}`); + } + } + + const store = useSessionStore.getState(); + store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId); + store.setAgentStreamState("test-server", agentId, { + acknowledgedClientMessageIds: [clientMessageId], + }); + store.applyAgentTurnLiveness("test-server", agentId, [ + { type: "stream_close", turnId: "turn-1" }, + { type: "snapshot", activeTurn: null }, + ]); + const settled = selectAgentTurnPresentation( + useSessionStore.getState().sessions["test-server"], + agentId, + ); + if (settled.isActive) settlementViolations.push(ordering.join(" → ")); + } + + expect({ continuityViolations, settlementViolations }).toEqual({ + continuityViolations: [], + settlementViolations: [], + }); + }); + + it("publishes the optimistic row and active presentation in one store notification", () => { + initializeTestSession(); + const notifications: Array<{ hasOptimisticRow: boolean; isActive: boolean }> = []; + const unsubscribe = useSessionStore.subscribe((state) => { + const session = state.sessions["test-server"]; + notifications.push({ + hasOptimisticRow: + hasSubmittedMessage(session?.agentStreamTail.get(agentId), clientMessageId) === true, + isActive: selectAgentTurnPresentation(session, agentId).isActive, + }); + }); + + useSessionStore + .getState() + .beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId)); + unsubscribe(); + + expect(notifications).toEqual([{ hasOptimisticRow: true, isActive: true }]); + }); + + it("keeps another unacknowledged submission active after one row is acknowledged", () => { + initializeTestSession(); + const store = useSessionStore.getState(); + store.beginAgentMessageSubmission("test-server", agentId, submittedMessage(clientMessageId)); + store.beginAgentMessageSubmission("test-server", agentId, submittedMessage("client-2")); + store.acceptAgentMessageSubmission("test-server", agentId, clientMessageId); + store.setAgentStreamState("test-server", agentId, { + acknowledgedClientMessageIds: [clientMessageId], + }); + + expect( + selectAgentTurnPresentation(useSessionStore.getState().sessions["test-server"], agentId) + .isActive, + ).toBe(true); + }); + + it("reconciles a created-agent row without creating a submission transaction", () => { + initializeTestSession(); + const message = submittedMessage(clientMessageId); + + const handedOff = useSessionStore + .getState() + .handoffCreatedAgentUserMessage("test-server", agentId, message); + + const session = useSessionStore.getState().sessions["test-server"]; + expect({ + handedOff, + tail: session?.agentStreamTail.get(agentId), + head: session?.agentStreamHead.get(agentId) ?? [], + submissions: session?.messageSubmissions.get(agentId) ?? [], + }).toEqual({ + handedOff: true, + tail: [message], + head: [], + submissions: [], + }); + }); +}); + describe("normalizeWorkspaceDescriptor", () => { it("normalizes workspace scripts and invalid activity timestamps", () => { const scripts = [ diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 5c9deefd1..2b4a0ad1b 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -5,10 +5,21 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import { + appendSubmittedUserMessage, handoffCreatedAgentUserMessageToStream, + removeSubmittedUserMessage, type StreamItem, type UserMessageItem, } from "@/types/stream"; +import { + acceptMessageSubmission, + beginMessageSubmission, + getActiveMessageSubmissions, + observeMessageSubmissionCanonical, + rejectMessageSubmission, + type MessageSubmissionRecord, + type MessageSubmissionRejectionOutcome, +} from "@/composer/submission/model"; import type { PendingPermission } from "@/types/shared"; import type { ComposerAttachment } from "@/attachments/types"; import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; @@ -41,6 +52,14 @@ import { buildWorkspaceAgentActivityIndex, type WorkspaceAgentActivity, } from "@/utils/workspace-agent-activity"; +import { + applyTurnLivenessTransition, + resolveTurnPresentation, + TURN_LIVENESS_IDLE, + type TurnLiveness, + type TurnPresentation, + type TurnLivenessTransition, +} from "@/timeline/turn-liveness"; // Re-export types that were in session-context export type MessageEntry = @@ -97,6 +116,7 @@ export interface Agent { id: string; provider: AgentProvider; status: AgentLifecycleStatus; + activeTurn: { turnId: string | null; startedAt: Date | null } | null; createdAt: Date; updatedAt: Date; lastUserMessageAt: Date | null; @@ -321,8 +341,6 @@ export interface AgentTimelineCursorState { export interface SessionReplicaTimeline { agentId: string; items: StreamItem[]; - cursor: AgentTimelineCursorState | null; - hasOlder: boolean; } export interface SessionReplica { @@ -332,6 +350,43 @@ export interface SessionReplica { timeline: SessionReplicaTimeline | null; } +export type AgentTimelineState = + | { status: "cold" } + | { status: "painted"; items: StreamItem[] } + | { + status: "synced"; + items: StreamItem[]; + range: AgentTimelineCursorState | null; + older: "available" | "none"; + }; + +export function selectAgentTimelineState( + session: SessionState | undefined, + agentId: string, +): AgentTimelineState { + if (!session) return { status: "cold" }; + const items = session.agentStreamTail.get(agentId) ?? []; + if (session.agentAuthoritativeHistoryApplied.get(agentId) === true) { + return { + status: "synced", + items, + range: session.agentTimelineCursor.get(agentId) ?? null, + older: session.agentTimelineHasOlder.get(agentId) === true ? "available" : "none", + }; + } + return items.length > 0 ? { status: "painted", items } : { status: "cold" }; +} + +export function selectAgentTurnPresentation( + session: SessionState | undefined, + agentId: string, +): TurnPresentation { + return resolveTurnPresentation( + session?.agentTurnLiveness.get(agentId) ?? TURN_LIVENESS_IDLE, + getActiveMessageSubmissions(session?.messageSubmissions.get(agentId)).length > 0, + ); +} + export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade"; // Per-session state @@ -364,6 +419,8 @@ export interface SessionState { // Stream state (head/tail model) agentStreamTail: Map; agentStreamHead: Map; + agentTurnLiveness: Map; + messageSubmissions: Map; agentTimelineCursor: Map; agentTimelineHasOlder: Map; agentTimelineOlderFetchInFlight: Map; @@ -454,8 +511,35 @@ interface SessionStoreActions { setAgentStreamState: ( serverId: string, agentId: string, - state: { tail?: StreamItem[]; head?: StreamItem[] }, + state: { + tail?: StreamItem[]; + head?: StreamItem[]; + acknowledgedClientMessageIds?: readonly string[]; + }, ) => void; + applyAgentTurnLiveness: ( + serverId: string, + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ) => void; + beginAgentCancellation: (serverId: string, agentId: string) => number; + settleAgentCancellation: (serverId: string, agentId: string, requestId: number) => void; + clearAgentTurnLiveness: (serverId: string) => void; + beginAgentMessageSubmission: ( + serverId: string, + agentId: string, + message: UserMessageItem, + ) => void; + acceptAgentMessageSubmission: ( + serverId: string, + agentId: string, + clientMessageId: string, + ) => void; + rejectAgentMessageSubmission: ( + serverId: string, + agentId: string, + clientMessageId: string, + ) => MessageSubmissionRejectionOutcome; handoffCreatedAgentUserMessage: ( serverId: string, agentId: string, @@ -483,6 +567,18 @@ interface SessionStoreActions { agentId: string, applied: boolean, ) => void; + applyAgentTimelineResponseState: ( + serverId: string, + agentId: string, + state: { + items: StreamItem[]; + head: StreamItem[]; + range: AgentTimelineCursorState | null; + older: "available" | "none" | "unchanged"; + synchronized: boolean; + acknowledgedClientMessageIds: string[]; + }, + ) => void; // Initializing agents setInitializingAgents: ( @@ -583,6 +679,8 @@ function createInitialSessionState( currentAssistantMessage: "", agentStreamTail: new Map(), agentStreamHead: new Map(), + agentTurnLiveness: new Map(), + messageSubmissions: new Map(), agentTimelineCursor: new Map(), agentTimelineHasOlder: new Map(), agentTimelineOlderFetchInFlight: new Map(), @@ -647,6 +745,7 @@ function isSessionServerInfoUnchanged(input: { export const useSessionStore = create()( subscribeWithSelector((set, get) => { + let nextCancellationRequestId = 0; const commitActivityUpdates: AgentLastActivityCommitter = (updates) => { set((prev) => { let nextActivity: Map | null = null; @@ -699,16 +798,8 @@ export const useSessionStore = create()( const session = createInitialSessionState(serverId, null); const timeline = replica.timeline; const agentStreamTail = new Map(); - const agentTimelineCursor = new Map(); - const agentTimelineHasOlder = new Map(); - const agentAuthoritativeHistoryApplied = new Map(); - const agentHistorySyncGeneration = new Map(); if (timeline) { agentStreamTail.set(timeline.agentId, timeline.items); - agentTimelineHasOlder.set(timeline.agentId, timeline.hasOlder); - agentAuthoritativeHistoryApplied.set(timeline.agentId, true); - agentHistorySyncGeneration.set(timeline.agentId, session.historySyncGeneration); - if (timeline.cursor) agentTimelineCursor.set(timeline.agentId, timeline.cursor); } const agentLastActivity = new Map(prev.agentLastActivity); for (const agent of replica.agents.values()) { @@ -725,10 +816,6 @@ export const useSessionStore = create()( workspaces: replica.workspaces, projects: replica.projects, agentStreamTail, - agentTimelineCursor, - agentTimelineHasOlder, - agentAuthoritativeHistoryApplied, - agentHistorySyncGeneration, }, }, agentLastActivity, @@ -1042,10 +1129,27 @@ export const useSessionStore = create()( } } - if (!changedTail && !changedHead) { + const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; + const observedSubmissions = observeMessageSubmissionCanonical( + currentSubmissions, + state.acknowledgedClientMessageIds ?? [], + ); + const changedSubmissions = observedSubmissions !== currentSubmissions; + + if (!changedTail && !changedHead && !changedSubmissions) { return prev; } + let messageSubmissions = session.messageSubmissions; + if (changedSubmissions) { + messageSubmissions = new Map(session.messageSubmissions); + if (observedSubmissions.length > 0) { + messageSubmissions.set(agentId, observedSubmissions); + } else { + messageSubmissions.delete(agentId); + } + } + return { ...prev, sessions: { @@ -1054,12 +1158,175 @@ export const useSessionStore = create()( ...session, agentStreamTail: nextTail, agentStreamHead: nextHead, + messageSubmissions, }, }, }; }); }, + applyAgentTurnLiveness: (serverId, agentId, transition) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + const agentTurnLiveness = applyTurnLivenessTransition( + session.agentTurnLiveness, + agentId, + transition, + ); + if (agentTurnLiveness === session.agentTurnLiveness) return prev; + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, agentTurnLiveness }, + }, + }; + }); + }, + + beginAgentCancellation: (serverId, agentId) => { + nextCancellationRequestId += 1; + const requestId = nextCancellationRequestId; + get().applyAgentTurnLiveness(serverId, agentId, { + type: "cancellation_started", + requestId, + }); + return requestId; + }, + + settleAgentCancellation: (serverId, agentId, requestId) => { + get().applyAgentTurnLiveness(serverId, agentId, { + type: "cancellation_settled", + requestId, + }); + }, + + clearAgentTurnLiveness: (serverId) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session || session.agentTurnLiveness.size === 0) return prev; + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, agentTurnLiveness: new Map() }, + }, + }; + }); + }, + + beginAgentMessageSubmission: (serverId, agentId, message) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + if (!message.clientMessageId) { + throw new Error("Beginning a message submission requires client identity"); + } + const currentTail = session.agentStreamTail.get(agentId) ?? []; + const currentHead = session.agentStreamHead.get(agentId) ?? []; + const stream = appendSubmittedUserMessage({ + tail: currentTail, + head: currentHead, + message, + }); + const submissions = beginMessageSubmission( + session.messageSubmissions.get(agentId) ?? [], + { clientMessageId: message.clientMessageId }, + ); + const messageSubmissions = new Map(session.messageSubmissions); + messageSubmissions.set(agentId, submissions); + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agentStreamTail: + stream.tail === currentTail + ? session.agentStreamTail + : new Map(session.agentStreamTail).set(agentId, stream.tail), + agentStreamHead: + stream.head === currentHead + ? session.agentStreamHead + : new Map(session.agentStreamHead).set(agentId, stream.head), + messageSubmissions, + }, + }, + }; + }); + }, + + acceptAgentMessageSubmission: (serverId, agentId, clientMessageId) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; + const submissions = acceptMessageSubmission(currentSubmissions, clientMessageId); + if (submissions === currentSubmissions) return prev; + const messageSubmissions = new Map(session.messageSubmissions); + if (submissions.length > 0) { + messageSubmissions.set(agentId, submissions); + } else { + messageSubmissions.delete(agentId); + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, messageSubmissions }, + }, + }; + }); + }, + + rejectAgentMessageSubmission: (serverId, agentId, clientMessageId) => { + let outcome: MessageSubmissionRejectionOutcome = "unknown"; + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + const currentTail = session.agentStreamTail.get(agentId) ?? []; + const currentHead = session.agentStreamHead.get(agentId) ?? []; + const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; + const result = rejectMessageSubmission(currentSubmissions, clientMessageId); + outcome = result.outcome; + if (outcome === "unknown") return prev; + const stream = + outcome === "rejected" + ? removeSubmittedUserMessage({ + tail: currentTail, + head: currentHead, + clientMessageId, + }) + : { tail: currentTail, head: currentHead }; + const messageSubmissions = new Map(session.messageSubmissions); + if (result.submissions.length > 0) { + messageSubmissions.set(agentId, result.submissions); + } else { + messageSubmissions.delete(agentId); + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agentStreamTail: + stream.tail === currentTail + ? session.agentStreamTail + : new Map(session.agentStreamTail).set(agentId, stream.tail), + agentStreamHead: + stream.head === currentHead + ? session.agentStreamHead + : new Map(session.agentStreamHead).set(agentId, stream.head), + messageSubmissions, + }, + }, + }; + }); + return outcome; + }, + handoffCreatedAgentUserMessage: (serverId, agentId, message) => { let didHandoff = false; set((prev) => { @@ -1264,6 +1531,63 @@ export const useSessionStore = create()( }); }, + applyAgentTimelineResponseState: (serverId, agentId, state) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session) return prev; + + const nextTail = new Map(session.agentStreamTail); + nextTail.set(agentId, state.items); + const nextHead = new Map(session.agentStreamHead); + if (state.head.length > 0) nextHead.set(agentId, state.head); + else nextHead.delete(agentId); + const nextCursor = new Map(session.agentTimelineCursor); + if (state.range) nextCursor.set(agentId, state.range); + else nextCursor.delete(agentId); + const nextHasOlder = new Map(session.agentTimelineHasOlder); + if (state.older !== "unchanged") { + nextHasOlder.set(agentId, state.older === "available"); + } + const nextAuthoritative = new Map(session.agentAuthoritativeHistoryApplied); + const nextSyncGeneration = new Map(session.agentHistorySyncGeneration); + const currentSubmissions = session.messageSubmissions.get(agentId) ?? []; + const observedSubmissions = observeMessageSubmissionCanonical( + currentSubmissions, + state.acknowledgedClientMessageIds, + ); + let messageSubmissions = session.messageSubmissions; + if (observedSubmissions !== currentSubmissions) { + messageSubmissions = new Map(session.messageSubmissions); + if (observedSubmissions.length > 0) { + messageSubmissions.set(agentId, observedSubmissions); + } else { + messageSubmissions.delete(agentId); + } + } + if (state.synchronized) { + nextAuthoritative.set(agentId, true); + nextSyncGeneration.set(agentId, session.historySyncGeneration); + } + + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + agentStreamTail: nextTail, + agentStreamHead: nextHead, + agentTimelineCursor: nextCursor, + agentTimelineHasOlder: nextHasOlder, + agentAuthoritativeHistoryApplied: nextAuthoritative, + agentHistorySyncGeneration: nextSyncGeneration, + messageSubmissions, + }, + }, + }; + }); + }, + // Initializing agents setInitializingAgents: (serverId, state) => { set((prev) => { diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 1559359f0..4dbaac712 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -36,6 +36,7 @@ const AGENT_DEFAULTS: Agent = { id: "agent", provider: "codex", status: "idle", + activeTurn: null, createdAt: AGENT_TIMESTAMP, updatedAt: AGENT_TIMESTAMP, lastUserMessageAt: null, diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index ae110167a..6d5b38e65 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -13,6 +13,7 @@ const AGENT_DEFAULTS: Agent = { id: "agent", provider: "codex", status: "idle", + activeTurn: null, createdAt: AGENT_TIMESTAMP, updatedAt: AGENT_TIMESTAMP, lastUserMessageAt: null, diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 491da6763..dc0505f28 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from "vitest"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import { - buildOptimisticUserMessage, + createUserMessage, hydrateStreamState, type AgentToolCallItem, type StreamItem, } from "@/types/stream"; import { createAgentStreamReducerQueue, + deriveAgentStreamTurnLiveness, processTimelineResponse, processAgentStreamEvent, processAgentStreamEvents, @@ -119,17 +120,83 @@ function makeAssistantItem( }; } -function makeOptimisticUserMessage( +function makeSubmittedUserMessage( text: string, - id = `optimistic-${text.length}`, + id = `submitted-${text.length}`, ): Extract { - return buildOptimisticUserMessage({ - id, + return createUserMessage({ + clientMessageId: id, text, timestamp: new Date(1000), }); } +describe("deriveAgentStreamTurnLiveness", () => { + it("tracks start through every terminal turn event", () => { + const started = makeStreamReducerEvent( + { type: "turn_started", provider: "claude", turnId: "turn-1" }, + 1, + ); + expect(deriveAgentStreamTurnLiveness([started])).toEqual([ + { + type: "stream_open", + turn: { turnId: "turn-1", startedAt: started.timestamp }, + }, + ]); + for (const event of [ + { type: "turn_completed", provider: "claude" }, + { type: "turn_failed", provider: "claude", error: "failed" }, + { type: "turn_canceled", provider: "claude", reason: "canceled" }, + ] as AgentStreamEventPayload[]) { + expect( + deriveAgentStreamTurnLiveness([ + started, + makeStreamReducerEvent({ ...event, turnId: "turn-1" } as AgentStreamEventPayload, 2), + ]), + ).toEqual([ + { type: "stream_open", turn: { turnId: "turn-1", startedAt: started.timestamp } }, + { type: "stream_close", turnId: "turn-1" }, + ]); + } + }); + + it("keeps an open turn without stream lifecycle and closes it on an ordered terminal event", () => { + expect( + deriveAgentStreamTurnLiveness([ + makeStreamReducerEvent(makeAssistantTimelineEvent("still working"), 1), + ]), + ).toEqual([]); + + expect( + deriveAgentStreamTurnLiveness([ + makeStreamReducerEvent( + { type: "turn_completed", provider: "claude" } as AgentStreamEventPayload, + 2, + ), + ]), + ).toEqual([{ type: "stream_close", turnId: null }]); + }); + + it("restarts timing when a terminal event and the next start share one reducer batch", () => { + const completed = makeStreamReducerEvent( + { type: "turn_completed", provider: "claude", turnId: "turn-1" }, + 2, + ); + const restarted = makeStreamReducerEvent( + { type: "turn_started", provider: "claude", turnId: "turn-2" }, + 3, + ); + + expect(deriveAgentStreamTurnLiveness([completed, restarted])).toEqual([ + { type: "stream_close", turnId: "turn-1" }, + { + type: "stream_open", + turn: { turnId: "turn-2", startedAt: restarted.timestamp }, + }, + ]); + }); +}); + function getAssistantTexts(items: StreamItem[]): string[] { return items .filter((item): item is Extract => { @@ -159,6 +226,7 @@ const baseTimelineInput: ProcessTimelineResponseInput = { direction: "after", reset: false, epoch: "epoch-1", + window: { minSeq: 1, maxSeq: 0, nextSeq: 1 }, startCursor: null, endCursor: null, entries: [], @@ -172,6 +240,7 @@ const baseTimelineInput: ProcessTimelineResponseInput = { isInitializing: false, hasActiveInitDeferred: false, initRequestDirection: "tail", + sendingClientMessageIds: [], }; const baseStreamInput: ProcessAgentStreamEventInput = { @@ -181,7 +250,6 @@ const baseStreamInput: ProcessAgentStreamEventInput = { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, timestamp: new Date(2000), }; @@ -190,6 +258,164 @@ const baseStreamInput: ProcessAgentStreamEventInput = { // --------------------------------------------------------------------------- describe("processTimelineResponse", () => { + it("discards an unchanged resume tail without replacing timeline state", () => { + const canonical = createUserMessage({ + id: "canonical-prompt", + clientMessageId: "client-message", + messageId: "provider-message", + text: "local prompt", + timestamp: new Date(1000), + timelineCursor: { epoch: "epoch-1", seq: 1 }, + }); + const currentTail = [canonical, makeAssistantItem("existing tail", "existing-tail")]; + const currentHead = [makeAssistantItem("existing head", "existing-head")]; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail, + currentHead, + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + window: { minSeq: 1, maxSeq: 40, nextSeq: 41 }, + startCursor: { seq: 1 }, + endCursor: { seq: 40 }, + entries: [ + { + ...makeTimelineEntry(1, "provider prompt", "user_message"), + item: { + type: "user_message", + text: "provider prompt", + messageId: "provider-message", + clientMessageId: "client-message", + }, + }, + makeTimelineEntry(2, "existing tail", "assistant_message", 40), + ], + }, + }); + + expect(result.commit).toBe("discard"); + expect(result.tail).toBe(currentTail); + expect(result.head).toBe(currentHead); + expect(result.cursorChanged).toBe(false); + expect(result.acknowledgedClientMessageIds).toEqual([]); + expect(result.sideEffects).toEqual([{ type: "flush_pending_updates" }]); + }); + + it("atomically replaces history when a resume tail leaves a middle gap", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [ + { + kind: "user_message", + id: "stale-history", + text: "stale history", + timestamp: new Date(1040), + timelineCursor: { epoch: "epoch-1", seq: 40 }, + }, + ], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + window: { minSeq: 1, maxSeq: 240, nextSeq: 241 }, + startCursor: { seq: 200 }, + endCursor: { seq: 240 }, + hasOlder: true, + entries: [ + makeTimelineEntry(200, "latest 200", "user_message"), + makeTimelineEntry(240, "latest 240", "user_message"), + ], + }, + }); + + expect(result.commit).toBe("apply"); + expect(getUserTexts(result.tail)).toEqual(["latest 200", "latest 240"]); + expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 200, endSeq: 240 }); + expect(result.sideEffects).toEqual([{ type: "flush_pending_updates" }]); + }); + + it("reconciles in-flight live rows and an unresolved submission during gap replacement", () => { + const unresolved = makeSubmittedUserMessage("unresolved prompt", "client-unresolved"); + const coveredLive: StreamItem = { + ...makeAssistantItem("covered live", "covered-live"), + timelineCursor: { epoch: "epoch-1", seq: 235 }, + }; + const newerLive: StreamItem = { + ...makeAssistantItem("newer live", "newer-live"), + timelineCursor: { epoch: "epoch-1", seq: 245 }, + }; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [makeAssistantItem("stale history", "stale-history"), unresolved], + currentHead: [coveredLive, newerLive], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + window: { minSeq: 1, maxSeq: 240, nextSeq: 241 }, + startCursor: { seq: 200 }, + endCursor: { seq: 240 }, + hasOlder: true, + entries: [makeTimelineEntry(240, "canonical tail")], + }, + }); + + expect(getAssistantTexts(result.tail)).toEqual(["canonical tail"]); + expect(getUserTexts(result.tail)).toEqual(["unresolved prompt"]); + expect(result.head).toEqual([newerLive]); + }); + + it("replaces destructively when a resume tail has a new epoch", () => { + const acknowledged: StreamItem = { + ...makeSubmittedUserMessage("acknowledged old prompt", "client-acknowledged"), + messageId: "provider-acknowledged", + }; + const sending = makeSubmittedUserMessage("still sending", "client-sending"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [acknowledged, sending], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, + sendingClientMessageIds: ["client-sending"], + payload: { + ...baseTimelineInput.payload, + direction: "tail", + epoch: "epoch-2", + window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, + startCursor: { seq: 1 }, + endCursor: { seq: 1 }, + entries: [makeTimelineEntry(1, "new epoch")], + }, + }); + + expect(getAssistantTexts(result.tail)).toEqual(["new epoch"]); + expect(getUserTexts(result.tail)).toEqual(["still sending"]); + expect(result.cursor).toEqual({ epoch: "epoch-2", startSeq: 1, endSeq: 1 }); + }); + + it("replaces destructively when the same epoch rewinds behind the local cursor", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [makeAssistantItem("future history", "future-history")], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 40 }, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + window: { minSeq: 1, maxSeq: 10, nextSeq: 11 }, + startCursor: { seq: 1 }, + endCursor: { seq: 10 }, + entries: [makeTimelineEntry(10, "rewound history")], + }, + }); + + expect(getAssistantTexts(result.tail)).toEqual(["rewound history"]); + expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 1, endSeq: 10 }); + }); + it("preserves the canonical end cursor on a projected assistant message", () => { const result = processTimelineResponse({ ...baseTimelineInput, @@ -293,6 +519,501 @@ describe("processTimelineResponse", () => { expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true); }); + it("keeps a live assistant and submitted head prompt in one lane during replacement", () => { + const submitted = makeSubmittedUserMessage("New prompt", "client-new-prompt"); + const liveAssistant = { + ...makeAssistantItem("Live answer", "answer-1"), + messageId: "answer-1", + }; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [], + currentHead: [liveAssistant, submitted], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: [], + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 1 }, + entries: [ + { + ...makeTimelineEntry(1, "Live", "assistant_message"), + item: { + type: "assistant_message", + text: "Live", + messageId: "answer-1", + }, + }, + ], + }, + }); + + expect(result.tail).toEqual([]); + expect(result.head).toEqual([{ ...liveAssistant, text: "Live" }, submitted]); + }); + + it("preserves newer live head items when canonical replacement ends in a tool call", () => { + const liveThought: StreamItem = { + kind: "thought", + id: "live-thought", + text: "newer reasoning", + timestamp: new Date(3000), + status: "loading", + }; + const liveAssistant = makeAssistantItem("newer answer", "live-answer"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: [liveThought, liveAssistant], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 1 }, + entries: [ + makeToolCallTimelineEntry(1, "canonical-call", "completed", { + type: "read", + filePath: "/tmp/older.ts", + }), + ], + }, + }); + + expect(result.tail.map((item) => item.kind)).toEqual(["tool_call"]); + expect(result.head).toEqual([liveThought, liveAssistant]); + }); + + it("keeps a newer live tool completion when bootstrap contains the running call", () => { + const callId = "toolu_live_completion"; + const liveCompletion = hydrateStreamState( + [ + { + event: { + type: "timeline", + provider: "claude", + item: makeToolCallTimelineEntry(2, callId, "completed", { + type: "read", + filePath: "/tmp/example.ts", + }).item, + } as AgentStreamEventPayload, + timestamp: new Date(2000), + timelineCursor: { epoch: "epoch-1", seq: 2 }, + }, + ], + { source: "canonical" }, + ); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: liveCompletion, + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + reset: false, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 1 }, + entries: [ + makeToolCallTimelineEntry(1, callId, "running", { + type: "unknown", + input: { file_path: "/tmp/example.ts" }, + output: null, + }), + ], + }, + }); + + expect(getAgentToolCalls([...result.tail, ...result.head])).toEqual([ + expect.objectContaining({ + timelineCursor: { epoch: "epoch-1", seq: 2 }, + payload: expect.objectContaining({ + data: expect.objectContaining({ callId, status: "completed" }), + }), + }), + ]); + }); + + it("keeps one newer todo state when bootstrap contains its older state", () => { + const liveTodo = hydrateStreamState( + [ + { + event: { + type: "timeline", + provider: "codex", + item: { + type: "todo", + items: [{ text: "Verify hydration", completed: true }], + }, + } as AgentStreamEventPayload, + timestamp: new Date(2000), + timelineCursor: { epoch: "epoch-1", seq: 2 }, + }, + ], + { source: "canonical" }, + ); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: liveTodo, + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + reset: false, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 1 }, + entries: [ + { + seqStart: 1, + seqEnd: 1, + provider: "codex", + item: { + type: "todo", + items: [{ text: "Verify hydration", completed: false }], + }, + timestamp: new Date(1000).toISOString(), + }, + ], + }, + }); + + expect([...result.tail, ...result.head].filter((item) => item.kind === "todo_list")).toEqual([ + expect.objectContaining({ + timelineCursor: { epoch: "epoch-1", seq: 2 }, + items: [{ text: "Verify hydration", completed: true }], + }), + ]); + }); + + it("keeps one completed compaction when bootstrap contains its loading state", () => { + const liveCompaction = hydrateStreamState( + [ + { + event: { + type: "timeline", + provider: "codex", + item: { type: "compaction", status: "completed", trigger: "auto", preTokens: 1200 }, + } as AgentStreamEventPayload, + timestamp: new Date(2000), + timelineCursor: { epoch: "epoch-1", seq: 2 }, + }, + ], + { source: "canonical" }, + ); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: liveCompaction, + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + reset: false, + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 1 }, + entries: [ + { + seqStart: 1, + seqEnd: 1, + provider: "codex", + item: { type: "compaction", status: "loading", trigger: "auto" }, + timestamp: new Date(1000).toISOString(), + }, + ], + }, + }); + + expect([...result.tail, ...result.head].filter((item) => item.kind === "compaction")).toEqual([ + expect.objectContaining({ + timelineCursor: { epoch: "epoch-1", seq: 2 }, + status: "completed", + trigger: "auto", + preTokens: 1200, + }), + ]); + }); + + it("replaces a painted replica with an empty authoritative timeline", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [makeAssistantItem("painted replica")], + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + startCursor: null, + endCursor: null, + entries: [], + hasOlder: false, + }, + }); + + expect(result.tail).toEqual([]); + expect(result.cursor).toBeNull(); + }); + + it("keeps a newer live assistant continuation when bootstrap ends on the same message", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [makeAssistantItem("painted replica")], + currentHead: [ + { + kind: "assistant_message", + id: "assistant-live", + messageId: "assistant-live", + text: " continuation", + timestamp: new Date(2001), + timelineCursor: { epoch: "epoch-1", seq: 101 }, + }, + ], + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + startCursor: { seq: 61 }, + endCursor: { seq: 100 }, + entries: [ + { + ...makeTimelineEntry(100, "canonical prefix"), + item: { + type: "assistant_message", + text: "canonical prefix", + messageId: "assistant-live", + }, + }, + ], + }, + }); + + const assistants = [...result.tail, ...result.head].filter( + (item) => item.kind === "assistant_message", + ); + expect(assistants).toHaveLength(1); + expect(assistants[0]?.text).toBe("canonical prefix continuation"); + expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101 }); + }); + + it("keeps assistant segments separate when a live tool sits between them", () => { + const messageId = "assistant-with-tool"; + const liveTool = hydrateStreamState( + [ + { + event: { + type: "timeline", + provider: "claude", + item: makeToolCallTimelineEntry(101, "tool-between-segments", "completed", { + type: "read", + filePath: "/tmp/example.ts", + }).item, + } as AgentStreamEventPayload, + timestamp: new Date(2101), + timelineCursor: { epoch: "epoch-1", seq: 101 }, + }, + { + event: makeAssistantTimelineEvent("after tool", messageId), + timestamp: new Date(2102), + timelineCursor: { epoch: "epoch-1", seq: 102 }, + }, + ], + { source: "canonical" }, + ); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [makeAssistantItem("painted replica")], + currentHead: liveTool, + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + startCursor: { seq: 61 }, + endCursor: { seq: 100 }, + entries: [ + { + ...makeTimelineEntry(100, "before tool"), + item: { + type: "assistant_message", + text: "before tool", + messageId, + }, + }, + ], + }, + }); + + expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([ + "assistant_message", + "tool_call", + "assistant_message", + ]); + expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ + "before tool", + "after tool", + ]); + }); + + it("keeps a newer live assistant continuation without a provider message ID", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [makeAssistantItem("painted replica")], + currentHead: [ + { + kind: "assistant_message", + id: "assistant-live", + text: "canonical prefix continuation", + timestamp: new Date(2001), + timelineCursor: { epoch: "epoch-1", seq: 101 }, + }, + ], + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + startCursor: { seq: 61 }, + endCursor: { seq: 100 }, + entries: [makeTimelineEntry(100, "canonical prefix")], + }, + }); + + const assistants = [...result.tail, ...result.head].filter( + (item) => item.kind === "assistant_message", + ); + expect(assistants).toHaveLength(1); + expect(assistants[0]?.text).toBe("canonical prefix continuation"); + expect(assistants[0]?.timelineCursor).toEqual({ epoch: "epoch-1", seq: 101 }); + }); + + it("keeps a canonical live user row newer than the bootstrap page", () => { + const live = processAgentStreamEvent({ + ...baseStreamInput, + event: { + type: "timeline", + provider: "claude", + item: { + type: "user_message", + text: "remote live prompt", + clientMessageId: "remote-client-message", + }, + }, + seq: 101, + epoch: "epoch-1", + currentTail: [makeAssistantItem("painted replica")], + hasAuthoritativeBaseline: false, + }); + expect(live.head[0]).toMatchObject({ + kind: "user_message", + timelineCursor: { epoch: "epoch-1", seq: 101 }, + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: live.tail, + currentHead: live.head, + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + startCursor: { seq: 61 }, + endCursor: { seq: 100 }, + entries: [makeTimelineEntry(100, "canonical answer")], + }, + }); + + expect(getUserTexts(result.head)).toEqual(["remote live prompt"]); + }); + + it("keeps a provider-acknowledged painted-tail prompt newer than the bootstrap page", () => { + const submitted = makeSubmittedUserMessage("submitted before bootstrap", "client-message-1"); + const live = processAgentStreamEvent({ + ...baseStreamInput, + event: { + type: "timeline", + provider: "claude", + item: { + type: "user_message", + text: "canonical presentation", + clientMessageId: "client-message-1", + messageId: "provider-message-1", + }, + }, + seq: 51, + epoch: "epoch-1", + currentTail: [makeAssistantItem("painted replica"), submitted], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: live.tail, + currentHead: live.head, + sendingClientMessageIds: [], + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + startCursor: { seq: 11 }, + endCursor: { seq: 50 }, + entries: [makeTimelineEntry(50, "canonical answer")], + }, + }); + + expect(getUserTexts([...result.tail, ...result.head])).toEqual(["submitted before bootstrap"]); + expect(result.head[0]).toMatchObject({ + kind: "user_message", + clientMessageId: "client-message-1", + messageId: "provider-message-1", + timelineCursor: { epoch: "epoch-1", seq: 51 }, + }); + }); + + it("does not duplicate a live row already covered by the bootstrap page", () => { + const live = processAgentStreamEvent({ + ...baseStreamInput, + event: makeTimelineEvent("thinking", "reasoning"), + seq: 100, + epoch: "epoch-1", + currentTail: [makeAssistantItem("painted replica")], + hasAuthoritativeBaseline: false, + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: live.tail, + currentHead: live.head, + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + startCursor: { seq: 61 }, + endCursor: { seq: 100 }, + entries: [makeTimelineEntry(100, "thinking", "reasoning")], + }, + }); + + expect([...result.tail, ...result.head].filter((item) => item.kind === "thought")).toHaveLength( + 1, + ); + }); + it("uses the timeline entry timestamp as canonical", () => { const result = processTimelineResponse({ ...baseTimelineInput, @@ -321,12 +1042,12 @@ describe("processTimelineResponse", () => { expect(assistant?.timestamp.toISOString()).toBe("2025-01-01T12:00:04.000Z"); }); - it("reconciles an optimistic user message during tail replacement", () => { + it("reconciles a submitted user message during tail replacement", () => { const image = { - id: "optimistic-image", + id: "submitted-image", mimeType: "image/png", storageType: "web-indexeddb" as const, - storageKey: "optimistic-image", + storageKey: "submitted-image", createdAt: 1000, }; const attachment = { @@ -335,8 +1056,8 @@ describe("processTimelineResponse", () => { text: "attached context", title: "context.txt", }; - const optimistic = buildOptimisticUserMessage({ - id: "optimistic-create-user", + const submitted = createUserMessage({ + clientMessageId: "submitted-create-user", text: "Analyze this", timestamp: new Date(1000), images: [image], @@ -345,7 +1066,7 @@ describe("processTimelineResponse", () => { const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [optimistic], + currentTail: [submitted], payload: { ...baseTimelineInput.payload, reset: true, @@ -358,6 +1079,7 @@ describe("processTimelineResponse", () => { type: "user_message", text: "server-rendered attachment text", messageId: "canonical-create-user", + clientMessageId: "submitted-create-user", }, }, ], @@ -367,13 +1089,14 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toHaveLength(1); expect(userMessages[0]).toMatchObject({ - id: "canonical-create-user", + id: "submitted-create-user", + clientMessageId: "submitted-create-user", + messageId: "canonical-create-user", text: "Analyze this", timestamp: new Date(1000), images: [image], attachments: [attachment], }); - expect(userMessages[0]?.optimistic).toBeUndefined(); const repeated = processTimelineResponse({ ...baseTimelineInput, @@ -390,6 +1113,7 @@ describe("processTimelineResponse", () => { type: "user_message", text: "server-rendered attachment text", messageId: "canonical-create-user", + clientMessageId: "submitted-create-user", }, }, ], @@ -399,24 +1123,171 @@ describe("processTimelineResponse", () => { expect(repeated.tail.filter((item) => item.kind === "user_message")).toEqual(userMessages); }); - it("keeps an unmatched optimistic user message during tail replacement", () => { - const optimistic = makeOptimisticUserMessage("still sending", "optimistic-unmatched"); + it("keeps an unmatched submitted user message during tail replacement", () => { + const submitted = makeSubmittedUserMessage("still sending", "submitted-unmatched"); const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [optimistic], + currentTail: [submitted], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: ["submitted-unmatched"], payload: { ...baseTimelineInput.payload, reset: true, + epoch: "epoch-2", entries: [], }, }); - expect(result.tail).toEqual([optimistic]); + expect(result.tail).toEqual([submitted]); }); - it("does not move an unmatched submission during timeline replacement", () => { - const unmatched = makeOptimisticUserMessage("first submission", "client-first"); + it("keeps every unresolved submission during replacement", () => { + const first = makeSubmittedUserMessage("first pending", "client-first"); + const second = makeSubmittedUserMessage("second pending", "client-second"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [first, second], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: ["client-first", "client-second"], + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-2", + entries: [], + }, + }); + + expect(result.tail).toEqual([first, second]); + }); + + it("keeps an unreconciled local presentation through same-epoch replacement", () => { + const image = { + id: "local-image", + mimeType: "image/png", + storageType: "web-indexeddb" as const, + storageKey: "local-image", + createdAt: 1000, + }; + const attachment = { + type: "text" as const, + mimeType: "text/plain" as const, + text: "attached context", + title: "context.txt", + }; + const local = createUserMessage({ + clientMessageId: "client-local-only", + text: "provider may not echo this", + timestamp: new Date(1000), + images: [image], + attachments: [attachment], + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [local], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: [], + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-1", + startCursor: { seq: 2 }, + endCursor: { seq: 2 }, + entries: [makeTimelineEntry(2, "another client's response")], + }, + }); + + expect(result.tail).toEqual([ + expect.objectContaining({ + kind: "assistant_message", + text: "another client's response", + }), + local, + ]); + }); + + it("keeps an unreconciled local presentation during non-reset bootstrap", () => { + const local = makeSubmittedUserMessage("first prompt", "client-first-prompt"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [local], + isInitializing: true, + hasActiveInitDeferred: true, + payload: { + ...baseTimelineInput.payload, + direction: "tail", + }, + }); + + expect(result.tail).toEqual([local]); + }); + + it("drops an unreconciled local row omitted by a known epoch change", () => { + const local = createUserMessage({ + clientMessageId: "client-prior-epoch", + text: "prior prompt", + timestamp: new Date(1000), + }); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [local], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: [], + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-2", + entries: [], + }, + }); + + expect(result.tail).toEqual([]); + }); + + it("drops an unreconciled head row omitted by a known epoch change", () => { + const local = makeSubmittedUserMessage("prior prompt", "client-prior-epoch"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: [local], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: [], + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-2", + entries: [], + }, + }); + + expect(result.head).toEqual([]); + }); + + it("keeps an active head submission across a known epoch change", () => { + const local = makeSubmittedUserMessage("pending prompt", "client-pending"); + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentHead: [local], + currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, + sendingClientMessageIds: ["client-pending"], + payload: { + ...baseTimelineInput.payload, + reset: true, + epoch: "epoch-2", + entries: [], + }, + }); + + expect(result.head).toEqual([local]); + }); + + it("keeps an unmatched submission after the canonical replacement range", () => { + const unmatched = makeSubmittedUserMessage("first submission", "client-first"); const acknowledged: StreamItem[] = [ { kind: "user_message", @@ -437,6 +1308,7 @@ describe("processTimelineResponse", () => { const result = processTimelineResponse({ ...baseTimelineInput, currentTail: [unmatched, ...acknowledged], + sendingClientMessageIds: ["client-first"], payload: { ...baseTimelineInput.payload, reset: true, @@ -462,10 +1334,10 @@ describe("processTimelineResponse", () => { }, }, { - ...makeTimelineEntry(4, "response to all three submissions"), + ...makeTimelineEntry(4, "response to canonical submissions"), item: { type: "assistant_message", - text: "response to all three submissions", + text: "response to canonical submissions", messageId: "assistant-response", }, }, @@ -480,14 +1352,14 @@ describe("processTimelineResponse", () => { text: "text" in item ? item.text : undefined, })), ).toEqual([ - { kind: "user_message", id: "client-first", text: "first submission" }, { kind: "user_message", id: "provider-second", text: "second submission" }, { kind: "user_message", id: "provider-third", text: "third submission" }, { kind: "assistant_message", id: "assistant-response", - text: "response to all three submissions", + text: "response to canonical submissions", }, + { kind: "user_message", id: "client-first", text: "first submission" }, ]); }); @@ -620,11 +1492,11 @@ describe("processTimelineResponse", () => { startSeq: 1, endSeq: 1, }; - const optimistic = makeOptimisticUserMessage("sent while catching up", "optimistic-after"); + const submitted = makeSubmittedUserMessage("sent while catching up", "submitted-after"); const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [optimistic], + currentTail: [submitted], currentCursor: existingCursor, payload: { ...baseTimelineInput.payload, @@ -644,16 +1516,19 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toHaveLength(1); - expect(userMessages[0]?.id).toBe("canonical-after"); - expect(userMessages[0]?.optimistic).toBeUndefined(); + expect(userMessages[0]).toMatchObject({ + id: "submitted-after", + clientMessageId: "submitted-after", + messageId: "canonical-after", + }); }); - it("reconciles an optimistic user message by client message id", () => { - const optimistic = makeOptimisticUserMessage("local presentation", "client-message"); + it("reconciles a submitted user message by client message id", () => { + const submitted = makeSubmittedUserMessage("local presentation", "client-message"); const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [optimistic], + currentTail: [submitted], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, payload: { ...baseTimelineInput.payload, @@ -675,20 +1550,59 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toEqual([ expect.objectContaining({ - id: "provider-message", + id: "client-message", clientMessageId: "client-message", + messageId: "provider-message", text: "local presentation", }), ]); - expect(userMessages[0]?.optimistic).toBeUndefined(); + expect(result.acknowledgedClientMessageIds).toEqual(["client-message"]); }); - it("reconciles multiple optimistic user messages in canonical order", () => { + it("acknowledges a submitted prompt from an otherwise unchanged tail snapshot", () => { + const clientMessageId = "client-message"; + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail: [makeSubmittedUserMessage("local presentation", clientMessageId)], + currentCursor: { epoch: "epoch-1", startSeq: 2, endSeq: 4 }, + sendingClientMessageIds: [clientMessageId], + payload: { + ...baseTimelineInput.payload, + direction: "tail", + window: { minSeq: 1, maxSeq: 4, nextSeq: 5 }, + startCursor: { seq: 1 }, + endCursor: { seq: 4 }, + entries: [ + { + ...makeTimelineEntry(1, "provider presentation", "user_message"), + item: { + type: "user_message", + text: "provider presentation", + messageId: clientMessageId, + clientMessageId, + }, + }, + { + ...makeTimelineEntry(2, "assistant response", "assistant_message", 4), + item: { + type: "assistant_message", + text: "assistant response", + messageId: "assistant-message", + }, + }, + ], + }, + }); + + expect(result.acknowledgedClientMessageIds).toEqual([clientMessageId]); + }); + + it("reconciles multiple submitted user messages in canonical order", () => { const result = processTimelineResponse({ ...baseTimelineInput, currentTail: [ - makeOptimisticUserMessage("first prompt", "optimistic-first"), - makeOptimisticUserMessage("second prompt", "optimistic-second"), + makeSubmittedUserMessage("first prompt", "submitted-first"), + makeSubmittedUserMessage("second prompt", "submitted-second"), ], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, payload: { @@ -699,14 +1613,14 @@ describe("processTimelineResponse", () => { entries: [ { ...makeTimelineEntry(2, "first prompt", "user_message"), - item: { type: "user_message", text: "first prompt", messageId: "optimistic-first" }, + item: { type: "user_message", text: "first prompt", messageId: "submitted-first" }, }, { ...makeTimelineEntry(3, "second prompt", "user_message"), item: { type: "user_message", text: "second prompt", - messageId: "optimistic-second", + messageId: "submitted-second", }, }, ], @@ -716,15 +1630,15 @@ describe("processTimelineResponse", () => { expect( result.tail .filter((item) => item.kind === "user_message") - .map((item) => ({ id: item.id, text: item.text, optimistic: item.optimistic })), + .map((item) => ({ id: item.id, text: item.text, messageId: item.messageId })), ).toEqual([ - { id: "optimistic-first", text: "first prompt", optimistic: undefined }, - { id: "optimistic-second", text: "second prompt", optimistic: undefined }, + { id: "submitted-first", text: "first prompt", messageId: "submitted-first" }, + { id: "submitted-second", text: "second prompt", messageId: "submitted-second" }, ]); }); - it("keeps a tail optimistic prompt before a reconciled live assistant head", () => { - const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt"); + it("keeps a tail submitted prompt before a reconciled live assistant head", () => { + const prompt = makeSubmittedUserMessage("new prompt", "submitted-new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -763,8 +1677,8 @@ describe("processTimelineResponse", () => { ).toEqual(["new prompt", "Hello"]); }); - it("keeps a tail optimistic prompt before a live head flushed by catch-up", () => { - const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt"); + it("keeps a tail submitted prompt before a live head flushed by catch-up", () => { + const prompt = makeSubmittedUserMessage("new prompt", "submitted-new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -835,7 +1749,6 @@ describe("processTimelineResponse", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); const result = processTimelineResponse({ @@ -913,7 +1826,6 @@ describe("processTimelineResponse", () => { currentTail: [], currentHead: [], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - currentAgent: null, }); expect(getAssistantTexts(live.tail)).toHaveLength(1); expect(getAssistantTexts(live.head)).toHaveLength(1); @@ -961,7 +1873,6 @@ describe("processTimelineResponse", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); const result = processTimelineResponse({ @@ -1000,7 +1911,7 @@ describe("processTimelineResponse", () => { }); it("does not move a submitted prompt when catch-up history arrives", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1024,7 +1935,7 @@ describe("processTimelineResponse", () => { }); it("does not move an unmatched head prompt when catch-up history arrives", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1052,8 +1963,8 @@ describe("processTimelineResponse", () => { ]); }); - it("acknowledges a head prompt in place while catch-up history arrives", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + it("moves an acknowledged head prompt to its catch-up sequence position", () => { + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1084,18 +1995,18 @@ describe("processTimelineResponse", () => { expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([ "assistant_message", - "user_message", "tool_call", + "user_message", ]); expect( [...result.tail, ...result.head] .filter((item) => item.kind === "user_message") - .map((item) => item.optimistic), - ).toEqual([undefined]); + .map((item) => item.clientMessageId), + ).toEqual(["new-prompt"]); }); it("does not move a prompt around unrelated catch-up history", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1141,7 +2052,7 @@ describe("processTimelineResponse", () => { }); it("does not move a prompt or its live answer around catch-up history", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const live = processAgentStreamEvents({ events: [ makeStreamReducerEvent( @@ -1152,7 +2063,6 @@ describe("processTimelineResponse", () => { currentTail: [prompt], currentHead: [], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - currentAgent: null, }); const result = processTimelineResponse({ @@ -1186,7 +2096,7 @@ describe("processTimelineResponse", () => { }); it("does not move a prompt or its live answer around catch-up tool history", () => { - const prompt = makeOptimisticUserMessage("New prompt", "new-prompt"); + const prompt = makeSubmittedUserMessage("New prompt", "new-prompt"); const live = processAgentStreamEvents({ events: [ makeStreamReducerEvent( @@ -1197,7 +2107,6 @@ describe("processTimelineResponse", () => { currentTail: [prompt], currentHead: [], currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 }, - currentAgent: null, }); const result = processTimelineResponse({ @@ -1228,7 +2137,7 @@ describe("processTimelineResponse", () => { }); it("never moves submitted messages behind a later assistant response", () => { - const unmatched = makeOptimisticUserMessage("first submission", "client-first"); + const unmatched = makeSubmittedUserMessage("first submission", "client-first"); const acknowledged: StreamItem[] = [ { kind: "user_message", @@ -1286,8 +2195,8 @@ describe("processTimelineResponse", () => { ]); }); - it("acknowledges a local prompt in place when a remote user row also arrives", () => { - const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt"); + it("places a local prompt after an earlier remote canonical row", () => { + const prompt = makeSubmittedUserMessage("Local prompt", "local-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1320,17 +2229,12 @@ describe("processTimelineResponse", () => { }); expect( - result.tail - .filter((item) => item.kind === "user_message") - .map((item) => ({ text: item.text, optimistic: item.optimistic })), - ).toEqual([ - { text: "Local prompt", optimistic: undefined }, - { text: "Remote prompt", optimistic: undefined }, - ]); + result.tail.filter((item) => item.kind === "user_message").map((item) => item.text), + ).toEqual(["Remote prompt", "Local prompt"]); }); - it("keeps an unmatched optimistic prompt when catch-up contains only a remote user row", () => { - const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt"); + it("keeps an unmatched submitted prompt when catch-up contains only a remote user row", () => { + const prompt = makeSubmittedUserMessage("Local prompt", "local-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1355,17 +2259,12 @@ describe("processTimelineResponse", () => { }); expect( - result.tail - .filter((item) => item.kind === "user_message") - .map((item) => ({ text: item.text, optimistic: item.optimistic })), - ).toEqual([ - { text: "Local prompt", optimistic: true }, - { text: "Remote prompt", optimistic: undefined }, - ]); + result.tail.filter((item) => item.kind === "user_message").map((item) => item.text), + ).toEqual(["Local prompt", "Remote prompt"]); }); it("does not match equal prompt text when canonical client message ids differ", () => { - const prompt = makeOptimisticUserMessage("continue", "local-prompt"); + const prompt = makeSubmittedUserMessage("continue", "local-prompt"); const result = processTimelineResponse({ ...baseTimelineInput, @@ -1393,10 +2292,10 @@ describe("processTimelineResponse", () => { expect( result.tail .filter((item) => item.kind === "user_message") - .map((item) => ({ id: item.id, optimistic: item.optimistic })), + .map((item) => ({ id: item.id, messageId: item.messageId })), ).toEqual([ - { id: "local-prompt", optimistic: true }, - { id: "remote-prompt", optimistic: undefined }, + { id: "local-prompt", messageId: undefined }, + { id: "remote-prompt", messageId: "remote-prompt" }, ]); }); @@ -1593,6 +2492,7 @@ describe("processTimelineResponse", () => { // No new items appended (all dropped as stale) expect(result.tail).toBe(baseTimelineInput.currentTail); expect(result.cursorChanged).toBe(false); + expect(result.older).toBe("unchanged"); }); it("prepends older before-cursor entries and only expands the start cursor", () => { @@ -1637,10 +2537,47 @@ describe("processTimelineResponse", () => { startSeq: 1, endSeq: 5, }); + expect(result.older).toBe("none"); }); - it("does not reconcile an active optimistic user message from a before-page response", () => { - const optimistic = makeOptimisticUserMessage("active prompt", "optimistic-active"); + it("drops a stale before page anchored before a resume-tail replacement", () => { + const currentTail: StreamItem[] = [ + { + kind: "user_message", + id: "current-200", + text: "current-200", + timestamp: new Date(3200), + timelineCursor: { epoch: "epoch-1", seq: 200 }, + }, + ]; + const existingCursor: TimelineCursor = { + epoch: "epoch-1", + startSeq: 200, + endSeq: 240, + }; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail, + currentCursor: existingCursor, + payload: { + ...baseTimelineInput.payload, + direction: "before", + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 39 }, + entries: [makeTimelineEntry(39, "stale older page", "user_message")], + }, + }); + + expect(result.tail).toBe(currentTail); + expect(result.cursor).toBe(existingCursor); + expect(result.cursorChanged).toBe(false); + expect(result.older).toBe("unchanged"); + }); + + it("does not reconcile an active submitted user message from a before-page response", () => { + const submitted = makeSubmittedUserMessage("active prompt", "submitted-active"); const existingCursor: TimelineCursor = { epoch: "epoch-1", startSeq: 3, @@ -1649,7 +2586,7 @@ describe("processTimelineResponse", () => { const result = processTimelineResponse({ ...baseTimelineInput, - currentTail: [optimistic], + currentTail: [submitted], currentCursor: existingCursor, payload: { ...baseTimelineInput.payload, @@ -1672,8 +2609,8 @@ describe("processTimelineResponse", () => { const userMessages = result.tail.filter((item) => item.kind === "user_message"); expect(userMessages).toHaveLength(2); - expect(userMessages.map((item) => item.id)).toEqual(["canonical-before", "optimistic-active"]); - expect(userMessages[1]?.optimistic).toBe(true); + expect(userMessages.map((item) => item.id)).toEqual(["canonical-before", "submitted-active"]); + expect(userMessages[1]?.clientMessageId).toBe("submitted-active"); }); it("leaves the cursor alone when a before page makes no progress", () => { @@ -1742,7 +2679,10 @@ describe("processTimelineResponse", () => { expect(getAssistantTexts(result.tail)).toEqual(["older chunk newer chunk"]); expect(result.tail[0]).toEqual( - expect.objectContaining({ timelineCursor: { epoch: "epoch-1", seq: 3 } }), + expect.objectContaining({ + id: "assistant-newer", + timelineCursor: { epoch: "epoch-1", seq: 3 }, + }), ); expect(result.cursor).toEqual({ epoch: "epoch-1", @@ -1813,6 +2753,68 @@ describe("processTimelineResponse", () => { ]); }); + it("removes a reconciled submitted prompt before coalescing a tool call at the pagination seam", () => { + const clientMessageId = "client-boundary-prompt"; + const callId = "toolu_submitted_boundary"; + const currentTail = [ + makeSubmittedUserMessage("Inspect the file", clientMessageId), + ...hydrateStreamState( + [ + { + event: { + type: "timeline", + provider: "claude", + item: makeToolCallTimelineEntry(3, callId, "completed", { + type: "read", + filePath: "/tmp/example.ts", + }).item, + } as AgentStreamEventPayload, + timestamp: new Date(3000), + }, + ], + { source: "canonical" }, + ), + ]; + + const result = processTimelineResponse({ + ...baseTimelineInput, + currentTail, + currentCursor: { epoch: "epoch-1", startSeq: 3, endSeq: 5 }, + payload: { + ...baseTimelineInput.payload, + direction: "before", + epoch: "epoch-1", + startCursor: { seq: 1 }, + endCursor: { seq: 2 }, + entries: [ + { + ...makeTimelineEntry(1, "Inspect the file", "user_message"), + item: { + type: "user_message", + text: "Inspect the file", + messageId: "provider-boundary-prompt", + clientMessageId, + }, + }, + makeToolCallTimelineEntry(2, callId, "running", { + type: "unknown", + input: { file_path: "/tmp/example.ts" }, + output: null, + }), + ], + }, + }); + + expect(result.tail.filter((item) => item.kind === "user_message")).toHaveLength(1); + expect(getAgentToolCalls(result.tail)).toEqual([ + expect.objectContaining({ + payload: expect.objectContaining({ + data: expect.objectContaining({ callId, status: "completed" }), + }), + }), + ]); + }); + it("does not coalesce tool call lifecycle rows away from the prepend boundary", () => { const callId = "toolu_not_boundary"; const currentTail = hydrateStreamState( @@ -2225,150 +3227,59 @@ describe("processAgentStreamEvent", () => { }); }); - it("derives optimistic idle status on turn_completed for running agent", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - + it("renders a live row over painted items without creating an authoritative cursor", () => { const result = processAgentStreamEvent({ ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(true); - expect(result.agent).not.toBe(null); - expect(result.agent!.status).toBe("idle"); - expect(result.agent!.updatedAt.getTime()).toBe(2000); - expect(result.agent!.lastActivityAt.getTime()).toBe(2000); - }); - - it("derives optimistic error status on turn_failed for running agent", () => { - const turnFailedEvent: AgentStreamEventPayload = { - type: "turn_failed", - provider: "claude", - error: "something broke", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnFailedEvent, - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(true); - expect(result.agent!.status).toBe("error"); - }); - - it("does not derive optimistic idle status on turn_canceled for running agent", () => { - const turnCanceledEvent: AgentStreamEventPayload = { - type: "turn_canceled", - provider: "codex", - reason: "interrupted", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCanceledEvent, - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(false); - expect(result.agent).toBe(null); - }); - - it("does not change agent when status is not running", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: { - status: "idle", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(false); - expect(result.agent).toBe(null); - }); - - it("does not change agent when no agent is provided", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: null, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(false); - expect(result.agent).toBe(null); - }); - - it("preserves updatedAt when agent timestamp is newer than event", () => { - const turnCompletedEvent: AgentStreamEventPayload = { - type: "turn_completed", - provider: "claude", - }; - - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: turnCompletedEvent, - currentAgent: { - status: "running", - updatedAt: new Date(5000), - lastActivityAt: new Date(5000), - }, - timestamp: new Date(2000), - }); - - expect(result.agentChanged).toBe(true); - expect(result.agent!.updatedAt.getTime()).toBe(5000); - expect(result.agent!.lastActivityAt.getTime()).toBe(5000); - }); - - it("does not produce agent patch for non-terminal events", () => { - const result = processAgentStreamEvent({ - ...baseStreamInput, - event: makeTimelineEvent("just text"), - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, - seq: 1, + event: makeTimelineEvent("live before bootstrap", "user_message"), + seq: 51, epoch: "epoch-1", - timestamp: new Date(2000), + currentTail: [makeAssistantItem("painted replica")], + currentCursor: undefined, + hasAuthoritativeBaseline: false, }); - expect(result.agentChanged).toBe(false); - expect(result.agent).toBe(null); + expect(getAssistantTexts(result.tail)).toEqual(["painted replica"]); + expect(getUserTexts(result.head)).toEqual(["live before bootstrap"]); + expect(result.head[0]).toMatchObject({ + kind: "user_message", + timelineCursor: { epoch: "epoch-1", seq: 51 }, + }); + expect(result.cursorChanged).toBe(false); + expect(result.cursor).toBeNull(); + expect(result.sideEffects).toEqual([]); + }); + + it("reconciles a pre-bootstrap echo with its submitted row", () => { + const submitted = makeSubmittedUserMessage("submitted before bootstrap", "client-message-1"); + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: { + type: "timeline", + provider: "claude", + item: { + type: "user_message", + text: "canonical presentation", + clientMessageId: "client-message-1", + messageId: "provider-message-1", + }, + }, + seq: 51, + epoch: "epoch-1", + currentTail: [makeAssistantItem("painted replica"), submitted], + currentCursor: undefined, + hasAuthoritativeBaseline: false, + }); + + const users = [...result.tail, ...result.head].filter((item) => item.kind === "user_message"); + expect(users).toEqual([ + expect.objectContaining({ + id: "client-message-1", + clientMessageId: "client-message-1", + messageId: "provider-message-1", + text: "submitted before bootstrap", + timelineCursor: { epoch: "epoch-1", seq: 51 }, + }), + ]); }); }); @@ -2382,7 +3293,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(false); @@ -2410,7 +3320,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(false); @@ -2433,7 +3342,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2451,7 +3359,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2460,30 +3367,39 @@ describe("processAgentStreamEvents", () => { expect(getAssistantTexts(result.head)).toEqual(["Identified"]); }); - it("promotes completed assistant markdown blocks to tail while keeping the live block in head", () => { - const result = processAgentStreamEvents({ + it("keeps a promoted live image block identity when the turn completes", () => { + const imageMarkdown = "![Architecture](docs/architecture.png)"; + const streaming = processAgentStreamEvents({ events: [ - makeStreamReducerEvent(makeTimelineEvent("First paragraph"), 1), - makeStreamReducerEvent(makeTimelineEvent("\n\nSecond paragraph"), 2), + makeStreamReducerEvent(makeTimelineEvent("Introductory paragraph"), 1), + makeStreamReducerEvent(makeTimelineEvent(`\n\n${imageMarkdown}`), 2), ], currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); - expect(result.changedTail).toBe(true); - expect(result.changedHead).toBe(true); - expect(result.tail).toHaveLength(1); - expect(result.tail[0]).toMatchObject({ - kind: "assistant_message", - text: "First paragraph", - }); - expect(result.head).toHaveLength(1); - expect(result.head[0]).toMatchObject({ - kind: "assistant_message", - text: "Second paragraph", + expect([streaming.changedTail, streaming.changedHead]).toEqual([true, true]); + expect(getAssistantTexts(streaming.tail)).toEqual(["Introductory paragraph"]); + expect(getAssistantTexts(streaming.head)).toEqual([imageMarkdown]); + const liveImageId = streaming.head[0]?.id; + + const completed = processAgentStreamEvent({ + ...baseStreamInput, + event: { type: "turn_completed", provider: "claude" } as AgentStreamEventPayload, + currentTail: streaming.tail, + currentHead: streaming.head, + currentCursor: streaming.cursor ?? undefined, }); + const assistantBlocks = [...completed.tail, ...completed.head].filter( + (item): item is Extract => + item.kind === "assistant_message", + ); + expect(assistantBlocks.map((item) => [item.id, item.text])).toEqual([ + [streaming.tail[0]?.id, "Introductory paragraph"], + [liveImageId, imageMarkdown], + ]); + expect(assistantBlocks.filter((item) => item.id === liveImageId)).toHaveLength(1); }); it("preserves a live block trailing newline after promoting completed markdown blocks", () => { @@ -2501,7 +3417,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2527,7 +3442,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2552,7 +3466,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(result.changedTail).toBe(true); @@ -2644,7 +3557,6 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }); expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([ @@ -2655,7 +3567,7 @@ describe("processAgentStreamEvents", () => { ]); }); - it("returns the final optimistic lifecycle patch across a batch", () => { + it("does not derive lifecycle state from a terminal event in a batch", () => { const result = processAgentStreamEvents({ events: [ makeStreamReducerEvent(makeTimelineEvent("Done"), 1), @@ -2669,21 +3581,10 @@ describe("processAgentStreamEvents", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: { - status: "running", - updatedAt: new Date(1000), - lastActivityAt: new Date(1000), - }, }); expect(result.head).toEqual([]); expect(result.tail).toHaveLength(1); - expect(result.agentChanged).toBe(true); - expect(result.agent).toMatchObject({ - status: "idle", - updatedAt: new Date(3000), - lastActivityAt: new Date(3000), - }); }); it("keeps a live Claude assistant paragraph contiguous when init tail hydration lands mid-stream", () => { @@ -2717,6 +3618,7 @@ describe("processAgentStreamEvents", () => { direction: "tail", reset: false, epoch: "epoch-1", + window: { minSeq: 186, maxSeq: 186, nextSeq: 187 }, startCursor: { seq: 186 }, endCursor: { seq: 186 }, entries: [makeTimelineEntry(186, seq186Text)], @@ -2760,6 +3662,7 @@ describe("processAgentStreamEvents", () => { direction: "tail", reset: false, epoch: "epoch-1", + window: { minSeq: 10, maxSeq: 10, nextSeq: 11 }, startCursor: { seq: 10 }, endCursor: { seq: 10 }, entries: [makeTimelineEntry(10, "Call-site API — exactly one primitive. Not")], @@ -2832,7 +3735,6 @@ describe("createAgentStreamReducerQueue", () => { currentTail, currentHead, currentCursor: undefined, - currentAgent: null, }), commit: (agentId, result) => { currentTail = result.tail; @@ -2874,7 +3776,6 @@ describe("createAgentStreamReducerQueue", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }), commit: (agentId, result) => { commits.push( @@ -2904,7 +3805,6 @@ describe("createAgentStreamReducerQueue", () => { currentTail, currentHead, currentCursor, - currentAgent: null, }), commit: (_agentId, result) => { currentTail = result.tail; @@ -2954,7 +3854,6 @@ describe("createAgentStreamReducerQueue", () => { currentTail: [], currentHead: [], currentCursor: undefined, - currentAgent: null, }), commit: (agentId, result) => { commits.push( diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 7458e855d..282552d52 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1,15 +1,16 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; -import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; -import type { Agent } from "@/stores/session-store"; -import { useSessionStore } from "@/stores/session-store"; -import type { AssistantMessageItem, StreamItem, UserMessageItem } from "@/types/stream"; +import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; +import type { AssistantMessageItem, StreamItem } from "@/types/stream"; +import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; import { applyStreamEvent, flushHeadToTail, hydrateStreamState, isAgentToolCallItem, mergeAgentToolCallItem, + replaceWithCanonicalStream, reduceStreamUpdate, + upsertUserMessageAcrossStream, } from "@/types/stream"; const AGENT_STREAM_REDUCER_FLUSH_DELAY_MS = 16 * 3; @@ -75,6 +76,7 @@ export interface ProcessTimelineResponseInput { direction: TimelineDirection; reset: boolean; epoch: string; + window: { minSeq: number; maxSeq: number; nextSeq: number }; startCursor: { seq: number } | null; endCursor: { seq: number } | null; entries: TimelineResponseEntry[]; @@ -88,17 +90,21 @@ export interface ProcessTimelineResponseInput { isInitializing: boolean; hasActiveInitDeferred: boolean; initRequestDirection: InitRequestDirection; + sendingClientMessageIds: readonly string[]; } export interface ProcessTimelineResponseOutput { + commit: "apply" | "discard"; tail: StreamItem[]; head: StreamItem[]; cursor: TimelineCursor | null | undefined; cursorChanged: boolean; + older: "available" | "none" | "unchanged"; initResolution: "resolve" | "reject" | null; clearInitializing: boolean; error: string | null; sideEffects: TimelineReducerSideEffect[]; + acknowledgedClientMessageIds: string[]; } interface TimelineUnit { @@ -114,7 +120,9 @@ interface TimelinePathResult { head: StreamItem[]; cursor: TimelineCursor | null | undefined; cursorChanged: boolean; + older: "available" | "none" | "unchanged"; sideEffects: TimelineReducerSideEffect[]; + acknowledgedClientMessageIds: string[]; } function classifySessionTimelineSeq({ @@ -174,6 +182,39 @@ function deriveBootstrapTailTimelinePolicy({ }; } +type ResumeTailPolicy = + | { kind: "not_resume" } + | { kind: "discard" } + | { kind: "append" } + | { kind: "replace"; preserveContinuity: boolean }; + +function deriveResumeTailPolicy(input: { + direction: TimelineDirection; + reset: boolean; + epoch: string; + windowMaxSeq: number; + pageStartSeq: number | null; + currentCursor: TimelineCursor | undefined; + bootstrapReplace: boolean; +}): ResumeTailPolicy { + if (input.direction !== "tail" || input.reset || input.bootstrapReplace || !input.currentCursor) { + return { kind: "not_resume" }; + } + if (input.currentCursor.epoch !== input.epoch) { + return { kind: "replace", preserveContinuity: false }; + } + if (input.windowMaxSeq === input.currentCursor.endSeq) { + return { kind: "discard" }; + } + if (input.windowMaxSeq < input.currentCursor.endSeq) { + return { kind: "replace", preserveContinuity: false }; + } + if (input.pageStartSeq !== null && input.pageStartSeq <= input.currentCursor.endSeq + 1) { + return { kind: "append" }; + } + return { kind: "replace", preserveContinuity: true }; +} + function shouldResolveTimelineInit({ hasActiveInitDeferred, hasNewer, @@ -201,76 +242,39 @@ function shouldResolveTimelineInit({ return responseDirection === initRequestDirection; } -function deriveOptimisticLifecycleStatus( - currentStatus: AgentLifecycleStatus, - event: AgentStreamEventPayload, -): AgentLifecycleStatus | null { - if (currentStatus !== "running") { - return null; - } - switch (event.type) { - case "turn_completed": - return "idle"; - case "turn_failed": - return "error"; - case "turn_canceled": - // A canceled turn can be either a final user cancel or an interrupt before - // a replacement turn starts. The daemon snapshot is authoritative here. - return null; - default: - return null; - } -} - -function preserveReplacePathAssistantHead(params: { - tail: StreamItem[]; - currentHead: StreamItem[]; -}): { - tail: StreamItem[]; - head: StreamItem[]; -} { - const { tail, currentHead } = params; - const liveAssistant = currentHead.findLast( - (item): item is Extract => - item.kind === "assistant_message", - ); - if (!liveAssistant) { - return { tail, head: [] }; - } - const tailAssistant = tail.at(-1); - if (!tailAssistant || tailAssistant.kind !== "assistant_message") { - return { tail, head: currentHead }; - } - if (!liveAssistant.text.startsWith(tailAssistant.text)) { - return { tail, head: [] }; - } - return { - tail: tail.slice(0, -1), - head: [{ ...liveAssistant, text: tailAssistant.text }], - }; -} - function applyTimelineReplacePath(args: { timelineUnits: TimelineUnit[]; payload: ProcessTimelineResponseInput["payload"]; bootstrapPolicy: ReturnType; currentTail: StreamItem[]; currentHead: StreamItem[]; + sendingClientMessageIds: readonly string[]; + preserveContinuity: boolean; toHydratedEvents: ( units: TimelineUnit[], ) => Array<{ event: AgentStreamEventPayload; timestamp: Date }>; }): TimelinePathResult { - const { timelineUnits, payload, bootstrapPolicy, currentTail, currentHead, toHydratedEvents } = - args; + const { + timelineUnits, + payload, + bootstrapPolicy, + currentTail, + currentHead, + sendingClientMessageIds, + preserveContinuity, + toHydratedEvents, + } = args; const hydratedTail = hydrateStreamState(toHydratedEvents(timelineUnits), { source: "canonical" }); - const reconciledTail = reconcileLocalUserPresentationAfterReplace({ - canonicalTail: hydratedTail, + const { tail, head, acknowledgedClientMessageIds } = replaceWithCanonicalStream({ + canonical: hydratedTail, previousTail: currentTail, previousHead: currentHead, - }); - const { tail, head } = preserveReplacePathAssistantHead({ - tail: reconciledTail, - currentHead, + sendingClientMessageIds, + preserveContinuity, + canonicalCoverage: { + epoch: payload.epoch, + endSeq: payload.endCursor?.seq ?? null, + }, }); const cursor: TimelineCursor | null = payload.startCursor && payload.endCursor @@ -284,136 +288,16 @@ function applyTimelineReplacePath(args: { if (bootstrapPolicy.catchUpCursor) { sideEffects.push({ type: "catch_up", cursor: bootstrapPolicy.catchUpCursor }); } - return { tail, head, cursor, cursorChanged: true, sideEffects }; -} - -function collectLocallyPresentedUserMessages(items: StreamItem[]): Array<{ - ordinal: number; - item: UserMessageItem; -}> { - const localUsers: Array<{ ordinal: number; item: UserMessageItem }> = []; - let ordinal = 0; - for (const item of items) { - if (item.kind !== "user_message") { - continue; - } - if (item.optimistic || item.images?.length || item.attachments?.length) { - localUsers.push({ ordinal, item }); - } - ordinal += 1; - } - return localUsers; -} - -function mergeCanonicalUserWithLocalPresentation( - canonical: UserMessageItem, - local: UserMessageItem, -): UserMessageItem { return { - kind: "user_message", - id: canonical.id, - ...(canonical.clientMessageId ? { clientMessageId: canonical.clientMessageId } : {}), - text: local.text, - timestamp: local.timestamp, - ...(local.images && local.images.length > 0 ? { images: local.images } : {}), - ...(local.attachments && local.attachments.length > 0 - ? { attachments: local.attachments } - : {}), + tail, + head, + cursor, + cursorChanged: true, + older: payload.hasOlder ? "available" : "none", + sideEffects, + acknowledgedClientMessageIds, }; } - -interface CanonicalUserMessageIdentity { - messageId?: string; - clientMessageId?: string; - text: string; -} - -function matchesLocalUserMessageIdentity( - canonical: CanonicalUserMessageIdentity, - optimistic: UserMessageItem, -): boolean { - if (canonical.clientMessageId !== undefined) { - return canonical.clientMessageId === optimistic.id; - } - if (canonical.messageId === optimistic.id) { - return true; - } - // COMPAT(userMessageClientId): added in v0.2.0, remove after 2027-01-20 once - // the supported daemon floor emits clientMessageId on submitted user messages. - return canonical.text.length > 0 && canonical.text === optimistic.text; -} - -function reconcileLocalUserPresentationAfterReplace(params: { - canonicalTail: StreamItem[]; - previousTail: StreamItem[]; - previousHead: StreamItem[]; -}): StreamItem[] { - const localUsers = collectLocallyPresentedUserMessages([ - ...params.previousTail, - ...params.previousHead, - ]); - if (localUsers.length === 0) { - return params.canonicalTail; - } - - const canonicalUserIndexes: number[] = []; - params.canonicalTail.forEach((item, index) => { - if (item.kind === "user_message") { - canonicalUserIndexes.push(index); - } - }); - - const nextTail = [...params.canonicalTail]; - const claimedCanonicalIndexes = new Set(); - const unmatched: UserMessageItem[] = []; - - for (const local of localUsers) { - const exactIndex = canonicalUserIndexes.find((index) => { - if (claimedCanonicalIndexes.has(index)) return false; - const canonical = params.canonicalTail[index]; - return ( - canonical?.kind === "user_message" && - matchesLocalUserMessageIdentity( - { - messageId: canonical.id, - clientMessageId: canonical.clientMessageId, - text: canonical.text, - }, - local.item, - ) - ); - }); - const ordinalIndex = canonicalUserIndexes[local.ordinal]; - const ordinalItem = ordinalIndex === undefined ? undefined : params.canonicalTail[ordinalIndex]; - const canonicalIndex = - exactIndex ?? - (ordinalIndex !== undefined && - !claimedCanonicalIndexes.has(ordinalIndex) && - ordinalItem?.kind === "user_message" && - ordinalItem.clientMessageId === undefined - ? ordinalIndex - : undefined); - const canonicalItem = canonicalIndex === undefined ? undefined : nextTail[canonicalIndex]; - if (canonicalIndex === undefined || !canonicalItem || canonicalItem.kind !== "user_message") { - if (local.item.optimistic) { - unmatched.push(local.item); - } - continue; - } - nextTail[canonicalIndex] = mergeCanonicalUserWithLocalPresentation(canonicalItem, local.item); - claimedCanonicalIndexes.add(canonicalIndex); - } - - for (const item of unmatched) { - const insertionIndex = nextTail.findIndex( - (canonical) => canonical.timestamp.getTime() > item.timestamp.getTime(), - ); - nextTail.splice(insertionIndex < 0 ? nextTail.length : insertionIndex, 0, item); - } - - return nextTail; -} - interface IncrementalAcceptResult { acceptedUnits: TimelineUnit[]; cursor: TimelineCursor | undefined; @@ -495,7 +379,7 @@ function acceptOlderTimelineUnits(args: { if ( responseStartSeq === undefined || responseEndSeq === undefined || - responseEndSeq >= currentCursor.startSeq + responseEndSeq !== currentCursor.startSeq - 1 ) { return { acceptedUnits: [], cursor: currentCursor, gapCursor: null }; } @@ -515,6 +399,34 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea return olderTail; } + const remainingOlder: StreamItem[] = []; + let reconciledCurrent = currentTail; + for (const item of olderTail) { + if (item.kind !== "user_message") { + remainingOlder.push(item); + continue; + } + const result = upsertUserMessageAcrossStream({ + tail: reconciledCurrent, + head: [], + message: item, + insert: "prepend-tail", + presentation: "existing", + }); + if (result.location?.matched) { + remainingOlder.push(result.location.message); + reconciledCurrent = [ + ...result.tail.slice(0, result.location.index), + ...result.tail.slice(result.location.index + 1), + ]; + } else { + remainingOlder.push(item); + } + } + olderTail = remainingOlder; + currentTail = reconciledCurrent; + if (olderTail.length === 0) return currentTail; + const olderLast = olderTail.at(-1); const currentFirst = currentTail[0]; @@ -536,16 +448,15 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea return [...olderTail, ...currentTail]; } - return [ - ...olderTail.slice(0, -1), - { - ...olderLast, - text: `${olderLast.text}${currentFirst.text}`, - timestamp: currentFirst.timestamp, - ...(currentFirst.timelineCursor ? { timelineCursor: currentFirst.timelineCursor } : {}), - }, - ...currentTail.slice(1), - ]; + const mergedAssistant: AssistantMessageItem = { + ...currentFirst, + text: `${olderLast.text}${currentFirst.text}`, + }; + if (mergedAssistant.messageId === undefined && olderLast.messageId !== undefined) { + mergedAssistant.messageId = olderLast.messageId; + } + + return [...olderTail.slice(0, -1), mergedAssistant, ...currentTail.slice(1)]; } function replaceLiveAssistantWithProjectedText(params: { @@ -745,9 +656,24 @@ function applyCanonicalForwardUnit(params: { head: StreamItem[]; unit: TimelineUnit; epoch: string; -}): { tail: StreamItem[]; head: StreamItem[] } { +}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { const { event, timestamp, seqEnd } = params.unit; const timelineCursor = { epoch: params.epoch, seq: seqEnd }; + if (event.type === "timeline" && event.item.type === "user_message") { + const applied = applyStreamEvent({ + tail: params.tail, + head: params.head, + event, + timestamp, + source: "canonical", + timelineCursor, + }); + return { + tail: applied.tail, + head: applied.head, + acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], + }; + } if (params.head.length === 0) { return { tail: reduceStreamUpdate(params.tail, event, timestamp, { @@ -755,6 +681,7 @@ function applyCanonicalForwardUnit(params: { timelineCursor, }), head: params.head, + acknowledgedClientMessageIds: [], }; } const replacedHead = replaceLiveAssistantWithProjectedText({ @@ -763,7 +690,9 @@ function applyCanonicalForwardUnit(params: { timestamp, timelineCursor, }); - if (replacedHead) return { tail: params.tail, head: replacedHead }; + if (replacedHead) { + return { tail: params.tail, head: replacedHead, acknowledgedClientMessageIds: [] }; + } const activeAssistant = params.head.findLast( (item): item is Extract => @@ -781,6 +710,7 @@ function applyCanonicalForwardUnit(params: { source: "canonical", timelineCursor, }), + acknowledgedClientMessageIds: [], }; } @@ -792,7 +722,11 @@ function applyCanonicalForwardUnit(params: { source: "canonical", timelineCursor, }); - return { tail: applied.tail, head: applied.head }; + return { + tail: applied.tail, + head: applied.head, + acknowledgedClientMessageIds: applied.acknowledgedClientMessageIds ?? [], + }; } function applyAcceptedForwardTimelineUnits(params: { @@ -801,7 +735,7 @@ function applyAcceptedForwardTimelineUnits(params: { currentTail: StreamItem[]; currentHead: StreamItem[]; currentEndSeq: number | undefined; -}): { tail: StreamItem[]; head: StreamItem[] } { +}): { tail: StreamItem[]; head: StreamItem[]; acknowledgedClientMessageIds: string[] } { const reconciled = reconcileOverlappingProjectedStreamItems({ tail: params.currentTail, head: params.currentHead, @@ -819,7 +753,37 @@ function applyAcceptedForwardTimelineUnits(params: { head = applied.head; } - return { tail, head }; + return { + tail, + head, + acknowledgedClientMessageIds: deriveCanonicalAcknowledgements({ + units: params.units, + epoch: params.epoch, + currentTail: params.currentTail, + currentHead: params.currentHead, + }), + }; +} + +function deriveCanonicalAcknowledgements(params: { + units: TimelineUnit[]; + epoch: string; + currentTail: StreamItem[]; + currentHead: StreamItem[]; +}): string[] { + let tail = params.currentTail; + let head = params.currentHead; + const acknowledged = new Set(); + for (const unit of params.units) { + if (unit.event.type !== "timeline" || unit.event.item.type !== "user_message") continue; + const applied = applyCanonicalForwardUnit({ tail, head, unit, epoch: params.epoch }); + tail = applied.tail; + head = applied.head; + for (const clientMessageId of applied.acknowledgedClientMessageIds) { + acknowledged.add(clientMessageId); + } + } + return [...acknowledged]; } function applyTimelineIncrementalPath(args: { @@ -834,10 +798,20 @@ function applyTimelineIncrementalPath(args: { let nextHead = currentHead; let nextCursor: TimelineCursor | null | undefined = currentCursor; let cursorChanged = false; + let older: TimelinePathResult["older"] = "unchanged"; const sideEffects: TimelineReducerSideEffect[] = []; + let acknowledgedClientMessageIds: string[] = []; if (timelineUnits.length === 0) { - return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects }; + return { + tail: nextTail, + head: nextHead, + cursor: nextCursor, + cursorChanged, + older, + sideEffects, + acknowledgedClientMessageIds, + }; } const { acceptedUnits, cursor, gapCursor } = @@ -855,6 +829,7 @@ function applyTimelineIncrementalPath(args: { if (acceptedUnits.length > 0) { if (payload.direction === "before") { + older = payload.hasOlder ? "available" : "none"; const olderTail = hydrateStreamState( acceptedUnits.map(({ event, timestamp, seqEnd }) => ({ event, @@ -874,6 +849,7 @@ function applyTimelineIncrementalPath(args: { }); nextTail = applied.tail; nextHead = applied.head; + acknowledgedClientMessageIds = applied.acknowledgedClientMessageIds; } } @@ -892,7 +868,15 @@ function applyTimelineIncrementalPath(args: { sideEffects.push({ type: "catch_up", cursor: gapCursor }); } - return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects }; + return { + tail: nextTail, + head: nextHead, + cursor: nextCursor, + cursorChanged, + older, + sideEffects, + acknowledgedClientMessageIds, + }; } export function processTimelineResponse( @@ -906,6 +890,7 @@ export function processTimelineResponse( isInitializing, hasActiveInitDeferred, initRequestDirection, + sendingClientMessageIds, } = input; // ------------------------------------------------------------------ @@ -913,14 +898,17 @@ export function processTimelineResponse( // ------------------------------------------------------------------ if (payload.error) { return { + commit: "apply", tail: currentTail, head: currentHead, cursor: currentCursor, cursorChanged: false, + older: "unchanged", initResolution: hasActiveInitDeferred ? "reject" : null, clearInitializing: isInitializing, error: payload.error, sideEffects: [], + acknowledgedClientMessageIds: [], }; } @@ -967,24 +955,62 @@ export function processTimelineResponse( hasActiveInitDeferred, }); const replace = bootstrapPolicy.replace; - const sideEffects: TimelineReducerSideEffect[] = []; - const timelineResult = replace - ? applyTimelineReplacePath({ - timelineUnits, - payload, - bootstrapPolicy, + const resumeTailPolicy = deriveResumeTailPolicy({ + direction: payload.direction, + reset: payload.reset, + epoch: payload.epoch, + windowMaxSeq: payload.window.maxSeq, + pageStartSeq: payload.startCursor?.seq ?? null, + currentCursor, + bootstrapReplace: replace, + }); + const discard = resumeTailPolicy.kind === "discard"; + let timelineResult: TimelinePathResult; + if (discard) { + timelineResult = { + tail: currentTail, + head: currentHead, + cursor: currentCursor, + cursorChanged: false, + older: "unchanged", + sideEffects: [], + acknowledgedClientMessageIds: deriveCanonicalAcknowledgements({ + units: timelineUnits, + epoch: payload.epoch, currentTail, currentHead, - toHydratedEvents, - }) - : applyTimelineIncrementalPath({ - timelineUnits, - payload, - currentTail, - currentHead, - currentCursor, - }); + }).filter((clientMessageId) => sendingClientMessageIds.includes(clientMessageId)), + }; + } else if (replace || resumeTailPolicy.kind === "replace") { + const isResumeReplacement = resumeTailPolicy.kind === "replace"; + timelineResult = applyTimelineReplacePath({ + timelineUnits, + payload, + bootstrapPolicy: isResumeReplacement + ? { replace: true, catchUpCursor: null } + : bootstrapPolicy, + currentTail, + currentHead, + sendingClientMessageIds, + preserveContinuity: isResumeReplacement + ? resumeTailPolicy.preserveContinuity + : currentCursor?.epoch === payload.epoch || !payload.reset, + toHydratedEvents, + }); + } else { + const incrementalUnits = + resumeTailPolicy.kind === "append" && currentCursor + ? timelineUnits.filter((unit) => unit.seqEnd > currentCursor.endSeq) + : timelineUnits; + timelineResult = applyTimelineIncrementalPath({ + timelineUnits: incrementalUnits, + payload, + currentTail, + currentHead, + currentCursor, + }); + } const nextTail = timelineResult.tail; const nextHead = timelineResult.head; @@ -1014,16 +1040,20 @@ export function processTimelineResponse( timelineResponseComplete; const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null; + const commit = discard ? "discard" : "apply"; return { + commit, tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, + older: timelineResult.older, initResolution, clearInitializing, error: null, sideEffects, + acknowledgedClientMessageIds: timelineResult.acknowledgedClientMessageIds, }; } @@ -1038,20 +1068,10 @@ export interface ProcessAgentStreamEventInput { currentTail: StreamItem[]; currentHead: StreamItem[]; currentCursor: TimelineCursor | undefined; - currentAgent: { - status: AgentLifecycleStatus; - updatedAt: Date; - lastActivityAt: Date; - } | null; + hasAuthoritativeBaseline?: boolean; timestamp: Date; } -export interface AgentPatch { - status: AgentLifecycleStatus; - updatedAt: Date; - lastActivityAt: Date; -} - export interface ProcessAgentStreamEventOutput { tail: StreamItem[]; head: StreamItem[]; @@ -1059,8 +1079,7 @@ export interface ProcessAgentStreamEventOutput { changedHead: boolean; cursor: TimelineCursor | null; cursorChanged: boolean; - agent: AgentPatch | null; - agentChanged: boolean; + acknowledgedClientMessageIds: string[]; sideEffects: AgentStreamReducerSideEffect[]; } @@ -1079,18 +1098,12 @@ interface TimelineSequencingGateResult { sideEffects: AgentStreamReducerSideEffect[]; } -export interface AgentStreamReducerAgentSnapshot { - status: AgentLifecycleStatus; - updatedAt: Date; - lastActivityAt: Date; -} - export interface ProcessAgentStreamEventsInput { events: AgentStreamReducerEvent[]; currentTail: StreamItem[]; currentHead: StreamItem[]; currentCursor: TimelineCursor | undefined; - currentAgent: AgentStreamReducerAgentSnapshot | null; + hasAuthoritativeBaseline?: boolean; } export type AgentStreamReducerSnapshot = Omit; @@ -1114,27 +1127,14 @@ export interface CreateAgentStreamReducerQueueInput { cancelFlush: (id: number) => void; } -function applyAgentPatch( - currentAgent: AgentStreamReducerAgentSnapshot | null, - patch: AgentPatch | null, -): AgentStreamReducerAgentSnapshot | null { - if (!currentAgent || !patch) { - return currentAgent; - } - return { - status: patch.status, - updatedAt: patch.updatedAt, - lastActivityAt: patch.lastActivityAt, - }; -} - function processTimelineSequencingGate(input: { event: AgentStreamEventPayload; seq: number | undefined; epoch: string | undefined; currentCursor: TimelineCursor | undefined; + hasAuthoritativeBaseline: boolean; }): TimelineSequencingGateResult { - const { event, seq, epoch, currentCursor } = input; + const { event, seq, epoch, currentCursor, hasAuthoritativeBaseline } = input; const base: TimelineSequencingGateResult = { shouldApplyStreamEvent: true, nextTimelineCursor: null, @@ -1145,6 +1145,9 @@ function processTimelineSequencingGate(input: { if (event.type !== "timeline" || typeof seq !== "number" || typeof epoch !== "string") { return base; } + if (!hasAuthoritativeBaseline) { + return base; + } const decision = classifySessionTimelineSeq({ cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null, @@ -1201,10 +1204,24 @@ function processTimelineSequencingGate(input: { export function processAgentStreamEvent( input: ProcessAgentStreamEventInput, ): ProcessAgentStreamEventOutput { - const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } = - input; + const { + event, + seq, + epoch, + currentTail, + currentHead, + currentCursor, + timestamp, + hasAuthoritativeBaseline = true, + } = input; - const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor }); + const sequencing = processTimelineSequencingGate({ + event, + seq, + epoch, + currentCursor, + hasAuthoritativeBaseline, + }); const timelineCursor = event.type === "timeline" && seq !== undefined && epoch !== undefined ? { epoch, seq } @@ -1213,49 +1230,52 @@ export function processAgentStreamEvent( // ------------------------------------------------------------------ // Apply stream event to tail/head // ------------------------------------------------------------------ - const { tail, head, changedTail, changedHead } = sequencing.shouldApplyStreamEvent - ? applyStreamEvent({ - tail: sequencing.resetLiveTimeline ? [] : currentTail, - head: sequencing.resetLiveTimeline ? [] : currentHead, + let streamResult: ReturnType; + if (!sequencing.shouldApplyStreamEvent) { + streamResult = { + tail: currentTail, + head: currentHead, + changedTail: false, + changedHead: false, + }; + } else if (!hasAuthoritativeBaseline) { + if (event.type === "timeline" && event.item.type === "user_message") { + streamResult = applyStreamEvent({ + tail: currentTail, + head: currentHead, event, timestamp, source: "live", timelineCursor, - }) - : { + unmatchedUserMessageInsert: "head", + }); + } else { + const overlay = applyStreamEvent({ + tail: currentHead, + head: [], + event, + timestamp, + source: "live", + timelineCursor, + }); + streamResult = { tail: currentTail, - head: currentHead, + head: [...overlay.tail, ...overlay.head], changedTail: false, - changedHead: false, + changedHead: overlay.changedTail || overlay.changedHead, }; - - // ------------------------------------------------------------------ - // Optimistic lifecycle status - // ------------------------------------------------------------------ - let agentPatch: AgentPatch | null = null; - let agentChanged = false; - - if ( - currentAgent && - (event.type === "turn_completed" || - event.type === "turn_canceled" || - event.type === "turn_failed") - ) { - const optimisticStatus = deriveOptimisticLifecycleStatus(currentAgent.status, event); - if (optimisticStatus) { - const nextUpdatedAtMs = Math.max(currentAgent.updatedAt.getTime(), timestamp.getTime()); - const nextLastActivityAtMs = Math.max( - currentAgent.lastActivityAt.getTime(), - timestamp.getTime(), - ); - agentPatch = { - status: optimisticStatus, - updatedAt: new Date(nextUpdatedAtMs), - lastActivityAt: new Date(nextLastActivityAtMs), - }; - agentChanged = true; } + } else { + streamResult = applyStreamEvent({ + tail: sequencing.resetLiveTimeline ? [] : currentTail, + head: sequencing.resetLiveTimeline ? [] : currentHead, + event, + timestamp, + source: "live", + timelineCursor, + }); } + const { tail, head, changedTail, changedHead } = streamResult; return { tail, @@ -1264,8 +1284,7 @@ export function processAgentStreamEvent( changedHead, cursor: sequencing.nextTimelineCursor, cursorChanged: sequencing.cursorChanged, - agent: agentPatch, - agentChanged, + acknowledgedClientMessageIds: streamResult.acknowledgedClientMessageIds ?? [], sideEffects: sequencing.sideEffects, }; } @@ -1276,12 +1295,10 @@ export function processAgentStreamEvents( let tail = input.currentTail; let head = input.currentHead; let cursor = input.currentCursor; - let agent = input.currentAgent; let changedTail = false; let changedHead = false; let cursorChanged = false; - let agentPatch: AgentPatch | null = null; - let agentChanged = false; + const acknowledgedClientMessageIds = new Set(); const sideEffects: AgentStreamReducerSideEffect[] = []; for (const reducerEvent of input.events) { @@ -1292,7 +1309,7 @@ export function processAgentStreamEvents( currentTail: tail, currentHead: head, currentCursor: cursor, - currentAgent: agent, + hasAuthoritativeBaseline: input.hasAuthoritativeBaseline, timestamp: reducerEvent.timestamp, }); @@ -1301,17 +1318,14 @@ export function processAgentStreamEvents( changedTail = changedTail || result.changedTail; changedHead = changedHead || result.changedHead; sideEffects.push(...result.sideEffects); + for (const clientMessageId of result.acknowledgedClientMessageIds) { + acknowledgedClientMessageIds.add(clientMessageId); + } if (result.cursorChanged) { cursor = result.cursor ?? undefined; cursorChanged = true; } - - if (result.agentChanged) { - agentPatch = result.agent; - agentChanged = true; - agent = applyAgentPatch(agent, result.agent); - } } return { @@ -1321,8 +1335,7 @@ export function processAgentStreamEvents( changedHead, cursor: cursor ?? null, cursorChanged, - agent: agentPatch, - agentChanged, + acknowledgedClientMessageIds: [...acknowledgedClientMessageIds], sideEffects, }; } @@ -1405,6 +1418,28 @@ export function createAgentStreamReducerQueue( interface StreamStatePatch { tail?: StreamItem[]; head?: StreamItem[]; + acknowledgedClientMessageIds?: readonly string[]; +} + +export function deriveAgentStreamTurnLiveness( + events: readonly AgentStreamReducerEvent[], +): TurnLivenessTransition[] { + const transitions: TurnLivenessTransition[] = []; + for (const { event, timestamp } of events) { + if (event.type === "turn_started") { + transitions.push({ + type: "stream_open", + turn: { turnId: event.turnId ?? null, startedAt: timestamp }, + }); + } else if ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) { + transitions.push({ type: "stream_close", turnId: event.turnId ?? null }); + } + } + return transitions; } export interface CreateSessionAgentStreamReducerQueueInput { @@ -1414,7 +1449,6 @@ export interface CreateSessionAgentStreamReducerQueueInput { serverId: string, state: (prev: Map) => Map, ) => void; - setAgents: (serverId: string, state: (prev: Map) => Map) => void; recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; } @@ -1429,34 +1463,33 @@ function cancelAgentStreamReducerFlush(id: number) { export function createSessionAgentStreamReducerQueue( input: CreateSessionAgentStreamReducerQueueInput, ): AgentStreamReducerQueue { - const { serverId, setAgentStreamState, setAgentTimelineCursor, setAgents, recoverTimelineGap } = - input; + const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap } = input; return createAgentStreamReducerQueue({ getSnapshot: (agentId) => { const session = useSessionStore.getState().sessions[serverId]; - const currentAgentEntry = session?.agents.get(agentId); + const timeline = selectAgentTimelineState(session, agentId); return { - currentTail: session?.agentStreamTail.get(agentId) ?? [], + currentTail: timeline.status === "cold" ? [] : timeline.items, currentHead: session?.agentStreamHead.get(agentId) ?? [], - currentCursor: session?.agentTimelineCursor.get(agentId), - currentAgent: currentAgentEntry - ? { - status: currentAgentEntry.status, - updatedAt: currentAgentEntry.updatedAt, - lastActivityAt: currentAgentEntry.lastActivityAt, - } - : null, + currentCursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined, + hasAuthoritativeBaseline: timeline.status === "synced", }; }, commit: (agentId, result, events) => { - if (result.changedTail || result.changedHead) { + if ( + result.changedTail || + result.changedHead || + result.acknowledgedClientMessageIds.length > 0 + ) { setAgentStreamState(serverId, agentId, { ...(result.changedTail ? { tail: result.tail } : {}), ...(result.changedHead ? { head: result.head } : {}), + ...(result.acknowledgedClientMessageIds.length > 0 + ? { acknowledgedClientMessageIds: result.acknowledgedClientMessageIds } + : {}), }); } - if (result.cursorChanged && result.cursor) { const nextCursor = result.cursor; const lastEvent = events.at(-1); @@ -1486,24 +1519,6 @@ export function createSessionAgentStreamReducerQueue( return next; }); } - - if (result.agentChanged && result.agent) { - const nextAgent = result.agent; - setAgents(serverId, (prev) => { - const current = prev.get(agentId); - if (!current) { - return prev; - } - const next = new Map(prev); - next.set(agentId, { - ...current, - status: nextAgent.status, - updatedAt: nextAgent.updatedAt, - lastActivityAt: nextAgent.lastActivityAt, - }); - return next; - }); - } }, handleSideEffects: (agentId, sideEffects) => { for (const effect of sideEffects) { diff --git a/packages/app/src/timeline/timeline-sync-plan.test.ts b/packages/app/src/timeline/timeline-sync-plan.test.ts index 918c97357..5d75754e6 100644 --- a/packages/app/src/timeline/timeline-sync-plan.test.ts +++ b/packages/app/src/timeline/timeline-sync-plan.test.ts @@ -2,54 +2,14 @@ import { describe, expect, test } from "vitest"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; import { isTimelineCatchUpComplete, - planInitialAgentTimelineSync, - planResumeTimelineSync, + isTimelineResumeSnapshotAuthoritative, planTimelineOlderFetch, + planTimelineTailFetch, } from "./timeline-sync-plan"; describe("timeline sync planning", () => { - test("initial open without an authoritative cursor loads a bounded tail page", () => { - const plan = planInitialAgentTimelineSync({ - cursor: undefined, - hasAuthoritativeHistory: false, - }); - - expect(plan).toEqual({ - direction: "tail", - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "projected", - }); - }); - - test("initial open with an authoritative cursor catches up after the cursor", () => { - const plan = planInitialAgentTimelineSync({ - cursor: { epoch: "epoch-1", startSeq: 1, endSeq: 42 }, - hasAuthoritativeHistory: true, - }); - - expect(plan).toEqual({ - direction: "after", - cursor: { epoch: "epoch-1", seq: 42 }, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "projected", - }); - }); - - test("resume with a cursor catches up after the cursor", () => { - const plan = planResumeTimelineSync({ - cursor: { epoch: "epoch-1", startSeq: 1, endSeq: 100 }, - }); - - expect(plan).toEqual({ - direction: "after", - cursor: { epoch: "epoch-1", seq: 100 }, - limit: TIMELINE_FETCH_PAGE_SIZE, - projection: "projected", - }); - }); - - test("resume without a cursor loads a bounded tail page", () => { - const plan = planResumeTimelineSync({ cursor: undefined }); + test("agent synchronization checks a bounded tail page", () => { + const plan = planTimelineTailFetch(); expect(plan).toEqual({ direction: "tail", @@ -74,4 +34,28 @@ describe("timeline sync planning", () => { true, ); }); + + test("only tail and completed forward pages carry an authoritative resume snapshot", () => { + expect( + isTimelineResumeSnapshotAuthoritative({ + direction: "tail", + hasNewer: false, + error: null, + }), + ).toBe(true); + expect( + isTimelineResumeSnapshotAuthoritative({ + direction: "after", + hasNewer: false, + error: null, + }), + ).toBe(true); + expect( + isTimelineResumeSnapshotAuthoritative({ + direction: "before", + hasNewer: false, + error: null, + }), + ).toBe(false); + }); }); diff --git a/packages/app/src/timeline/timeline-sync-plan.ts b/packages/app/src/timeline/timeline-sync-plan.ts index d1cd4b376..95b7e859b 100644 --- a/packages/app/src/timeline/timeline-sync-plan.ts +++ b/packages/app/src/timeline/timeline-sync-plan.ts @@ -5,12 +5,6 @@ export interface TimelineSyncCursor { seq: number; } -export interface AgentTimelineCursorRange { - epoch: string; - startSeq: number; - endSeq: number; -} - export interface ProjectedTimelineTailFetchPlan { direction: "tail"; limit: number; @@ -40,27 +34,6 @@ export type ProjectedTimelineForwardFetchPlan = | ProjectedTimelineTailFetchPlan | ProjectedTimelineAfterFetchPlan; -export function planInitialAgentTimelineSync(input: { - cursor: AgentTimelineCursorRange | undefined; - hasAuthoritativeHistory: boolean; -}): ProjectedTimelineForwardFetchPlan { - if (input.hasAuthoritativeHistory && input.cursor) { - return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq }); - } - - return planTimelineTailFetch(); -} - -export function planResumeTimelineSync(input: { - cursor: AgentTimelineCursorRange | undefined; -}): ProjectedTimelineForwardFetchPlan { - if (input.cursor) { - return planTimelineCatchUpAfter({ epoch: input.cursor.epoch, seq: input.cursor.endSeq }); - } - - return planTimelineTailFetch(); -} - export function planTimelineCatchUpAfter(cursor: TimelineSyncCursor) { return { direction: "after", @@ -98,3 +71,12 @@ export function isTimelineCatchUpComplete(input: { return input.direction !== "after" || !input.hasNewer; } + +export function isTimelineResumeSnapshotAuthoritative(input: { + direction: "tail" | "before" | "after"; + hasNewer: boolean; + error: string | null; +}): boolean { + if (input.error || input.direction === "before") return false; + return input.direction === "tail" || !input.hasNewer; +} diff --git a/packages/app/src/timeline/turn-liveness.test.ts b/packages/app/src/timeline/turn-liveness.test.ts new file mode 100644 index 000000000..b3a39742d --- /dev/null +++ b/packages/app/src/timeline/turn-liveness.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { reduceTurnLiveness, resolveTurnPresentation, TURN_LIVENESS_IDLE } from "./turn-liveness"; + +describe("turn activity", () => { + const startedAt = new Date("2026-07-31T10:00:00.000Z"); + + it("ignores a terminal event for another turn", () => { + const open = reduceTurnLiveness(TURN_LIVENESS_IDLE, { + type: "stream_open", + turn: { turnId: "turn-2", startedAt }, + }); + + expect(reduceTurnLiveness(open, { type: "stream_close", turnId: "turn-1" })).toBe(open); + }); + + it("replaces activity from an authoritative snapshot", () => { + const open = reduceTurnLiveness(TURN_LIVENESS_IDLE, { + type: "stream_open", + turn: { turnId: "turn-1", startedAt }, + }); + + expect(reduceTurnLiveness(open, { type: "snapshot", activeTurn: null })).toEqual( + TURN_LIVENESS_IDLE, + ); + }); + + it("opens an autonomous turn from an authoritative snapshot", () => { + expect( + reduceTurnLiveness(TURN_LIVENESS_IDLE, { + type: "snapshot", + activeTurn: { turnId: "autonomous-turn-1", startedAt }, + }), + ).toEqual({ + phase: "open", + turnId: "autonomous-turn-1", + startedAt, + cancellationRequestId: null, + }); + }); + + it("does not let an old cancel completion clear the current request", () => { + const first = reduceTurnLiveness(TURN_LIVENESS_IDLE, { + type: "cancellation_started", + requestId: 1, + }); + const second = reduceTurnLiveness(first, { type: "cancellation_started", requestId: 2 }); + + expect(reduceTurnLiveness(second, { type: "cancellation_settled", requestId: 1 })).toBe(second); + }); + + it("keeps cancellation pending across submission-to-turn handoff", () => { + const canceling = reduceTurnLiveness(TURN_LIVENESS_IDLE, { + type: "cancellation_started", + requestId: 1, + }); + + expect( + reduceTurnLiveness(canceling, { + type: "stream_open", + turn: { turnId: "turn-1", startedAt }, + }), + ).toEqual({ + phase: "open", + turnId: "turn-1", + startedAt, + cancellationRequestId: 1, + }); + }); + + it("keeps submission-only activity untimed", () => { + expect(resolveTurnPresentation(TURN_LIVENESS_IDLE, true)).toEqual({ + isActive: true, + isCancelling: false, + startedAt: null, + turnId: null, + }); + }); +}); diff --git a/packages/app/src/timeline/turn-liveness.ts b/packages/app/src/timeline/turn-liveness.ts new file mode 100644 index 000000000..deadee830 --- /dev/null +++ b/packages/app/src/timeline/turn-liveness.ts @@ -0,0 +1,125 @@ +export interface ActiveTurnIdentity { + turnId: string | null; + startedAt: Date | null; +} + +export type TurnLiveness = + | { phase: "idle"; cancellationRequestId: number | null } + | { + phase: "open"; + turnId: string | null; + startedAt: Date | null; + cancellationRequestId: number | null; + }; + +export type TurnLivenessTransition = + | { type: "snapshot"; activeTurn: ActiveTurnIdentity | null } + | { type: "stream_open"; turn: ActiveTurnIdentity } + | { type: "stream_close"; turnId: string | null } + | { type: "cancellation_started"; requestId: number } + | { type: "cancellation_settled"; requestId: number } + | { type: "destructive_close" }; + +export const TURN_LIVENESS_IDLE: TurnLiveness = { + phase: "idle", + cancellationRequestId: null, +}; + +export function readTurnLiveness( + turns: ReadonlyMap, + agentId: string, +): TurnLiveness { + return turns.get(agentId) ?? TURN_LIVENESS_IDLE; +} + +export function applyTurnLivenessTransition( + turns: ReadonlyMap, + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], +): Map { + const current = readTurnLiveness(turns, agentId); + const transitions = Array.isArray(transition) ? transition : [transition]; + const next = transitions.reduce(reduceTurnLiveness, current); + if (next === current) return turns as Map; + const updated = new Map(turns); + if (next.phase === "idle" && next.cancellationRequestId === null) updated.delete(agentId); + else updated.set(agentId, next); + return updated; +} + +export interface TurnPresentation { + isActive: boolean; + isCancelling: boolean; + startedAt: Date | null; + turnId: string | null; +} + +export function resolveTurnPresentation( + liveness: TurnLiveness, + hasActiveSubmission: boolean, +): TurnPresentation { + if (liveness.phase === "open") { + return { + isActive: true, + isCancelling: liveness.cancellationRequestId !== null, + startedAt: liveness.startedAt, + turnId: liveness.turnId, + }; + } + return { + isActive: hasActiveSubmission, + isCancelling: liveness.cancellationRequestId !== null, + startedAt: null, + turnId: null, + }; +} + +function closeTurn(current: TurnLiveness, turnId: string | null): TurnLiveness { + // COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6. + const targetsDifferentIdentifiedTurn = + current.phase === "open" && + turnId !== null && + current.turnId !== null && + turnId !== current.turnId; + return targetsDifferentIdentifiedTurn ? current : TURN_LIVENESS_IDLE; +} + +function openTurn(current: TurnLiveness, activeTurn: ActiveTurnIdentity): TurnLiveness { + // COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6. + const sameTurn = + current.phase === "open" && + ((activeTurn.turnId !== null && current.turnId === activeTurn.turnId) || + (activeTurn.turnId === null && current.turnId === null)); + return { + phase: "open", + turnId: activeTurn.turnId, + startedAt: sameTurn ? (current.startedAt ?? activeTurn.startedAt) : activeTurn.startedAt, + cancellationRequestId: + sameTurn || current.phase === "idle" ? current.cancellationRequestId : null, + }; +} + +export function reduceTurnLiveness( + current: TurnLiveness, + transition: TurnLivenessTransition, +): TurnLiveness { + if (transition.type === "destructive_close") return TURN_LIVENESS_IDLE; + + if (transition.type === "cancellation_started") { + if (current.cancellationRequestId === transition.requestId) return current; + return { ...current, cancellationRequestId: transition.requestId }; + } + + if (transition.type === "cancellation_settled") { + if (current.cancellationRequestId !== transition.requestId) return current; + return { ...current, cancellationRequestId: null }; + } + + if (transition.type === "stream_close") { + return closeTurn(current, transition.turnId); + } + + const activeTurn = transition.type === "snapshot" ? transition.activeTurn : transition.turn; + if (!activeTurn) return TURN_LIVENESS_IDLE; + return openTurn(current, activeTurn); +} diff --git a/packages/app/src/timeline/turn-time.test.ts b/packages/app/src/timeline/turn-time.test.ts index d63d2de7d..b5c13ee00 100644 --- a/packages/app/src/timeline/turn-time.test.ts +++ b/packages/app/src/timeline/turn-time.test.ts @@ -22,34 +22,17 @@ function assistant(id: string, timestamp: Date): StreamItem { } describe("deriveStreamTurnTiming", () => { - it("reserves a running footer for an optimistic prompt before the host starts the turn", () => { - const optimisticPrompt = { - ...user("optimistic", new Date("2026-05-15T00:00:00.000Z")), - optimistic: true as const, - }; + it("starts elapsed time from the submitted prompt", () => { + const submittedAt = new Date("2026-05-15T00:00:00.000Z"); const timing = deriveStreamTurnTiming({ - agentStatus: "idle", + isTurnActive: true, + activeTurnStartedAt: submittedAt, tail: [], - head: [optimisticPrompt], + head: [user("submitted", submittedAt)], }); - assert.equal(timing.isActive, true); - }); - - it("does not start elapsed time from an optimistic prompt", () => { - const optimisticPrompt = { - ...user("optimistic", new Date("2026-05-15T00:00:00.000Z")), - optimistic: true as const, - }; - - const timing = deriveStreamTurnTiming({ - agentStatus: "running", - tail: [], - head: [optimisticPrompt], - }); - - assert.equal(timing.runningStartedAt, null); + assert.equal(timing.runningStartedAt, submittedAt); }); it("uses the last user message as the running turn start", () => { @@ -57,7 +40,8 @@ describe("deriveStreamTurnTiming", () => { const secondUserAt = new Date("2026-05-15T00:01:00.000Z"); const timing = deriveStreamTurnTiming({ - agentStatus: "running", + isTurnActive: true, + activeTurnStartedAt: secondUserAt, tail: [ user("u1", firstUserAt), assistant("a1", new Date("2026-05-15T00:00:05.000Z")), @@ -75,7 +59,8 @@ describe("deriveStreamTurnTiming", () => { const assistantAt = new Date("2026-05-15T00:00:07.000Z"); const timing = deriveStreamTurnTiming({ - agentStatus: "idle", + isTurnActive: false, + activeTurnStartedAt: null, tail: [ user("u1", userAt), assistant("a1", assistantAt), @@ -97,7 +82,8 @@ describe("deriveStreamTurnTiming", () => { const lastAssistantAt = new Date("2026-05-15T00:00:07.000Z"); const timing = deriveStreamTurnTiming({ - agentStatus: "idle", + isTurnActive: false, + activeTurnStartedAt: null, tail: [ user("u1", userAt), assistant("a1", firstAssistantAt), diff --git a/packages/app/src/timeline/turn-time.ts b/packages/app/src/timeline/turn-time.ts index 435694524..3cb8d1824 100644 --- a/packages/app/src/timeline/turn-time.ts +++ b/packages/app/src/timeline/turn-time.ts @@ -9,18 +9,16 @@ export interface TurnTiming { export interface StreamTurnTiming { byAssistantId: Map; runningStartedAt: Date | null; - isActive: boolean; } export function deriveStreamTurnTiming(params: { - agentStatus: string; + isTurnActive: boolean; + activeTurnStartedAt: Date | null; tail: StreamItem[]; head: StreamItem[]; }): StreamTurnTiming { const byAssistantId = new Map(); let currentUserAt: Date | null = null; - let currentAuthoritativeUserAt: Date | null = null; - let currentUserIsOptimistic = false; let currentLastItemAt: Date | null = null; let currentAssistantIds: string[] = []; @@ -42,8 +40,6 @@ export function deriveStreamTurnTiming(params: { if (item.kind === "user_message") { flushCompletedTurn(); currentUserAt = item.timestamp; - currentAuthoritativeUserAt = item.optimistic ? null : item.timestamp; - currentUserIsOptimistic = item.optimistic === true; currentLastItemAt = null; currentAssistantIds = []; return; @@ -64,15 +60,13 @@ export function deriveStreamTurnTiming(params: { visitItem(item); } - const isRunning = params.agentStatus === "running"; - const runningStartedAt = isRunning ? currentAuthoritativeUserAt : null; - if (params.agentStatus !== "running") { + const runningStartedAt = params.isTurnActive ? params.activeTurnStartedAt : null; + if (!params.isTurnActive) { flushCompletedTurn(); } return { byAssistantId, runningStartedAt, - isActive: isRunning || currentUserIsOptimistic, }; } diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index 307680aa3..85b52092e 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -48,8 +48,6 @@ class TimelineWorld { this.releaseMembershipWaiter(); return result.promise; }, - readCursor: (agentId) => this.cursors.get(agentId), - hasAuthoritativeHistory: (agentId) => this.authoritativeHistory.has(agentId), fetchPage: async (agentId, request) => { const result = deferred<{ hasNewer: boolean; @@ -95,21 +93,10 @@ class TimelineWorld { agentId: string; resolve(fetch: TimelineFetch): void; }> = []; - private readonly cursors = new Map(); - private readonly authoritativeHistory = new Set(); private readonly errorWaiters: Array<(message: string) => void> = []; private readonly scheduled: Array<{ task: () => void; delayMs: number }> = []; private readonly retryWaiters: Array<(retry: () => void) => void> = []; - setCursor(agentId: string, endSeq: number): void { - this.cursors.set(agentId, { epoch: `epoch-${agentId}`, startSeq: 1, endSeq }); - this.authoritativeHistory.add(agentId); - } - - setLiveCursor(agentId: string, endSeq: number): void { - this.cursors.set(agentId, { epoch: `epoch-${agentId}`, startSeq: 1, endSeq }); - } - nextMembership(): Promise { const request = this.memberships.shift(); if (request) return Promise.resolve(request); @@ -178,9 +165,8 @@ test("keeps hidden agents subscribed for thirty seconds", () => { expect(VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS).toBe(30_000); }); -test("uses a tail fetch when a live cursor is not authoritative", async () => { +test("uses a tail fetch when an agent becomes visible", async () => { const world = new TimelineWorld(); - world.setLiveCursor("agent-a", 9); world.sync.setConnected(true); world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); const membership = await world.nextMembership(); @@ -191,6 +177,63 @@ test("uses a tail fetch when a live cursor is not authoritative", async () => { fetch.respond({ hasNewer: false }); }); +test("a gap absorbed by a running tail is recovered after the tail completes", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const tail = await world.nextFetch("agent-a"); + + world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 }); + + world.expectNoPendingFetch(); + tail.respond({ hasNewer: false }); + + const recovery = await world.nextFetch("agent-a"); + expect(recovery.request).toEqual({ + direction: "after", + cursor: { epoch: "epoch-agent-a", seq: 9 }, + limit: 40, + projection: "projected", + }); + recovery.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); +}); + +test("an idle reconciliation waits for an in-flight tail and fetches a fresh snapshot", async () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const initialTail = await world.nextFetch("agent-a"); + + world.sync.reconcileAgent("agent-a"); + + world.expectNoPendingFetch(); + initialTail.respond({ hasNewer: false }); + + const reconciliation = await world.nextFetch("agent-a"); + expect(reconciliation.request).toEqual({ + direction: "tail", + limit: 40, + projection: "projected", + }); + reconciliation.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); +}); + +test("does not reconcile an agent outside the viewed membership", () => { + const world = new TimelineWorld(); + world.sync.setConnected(true); + + world.sync.reconcileAgent("agent-a"); + + world.expectNoPendingFetch(); + world.expectNoPendingMembership(); +}); + test("unchanged visible-set publication does not cancel paged catch-up", async () => { const world = new TimelineWorld(); world.sync.setConnected(true); @@ -364,6 +407,7 @@ test("gap recovery supersedes completed catch-up and pages through the current t membership.succeed(); const initial = await world.nextFetch("agent-a"); initial.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); const gapPage = await world.nextFetch("agent-a"); @@ -395,6 +439,7 @@ test("repeated recovery for the same running gap reuses the in-flight fetch", as membership.succeed(); const initial = await world.nextFetch("agent-a"); initial.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); const cursor = { epoch: "epoch-agent-a", endSeq: 10 }; world.sync.recoverGap("agent-a", cursor); @@ -656,6 +701,7 @@ test("legacy delivery skips subscription RPCs while retaining visibility catch-u world.expectNoPendingMembership(); const initial = await world.nextFetch("agent-a"); initial.respond({ hasNewer: false }); + await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready")); world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 }); const recovery = await world.nextFetch("agent-a"); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index a7151b4e7..caa4c63d7 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -1,8 +1,6 @@ -import type { AgentTimelineCursorState } from "@/stores/session-store"; import { - planInitialAgentTimelineSync, - planResumeTimelineSync, planTimelineCatchUpAfter, + planTimelineTailFetch, type ProjectedTimelineForwardFetchPlan, } from "./timeline-sync-plan"; @@ -14,8 +12,6 @@ interface TimelinePageResult { interface ViewedTimelineSyncPorts { initialDeliveryMode: TimelineDeliveryMode; setSubscription(agentIds: string[]): Promise; - readCursor(agentId: string): AgentTimelineCursorState | undefined; - hasAuthoritativeHistory(agentId: string): boolean; fetchPage( agentId: string, request: ProjectedTimelineForwardFetchPlan, @@ -37,6 +33,7 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge { setActive(active: boolean): void; setConnected(connected: boolean): void; setDeliveryMode(mode: TimelineDeliveryMode): void; + reconcileAgent(agentId: string): void; recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void; dispose(): void; } @@ -62,19 +59,29 @@ function isSameCatchUpRequest( return left.cursor.epoch === right.cursor.epoch && left.cursor.seq === right.cursor.seq; } -function shouldKeepCurrentCatchUp(input: { +type CatchUpDecision = "keep" | "keep-and-park" | "replace"; + +function decideCatchUp(input: { current: CatchUpState | undefined; - request: ProjectedTimelineForwardFetchPlan | undefined; + request: ProjectedTimelineForwardFetchPlan; supersede: boolean; -}): boolean { - if (!input.current) return false; +}): CatchUpDecision { + if (!input.current) return "replace"; if (input.supersede) { - return ( + if ( input.current.status === "running" && isSameCatchUpRequest(input.current.request, input.request) - ); + ) { + return "keep"; + } + if (input.current.status === "running" && input.current.request?.direction === "tail") { + return "keep-and-park"; + } + return "replace"; } - return input.current.status === "running" || input.current.status === "complete"; + return input.current.status === "running" || input.current.status === "complete" + ? "keep" + : "replace"; } function normalizeAgentIds(agentIds: string[]): string[] { @@ -89,7 +96,9 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed const sources = new Map(); const catchUps = new Map(); const catchUpGenerations = new Map(); - const pendingGaps = new Map(); + // Authoritative fetch owed but not runnable yet: disconnected, unacknowledged, or parked. + // Acknowledgement and tail completion are the only drain points. + const pendingCatchUps = new Map(); const lingeringRemovals = new Map void>(); const visibilityCatchUpPending = new Set(); const visibilityCatchUpErrors = new Set(); @@ -137,7 +146,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed catchUpGenerations.set(agentId, (catchUpGenerations.get(agentId) ?? 0) + 1); catchUps.get(agentId)?.cancelRetry?.(); catchUps.delete(agentId); - pendingGaps.delete(agentId); + pendingCatchUps.delete(agentId); }; const fetchUntilCurrent = async ( @@ -174,6 +183,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed throw new Error(`Timeline page for ${agentId} hasNewer without an end cursor`); } catchUps.set(agentId, { generation, status: "complete" }); + const pendingCatchUp = pendingCatchUps.get(agentId); + if (pendingCatchUp) { + startCatchUp(agentId, { request: pendingCatchUp, supersede: true }); + return; + } setVisibilityCatchUpReady(agentId); } catch (error) { if (catchUps.get(agentId)?.generation === generation) { @@ -198,31 +212,34 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed ) => { const { request, supersede = false } = options; if (!connected || !isDesired(agentId) || !isAcknowledged(agentId)) { - if (request) pendingGaps.set(agentId, request); + if (request) pendingCatchUps.set(agentId, request); return; } + const nextRequest = request ?? planTimelineTailFetch(); const current = catchUps.get(agentId); - if (shouldKeepCurrentCatchUp({ current, request, supersede })) { + const decision = decideCatchUp({ current, request: nextRequest, supersede }); + if (decision === "keep-and-park") { + pendingCatchUps.set(agentId, nextRequest); + return; + } + if (decision === "keep") { return; } current?.cancelRetry?.(); const generation = (catchUpGenerations.get(agentId) ?? 0) + 1; catchUpGenerations.set(agentId, generation); - catchUps.set(agentId, { generation, status: "running", request }); - pendingGaps.delete(agentId); - const cursor = ports.readCursor(agentId); - const nextRequest = - request ?? - (ports.hasAuthoritativeHistory(agentId) - ? planResumeTimelineSync({ cursor }) - : planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory: false })); + catchUps.set(agentId, { generation, status: "running", request: nextRequest }); + pendingCatchUps.delete(agentId); void fetchUntilCurrent(agentId, generation, nextRequest); }; const startAcknowledgedCatchUps = () => { for (const agentId of acknowledged) { - const gap = pendingGaps.get(agentId); - startCatchUp(agentId, { request: gap, supersede: Boolean(gap) }); + const pendingCatchUp = pendingCatchUps.get(agentId); + startCatchUp(agentId, { + request: pendingCatchUp, + supersede: Boolean(pendingCatchUp), + }); } }; @@ -432,6 +449,15 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (deliveryMode === "selective" && connected) void reconcileMembership(); else if (connected) startAcknowledgedCatchUps(); }, + reconcileAgent(agentId) { + if (!isDesired(agentId)) return; + const request = planTimelineTailFetch(); + if (catchUps.get(agentId)?.status === "running") { + pendingCatchUps.set(agentId, request); + return; + } + startCatchUp(agentId, { request, supersede: true }); + }, recoverGap(agentId, cursor) { if (!isDesired(agentId)) return; startCatchUp(agentId, { diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index fb68d7a02..e3de58f42 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -4,9 +4,7 @@ import { describe, expect, it } from "vitest"; import { applyStreamEvent, - appendOptimisticUserMessageToStream, - buildOptimisticUserMessage, - clearOptimisticUserMessages, + createUserMessage, handoffCreatedAgentUserMessageToStream, hydrateStreamState, mergeToolCallDetail, @@ -14,6 +12,8 @@ import { type AgentToolCallItem, type StreamItem, isAgentToolCallItem, + upsertUserMessage, + upsertUserMessageAcrossStream, } from "./stream"; import type { AgentProvider, ToolCallDetail } from "@getpaseo/protocol/agent-types"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; @@ -21,6 +21,109 @@ import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display" type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled"; +describe("user message identity", () => { + it("adds provider identity without replacing local presentation", () => { + const timestamp = new Date("2026-07-26T10:00:00.000Z"); + const local = createUserMessage({ + clientMessageId: "client-1", + text: "local text", + timestamp, + images: [ + { + id: "image-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "image-1.png", + createdAt: timestamp.getTime(), + }, + ], + attachments: [{ type: "text", mimeType: "text/plain", text: "attachment" }], + }); + const canonical = createUserMessage({ + id: "provider-1", + messageId: "provider-1", + clientMessageId: "client-1", + text: "provider text", + timestamp: new Date("2026-07-26T10:00:01.000Z"), + }); + + const first = upsertUserMessage([local], canonical); + const second = upsertUserMessage(first, canonical); + + expect(first).toEqual([ + { + ...local, + messageId: "provider-1", + clientMessageId: "client-1", + }, + ]); + expect(first[0]).toBe(second[0]); + }); + + it("keeps local presentation when a later canonical row omits provider identity", () => { + const timestamp = new Date("2026-07-27T10:00:00.000Z"); + const local = createUserMessage({ + clientMessageId: "client-1", + messageId: "provider-1", + text: "local text", + timestamp, + images: [ + { + id: "image-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "image-1.png", + createdAt: timestamp.getTime(), + }, + ], + attachments: [{ type: "text", mimeType: "text/plain", text: "local attachment" }], + }); + const canonicalWithoutProviderIdentity = createUserMessage({ + id: "canonical-page-row", + clientMessageId: "client-1", + text: "provider-shaped text", + timestamp: new Date("2026-07-27T10:00:01.000Z"), + }); + + const result = upsertUserMessage([local], canonicalWithoutProviderIdentity); + + expect(result).toEqual([local]); + }); + + it("matches a submitted message against a legacy canonical row that has no client identity", () => { + // Daemons before v0.2.0 do not echo clientMessageId. During agent creation the + // legacy canonical row can land before the local submission is handed off, so the + // submitted row arrives as `incoming` and must still match by text. + const timestamp = new Date("2026-07-27T11:00:00.000Z"); + const legacyCanonical = createUserMessage({ + id: "provider-1", + messageId: "provider-1", + text: "review this", + timestamp, + }); + const submitted = createUserMessage({ + clientMessageId: "client-1", + text: "review this", + timestamp: new Date("2026-07-27T11:00:01.000Z"), + attachments: [{ type: "text", mimeType: "text/plain", text: "attachment" }], + }); + + const result = handoffCreatedAgentUserMessageToStream({ + tail: [legacyCanonical], + head: [], + message: submitted, + }); + + expect(result.tail).toEqual([ + { + ...submitted, + id: "client-1", + messageId: "provider-1", + }, + ]); + }); +}); + function assistantTimeline( text: string, provider: AgentProvider = "claude", @@ -895,15 +998,15 @@ describe("stream reducer canonical tool calls", () => { assert.strictEqual(todos.items[0]?.text, "Task 1"); }); - it("preserves optimistic user message images when authoritative user message arrives", () => { + it("preserves submitted user message images when authoritative user message arrives", () => { const messageId = "msg-user-images"; - const optimisticTimestamp = new Date("2025-01-01T11:10:00Z"); - const optimisticImages = [ + const submittedTimestamp = new Date("2025-01-01T11:10:00Z"); + const submittedImages = [ { - id: "att-optimistic", + id: "att-submitted", mimeType: "image/jpeg", storageType: "native-file" as const, - storageKey: "/tmp/optimistic.jpg", + storageKey: "/tmp/submitted.jpg", createdAt: Date.now(), }, ]; @@ -911,10 +1014,10 @@ describe("stream reducer canonical tool calls", () => { { kind: "user_message", id: messageId, + clientMessageId: messageId, text: "Analyze this image", - timestamp: optimisticTimestamp, - optimistic: true, - images: optimisticImages, + timestamp: submittedTimestamp, + images: submittedImages, }, ]; const event: AgentStreamEventPayload = { @@ -933,9 +1036,9 @@ describe("stream reducer canonical tool calls", () => { assert.ok(message); assert.strictEqual(message.id, messageId); - assert.deepStrictEqual(message.images, optimisticImages); + assert.deepStrictEqual(message.images, submittedImages); assert.strictEqual(message.text, "Analyze this image"); - assert.strictEqual(message.timestamp.getTime(), optimisticTimestamp.getTime()); + assert.strictEqual(message.timestamp.getTime(), submittedTimestamp.getTime()); }); it("keeps canonical assistant/user/assistant order during replay", () => { @@ -981,7 +1084,7 @@ describe("stream reducer canonical tool calls", () => { ); }); - it("keeps live optimistic assistant merge behavior", () => { + it("keeps live submitted assistant merge behavior", () => { const state: StreamItem[] = [ { kind: "assistant_message", @@ -1117,23 +1220,23 @@ describe("turn lifecycle events", () => { }); it.each(["codex", "opencode", "pi"] satisfies AgentProvider[])( - "replaces an optimistic user message when a live %s provider-owned id echo arrives without text matching", + "replaces a submitted user message when a live %s provider-owned id echo arrives without text matching", (provider) => { - const optimisticTimestamp = new Date("2025-01-01T15:02:00Z"); + const submittedTimestamp = new Date("2025-01-01T15:02:00Z"); const serverTimestamp = new Date("2025-01-01T15:02:01Z"); - const optimistic: StreamItem = { + const submitted: StreamItem = { kind: "user_message", - id: "msg_optimistic", + id: "msg_submitted", + clientMessageId: "msg_submitted", text: "same user text", - timestamp: optimisticTimestamp, - optimistic: true, + timestamp: submittedTimestamp, images: [ { id: "image-1", mimeType: "image/png", storageType: "web-indexeddb", storageKey: "image-1", - createdAt: optimisticTimestamp.getTime(), + createdAt: submittedTimestamp.getTime(), }, ], attachments: [ @@ -1147,7 +1250,7 @@ describe("turn lifecycle events", () => { }; const state = reduceStreamUpdate( - [optimistic], + [submitted], { type: "timeline", provider, @@ -1155,6 +1258,7 @@ describe("turn lifecycle events", () => { type: "user_message", text: "server-owned rendered text", messageId: "provider-owned-id", + clientMessageId: "msg_submitted", }, }, serverTimestamp, @@ -1165,28 +1269,28 @@ describe("turn lifecycle events", () => { assert.strictEqual(userMessages.length, 1); const userMessage = userMessages[0]; invariant(userMessage?.kind === "user_message"); - assert.strictEqual(userMessage.id, "provider-owned-id"); - assert.strictEqual(userMessage.text, optimistic.text); - assert.strictEqual(userMessage.timestamp.getTime(), optimistic.timestamp.getTime()); - assert.strictEqual(userMessage.optimistic, undefined); - assert.deepStrictEqual(userMessage.images, optimistic.images); - assert.deepStrictEqual(userMessage.attachments, optimistic.attachments); + assert.strictEqual(userMessage.id, "msg_submitted"); + assert.strictEqual(userMessage.messageId, "provider-owned-id"); + assert.strictEqual(userMessage.text, submitted.text); + assert.strictEqual(userMessage.timestamp.getTime(), submitted.timestamp.getTime()); + assert.deepStrictEqual(userMessage.images, submitted.images); + assert.deepStrictEqual(userMessage.attachments, submitted.attachments); }, ); - it("replaces one optimistic plain-text user message with the next live server user message", () => { - const optimisticTimestamp = new Date("2025-01-01T15:03:00Z"); + it("replaces one submitted plain-text user message with the next live server user message", () => { + const submittedTimestamp = new Date("2025-01-01T15:03:00Z"); const serverTimestamp = new Date("2025-01-01T15:03:01Z"); - const optimistic: StreamItem = { + const submitted: StreamItem = { kind: "user_message", - id: "msg_optimistic", + id: "msg_submitted", + clientMessageId: "msg_submitted", text: "typed plain text", - timestamp: optimisticTimestamp, - optimistic: true, + timestamp: submittedTimestamp, }; const state = reduceStreamUpdate( - [optimistic], + [submitted], { type: "timeline", provider: "opencode", @@ -1204,20 +1308,20 @@ describe("turn lifecycle events", () => { assert.strictEqual(userMessages.length, 1); const userMessage = userMessages[0]; invariant(userMessage?.kind === "user_message"); - assert.strictEqual(userMessage.id, "msg_opencode_provider_owned"); + assert.strictEqual(userMessage.id, "msg_submitted"); + assert.strictEqual(userMessage.messageId, "msg_opencode_provider_owned"); assert.strictEqual(userMessage.text, "typed plain text"); - assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime()); - assert.strictEqual(userMessage.optimistic, undefined); + assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime()); }); - it("replaces an optimistic image user message with the next canonical server user message", () => { - const optimisticTimestamp = new Date("2025-01-01T15:03:10Z"); + it("replaces a submitted image user message with the next canonical server user message", () => { + const submittedTimestamp = new Date("2025-01-01T15:03:10Z"); const image = { id: "image-canonical", mimeType: "image/png", storageType: "web-indexeddb" as const, storageKey: "image-canonical", - createdAt: optimisticTimestamp.getTime(), + createdAt: submittedTimestamp.getTime(), }; const attachment = { type: "text" as const, @@ -1225,16 +1329,16 @@ describe("turn lifecycle events", () => { text: "context", title: "context.txt", }; - const optimistic = buildOptimisticUserMessage({ - id: "msg_optimistic_canonical", + const submitted = createUserMessage({ + clientMessageId: "msg_submitted_canonical", text: "Analyze this", - timestamp: optimisticTimestamp, + timestamp: submittedTimestamp, images: [image], attachments: [attachment], }); const state = reduceStreamUpdate( - [optimistic], + [submitted], { type: "timeline", provider: "claude", @@ -1242,28 +1346,32 @@ describe("turn lifecycle events", () => { type: "user_message", text: "server-rendered attachment text", messageId: "provider-owned-canonical", - clientMessageId: optimistic.id, + clientMessageId: submitted.id, }, }, new Date("2025-01-01T15:03:11Z"), - { source: "canonical" }, + { + source: "canonical", + timelineCursor: { epoch: "epoch-1", seq: 42 }, + }, ); const userMessages = state.filter((item) => item.kind === "user_message"); assert.strictEqual(userMessages.length, 1); const userMessage = userMessages[0]; invariant(userMessage?.kind === "user_message"); - assert.strictEqual(userMessage.id, "provider-owned-canonical"); + assert.strictEqual(userMessage.id, "msg_submitted_canonical"); + assert.strictEqual(userMessage.messageId, "provider-owned-canonical"); assert.strictEqual(userMessage.text, "Analyze this"); - assert.strictEqual(userMessage.timestamp.getTime(), optimisticTimestamp.getTime()); - assert.strictEqual(userMessage.optimistic, undefined); + assert.strictEqual(userMessage.timestamp.getTime(), submittedTimestamp.getTime()); + assert.deepStrictEqual(userMessage.timelineCursor, { epoch: "epoch-1", seq: 42 }); assert.deepStrictEqual(userMessage.images, [image]); assert.deepStrictEqual(userMessage.attachments, [attachment]); }); - it("places optimistic user messages through one append helper", () => { - const optimistic = buildOptimisticUserMessage({ - id: "msg_append_once", + it("places submitted user messages through the identity producer", () => { + const submitted = createUserMessage({ + clientMessageId: "msg_append_once", text: "append once", timestamp: new Date("2025-01-01T15:03:20Z"), }); @@ -1274,28 +1382,30 @@ describe("turn lifecycle events", () => { timestamp: new Date("2025-01-01T15:03:19Z"), }; - const first = appendOptimisticUserMessageToStream({ + const first = upsertUserMessageAcrossStream({ tail: [], head: [headItem], - message: optimistic, - placement: "active-head", + message: submitted, + insert: "head", + presentation: "existing", }); - const second = appendOptimisticUserMessageToStream({ + const second = upsertUserMessageAcrossStream({ tail: first.tail, head: first.head, - message: optimistic, - placement: "active-head", + message: submitted, + insert: "head", + presentation: "existing", }); assert.deepStrictEqual(first.tail, []); - assert.deepStrictEqual(first.head, [headItem, optimistic]); + assert.deepStrictEqual(first.head, [headItem, submitted]); assert.strictEqual(second.changedHead, false); assert.strictEqual(second.head, first.head); }); - it("hands rich optimistic content to an authoritative create message without duplicating it", () => { + it("hands rich submitted content to its create message without overwriting an earlier user row", () => { const timestamp = new Date("2025-01-01T15:03:20Z"); - const optimistic = buildOptimisticUserMessage({ - id: "client-user", + const submitted = createUserMessage({ + clientMessageId: "client-user", text: "", timestamp, images: [ @@ -1317,32 +1427,44 @@ describe("turn lifecycle events", () => { }, ], }); + const precedingProviderRow: StreamItem = { + kind: "user_message", + id: "provider-system-user", + messageId: "provider-system-user", + text: "provider setup prompt", + timestamp: new Date("2025-01-01T15:03:20.500Z"), + }; const canonical: StreamItem = { kind: "user_message", id: "provider-user", + messageId: "provider-user", + clientMessageId: "client-user", text: "server-rendered attachment text", timestamp: new Date("2025-01-01T15:03:21Z"), }; const handedOff = handoffCreatedAgentUserMessageToStream({ - tail: [canonical], + tail: [precedingProviderRow, canonical], head: [], - message: optimistic, + message: submitted, }); const repeated = handoffCreatedAgentUserMessageToStream({ tail: handedOff.tail, head: handedOff.head, - message: optimistic, + message: submitted, }); assert.deepStrictEqual(handedOff.tail, [ + precedingProviderRow, { kind: "user_message", - id: "provider-user", - text: optimistic.text, - timestamp: optimistic.timestamp, - images: optimistic.images, - attachments: optimistic.attachments, + id: "client-user", + clientMessageId: "client-user", + messageId: "provider-user", + text: submitted.text, + timestamp: submitted.timestamp, + images: submitted.images, + attachments: submitted.attachments, }, ]); assert.deepStrictEqual(handedOff.head, []); @@ -1364,22 +1486,22 @@ describe("turn lifecycle events", () => { ); assert.deepStrictEqual( afterNextUser.filter((item) => item.kind === "user_message").map((item) => item.id), - ["provider-user", "provider-next-user"], + ["provider-system-user", "client-user", "provider-next-user"], ); }); - it("reconciles an optimistic user message that was pending in the streaming head", () => { - const optimistic: StreamItem = { + it("flushes an interrupted head when its submitted prompt becomes canonical", () => { + const submitted: StreamItem = { kind: "user_message", - id: "msg_head_optimistic", + id: "msg_head_submitted", + clientMessageId: "msg_head_submitted", text: "plain text in head", timestamp: new Date("2025-01-01T15:03:02Z"), - optimistic: true, }; const result = applyStreamEvent({ tail: [], - head: [optimistic], + head: [submitted], event: { type: "timeline", provider: "opencode", @@ -1393,33 +1515,80 @@ describe("turn lifecycle events", () => { source: "live", }); - assert.strictEqual(result.head.length, 0); + assert.deepStrictEqual(result.head, []); const userMessages = result.tail.filter((item) => item.kind === "user_message"); assert.strictEqual(userMessages.length, 1); - assert.strictEqual(userMessages[0]?.id, "provider-owned-head"); - assert.strictEqual(userMessages[0]?.optimistic, undefined); + assert.strictEqual(userMessages[0]?.id, "msg_head_submitted"); + assert.strictEqual(userMessages[0]?.messageId, "provider-owned-head"); }); - it("replaces multiple optimistic user messages in FIFO order", () => { - const optimisticTimestamp = new Date("2025-01-01T15:04:00Z"); - const serverTimestamp = new Date("2025-01-01T15:04:01Z"); - const firstOptimistic: StreamItem = { - kind: "user_message", - id: "msg_optimistic_1", - text: "first typed text", - timestamp: optimisticTimestamp, - optimistic: true, + it("keeps a replacement assistant separate after an interrupted prompt is reconciled", () => { + const interruptedAssistant: StreamItem = { + kind: "assistant_message", + id: "interrupted", + text: "old answer", + timestamp: new Date("2025-01-01T15:03:01Z"), }; - const secondOptimistic: StreamItem = { + const submitted = createUserMessage({ + clientMessageId: "msg_interrupt", + text: "replacement prompt", + timestamp: new Date("2025-01-01T15:03:02Z"), + }); + const reconciled = applyStreamEvent({ + tail: [], + head: [interruptedAssistant, submitted], + event: { + type: "timeline", + provider: "opencode", + item: { + type: "user_message", + text: submitted.text, + messageId: "provider-prompt", + clientMessageId: submitted.clientMessageId, + }, + }, + timestamp: new Date("2025-01-01T15:03:03Z"), + }); + const replacement = applyStreamEvent({ + tail: reconciled.tail, + head: reconciled.head, + event: { + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text: "new answer" }, + }, + timestamp: new Date("2025-01-01T15:03:04Z"), + }); + + expect(replacement.tail.map((item) => item.kind)).toEqual([ + "assistant_message", + "user_message", + ]); + expect(replacement.head).toEqual([ + expect.objectContaining({ kind: "assistant_message", text: "new answer" }), + ]); + }); + + it("replaces multiple submitted user messages in FIFO order", () => { + const submittedTimestamp = new Date("2025-01-01T15:04:00Z"); + const serverTimestamp = new Date("2025-01-01T15:04:01Z"); + const firstSubmitted: StreamItem = { kind: "user_message", - id: "msg_optimistic_2", + id: "msg_submitted_1", + clientMessageId: "msg_submitted_1", + text: "first typed text", + timestamp: submittedTimestamp, + }; + const secondSubmitted: StreamItem = { + kind: "user_message", + id: "msg_submitted_2", + clientMessageId: "msg_submitted_2", text: "second typed text", timestamp: new Date("2025-01-01T15:04:00.500Z"), - optimistic: true, }; const afterFirstEcho = reduceStreamUpdate( - [firstOptimistic, secondOptimistic], + [firstSubmitted, secondSubmitted], { type: "timeline", provider: "opencode", @@ -1427,6 +1596,7 @@ describe("turn lifecycle events", () => { type: "user_message", text: "first server text", messageId: "provider-owned-first", + clientMessageId: "msg_submitted_1", }, }, serverTimestamp, @@ -1441,6 +1611,7 @@ describe("turn lifecycle events", () => { type: "user_message", text: "second server text", messageId: "provider-owned-second", + clientMessageId: "msg_submitted_2", }, }, new Date("2025-01-01T15:04:02Z"), @@ -1450,30 +1621,30 @@ describe("turn lifecycle events", () => { const userMessages = state.filter((item) => item.kind === "user_message"); assert.strictEqual(userMessages.length, 2); assert.deepStrictEqual( - userMessages.map((item) => [item.id, item.text, item.optimistic]), + userMessages.map((item) => [item.id, item.text, item.messageId]), [ - ["provider-owned-first", "first typed text", undefined], - ["provider-owned-second", "second typed text", undefined], + ["msg_submitted_1", "first typed text", "provider-owned-first"], + ["msg_submitted_2", "second typed text", "provider-owned-second"], ], ); }); - it("does not shift later prompts when an earlier optimistic prompt has no canonical echo", () => { + it("does not shift later prompts when an earlier submitted prompt has no canonical echo", () => { const staleTimestamp = new Date("2025-01-01T15:04:00Z"); const submittedTimestamp = new Date("2025-01-01T15:04:01Z"); const stalePrompt: StreamItem = { kind: "user_message", id: "msg_stale", + clientMessageId: "msg_stale", text: "first prompt without an echo", timestamp: staleTimestamp, - optimistic: true, }; const submittedPrompt: StreamItem = { kind: "user_message", id: "msg_submitted", + clientMessageId: "msg_submitted", text: "later submitted prompt", timestamp: submittedTimestamp, - optimistic: true, }; const state = reduceStreamUpdate( @@ -1496,15 +1667,16 @@ describe("turn lifecycle events", () => { stalePrompt, { kind: "user_message", - id: "provider-owned-submitted", + id: "msg_submitted", clientMessageId: submittedPrompt.id, + messageId: "provider-owned-submitted", text: submittedPrompt.text, timestamp: submittedPrompt.timestamp, }, ]); }); - it("appends a live server user message when no optimistic user message is pending", () => { + it("appends a live server user message when no submitted user message is pending", () => { const state = reduceStreamUpdate( [], { @@ -1523,21 +1695,11 @@ describe("turn lifecycle events", () => { const userMessages = state.filter((item) => item.kind === "user_message"); assert.strictEqual(userMessages.length, 1); assert.strictEqual(userMessages[0]?.id, "provider-owned-resume"); - assert.strictEqual(userMessages[0]?.optimistic, undefined); }); - it("does not match a server user message to an optimistic from a rewound turn after pending optimistics are cleared", () => { - const optimistic: StreamItem = { - kind: "user_message", - id: "msg_rewound_optimistic", - text: "rewound text", - timestamp: new Date("2025-01-01T15:04:04Z"), - optimistic: true, - }; - const cleared = clearOptimisticUserMessages([optimistic]); - + it("appends a server user message after a rewound local row was removed", () => { const state = reduceStreamUpdate( - cleared, + [], { type: "timeline", provider: "opencode", @@ -1555,7 +1717,6 @@ describe("turn lifecycle events", () => { assert.strictEqual(userMessages.length, 1); assert.strictEqual(userMessages[0]?.id, "provider-owned-after-rewind"); assert.strictEqual(userMessages[0]?.text, "future server echo"); - assert.strictEqual(userMessages[0]?.optimistic, undefined); }); it("keeps canonical repeated user messages distinct during hydration", () => { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 76407d84c..1c02d1b31 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -87,22 +87,590 @@ export interface UserMessageItem { kind: "user_message"; id: string; clientMessageId?: string; - text: string; - timestamp: Date; - optimistic?: true; - images?: UserMessageImageAttachment[]; - attachments?: AgentAttachment[]; -} - -export interface OptimisticUserMessageInput { - id: string; + messageId?: string; + timelineCursor?: TimelinePosition; text: string; timestamp: Date; images?: UserMessageImageAttachment[]; attachments?: AgentAttachment[]; } -export type OptimisticUserMessagePlacement = "tail" | "active-head"; +export interface UserMessageInput { + id?: string; + clientMessageId?: string; + messageId?: string; + timelineCursor?: TimelinePosition; + text: string; + timestamp: Date; + images?: UserMessageImageAttachment[]; + attachments?: AgentAttachment[]; +} + +export function createUserMessage(input: UserMessageInput): UserMessageItem { + const id = input.id ?? input.clientMessageId ?? input.messageId; + if (!id) { + throw new Error("User message identity is required"); + } + return { + kind: "user_message", + id, + ...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}), + ...(input.messageId ? { messageId: input.messageId } : {}), + ...(input.timelineCursor ? { timelineCursor: input.timelineCursor } : {}), + text: input.text, + timestamp: input.timestamp, + ...(input.images && input.images.length > 0 ? { images: input.images } : {}), + ...(input.attachments && input.attachments.length > 0 + ? { attachments: input.attachments } + : {}), + }; +} + +export function isUnreconciledLocalUserMessage(message: UserMessageItem): boolean { + return ( + message.clientMessageId !== undefined && + message.messageId === undefined && + message.timelineCursor === undefined + ); +} + +export function appendSubmittedUserMessage(input: { + tail: StreamItem[]; + head: StreamItem[]; + message: UserMessageItem; +}): { tail: StreamItem[]; head: StreamItem[] } { + const clientMessageId = input.message.clientMessageId; + if (!clientMessageId) { + throw new Error("Submitted user message requires client identity"); + } + const alreadyExists = [...input.tail, ...input.head].some( + (item) => item.kind === "user_message" && item.clientMessageId === clientMessageId, + ); + if (alreadyExists) { + throw new Error(`Submitted user message already exists: ${clientMessageId}`); + } + return input.head.length > 0 + ? { tail: input.tail, head: [...input.head, input.message] } + : { tail: [...input.tail, input.message], head: input.head }; +} + +export function removeSubmittedUserMessage(input: { + tail: StreamItem[]; + head: StreamItem[]; + clientMessageId: string; +}): { tail: StreamItem[]; head: StreamItem[] } { + const remove = (items: StreamItem[]) => { + const next = items.filter( + (item) => + item.kind !== "user_message" || + item.clientMessageId !== input.clientMessageId || + !isUnreconciledLocalUserMessage(item), + ); + return next.length === items.length ? items : next; + }; + return { tail: remove(input.tail), head: remove(input.head) }; +} + +// COMPAT(userMessageClientId): added in v0.2.0, remove after 2027-01-20 once the +// supported daemon floor emits clientMessageId on submitted user messages. Until then a +// locally submitted row (clientMessageId, no messageId) and its canonical twin from an +// old daemon (messageId, no clientMessageId) share no identifier, so canonical ingestion +// may match an explicit local candidate by the id supplied over the wire or by text. +function matchesLegacyCanonicalUserMessage( + submitted: UserMessageItem, + canonical: UserMessageItem, +): boolean { + if (submitted.clientMessageId === undefined || submitted.messageId !== undefined) return false; + if (canonical.messageId === undefined) return false; + return canonical.messageId === submitted.clientMessageId || canonical.text === submitted.text; +} + +type UserMessageMatchPolicy = "canonical-incoming" | "handoff"; + +function matchesUserMessage( + existing: UserMessageItem, + incoming: UserMessageItem, + policy: UserMessageMatchPolicy, +): boolean { + if (existing.clientMessageId && incoming.clientMessageId) { + return existing.clientMessageId === incoming.clientMessageId; + } + if (existing.messageId && incoming.messageId) { + return existing.messageId === incoming.messageId; + } + if (matchesLegacyCanonicalUserMessage(existing, incoming)) return true; + return policy === "handoff" && matchesLegacyCanonicalUserMessage(incoming, existing); +} + +export function upsertUserMessage( + items: StreamItem[], + incoming: UserMessageItem, + insertAt = items.length, +): StreamItem[] { + return produceUserMessage(items, incoming, insertAt, "existing").items; +} + +type UserMessagePresentationPolicy = "existing" | "incoming"; + +interface UserMessageProductionResult { + items: StreamItem[]; + index: number; + message: UserMessageItem; + matched: boolean; +} + +function produceUserMessage( + items: StreamItem[], + incoming: UserMessageItem, + insertAt: number | null, + presentationPolicy: UserMessagePresentationPolicy, + matchPolicy: UserMessageMatchPolicy = "canonical-incoming", +): UserMessageProductionResult { + const index = items.findIndex( + (item) => item.kind === "user_message" && matchesUserMessage(item, incoming, matchPolicy), + ); + if (index < 0) { + if (insertAt === null) { + return { items, index: -1, message: incoming, matched: false }; + } + return { + items: [...items.slice(0, insertAt), incoming, ...items.slice(insertAt)], + index: insertAt, + message: incoming, + matched: false, + }; + } + + const existing = items[index]; + if (!existing || existing.kind !== "user_message") { + throw new Error("User message upsert matched a non-user row"); + } + const presentation = presentationPolicy === "incoming" ? incoming : existing; + const merged = createUserMessage({ + ...presentation, + clientMessageId: incoming.clientMessageId ?? existing.clientMessageId, + messageId: incoming.messageId ?? existing.messageId, + timelineCursor: incoming.timelineCursor ?? existing.timelineCursor, + }); + if ( + existing.id === merged.id && + existing.clientMessageId === merged.clientMessageId && + existing.messageId === merged.messageId && + existing.timelineCursor === merged.timelineCursor && + existing.text === merged.text && + existing.timestamp === merged.timestamp && + existing.images === merged.images && + existing.attachments === merged.attachments + ) { + return { items, index, message: existing, matched: true }; + } + const next = [...items]; + next[index] = merged; + return { items: next, index, message: merged, matched: true }; +} + +export interface UserMessageStreamUpsertInput { + tail: StreamItem[]; + head: StreamItem[]; + message: UserMessageItem; + insert: "tail" | "head" | "prepend-tail" | "none"; + presentation: UserMessagePresentationPolicy; + matchPolicy?: UserMessageMatchPolicy; +} + +export interface UserMessageStreamUpsertResult extends ApplyStreamEventResult { + location: { + lane: "tail" | "head"; + index: number; + message: UserMessageItem; + matched: boolean; + } | null; +} + +export function upsertUserMessageAcrossStream( + input: UserMessageStreamUpsertInput, +): UserMessageStreamUpsertResult { + const tailResult = produceUserMessage( + input.tail, + input.message, + null, + input.presentation, + input.matchPolicy, + ); + if (tailResult.matched) { + return { + tail: tailResult.items, + head: input.head, + changedTail: tailResult.items !== input.tail, + changedHead: false, + location: { + lane: "tail", + index: tailResult.index, + message: tailResult.message, + matched: true, + }, + }; + } + const headResult = produceUserMessage( + input.head, + input.message, + null, + input.presentation, + input.matchPolicy, + ); + if (headResult.matched) { + return { + tail: input.tail, + head: headResult.items, + changedTail: false, + changedHead: headResult.items !== input.head, + location: { + lane: "head", + index: headResult.index, + message: headResult.message, + matched: true, + }, + }; + } + if (input.insert === "none") { + return { + tail: input.tail, + head: input.head, + changedTail: false, + changedHead: false, + location: null, + }; + } + if (input.insert === "head") { + const inserted = produceUserMessage( + input.head, + input.message, + input.head.length, + input.presentation, + input.matchPolicy, + ); + return { + tail: input.tail, + head: inserted.items, + changedTail: false, + changedHead: true, + location: { + lane: "head", + index: inserted.index, + message: inserted.message, + matched: false, + }, + }; + } + const inserted = produceUserMessage( + input.tail, + input.message, + input.insert === "prepend-tail" ? 0 : input.tail.length, + input.presentation, + input.matchPolicy, + ); + return { + tail: inserted.items, + head: input.head, + changedTail: true, + changedHead: false, + location: { + lane: "tail", + index: inserted.index, + message: inserted.message, + matched: false, + }, + }; +} + +function placeCanonicalUserMessageAtTail( + tail: StreamItem[], + message: UserMessageItem, + insertWhenUnmatched: boolean, +): Pick { + const produced = produceUserMessage(tail, message, null, "existing"); + if (!produced.matched && !insertWhenUnmatched) { + return produced; + } + const preceding = produced.matched + ? [...produced.items.slice(0, produced.index), ...produced.items.slice(produced.index + 1)] + : produced.items; + return { + items: [...preceding, produced.message], + message: produced.message, + matched: produced.matched, + }; +} + +export interface CanonicalStreamReplacementInput { + canonical: StreamItem[]; + previousTail: StreamItem[]; + previousHead: StreamItem[]; + sendingClientMessageIds: readonly string[]; + preserveContinuity: boolean; + canonicalCoverage: { epoch: string; endSeq: number | null }; +} + +export interface CanonicalStreamReplacementResult { + tail: StreamItem[]; + head: StreamItem[]; + acknowledgedClientMessageIds: string[]; +} + +function isAfterCanonicalCoverage( + position: TimelinePosition, + coverage: CanonicalStreamReplacementInput["canonicalCoverage"], +): boolean { + return ( + position.epoch === coverage.epoch && + (coverage.endSeq === null || position.seq > coverage.endSeq) + ); +} + +function removeUserMessageAt(items: UserMessageItem[], index: number): UserMessageItem[] { + return [...items.slice(0, index), ...items.slice(index + 1)]; +} + +function mergeRetainedLifecycleItem(tail: StreamItem[], retained: StreamItem): StreamItem[] | null { + if (!retained.timelineCursor) { + return null; + } + if (isAgentToolCallItem(retained)) { + const tailIndex = findExistingAgentToolCallIndex(tail, retained.payload.data.callId); + const existing = tail[tailIndex]; + if (tailIndex < 0 || !existing || !isAgentToolCallItem(existing)) { + return null; + } + const next = [...tail]; + next[tailIndex] = mergeAgentToolCallItem( + existing, + retained.payload.data, + retained.timestamp, + retained.timelineCursor, + ); + return next; + } + if (retained.kind === "todo_list") { + const tailIndex = tail.length - 1; + const existing = tail[tailIndex]; + if (!existing || existing.kind !== "todo_list" || existing.provider !== retained.provider) { + return null; + } + const next = [...tail]; + next[tailIndex] = { + ...existing, + timelineCursor: retained.timelineCursor, + timestamp: retained.timestamp, + items: retained.items, + }; + return next; + } + if (retained.kind === "compaction" && retained.status === "completed") { + const tailIndex = tail.findIndex( + (item) => item.kind === "compaction" && item.status === "loading", + ); + const existing = tail[tailIndex]; + if (tailIndex < 0 || !existing || existing.kind !== "compaction") { + return null; + } + const next = [...tail]; + next[tailIndex] = { + ...existing, + timelineCursor: retained.timelineCursor, + status: "completed", + trigger: retained.trigger ?? existing.trigger, + preTokens: retained.preTokens ?? existing.preTokens, + }; + return next; + } + return null; +} + +function reconcileReplacementHeadAgainstTail( + tail: StreamItem[], + retainedHead: StreamItem[], +): { tail: StreamItem[]; head: StreamItem[] } { + let reconciledTail = tail; + const reconciledHeadIndexes = new Set(); + for (const [headIndex, item] of retainedHead.entries()) { + const nextTail = mergeRetainedLifecycleItem(reconciledTail, item); + if (!nextTail) { + continue; + } + reconciledTail = nextTail; + reconciledHeadIndexes.add(headIndex); + } + + const tailIds = new Set(reconciledTail.map((item) => item.id)); + return { + tail: reconciledTail, + head: retainedHead.filter( + (item, index) => + !reconciledHeadIndexes.has(index) && + (item.kind === "assistant_message" || !tailIds.has(item.id)), + ), + }; +} + +function preserveReplacementHead( + tail: StreamItem[], + currentHead: StreamItem[], + preserveContinuity: boolean, + sendingClientMessageIds: ReadonlySet, + canonicalCoverage: CanonicalStreamReplacementInput["canonicalCoverage"], +): CanonicalStreamReplacementResult { + const canonicalTailAssistant = tail.at(-1); + const retainedHead = preserveContinuity + ? currentHead.filter( + (item) => + !item.timelineCursor || + isAfterCanonicalCoverage(item.timelineCursor, canonicalCoverage) || + (item.kind === "assistant_message" && + canonicalTailAssistant?.kind === "assistant_message" && + item.text.startsWith(canonicalTailAssistant.text)), + ) + : currentHead.filter( + (item) => + item.kind === "user_message" && + item.clientMessageId !== undefined && + sendingClientMessageIds.has(item.clientMessageId), + ); + const { tail: reconciledTail, head: unreconciledHead } = reconcileReplacementHeadAgainstTail( + tail, + retainedHead, + ); + const liveAssistantIndex = unreconciledHead[0]?.kind === "assistant_message" ? 0 : -1; + if (liveAssistantIndex < 0) { + return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; + } + + const liveAssistant = unreconciledHead[liveAssistantIndex]; + const tailAssistant = reconciledTail.at(-1); + if ( + liveAssistant.kind !== "assistant_message" || + !tailAssistant || + tailAssistant.kind !== "assistant_message" + ) { + return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; + } + + const hasNewerCursor = + liveAssistant.timelineCursor !== undefined && + tailAssistant.timelineCursor !== undefined && + liveAssistant.timelineCursor.epoch === tailAssistant.timelineCursor.epoch && + liveAssistant.timelineCursor.seq > tailAssistant.timelineCursor.seq; + const hasMatchingProviderMessageId = + liveAssistant.messageId !== undefined && liveAssistant.messageId === tailAssistant.messageId; + const hasIdlessTextContinuation = + liveAssistant.messageId === undefined && + tailAssistant.messageId === undefined && + liveAssistant.text.startsWith(tailAssistant.text); + const isNewerContinuation = + hasNewerCursor && (hasMatchingProviderMessageId || hasIdlessTextContinuation); + if (isNewerContinuation) { + const text = liveAssistant.text.startsWith(tailAssistant.text) + ? liveAssistant.text + : `${tailAssistant.text}${liveAssistant.text}`; + const head = [ + ...unreconciledHead.slice(0, liveAssistantIndex), + { ...liveAssistant, text }, + ...unreconciledHead.slice(liveAssistantIndex + 1), + ]; + return { + tail: reconciledTail.slice(0, -1), + head, + acknowledgedClientMessageIds: [], + }; + } + if (!liveAssistant.text.startsWith(tailAssistant.text)) { + return { tail: reconciledTail, head: unreconciledHead, acknowledgedClientMessageIds: [] }; + } + + const head = [ + ...unreconciledHead.slice(0, liveAssistantIndex), + { ...liveAssistant, text: tailAssistant.text }, + ...unreconciledHead.slice(liveAssistantIndex + 1), + ]; + return { + tail: reconciledTail.slice(0, -1), + head, + acknowledgedClientMessageIds: [], + }; +} + +export function replaceWithCanonicalStream( + input: CanonicalStreamReplacementInput, +): CanonicalStreamReplacementResult { + const sendingClientMessageIds = new Set(input.sendingClientMessageIds); + let unmatchedTailMessages = input.previousTail.filter( + (item): item is UserMessageItem => + item.kind === "user_message" && item.clientMessageId !== undefined, + ); + let nextHead = input.previousHead; + const nextTail: StreamItem[] = []; + const acknowledgedClientMessageIds = new Set(); + + for (const item of input.canonical) { + if (item.kind !== "user_message") { + nextTail.push(item); + continue; + } + + const tailResult = produceUserMessage(unmatchedTailMessages, item, null, "existing"); + if (tailResult.matched) { + unmatchedTailMessages = removeUserMessageAt(unmatchedTailMessages, tailResult.index); + nextTail.push(tailResult.message); + if (tailResult.message.clientMessageId) { + acknowledgedClientMessageIds.add(tailResult.message.clientMessageId); + } + continue; + } + + const headResult = produceUserMessage(nextHead, item, null, "existing"); + if (headResult.matched) { + nextHead = [ + ...headResult.items.slice(0, headResult.index), + ...headResult.items.slice(headResult.index + 1), + ]; + nextTail.push(headResult.message); + if (headResult.message.clientMessageId) { + acknowledgedClientMessageIds.add(headResult.message.clientMessageId); + } + continue; + } + + nextTail.push(item); + } + + const retainedTailMessages: UserMessageItem[] = []; + for (const local of unmatchedTailMessages) { + const preserveLocal = input.preserveContinuity + ? isUnreconciledLocalUserMessage(local) + : local.clientMessageId !== undefined && sendingClientMessageIds.has(local.clientMessageId); + if (preserveLocal) { + nextTail.push(local); + } else if ( + input.preserveContinuity && + local.timelineCursor && + isAfterCanonicalCoverage(local.timelineCursor, input.canonicalCoverage) + ) { + retainedTailMessages.push(local); + } + } + nextHead = [...retainedTailMessages, ...nextHead]; + + const replacement = preserveReplacementHead( + nextTail, + nextHead, + input.preserveContinuity, + sendingClientMessageIds, + input.canonicalCoverage, + ); + return { + ...replacement, + acknowledgedClientMessageIds: [...acknowledgedClientMessageIds], + }; +} export interface AssistantMessageItem { kind: "assistant_message"; @@ -125,6 +693,7 @@ export type ThoughtStatus = "loading" | "ready"; export interface ThoughtItem { kind: "thought"; id: string; + timelineCursor?: TimelinePosition; text: string; timestamp: Date; status: ThoughtStatus; @@ -159,6 +728,7 @@ export type ToolCallPayload = export interface ToolCallItem { kind: "tool_call"; id: string; + timelineCursor?: TimelinePosition; timestamp: Date; payload: ToolCallPayload; } @@ -176,6 +746,7 @@ type ActivityLogType = "system" | "info" | "success" | "error"; export interface ActivityLogItem { kind: "activity_log"; id: string; + timelineCursor?: TimelinePosition; timestamp: Date; activityType: ActivityLogType; message: string; @@ -185,6 +756,7 @@ export interface ActivityLogItem { export interface CompactionItem { kind: "compaction"; id: string; + timelineCursor?: TimelinePosition; timestamp: Date; status: "loading" | "completed"; trigger?: "auto" | "manual"; @@ -199,6 +771,7 @@ export interface TodoEntry { export interface TodoListItem { kind: "todo_list"; id: string; + timelineCursor?: TimelinePosition; timestamp: Date; provider: AgentProvider; items: TodoEntry[]; @@ -237,126 +810,27 @@ function markThoughtReady(item: ThoughtItem): ThoughtItem { }; } -function buildUserMessageItem(input: { - id: string; - clientMessageId?: string; - text: string; - timestamp: Date; - optimistic?: UserMessageItem | null; -}): UserMessageItem { - if (input.optimistic) { - return { - kind: "user_message", - id: input.id, - ...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}), - text: input.optimistic.text, - timestamp: input.optimistic.timestamp, - ...(input.optimistic.images && input.optimistic.images.length > 0 - ? { images: input.optimistic.images } - : {}), - ...(input.optimistic.attachments && input.optimistic.attachments.length > 0 - ? { attachments: input.optimistic.attachments } - : {}), - }; - } - - return { - kind: "user_message", - id: input.id, - ...(input.clientMessageId ? { clientMessageId: input.clientMessageId } : {}), - text: input.text, - timestamp: input.timestamp, - }; -} - -export function buildOptimisticUserMessage(input: OptimisticUserMessageInput): UserMessageItem { - return { - kind: "user_message", - id: input.id, - text: input.text, - timestamp: input.timestamp, - optimistic: true, - ...(input.images && input.images.length > 0 ? { images: input.images } : {}), - ...(input.attachments && input.attachments.length > 0 - ? { attachments: input.attachments } - : {}), - }; -} - -export function appendOptimisticUserMessageToStream(params: { - tail: StreamItem[]; - head: StreamItem[]; - message: UserMessageItem; - placement: OptimisticUserMessagePlacement; -}): ApplyStreamEventResult { - const { tail, head, message, placement } = params; - if (tail.some((item) => item.id === message.id) || head.some((item) => item.id === message.id)) { - return { tail, head, changedTail: false, changedHead: false }; - } - - if (placement === "active-head" && head.length > 0) { - return { - tail, - head: [...head, message], - changedTail: false, - changedHead: true, - }; - } - - return { - tail: [...tail, message], - head, - changedTail: true, - changedHead: false, - }; -} - export function handoffCreatedAgentUserMessageToStream(params: { tail: StreamItem[]; head: StreamItem[]; message: UserMessageItem; }): ApplyStreamEventResult { - const { tail, head, message } = params; - const items = [...tail, ...head]; - const userIndex = items.findIndex((item) => item.kind === "user_message"); - if (userIndex < 0) { - return appendOptimisticUserMessageToStream({ - tail, - head, - message, - placement: "tail", - }); - } - - const userMessage = items[userIndex]; - if (!userMessage || userMessage.kind !== "user_message" || userMessage.optimistic) { - return { tail, head, changedTail: false, changedHead: false }; - } - - const handedOffMessage = buildUserMessageItem({ - id: userMessage.id, - text: message.text, - timestamp: message.timestamp, - optimistic: message, + return upsertUserMessageAcrossStream({ + ...params, + insert: "tail", + presentation: "incoming", + matchPolicy: "handoff", }); - if (userIndex < tail.length) { - const nextTail = [...tail]; - nextTail[userIndex] = handedOffMessage; - return { tail: nextTail, head, changedTail: true, changedHead: false }; - } - - const nextHead = [...head]; - nextHead[userIndex - tail.length] = handedOffMessage; - return { tail, head: nextHead, changedTail: false, changedHead: true }; } function appendUserMessage( state: StreamItem[], text: string, timestamp: Date, - source: StreamUpdateSource, + _source: StreamUpdateSource, messageId?: string, clientMessageId?: string, + timelineCursor?: TimelinePosition, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!hasContent) { @@ -364,37 +838,15 @@ function appendUserMessage( } const chunkSeed = chunk.trim() || chunk; - const entryId = messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp); - const optimisticIndex = state.findIndex( - (entry) => - entry.kind === "user_message" && - entry.optimistic && - (clientMessageId !== undefined - ? entry.id === clientMessageId - : source === "live" || entry.id === messageId || entry.text === chunk), - ); - const optimistic = optimisticIndex >= 0 ? (state[optimisticIndex] as UserMessageItem) : null; - - const nextItem = buildUserMessageItem({ - id: entryId, + const nextItem = createUserMessage({ + id: messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp), clientMessageId, + messageId, + timelineCursor, text: chunk, timestamp, - optimistic, }); - - if (optimisticIndex >= 0) { - const next = [...state]; - next[optimisticIndex] = nextItem; - return next; - } - - return [...state, nextItem]; -} - -export function clearOptimisticUserMessages(state: StreamItem[]): StreamItem[] { - const next = state.filter((item) => item.kind !== "user_message" || !item.optimistic); - return next.length === state.length ? state : next; + return upsertUserMessage(state, nextItem); } function appendAssistantMessage( @@ -426,8 +878,8 @@ function appendAssistantMessage( return [...state.slice(0, -1), updated]; } - // If the last item is a user_message (optimistic append to head during - // interrupt), look one further back for the streaming assistant_message. + // A submitted user row can follow the streaming assistant during interrupt. + // In that case, look one row further back for the assistant to extend. const secondLast = state[state.length - 2]; if ( source === "live" && @@ -461,7 +913,12 @@ function appendAssistantMessage( return [...state, item]; } -function appendThought(state: StreamItem[], text: string, timestamp: Date): StreamItem[] { +function appendThought( + state: StreamItem[], + text: string, + timestamp: Date, + timelineCursor?: TimelinePosition, +): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { return state; @@ -471,6 +928,7 @@ function appendThought(state: StreamItem[], text: string, timestamp: Date): Stre if (last && last.kind === "thought") { const updated: ThoughtItem = { ...last, + ...(timelineCursor ? { timelineCursor } : {}), text: `${last.text}${chunk}`, timestamp, status: "loading", @@ -486,6 +944,7 @@ function appendThought(state: StreamItem[], text: string, timestamp: Date): Stre const item: ThoughtItem = { kind: "thought", id: createUniqueTimelineId(state, "thought", idSeed, timestamp), + ...(timelineCursor ? { timelineCursor } : {}), text: chunk, timestamp, status: "loading", @@ -621,6 +1080,7 @@ export function mergeAgentToolCallItem( existing: AgentToolCallItem, data: AgentToolCallData, timestamp: Date, + timelineCursor?: TimelinePosition, ): AgentToolCallItem { const mergedStatus = mergeAgentToolCallStatus(existing.payload.data.status, data.status); const mergedError = @@ -632,6 +1092,7 @@ export function mergeAgentToolCallItem( return { ...existing, + ...(timelineCursor ? { timelineCursor } : {}), timestamp, payload: { source: "agent", @@ -651,6 +1112,7 @@ function appendAgentToolCall( state: StreamItem[], data: AgentToolCallData, timestamp: Date, + timelineCursor?: TimelinePosition, ): StreamItem[] { const existingIndex = findExistingAgentToolCallIndex(state, data.callId); @@ -659,7 +1121,7 @@ function appendAgentToolCall( if (!existing || !isAgentToolCallItem(existing)) { return state; } - const merged = mergeAgentToolCallItem(existing, data, timestamp); + const merged = mergeAgentToolCallItem(existing, data, timestamp, timelineCursor); if ( merged.payload.data.provider === existing.payload.data.provider && @@ -668,7 +1130,8 @@ function appendAgentToolCall( merged.payload.data.status === existing.payload.data.status && merged.payload.data.error === existing.payload.data.error && merged.payload.data.detail === existing.payload.data.detail && - merged.payload.data.metadata === existing.payload.data.metadata + merged.payload.data.metadata === existing.payload.data.metadata && + merged.timelineCursor === existing.timelineCursor ) { return state; } @@ -681,6 +1144,7 @@ function appendAgentToolCall( const item: ToolCallItem = { kind: "tool_call", id: `agent_tool_${data.callId}`, + ...(timelineCursor ? { timelineCursor } : {}), timestamp, payload: { source: "agent", @@ -709,6 +1173,7 @@ function appendTodoList( provider: AgentProvider, items: TodoEntry[], timestamp: Date, + timelineCursor?: TimelinePosition, ): StreamItem[] { const normalizedItems = items.map((item) => ({ text: item.text, @@ -720,6 +1185,7 @@ function appendTodoList( const next = [...state]; const updated: TodoListItem = { ...lastItem, + ...(timelineCursor ? { timelineCursor } : {}), items: normalizedItems, timestamp, }; @@ -733,6 +1199,7 @@ function appendTodoList( const entry: TodoListItem = { kind: "todo_list", id: entryId, + ...(timelineCursor ? { timelineCursor } : {}), timestamp, provider, items: normalizedItems, @@ -749,6 +1216,7 @@ function reduceTimelineToolCall( { type: "tool_call" } >, timestamp: Date, + timelineCursor?: TimelinePosition, ): StreamItem[] { const normalizedToolName = item.name .trim() @@ -771,6 +1239,7 @@ function reduceTimelineToolCall( event.provider, tasks.map((entry) => ({ text: entry.text, completed: entry.completed })), timestamp, + timelineCursor, ); } @@ -781,6 +1250,7 @@ function reduceTimelineToolCall( event.provider, tasks.map((entry) => ({ text: entry.text, completed: entry.completed })), timestamp, + timelineCursor, ); } @@ -796,6 +1266,7 @@ function reduceTimelineToolCall( metadata: item.metadata, }, timestamp, + timelineCursor, ); } @@ -806,6 +1277,7 @@ function reduceTimelineCompaction( { type: "compaction" } >, timestamp: Date, + timelineCursor?: TimelinePosition, ): StreamItem[] { if (item.status === "completed") { const loadingIdx = state.findIndex((s) => s.kind === "compaction" && s.status === "loading"); @@ -813,6 +1285,7 @@ function reduceTimelineCompaction( if (loadingIdx >= 0 && existing && existing.kind === "compaction") { const updated: CompactionItem = { ...existing, + ...(timelineCursor ? { timelineCursor } : {}), status: "completed", trigger: item.trigger ?? existing.trigger, preTokens: item.preTokens ?? existing.preTokens, @@ -826,6 +1299,7 @@ function reduceTimelineCompaction( const compaction: CompactionItem = { kind: "compaction", id: createTimelineId("compaction", item.status, timestamp), + ...(timelineCursor ? { timelineCursor } : {}), timestamp, status: item.status, trigger: item.trigger, @@ -853,6 +1327,7 @@ function reduceTimelineEvent( source, item.messageId, item.clientMessageId, + timelineCursor, ), ); case "assistant_message": @@ -868,9 +1343,11 @@ function reduceTimelineEvent( ), ); case "reasoning": - return appendThought(state, item.text, timestamp); + return appendThought(state, item.text, timestamp, timelineCursor); case "tool_call": - return finalizeActiveThoughts(reduceTimelineToolCall(state, event, item, timestamp)); + return finalizeActiveThoughts( + reduceTimelineToolCall(state, event, item, timestamp, timelineCursor), + ); case "todo": { if (event.provider === "claude") { return finalizeActiveThoughts(state); @@ -879,12 +1356,15 @@ function reduceTimelineEvent( text: todo.text, completed: todo.completed, })); - return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp)); + return finalizeActiveThoughts( + appendTodoList(state, event.provider, items, timestamp, timelineCursor), + ); } case "error": { const activity: ActivityLogItem = { kind: "activity_log", id: createTimelineId("error", item.message ?? "", timestamp), + ...(timelineCursor ? { timelineCursor } : {}), timestamp, activityType: "error", message: item.message ?? "Unknown error", @@ -892,7 +1372,9 @@ function reduceTimelineEvent( return finalizeActiveThoughts(appendActivityLog(state, activity)); } case "compaction": - return finalizeActiveThoughts(reduceTimelineCompaction(state, item, timestamp)); + return finalizeActiveThoughts( + reduceTimelineCompaction(state, item, timestamp, timelineCursor), + ); default: return state; } @@ -1019,15 +1501,6 @@ function finalizeHeadItems(head: StreamItem[]): StreamItem[] { if (item.kind === "thought" && item.status !== "ready") { return markThoughtReady(item); } - if (item.kind === "assistant_message" && item.blockGroupId) { - return { - ...item, - id: createAssistantBlockId({ - groupId: item.blockGroupId, - blockIndex: item.blockIndex ?? 0, - }), - }; - } return item; }); } @@ -1115,7 +1588,10 @@ function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: Str const nextTail = flushHeadToTail(params.tail, promotedItems); const liveItem: AssistantMessageItem = { ...activeItem, - id: `${blockGroupId}:head`, + id: createAssistantBlockId({ + groupId: blockGroupId, + blockIndex: firstBlockIndex + completedBlocks.length, + }), blockGroupId, blockIndex: firstBlockIndex + completedBlocks.length, text: liveBlock, @@ -1149,7 +1625,6 @@ export function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamI if (newItems.length === 0) { return tail; } - return [...tail, ...newItems]; } @@ -1177,8 +1652,7 @@ function shouldFlushHead(input: { return true; } - // Find the last streamable item in head (skip trailing non-streamable - // items like an optimistic user_message appended during interrupt). + // Find the last streamable item in head (skip trailing non-streamable items). let lastStreamable: StreamItem | undefined; for (let i = head.length - 1; i >= 0; i--) { if (isStreamableKind(head[i].kind)) { @@ -1209,6 +1683,63 @@ export interface ApplyStreamEventResult { head: StreamItem[]; changedTail: boolean; changedHead: boolean; + acknowledgedClientMessageIds?: string[]; +} + +function applyCanonicalUserMessageEvent(params: { + tail: StreamItem[]; + head: StreamItem[]; + event: AgentStreamEventPayload; + timestamp: Date; + timelineCursor?: TimelinePosition; + unmatchedInsert?: "tail" | "head"; +}): ApplyStreamEventResult | null { + const { tail, head, event, timestamp, timelineCursor, unmatchedInsert = "tail" } = params; + if (event.type !== "timeline" || event.item.type !== "user_message") return null; + const normalized = normalizeChunk(event.item.text); + + const flushedTail = head.length > 0 ? flushHeadToTail(tail, head) : tail; + const flushedHead = head.length > 0 ? [] : head; + const canonical = createUserMessage({ + id: + event.item.messageId ?? + createUniqueTimelineId([...tail, ...head], "user", normalized.chunk.trim(), timestamp), + messageId: event.item.messageId, + clientMessageId: event.item.clientMessageId, + timelineCursor, + text: normalized.chunk, + timestamp, + }); + if (unmatchedInsert === "head") { + const reconciled = upsertUserMessageAcrossStream({ + tail, + head, + message: canonical, + insert: normalized.hasContent ? "head" : "none", + presentation: "existing", + }); + return { + tail: reconciled.tail, + head: reconciled.head, + changedTail: reconciled.changedTail, + changedHead: reconciled.changedHead, + acknowledgedClientMessageIds: + reconciled.location?.matched && reconciled.location.message.clientMessageId + ? [reconciled.location.message.clientMessageId] + : [], + }; + } + const reconciled = placeCanonicalUserMessageAtTail(flushedTail, canonical, normalized.hasContent); + return { + tail: reconciled.items, + head: flushedHead, + changedTail: flushedTail !== tail || reconciled.items !== flushedTail, + changedHead: flushedHead !== head, + acknowledgedClientMessageIds: + reconciled.matched && reconciled.message.clientMessageId + ? [reconciled.message.clientMessageId] + : [], + }; } /** @@ -1229,14 +1760,23 @@ export function applyStreamEvent(params: { timestamp: Date; source?: StreamUpdateSource; timelineCursor?: TimelinePosition; + unmatchedUserMessageInsert?: "tail" | "head"; }): ApplyStreamEventResult { const { tail, head, event, timestamp } = params; + const canonicalUserResult = applyCanonicalUserMessageEvent({ + tail, + head, + event, + timestamp, + timelineCursor: params.timelineCursor, + unmatchedInsert: params.unmatchedUserMessageInsert, + }); + if (canonicalUserResult) return canonicalUserResult; const source = params.source ?? "live"; let nextTail = tail; let nextHead = head; let changedTail = false; let changedHead = false; - const flushHead = () => { if (nextHead.length === 0) { return; diff --git a/packages/app/src/utils/agent-attention.test.ts b/packages/app/src/utils/agent-attention.test.ts index 2fc70a20b..b9a0d8e6b 100644 --- a/packages/app/src/utils/agent-attention.test.ts +++ b/packages/app/src/utils/agent-attention.test.ts @@ -34,6 +34,7 @@ function createAgent(input: Partial & Pick): Agent { attentionReason: null, attentionTimestamp: null, ...rest, + activeTurn: rest.activeTurn ?? null, }; } diff --git a/packages/app/src/utils/agent-directory-reconciliation.test.ts b/packages/app/src/utils/agent-directory-reconciliation.test.ts index 03387d010..bf1e0a53e 100644 --- a/packages/app/src/utils/agent-directory-reconciliation.test.ts +++ b/packages/app/src/utils/agent-directory-reconciliation.test.ts @@ -53,6 +53,7 @@ function entry(id: string, status: AgentSnapshotPayload["status"]): FetchAgentsE function replica(id: string, status: Agent["status"]): Agent { return { ...snapshot(id, status), + activeTurn: null, serverId: "server", createdAt: new Date("2026-07-12T10:00:00.000Z"), updatedAt: new Date("2026-07-12T10:00:00.000Z"), diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index 1ec16bde2..71fb4cc35 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -7,6 +7,7 @@ import { useSessionStore } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent"; import { queryClient } from "@/data/query-client"; +import { createUserMessage } from "@/types/stream"; import { applyAgentDirectoryDelta, replaceFetchedAgentDirectory } from "./agent-directory-sync"; function createAgentPayload( @@ -64,6 +65,147 @@ function permission(id: string): AgentPermissionRequest { return { id, provider: "codex", name: id, kind: "tool", title: id }; } +function beginPendingSubmission(serverId: string, agentId: string): string { + const clientMessageId = `client-${agentId}`; + useSessionStore.getState().beginAgentMessageSubmission( + serverId, + agentId, + createUserMessage({ + clientMessageId, + text: "Run this", + timestamp: new Date("2026-07-27T10:00:00.000Z"), + }), + ); + return clientMessageId; +} + +function applyAgentStatus(input: { + serverId: string; + agentId: string; + status: AgentSnapshotPayload["status"]; + updatedAt: string; + lastUserMessageAt?: string; +}): void { + const agent = createAgentPayload({ + id: input.agentId, + status: input.status, + updatedAt: input.updatedAt, + lastUserMessageAt: input.lastUserMessageAt ?? null, + }); + applyAgentDirectoryDelta({ + serverId: input.serverId, + delta: { kind: "upsert", agent, project: createEntry(agent).project }, + }); +} + +describe("turn liveness authority", () => { + it("normalizes old-daemon status into the shared activity replica", () => { + const serverId = "server-turn-liveness"; + const agentId = "agent-1"; + const startedAt = "2026-07-27T10:00:01.000Z"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + applyAgentStatus({ serverId, agentId, status: "idle", updatedAt: startedAt }); + + applyAgentStatus({ + serverId, + agentId, + status: "running", + updatedAt: "2026-07-27T10:00:02.000Z", + lastUserMessageAt: startedAt, + }); + expect(store.getSession(serverId)?.agentTurnLiveness.get(agentId)).toEqual({ + phase: "open", + turnId: null, + startedAt: new Date(startedAt), + cancellationRequestId: null, + }); + + applyAgentStatus({ + serverId, + agentId, + status: "idle", + updatedAt: "2026-07-27T10:00:03.000Z", + }); + + expect(store.getSession(serverId)?.agentTurnLiveness.has(agentId)).toBe(false); + store.clearSession(serverId); + }); +}); + +describe("message submission authority", () => { + it("does not settle a submission from an unrelated running transition", () => { + const serverId = "server-running-is-not-submission-ack"; + const agentId = "agent-1"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + applyAgentStatus({ + serverId, + agentId, + status: "idle", + updatedAt: "2026-07-27T10:00:00.000Z", + }); + const clientMessageId = beginPendingSubmission(serverId, agentId); + + applyAgentStatus({ + serverId, + agentId, + status: "running", + updatedAt: "2026-07-27T10:00:01.000Z", + }); + + expect(useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)).toEqual([ + { + clientMessageId, + providerAcknowledged: false, + rpcSettled: false, + }, + ]); + store.clearSession(serverId); + }); + + it("settles provider acknowledgement only when timeline ingestion reports it", () => { + const serverId = "server-explicit-provider-ack"; + const agentId = "agent-1"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + applyAgentStatus({ + serverId, + agentId, + status: "idle", + updatedAt: "2026-07-27T10:00:00.000Z", + }); + const clientMessageId = beginPendingSubmission(serverId, agentId); + store.setAgentStreamState(serverId, agentId, { + tail: [ + createUserMessage({ + id: "provider-message", + messageId: "provider-message", + clientMessageId, + text: "Run this", + timestamp: new Date("2026-07-27T10:00:01.000Z"), + }), + ], + head: [], + }); + + expect( + useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0] + ?.providerAcknowledged, + ).toBe(false); + + store.setAgentStreamState(serverId, agentId, { + acknowledgedClientMessageIds: [clientMessageId], + }); + + expect( + useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)?.[0] + ?.providerAcknowledged, + ).toBe(true); + store.clearSession(serverId); + }); +}); + describe("replaceFetchedAgentDirectory", () => { it("preserves timeline initialization while replacing directory state", () => { const serverId = "server-initializing"; diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index 24e14c420..1e0f0f87c 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -73,9 +73,17 @@ export function upsertAgentReplica(serverId: string, agent: Agent): Agent { next.set(agent.id, acceptedAgent); return next; }); + applyAgentTurnSnapshot(serverId, acceptedAgent); return acceptedAgent; } +function applyAgentTurnSnapshot(serverId: string, agent: Agent): void { + useSessionStore.getState().applyAgentTurnLiveness(serverId, agent.id, { + type: "snapshot", + activeTurn: agent.activeTurn, + }); +} + export function replaceAgentPendingPermissions(serverId: string, agent: Agent): void { const pendingPermissions = new Map( useSessionStore.getState().sessions[serverId]?.pendingPermissions, @@ -114,6 +122,7 @@ export function removeAgentDirectoryReplica(serverId: string, agentId: string): store.setAgentAuthoritativeHistoryApplied(serverId, agentId, false); store.setAgentStreamTail(serverId, removeKey); store.clearAgentStreamHead(serverId, agentId); + store.applyAgentTurnLiveness(serverId, agentId, { type: "destructive_close" }); useSessionStore.setState((state) => { if (!state.agentLastActivity.has(agentId)) return state; const agentLastActivity = new Map(state.agentLastActivity); @@ -172,7 +181,6 @@ export function replaceFetchedAgentDirectory(input: { }): { agents: Map } { const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input); const store = useSessionStore.getState(); - for (const agent of fetchedAgents.values()) { if (agent.archivedAt) { clearArchiveAgentPending({ queryClient, serverId: input.serverId, agentId: agent.id }); @@ -180,6 +188,9 @@ export function replaceFetchedAgentDirectory(input: { } store.setAgents(input.serverId, fetchedAgents); + for (const agent of fetchedAgents.values()) { + applyAgentTurnSnapshot(input.serverId, agent); + } store.setAgentDetails(input.serverId, (prev) => { let next: Map | null = null; for (const agentId of fetchedAgents.keys()) { diff --git a/packages/app/src/utils/agent-snapshots.test.ts b/packages/app/src/utils/agent-snapshots.test.ts index b514db1a0..966b17eb3 100644 --- a/packages/app/src/utils/agent-snapshots.test.ts +++ b/packages/app/src/utils/agent-snapshots.test.ts @@ -17,6 +17,7 @@ function createSnapshot( updatedAt: input.updatedAt ?? "2026-04-20T00:01:00.000Z", lastUserMessageAt: input.lastUserMessageAt ?? null, status: input.status ?? "idle", + activeTurn: input.activeTurn, capabilities: input.capabilities ?? { supportsStreaming: true, supportsSessionPersistence: true, @@ -35,6 +36,26 @@ function createSnapshot( } describe("normalizeAgentSnapshot", () => { + it("normalizes identified and legacy active turns at the snapshot boundary", () => { + const startedAt = "2026-07-31T12:00:00.000Z"; + expect( + normalizeAgentSnapshot( + createSnapshot({ + status: "running", + activeTurn: { turnId: "turn-1", startedAt }, + }), + "server-1", + ).activeTurn, + ).toEqual({ turnId: "turn-1", startedAt: new Date(startedAt) }); + + expect( + normalizeAgentSnapshot( + createSnapshot({ status: "running", lastUserMessageAt: startedAt }), + "server-1", + ).activeTurn, + ).toEqual({ turnId: null, startedAt: new Date(startedAt) }); + }); + it("derives parentAgentId from the parent label while preserving labels", () => { const labels = { [PARENT_AGENT_ID_LABEL]: "parent-1", diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index 08de1633e..aeaef3ca4 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -1,6 +1,21 @@ import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; +import type { ActiveTurnIdentity } from "@/timeline/turn-liveness"; + +function normalizeActiveTurn( + snapshot: AgentSnapshotPayload, + lastUserMessageAt: Date | null, +): ActiveTurnIdentity | null { + if (snapshot.activeTurn === null) return null; + if (snapshot.activeTurn) { + return { + turnId: snapshot.activeTurn.turnId, + startedAt: snapshot.activeTurn.startedAt ? new Date(snapshot.activeTurn.startedAt) : null, + }; + } + return snapshot.status === "running" ? { turnId: null, startedAt: lastUserMessageAt } : null; +} export function derivePendingPermissionKey( agentId: string, @@ -27,12 +42,17 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: : null; const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null; const parentAgentId = getParentAgentIdFromLabels(snapshot.labels); + // COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6. + // Old daemons expose only status. Normalize that legacy signal once so the rest + // of the app consumes one activity shape. + const activeTurn = normalizeActiveTurn(snapshot, lastUserMessageAt); return { serverId, id: snapshot.id, provider: snapshot.provider, status: snapshot.status, + activeTurn, createdAt, updatedAt, lastUserMessageAt, diff --git a/packages/app/src/utils/assistant-image-metadata.test.ts b/packages/app/src/utils/assistant-image-metadata.test.ts index e6138dc71..d4b99655f 100644 --- a/packages/app/src/utils/assistant-image-metadata.test.ts +++ b/packages/app/src/utils/assistant-image-metadata.test.ts @@ -3,7 +3,6 @@ import { clearAssistantImageMetadataCache, estimateAssistantMessageHeightFromCache, extractAssistantImageSources, - getAssistantImageLoadStateFromMetadata, getAssistantImageMetadata, setAssistantImageMetadata, } from "./assistant-image-metadata"; @@ -42,23 +41,6 @@ describe("assistant image metadata", () => { }); }); - it("maps missing metadata to the image loading state", () => { - expect(getAssistantImageLoadStateFromMetadata(null)).toEqual({ status: "loading" }); - }); - - it("maps cached metadata to the image ready state", () => { - expect( - getAssistantImageLoadStateFromMetadata({ - width: 900, - height: 1600, - aspectRatio: 9 / 16, - }), - ).toEqual({ - status: "ready", - aspectRatio: 9 / 16, - }); - }); - it("estimates assistant message height from cached image metadata", () => { setAssistantImageMetadata( { diff --git a/packages/app/src/utils/assistant-image-metadata.ts b/packages/app/src/utils/assistant-image-metadata.ts index b11d26baf..9f6e159b0 100644 --- a/packages/app/src/utils/assistant-image-metadata.ts +++ b/packages/app/src/utils/assistant-image-metadata.ts @@ -8,11 +8,6 @@ export interface AssistantImageMetadata { aspectRatio: number; } -export type AssistantImageLoadState = - | { status: "loading" } - | { status: "ready"; aspectRatio: number } - | { status: "error" }; - const assistantImageMetadataCache = new Map(); const assistantImageParseCache = new Map(); const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500; @@ -131,15 +126,6 @@ export function getAssistantImageMetadata(input: { return null; } -export function getAssistantImageLoadStateFromMetadata( - metadata: AssistantImageMetadata | null, -): AssistantImageLoadState { - if (!metadata) { - return { status: "loading" }; - } - return { status: "ready", aspectRatio: metadata.aspectRatio }; -} - export function setAssistantImageMetadata( input: { source: string; diff --git a/packages/app/src/utils/workspace-agent-activity.test.ts b/packages/app/src/utils/workspace-agent-activity.test.ts index 7cea82191..09ada9569 100644 --- a/packages/app/src/utils/workspace-agent-activity.test.ts +++ b/packages/app/src/utils/workspace-agent-activity.test.ts @@ -19,6 +19,7 @@ function agent(input: { id: input.id, provider: "codex", status: input.status ?? "idle", + activeTurn: input.status === "running" ? { turnId: "turn-1", startedAt: null } : null, createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date(input.updatedAt), lastUserMessageAt: null, diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index 33b30f80d..b0fd6a861 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -23,6 +23,7 @@ function makeAgent(input: { id: input.id, provider: "codex", status: "idle", + activeTurn: null, createdAt, updatedAt: createdAt, lastUserMessageAt: null, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d7f21f124..a0fdbd5e7 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -478,7 +478,9 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor latest = result?.agent ?? null; return result; }, - send: (text, options) => daemonClient.sendAgentMessage(id, text, options), + send: async (text, options) => { + await daemonClient.sendAgentMessage(id, text, options); + }, archive: async () => { const result = await daemonClient.archiveAgent(id); if (latest) { diff --git a/packages/desktop/scripts/dev-runner-config.mjs b/packages/desktop/scripts/dev-runner-config.mjs new file mode 100644 index 000000000..8efbd3a03 --- /dev/null +++ b/packages/desktop/scripts/dev-runner-config.mjs @@ -0,0 +1,17 @@ +export function createElectronSpawnOptions({ env, colorEnv, expoDevUrl }) { + return { + // Electron must stay in the runner's process group. Paseo workspace scripts + // own the terminal process group, so detaching Electron lets it survive a + // service stop with broken stdout/stderr pipes and block the next launch. + detached: false, + env: { + ...env, + ...colorEnv, + EXPO_DEV_URL: expoDevUrl, + }, + }; +} + +export function resolveChildKillTarget(pid, detached) { + return detached ? -pid : pid; +} diff --git a/packages/desktop/scripts/dev-runner-config.test.mjs b/packages/desktop/scripts/dev-runner-config.test.mjs new file mode 100644 index 000000000..813bc6760 --- /dev/null +++ b/packages/desktop/scripts/dev-runner-config.test.mjs @@ -0,0 +1,26 @@ +import { describe, expect, test } from "vitest"; +import { createElectronSpawnOptions, resolveChildKillTarget } from "./dev-runner-config.mjs"; + +describe("desktop dev process ownership", () => { + test("keeps Electron in the runner process group", () => { + const options = createElectronSpawnOptions({ + env: { PATH: "/usr/bin" }, + colorEnv: { FORCE_COLOR: "1" }, + expoDevUrl: "http://localhost:8082", + }); + + expect(options).toMatchObject({ + detached: false, + env: { + PATH: "/usr/bin", + FORCE_COLOR: "1", + EXPO_DEV_URL: "http://localhost:8082", + }, + }); + }); + + test("targets the whole process group for detached child trees", () => { + expect(resolveChildKillTarget(42, true)).toBe(-42); + expect(resolveChildKillTarget(42, false)).toBe(42); + }); +}); diff --git a/packages/desktop/scripts/dev-runner.mjs b/packages/desktop/scripts/dev-runner.mjs index 089092472..ac96fee5a 100644 --- a/packages/desktop/scripts/dev-runner.mjs +++ b/packages/desktop/scripts/dev-runner.mjs @@ -4,6 +4,7 @@ import path from "node:path"; import { spawn } from "node:child_process"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; +import { createElectronSpawnOptions, resolveChildKillTarget } from "./dev-runner-config.mjs"; import { resolveDevElectronArgs } from "./dev-runner-args.mjs"; @@ -60,7 +61,11 @@ function spawnChild(name, command, args, options = {}) { ...options, }); - children.set(name, child); + const managedChild = { + process: child, + detached: options.detached === true, + }; + children.set(name, managedChild); prefixStream(name, child.stdout, process.stdout); prefixStream(name, child.stderr, process.stderr); @@ -84,17 +89,13 @@ function spawnChild(name, command, args, options = {}) { return child; } -function killChild(child, signal) { +function killChild({ process: child, detached }, signal) { if (!child.pid || child.killed) { return; } try { - if (child.detached) { - process.kill(-child.pid, signal); - } else { - child.kill(signal); - } + process.kill(resolveChildKillTarget(child.pid, detached), signal); } catch { // The child may have exited between the liveness check and the signal. } @@ -178,12 +179,14 @@ try { } if (!stopping) { - spawnChild("electron", electron, [...electronArgs, desktopDir], { - detached: true, - env: { - ...process.env, - ...colorEnv, - EXPO_DEV_URL: expoDevUrl, - }, - }); + spawnChild( + "electron", + electron, + [...electronArgs, desktopDir], + createElectronSpawnOptions({ + env: process.env, + colorEnv, + expoDevUrl, + }), + ); } diff --git a/packages/desktop/scripts/dev-runtime-config.mjs b/packages/desktop/scripts/dev-runtime-config.mjs new file mode 100644 index 000000000..322834a5f --- /dev/null +++ b/packages/desktop/scripts/dev-runtime-config.mjs @@ -0,0 +1,45 @@ +import path from "node:path"; + +export function resolveDevUserDataDir({ devRoot, inheritedUserDataDir, fallbackRoot }) { + if (devRoot) { + return path.join(devRoot, ".dev", "user-data"); + } + if (inheritedUserDataDir) { + return inheritedUserDataDir; + } + if (!fallbackRoot) { + throw new Error("A dev root or fallback root is required"); + } + return path.join(fallbackRoot, ".dev", "user-data"); +} + +export function buildElectronFlags(inheritedFlags, remoteDebuggingPort) { + const flags = (inheritedFlags ?? "") + .split(/\s+/) + .filter(Boolean) + .filter((flag) => !flag.startsWith("--remote-debugging-port=")); + flags.push(`--remote-debugging-port=${remoteDebuggingPort}`); + return flags.join(" "); +} + +function parsePort(value, name) { + const port = Number(value); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error(`${name} must be an integer between 1 and 65535`); + } + return port; +} + +export function resolveDevRuntime(env = process.env) { + const remoteDebuggingPort = env.PASEO_ELECTRON_REMOTE_DEBUGGING_PORT + ? parsePort(env.PASEO_ELECTRON_REMOTE_DEBUGGING_PORT, "PASEO_ELECTRON_REMOTE_DEBUGGING_PORT") + : 0; + return { + electronFlags: buildElectronFlags(env.PASEO_ELECTRON_FLAGS, remoteDebuggingPort), + userDataDir: resolveDevUserDataDir({ + devRoot: env.PASEO_DEV_ROOT, + inheritedUserDataDir: env.PASEO_ELECTRON_USER_DATA_DIR, + fallbackRoot: env.PASEO_DEV_RUNTIME_FALLBACK_ROOT, + }), + }; +} diff --git a/packages/desktop/scripts/dev-runtime.mjs b/packages/desktop/scripts/dev-runtime.mjs new file mode 100644 index 000000000..ccb190539 --- /dev/null +++ b/packages/desktop/scripts/dev-runtime.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveDevRuntime } from "./dev-runtime-config.mjs"; + +const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isMain) { + try { + process.stdout.write(JSON.stringify(await resolveDevRuntime())); + } catch (error) { + console.error(`[dev-runtime] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + } +} diff --git a/packages/desktop/scripts/dev-runtime.test.mjs b/packages/desktop/scripts/dev-runtime.test.mjs new file mode 100644 index 000000000..ca2b322be --- /dev/null +++ b/packages/desktop/scripts/dev-runtime.test.mjs @@ -0,0 +1,50 @@ +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { + buildElectronFlags, + resolveDevRuntime, + resolveDevUserDataDir, +} from "./dev-runtime-config.mjs"; + +describe("desktop dev runtime isolation", () => { + test("scopes inherited user data to the explicitly selected dev root", () => { + expect( + resolveDevUserDataDir({ + devRoot: "/worktrees/feature-a", + inheritedUserDataDir: "/checkouts/paseo/.dev/user-data", + }), + ).toBe(path.join("/worktrees/feature-a", ".dev", "user-data")); + }); + + test("keeps an explicit user data override outside a managed dev root", () => { + expect( + resolveDevUserDataDir({ + inheritedUserDataDir: "/tmp/paseo-user-data", + fallbackRoot: "/checkouts/paseo", + }), + ).toBe("/tmp/paseo-user-data"); + }); + + test("replaces an inherited CDP flag with this instance's selected port", () => { + expect(buildElectronFlags("--disable-gpu --remote-debugging-port=9223", 43127)).toBe( + "--disable-gpu --remote-debugging-port=43127", + ); + }); + + test("lets Chromium atomically allocate the default CDP port", async () => { + const runtime = await resolveDevRuntime({ + PASEO_DEV_RUNTIME_FALLBACK_ROOT: "/checkouts/paseo", + }); + + expect(runtime.electronFlags).toBe("--remote-debugging-port=0"); + }); + + test("honors an explicit CDP port without silently changing it", async () => { + const runtime = await resolveDevRuntime({ + PASEO_DEV_RUNTIME_FALLBACK_ROOT: "/checkouts/paseo", + PASEO_ELECTRON_REMOTE_DEBUGGING_PORT: "9333", + }); + + expect(runtime.electronFlags).toBe("--remote-debugging-port=9333"); + }); +}); diff --git a/packages/desktop/scripts/dev.ps1 b/packages/desktop/scripts/dev.ps1 index 22818528e..81f8e4ab6 100644 --- a/packages/desktop/scripts/dev.ps1 +++ b/packages/desktop/scripts/dev.ps1 @@ -33,17 +33,12 @@ try { # Set EXPO_DEV_URL in the environment so Electron inherits it $env:EXPO_DEV_URL = "http://localhost:$($env:EXPO_PORT)" -$RemoteDebuggingPort = if ($env:PASEO_ELECTRON_REMOTE_DEBUGGING_PORT) { - $env:PASEO_ELECTRON_REMOTE_DEBUGGING_PORT -} else { - "9223" -} -$ExistingElectronFlags = if ($env:PASEO_ELECTRON_FLAGS) { - "$($env:PASEO_ELECTRON_FLAGS) " -} else { - "" -} -$env:PASEO_ELECTRON_FLAGS = "$($ExistingElectronFlags)--remote-debugging-port=$RemoteDebuggingPort" +$env:PASEO_DEV_ROOT = $RootDir +$env:PASEO_DEV_RUNTIME_FALLBACK_ROOT = $RootDir +$DevRuntime = node "$ScriptDir\dev-runtime.mjs" | ConvertFrom-Json +$env:PASEO_ELECTRON_FLAGS = $DevRuntime.electronFlags +$env:PASEO_ELECTRON_USER_DATA_DIR = $DevRuntime.userDataDir +Remove-Item Env:\PASEO_DEV_RUNTIME_FALLBACK_ROOT -ErrorAction SilentlyContinue # Allow any origin in dev so Electron on random ports works. # SECURITY: wildcard CORS is unsafe in production — only acceptable here because @@ -64,7 +59,6 @@ if (-not $env:PASEO_HOME) { } else { $PaseoHomeManaged = $false } -if (-not $env:PASEO_ELECTRON_USER_DATA_DIR) { $env:PASEO_ELECTRON_USER_DATA_DIR = "$DevStateDir\user-data" } New-Item -ItemType Directory -Force -Path $env:PASEO_HOME, $env:PASEO_ELECTRON_USER_DATA_DIR | Out-Null $DevDaemonPort = if ($env:PASEO_DEV_DAEMON_PORT) { $env:PASEO_DEV_DAEMON_PORT } else { "6788" } @@ -109,7 +103,6 @@ Write-Host @" Paseo Desktop Dev (Windows) ====================================================== Metro: http://localhost:$($env:EXPO_PORT) - CDP: http://127.0.0.1:$RemoteDebuggingPort Daemon: $($env:PASEO_LISTEN) (isolated) PASEO_HOME: $($env:PASEO_HOME) userData: $($env:PASEO_ELECTRON_USER_DATA_DIR) diff --git a/packages/desktop/scripts/dev.sh b/packages/desktop/scripts/dev.sh index bcf233bf4..f00625d87 100755 --- a/packages/desktop/scripts/dev.sh +++ b/packages/desktop/scripts/dev.sh @@ -12,7 +12,12 @@ export PASEO_LISTEN="${PASEO_LISTEN:-127.0.0.1:6768}" configure_dev_paseo_home DEV_ROOT="${PASEO_DEV_ROOT:-$(default_dev_paseo_root)}" -export PASEO_ELECTRON_USER_DATA_DIR="${PASEO_ELECTRON_USER_DATA_DIR:-$DEV_ROOT/.dev/user-data}" +export PASEO_DEV_ROOT="$DEV_ROOT" +export PASEO_DEV_RUNTIME_FALLBACK_ROOT="$DEV_ROOT" +DEV_RUNTIME="$(node "$SCRIPT_DIR/dev-runtime.mjs")" +export PASEO_ELECTRON_FLAGS="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).electronFlags)' "$DEV_RUNTIME")" +export PASEO_ELECTRON_USER_DATA_DIR="$(node -e 'process.stdout.write(JSON.parse(process.argv[1]).userDataDir)' "$DEV_RUNTIME")" +unset PASEO_DEV_RUNTIME_FALLBACK_ROOT mkdir -p "$PASEO_ELECTRON_USER_DATA_DIR" if [ -z "${EXPO_PORT:-}" ]; then @@ -24,8 +29,6 @@ export EXPO_DEV_URL="http://localhost:${EXPO_PORT}" DAEMON_ENDPOINT="$(resolve_dev_daemon_endpoint)" export PASEO_DAEMON_ENDPOINT="$DAEMON_ENDPOINT" -REMOTE_DEBUGGING_PORT="${PASEO_ELECTRON_REMOTE_DEBUGGING_PORT:-9223}" -export PASEO_ELECTRON_FLAGS="${PASEO_ELECTRON_FLAGS:+$PASEO_ELECTRON_FLAGS }--remote-debugging-port=$REMOTE_DEBUGGING_PORT" export PASEO_CORS_ORIGINS="${PASEO_CORS_ORIGINS:-*}" npm run build:main @@ -34,7 +37,6 @@ echo "════════════════════════ echo " Paseo Desktop Dev" echo "══════════════════════════════════════════════════════" echo " Metro: ${EXPO_DEV_URL}" -echo " CDP: http://127.0.0.1:${REMOTE_DEBUGGING_PORT}" echo " Daemon: ${PASEO_LISTEN}" echo " Home: ${PASEO_HOME}" echo " userData: ${PASEO_ELECTRON_USER_DATA_DIR}" diff --git a/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts b/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts index f56086d71..4f74eba9a 100644 --- a/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts +++ b/packages/desktop/src/daemon/node-entrypoint-launcher.test.ts @@ -50,7 +50,7 @@ describe("node-entrypoint-launcher", () => { entrypoint: CLI_ENTRYPOINT, argvMode: "node-script", args: ["ls"], - baseEnv: { PATH: "/usr/bin" }, + baseEnv: { PATH: "/usr/bin", PASEO_NODE_ENV: "production" }, }), ).toEqual({ command: "/opt/homebrew/bin/electron", @@ -58,6 +58,7 @@ describe("node-entrypoint-launcher", () => { env: { PATH: "/usr/bin", ELECTRON_RUN_AS_NODE: "1", + PASEO_NODE_ENV: "development", }, }); }); diff --git a/packages/desktop/src/daemon/node-entrypoint-launcher.ts b/packages/desktop/src/daemon/node-entrypoint-launcher.ts index 302a87ddc..8f7e2d873 100644 --- a/packages/desktop/src/daemon/node-entrypoint-launcher.ts +++ b/packages/desktop/src/daemon/node-entrypoint-launcher.ts @@ -30,7 +30,7 @@ export function createElectronNodeEnv( return { ...baseEnv, ELECTRON_RUN_AS_NODE: "1", - ...(options?.isPackaged === true ? { [PASEO_NODE_ENV]: "production" } : {}), + [PASEO_NODE_ENV]: options?.isPackaged === true ? "production" : "development", }; } diff --git a/packages/protocol/src/agent-feature-schemas.test.ts b/packages/protocol/src/agent-feature-schemas.test.ts index 1eb5d6e50..a14ef5f74 100644 --- a/packages/protocol/src/agent-feature-schemas.test.ts +++ b/packages/protocol/src/agent-feature-schemas.test.ts @@ -144,6 +144,47 @@ describe("agent feature schemas", () => { expect(parsed.features?.[0]?.id).toBe("fast_mode"); }); + it("accepts optional active turn identity without requiring it from old daemons", () => { + const base = { + id: "agent-turn-identity", + provider: "codex", + cwd: "/tmp/project", + model: "gpt-5", + createdAt: "2026-07-31T12:00:00.000Z", + updatedAt: "2026-07-31T12:00:01.000Z", + lastUserMessageAt: "2026-07-31T12:00:00.000Z", + status: "running", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, + }; + + expect(AgentSnapshotPayloadSchema.parse(base).activeTurn).toBeUndefined(); + expect( + AgentSnapshotPayloadSchema.parse({ + ...base, + activeTurn: { + turnId: "turn-1", + startedAt: "2026-07-31T12:00:00.000Z", + }, + }).activeTurn, + ).toEqual({ + turnId: "turn-1", + startedAt: "2026-07-31T12:00:00.000Z", + }); + }); + it("defaults missing rewind capabilities to false", () => { const parsed = AgentSnapshotPayloadSchema.parse({ id: "agent-123", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 378cd5576..7c51205fc 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -620,15 +620,18 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("turn_started"), provider: AgentProviderSchema, + turnId: z.string().optional(), }), z.object({ type: z.literal("turn_completed"), provider: AgentProviderSchema, + turnId: z.string().optional(), usage: AgentUsageSchema.optional(), }), z.object({ type: z.literal("turn_failed"), provider: AgentProviderSchema, + turnId: z.string().optional(), error: z.string(), code: z.string().optional(), diagnostic: z.string().optional(), @@ -636,6 +639,7 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("turn_canceled"), provider: AgentProviderSchema, + turnId: z.string().optional(), reason: z.string(), }), z.object({ @@ -693,6 +697,11 @@ const AgentRuntimeInfoSchema: z.ZodType = z.object({ extra: z.record(z.string(), z.unknown()).optional(), }); +const AgentActiveTurnPayloadSchema = z.object({ + turnId: z.string(), + startedAt: z.string().nullable(), +}); + export const AgentSnapshotPayloadSchema = z.object({ id: z.string(), provider: AgentProviderSchema, @@ -706,6 +715,7 @@ export const AgentSnapshotPayloadSchema = z.object({ updatedAt: z.string(), lastUserMessageAt: z.string().nullable(), status: AgentStatusSchema, + activeTurn: AgentActiveTurnPayloadSchema.nullable().optional(), capabilities: AgentCapabilityFlagsSchema, currentModeId: z.string().nullable(), availableModes: z.array(AgentModeSchema), @@ -2878,6 +2888,10 @@ export const ServerInfoStatusPayloadSchema = z forgeProviders: z.boolean().optional(), // COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. selectiveAgentTimeline: z.boolean().optional(), + // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. + canonicalSubmittedPrompts: z.boolean().optional(), + // COMPAT(agentTurnIdentity): accept peers that observed pre-release v0.2.6 through 2027-01-31. + agentTurnIdentity: z.boolean().optional(), // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. stableProjectIdentity: z.boolean().optional(), // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. diff --git a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts index 6e7f813c5..882c0751e 100644 --- a/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts +++ b/packages/server/src/server/agent/agent-manager-stream-coalescing.test.ts @@ -1248,6 +1248,10 @@ describe("target coalesced behavior", () => { await waitForSessionEventQueue(); await expect(firstEvent).resolves.toEqual({ + done: false, + value: { type: "turn_started", provider: "codex", turnId: "turn-1" }, + }); + await expect(stream.next()).resolves.toEqual({ done: false, value: { type: "timeline", @@ -1267,14 +1271,20 @@ describe("target coalesced behavior", () => { expect(getTimelineItems(rows)).toEqual([{ type: "assistant_message", text: "hello world" }]); expect(streamEvents[0]).toMatchObject({ type: "agent_stream", - event: { - type: "timeline", - item: { type: "assistant_message", text: "hello world" }, - }, + event: { type: "turn_started", provider: "codex", turnId: "turn-1" }, }); expect(streamEvents[1]).toMatchObject({ type: "agent_stream", - event: { type: "turn_completed" }, + event: { + type: "timeline", + provider: "codex", + turnId: "turn-1", + item: { type: "assistant_message", text: "hello world" }, + }, + }); + expect(streamEvents[2]).toMatchObject({ + type: "agent_stream", + event: { type: "turn_completed", provider: "codex", turnId: "turn-1" }, }); } finally { harness.cleanup(); diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 045e24332..2a3ec15d4 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -16,7 +16,7 @@ import { import { AgentStorage } from "./agent-storage.js"; import { toAgentPayload } from "./agent-projections.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; -import { formatSystemNotificationPrompt } from "./agent-prompt.js"; +import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js"; import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js"; import type { StoredAgentRecord } from "./agent-storage.js"; import type { @@ -4633,6 +4633,7 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the const interruptStarted = deferred(); const allowInterruptToFinish = deferred(); const allowSecondStartToResolve = deferred(); + const allowSecondTurnToComplete = deferred(); let capturedSession: StaleReplacementSession | null = null; class StaleReplacementSession extends TestAgentSession { @@ -4651,8 +4652,9 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the secondStartEntered.resolve(); await allowSecondStartToResolve.promise; - setTimeout(() => { + setTimeout(async () => { this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + await allowSecondTurnToComplete.promise; this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); }, 0); return { turnId }; @@ -4709,7 +4711,9 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the { agentId: snapshot.id, replayState: false }, ); - const firstRun = manager.streamAgent(snapshot.id, "first run"); + const firstRun = manager.streamAgent(snapshot.id, "first run", { + clientMessageId: "first-client-message", + }); const firstRunDrain = (async () => { for await (const _event of firstRun) { // Drain events so lifecycle updates are applied. @@ -4720,7 +4724,9 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the const replaceUpdatesStart = stateUpdates.length; const beforeReplaceUpdatedAt = manager.getAgent(snapshot.id)?.updatedAt.getTime() ?? 0; - const secondRunPromise = manager.replaceAgentRun(snapshot.id, "replacement run"); + const secondRunPromise = manager.replaceAgentRun(snapshot.id, "replacement run", { + clientMessageId: "replacement-client-message", + }); await interruptStarted.promise; const replacementUpdates = stateUpdates.slice(replaceUpdatesStart); @@ -4766,6 +4772,18 @@ test("replaceAgentRun stays running when a stale old terminal arrives before the allowSecondStartToResolve.resolve(); await replacementStart; + capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-1" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect( + manager + .fetchTimeline(snapshot.id, { direction: "tail", limit: 0 }) + .rows.find( + (row) => + row.item.type === "user_message" && + row.item.clientMessageId === "replacement-client-message", + )?.item, + ).toMatchObject({ messageId: "replacement-client-message" }); + allowSecondTurnToComplete.resolve(); await firstRunDrain; await secondRunDrain; unsubscribe(); @@ -4830,6 +4848,14 @@ test("applies live autonomous events and preserves usage omitted from completion provider: "codex", turnId: autonomousTurnId, }); + await vi.waitFor(() => { + const running = manager.getAgent(snapshot.id); + expect(running?.lifecycle).toBe("running"); + expect(running ? toAgentPayload(running).activeTurn : null).toEqual({ + turnId: autonomousTurnId, + startedAt: expect.any(String), + }); + }); capturedSession!.pushEvent({ type: "usage_updated", provider: "codex", @@ -4855,6 +4881,7 @@ test("applies live autonomous events and preserves usage omitted from completion const updated = manager.getAgent(snapshot.id); expect(updated?.lifecycle).toBe("idle"); + expect(updated ? toAgentPayload(updated).activeTurn : null).toBeNull(); expect(updated?.lastUsage).toEqual({ inputTokens: 10, contextWindowMaxTokens: 200_000, @@ -4868,6 +4895,149 @@ test("applies live autonomous events and preserves usage omitted from completion expect(lifecycleUpdates).toContain("idle"); }); +test("ignores stale autonomous terminals without lowering the active turn lifecycle", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-stale-autonomous-terminal-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let capturedSession: TestAgentSession | null = null; + + class LiveEventClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + capturedSession = new TestAgentSession(config); + return capturedSession; + } + } + + const manager = new AgentManager({ + clients: { codex: new LiveEventClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000135", + }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + + capturedSession!.pushEvent({ + type: "turn_completed", + provider: "codex", + turnId: "prior-untracked-turn", + usage: { inputTokens: 7 }, + }); + capturedSession!.pushEvent({ + type: "turn_failed", + provider: "codex", + turnId: "prior-untracked-turn", + error: "turn-b marker", + }); + await vi.waitFor(() => { + const priorFailure = manager.getAgent(snapshot.id); + expect(priorFailure?.lastError).toBe("turn-b marker"); + expect(priorFailure?.lastUsage).toEqual({ inputTokens: 7 }); + }); + + capturedSession!.pushEvent({ type: "turn_started", provider: "codex", turnId: "turn-b" }); + await vi.waitFor(() => { + const running = manager.getAgent(snapshot.id); + expect(running?.lifecycle).toBe("running"); + expect(running ? toAgentPayload(running).activeTurn?.turnId : null).toBe("turn-b"); + }); + + const active = manager.getAgent(snapshot.id)!; + active.pendingPermissions.set("turn-b-permission", { + id: "turn-b-permission", + provider: "codex", + name: "Current turn permission", + kind: "tool", + }); + const timelineBeforeStaleTerminals = manager.getTimeline(snapshot.id); + + const staleTerminals: AgentStreamEvent[] = [ + { + type: "turn_completed", + provider: "codex", + turnId: "turn-a", + usage: { inputTokens: 99 }, + }, + { type: "turn_failed", provider: "codex", turnId: "turn-a", error: "late failure" }, + { type: "turn_canceled", provider: "codex", turnId: "turn-a", reason: "late cancel" }, + ]; + for (const terminal of staleTerminals) { + const processed = new Promise((resolve) => { + const unsubscribe = manager.subscribe( + (event) => { + if ( + event.type === "agent_stream" && + event.agentId === snapshot.id && + event.event.type === terminal.type + ) { + unsubscribe(); + resolve(); + } + }, + { agentId: snapshot.id, replayState: false }, + ); + }); + capturedSession!.pushEvent(terminal); + await processed; + const stillRunning = manager.getAgent(snapshot.id); + expect(stillRunning?.lifecycle).toBe("running"); + expect(stillRunning ? toAgentPayload(stillRunning).activeTurn?.turnId : null).toBe("turn-b"); + expect(stillRunning?.lastError).toBe("turn-b marker"); + expect(stillRunning?.lastUsage).toEqual({ inputTokens: 7 }); + expect(stillRunning?.pendingPermissions.has("turn-b-permission")).toBe(true); + expect(manager.getTimeline(snapshot.id)).toEqual(timelineBeforeStaleTerminals); + } + + capturedSession!.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-b" }); + await vi.waitFor(() => { + const settled = manager.getAgent(snapshot.id); + expect(settled?.lifecycle).toBe("idle"); + expect(settled ? toAgentPayload(settled).activeTurn : null).toBeNull(); + }); +}); + +test("preserves terminal fallback when no active turn identity was observed", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-untracked-terminal-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let capturedSession: TestAgentSession | null = null; + + class LiveEventClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + capturedSession = new TestAgentSession(config); + return capturedSession; + } + } + + const manager = new AgentManager({ + clients: { codex: new LiveEventClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000136", + }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + + capturedSession!.pushEvent({ + type: "turn_failed", + provider: "codex", + turnId: "untracked-turn", + error: "untracked failure", + }); + + await vi.waitFor(() => { + const failed = manager.getAgent(snapshot.id); + expect(failed?.lifecycle).toBe("error"); + expect(failed?.lastError).toBe("untracked failure"); + }); + expect(manager.getTimeline(snapshot.id)).toContainEqual( + expect.objectContaining({ + type: "assistant_message", + text: expect.stringContaining("untracked failure"), + }), + ); +}); + test("cancelAgentRun waits for an acknowledged autonomous interrupt to settle", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-cancel-")); const storagePath = join(workdir, "agents"); @@ -5147,6 +5317,11 @@ test("autonomous events arriving during foreground run are processed via subscri provider: "codex", turnId: autonomousTurnId, }); + await vi.waitFor(() => { + const running = manager.getAgent(snapshot.id); + expect(running?.lifecycle).toBe("running"); + expect(running ? toAgentPayload(running).activeTurn?.turnId : null).toBe("fg-turn-1"); + }); capturedSession!.pushEvent({ type: "timeline", provider: "codex", @@ -5159,6 +5334,18 @@ test("autonomous events arriving during foreground run are processed via subscri turnId: autonomousTurnId, }); + await vi.waitFor(() => + expect(manager.getTimeline(snapshot.id)).toContainEqual({ + type: "assistant_message", + text: "AUTONOMOUS_DURING_FOREGROUND", + }), + ); + const stillForeground = manager.getAgent(snapshot.id); + expect(stillForeground?.lifecycle).toBe("running"); + expect(stillForeground ? toAgentPayload(stillForeground).activeTurn?.turnId : null).toBe( + "fg-turn-1", + ); + releaseForeground.resolve(); const foregroundEvents = await foregroundResults; @@ -7907,23 +8094,21 @@ test("provider user_message is recorded from the live stream", async () => { class UnexpectedUserMessageSession extends TestAgentSession { override async startTurn(): Promise<{ turnId: string }> { const turnId = "turn-unexpected-1"; - setTimeout(() => { - this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); - // Provider yields user_message (e.g., system continuation) - this.pushEvent({ - type: "timeline", - provider: this.provider, - item: { type: "user_message", text: "continuation prompt" }, - turnId, - }); - this.pushEvent({ - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "continuation reply" }, - turnId, - }); - this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); - }, 0); + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + // Provider yields a staged user_message without client identity (e.g., system continuation). + this.pushEvent({ + type: "timeline", + provider: this.provider, + item: { type: "user_message", text: "continuation prompt" }, + turnId, + }); + this.pushEvent({ + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "continuation reply" }, + turnId, + }); + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); return { turnId }; } } @@ -7963,20 +8148,35 @@ test("provider user_message is recorded from the live stream", async () => { expect(userMessages[0].text).toBe("continuation prompt"); }); -test("authoritative timeline includes provider-emitted submitted user prompt", async () => { +test("canonical submitted prompt keeps wire identity while rewind resolves provider identity", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-submitted-prompt-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); + const allowProviderEcho = deferred(); class SubmittedUserMessageSession extends TestAgentSession { + override readonly capabilities = { + ...TEST_CAPABILITIES, + supportsRewindFiles: true, + }; + readonly rewindMessageIds: string[] = []; + interruptCount = 0; + override async startTurn( prompt: AgentPromptInput, options?: AgentRunOptions, ): Promise<{ turnId: string }> { const turnId = "turn-submitted-user-message"; const text = typeof prompt === "string" ? prompt : ""; - setTimeout(() => { + setTimeout(async () => { this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + this.pushEvent({ + type: "timeline", + provider: this.provider, + turnId, + item: { type: "assistant_message", text: "output before provider echo" }, + }); + await allowProviderEcho.promise; this.pushEvent({ type: "timeline", provider: this.provider, @@ -7992,37 +8192,200 @@ test("authoritative timeline includes provider-emitted submitted user prompt", a }, 0); return { turnId }; } - } - class SubmittedUserMessageClient extends TestAgentClient { - override async createSession(config: AgentSessionConfig): Promise { - return new SubmittedUserMessageSession(config); + override async interrupt(): Promise { + this.interruptCount += 1; + this.pushEvent({ + type: "turn_canceled", + provider: this.provider, + turnId: "turn-submitted-user-message", + }); + } + + override async revertFiles({ messageId }: { messageId: string }): Promise { + this.rewindMessageIds.push(messageId); } } + class SubmittedUserMessageClient extends TestAgentClient { + session: SubmittedUserMessageSession | null = null; + + override async createSession(config: AgentSessionConfig): Promise { + this.session = new SubmittedUserMessageSession(config); + return this.session; + } + } + + const client = new SubmittedUserMessageClient(); const manager = new AgentManager({ - clients: { codex: new SubmittedUserMessageClient() }, + clients: { codex: client }, registry: storage, logger, idFactory: () => "00000000-0000-4000-8000-000000000402", }); + const events: AgentManagerEvent[] = []; + manager.subscribe((event) => events.push(event), { replayState: false }); + const streamEvents = () => + events.flatMap((event) => { + if (event.type !== "agent_stream") return []; + if (event.event.type !== "timeline") return [{ type: event.event.type }]; + const item = event.event.item; + return [ + { + type: item.type, + seq: event.seq, + ...(item.type === "user_message" + ? { + text: item.text, + clientMessageId: item.clientMessageId, + messageId: item.messageId, + } + : {}), + }, + ]; + }); try { const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { workspaceId: undefined, }); - await manager.runAgent(snapshot.id, "hello from composer", { + const run = manager.runAgent(snapshot.id, "hello from composer", { clientMessageId: "msg-client-1", }); + await manager.waitForAgentRunStart(snapshot.id); + + await expect(manager.rewind(snapshot.id, "msg-client-1", "files")).rejects.toThrow( + "Cannot rewind before the provider acknowledges the submitted prompt", + ); + expect(client.session?.interruptCount).toBe(0); + + allowProviderEcho.resolve(); + expect(await run).toMatchObject({ canceled: false }); + + expect(streamEvents()).toEqual([ + { type: "turn_started" }, + { + type: "user_message", + seq: 1, + text: "hello from composer", + clientMessageId: "msg-client-1", + messageId: "msg-client-1", + }, + { type: "assistant_message", seq: 2 }, + { type: "turn_completed" }, + ]); const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 20 }).rows; - expect(timeline.map((row) => row.item)).toContainEqual({ - type: "user_message", - text: "hello from composer", - messageId: "provider-message-1", - clientMessageId: "msg-client-1", + expect(timeline).toEqual([ + { + seq: 1, + timestamp: expect.any(String), + providerMessageId: "provider-message-1", + item: { + type: "user_message", + text: "hello from composer", + messageId: "msg-client-1", + clientMessageId: "msg-client-1", + }, + }, + { + seq: 2, + timestamp: expect.any(String), + item: { type: "assistant_message", text: "output before provider echo" }, + }, + ]); + + await manager.rewind(snapshot.id, "msg-client-1", "files"); + await manager.rewind(snapshot.id, "provider-native-message", "files"); + expect(client.session?.rewindMessageIds).toEqual([ + "provider-message-1", + "provider-native-message", + ]); + } finally { + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + rmSync(workdir, { recursive: true, force: true }); + } +}); + +test("authoritative timeline records a daemon-handled submitted prompt before its output", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-daemon-handled-prompt-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const commandCompleted = deferred(); + + class DaemonHandledPromptSession extends TestAgentSession { + override readonly capabilities = { + ...TEST_CAPABILITIES, + supportsRewindConversation: true, + }; + + override tryHandleOutOfBand(prompt: AgentPromptInput) { + if (prompt !== "/handled") return null; + return { + run: async ({ emit }: { emit: (event: AgentStreamEvent) => void }) => { + emit({ + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "Handled by the daemon" }, + }); + commandCompleted.resolve(); + }, + }; + } + } + + class DaemonHandledPromptClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new DaemonHandledPromptSession(config); + } + } + + const manager = new AgentManager({ + clients: { codex: new DaemonHandledPromptClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000403", + }); + + try { + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, }); + const events: AgentManagerEvent[] = []; + manager.subscribe((event) => events.push(event), { + agentId: snapshot.id, + replayState: false, + }); + + await startAgentRun(manager, snapshot.id, "/handled", logger, { + runOptions: { clientMessageId: "msg-client-daemon-handled" }, + }); + await commandCompleted.promise; + + expect( + events.flatMap((event) => + event.type === "agent_stream" && event.event.type === "timeline" ? [event.event.item] : [], + ), + ).toEqual([ + { + type: "user_message", + text: "/handled", + clientMessageId: "msg-client-daemon-handled", + }, + { type: "assistant_message", text: "Handled by the daemon" }, + ]); + + const timeline = manager.fetchTimeline(snapshot.id, { direction: "tail", limit: 20 }).rows; + expect(timeline.map((row) => row.item)).toEqual([ + { + type: "user_message", + text: "/handled", + clientMessageId: "msg-client-daemon-handled", + }, + { type: "assistant_message", text: "Handled by the daemon" }, + ]); } finally { await manager.flush().catch(() => undefined); await storage.flush().catch(() => undefined); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 72fe1a7ec..e0eba7800 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -90,6 +90,16 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = { type TimeoutResult = "completed" | "timed_out"; +function submittedPromptText(prompt: AgentPromptInput): string { + if (typeof prompt === "string") { + return prompt; + } + return prompt + .flatMap((block) => (block.type === "text" && !("mimeType" in block) ? [block.text] : [])) + .join("\n") + .trim(); +} + export class AgentManagerShuttingDownError extends Error { constructor() { super("Agent manager is shutting down"); @@ -296,6 +306,8 @@ interface StreamEventFlags { shouldNotifyWaiters: boolean; } +type ActiveTurnTerminalDisposition = "closed_current" | "stale" | "untracked"; + interface HandleStreamEventOptions { fromHistory?: boolean; } @@ -329,6 +341,8 @@ interface ManagedAgentBase { persistence: AgentPersistenceHandle | null; historyPrimed: boolean; lastUserMessageAt: Date | null; + activeTurnId: string | null; + activeTurnStartedAt: Date | null; lastUsage?: AgentUsage; lastError?: string; attention: AttentionState; @@ -403,6 +417,38 @@ type ActiveManagedAgent = type LiveManagedAgent = ActiveManagedAgent; type AgentLabelPatch = Record; +function attachManagedTurnIdentity( + agent: ActiveManagedAgent, + event: AgentStreamEvent, + fromHistory: boolean, +): { event: AgentStreamEvent; turnId: string | undefined } { + const existingTurnId = getAgentStreamEventTurnId(event); + if (fromHistory || existingTurnId !== undefined) { + return { event, turnId: existingTurnId }; + } + switch (event.type) { + case "turn_started": { + const turnId = + agent.activeForegroundTurnId ?? agent.activeTurnId ?? `autonomous-${randomUUID()}`; + return { event: { ...event, turnId }, turnId }; + } + case "turn_completed": + case "turn_failed": + case "turn_canceled": { + const turnId = agent.activeForegroundTurnId ?? agent.activeTurnId ?? undefined; + return turnId ? { event: { ...event, turnId }, turnId } : { event, turnId }; + } + default: + return { event, turnId: undefined }; + } +} + +function limitAgentStreamEventContent(event: AgentStreamEvent): AgentStreamEvent { + return event.type === "timeline" + ? { ...event, item: limitAgentTimelineItemContent(event.item) } + : event; +} + interface WriteLabelsResult { record: StoredAgentRecord | null; live: boolean; @@ -1523,6 +1569,8 @@ export class AgentManager { inFlightPermissionResponses: new Set(), pendingReplacement: false, activeForegroundTurnId: null, + activeTurnId: null, + activeTurnStartedAt: null, foregroundTurnWaiters: new Set(), finalizedForegroundTurnIds: new Set(), unsubscribeSession: null, @@ -1873,12 +1921,16 @@ export class AgentManager { * emitted by the handler flow through dispatchStream so they persist and * broadcast like normal timeline events. */ - tryRunOutOfBand(agentId: string, prompt: AgentPromptInput): boolean { + tryRunOutOfBand(agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions): boolean { const agent = this.requireSessionAgent(agentId); const handler = agent.session.tryHandleOutOfBand?.(prompt); if (!handler) { return false; } + if (options?.clientMessageId) { + this.recordSubmittedPrompt(agent, prompt, options.clientMessageId); + this.emitState(agent); + } const dispatch = (event: AgentStreamEvent): void => { // Persist timeline items so they show up in fetchAgentTimeline; broadcast // for live subscribers. Other event types are broadcast only. @@ -2000,14 +2052,49 @@ export class AgentManager { throw error; } - pendingRun.started = true; - pendingRun.turnId = turnId; if (isReplacement) { agent.pendingReplacement = false; } + const turnStartedAt = new Date(); + pendingRun.started = true; + pendingRun.turnId = turnId; agent.activeForegroundTurnId = turnId; + this.openActiveTurn(agent, turnId, turnStartedAt); agent.lifecycle = "running"; this.touchUpdatedAt(agent); + // AgentManager owns the accepted-turn boundary. Publish liveness before the canonical + // prompt so clients can retire optimistic activity without painting an idle frame. + // The provider's duplicate start for this turn is suppressed at the ingestion boundary. + this.dispatchStream( + agent.id, + { type: "turn_started", provider: agent.provider, turnId }, + { timestamp: turnStartedAt.toISOString() }, + ); + const stagedSubmittedPromptEcho = options?.clientMessageId + ? pendingRun.stagedEvents.find( + (event): event is Extract => + event.type === "timeline" && + event.item.type === "user_message" && + event.item.clientMessageId === options.clientMessageId, + ) + : undefined; + if (options?.clientMessageId) { + this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, { + messageId: options.clientMessageId, + providerMessageId: + stagedSubmittedPromptEcho?.item.type === "user_message" + ? stagedSubmittedPromptEcho.item.messageId + : undefined, + }); + } + for (const stagedEvent of pendingRun.stagedEvents.splice(0)) { + const isAcceptedTurnStart = + stagedEvent.type === "turn_started" && getAgentStreamEventTurnId(stagedEvent) === turnId; + if (isAcceptedTurnStart || stagedEvent === stagedSubmittedPromptEcho) { + continue; + } + this.enqueueSessionEvent(agent.id, stagedEvent); + } this.emitState(agent); this.logger.trace( { @@ -2025,6 +2112,12 @@ export class AgentManager { this.runs.addWaiter(agent, turnStream.waiter); try { + const acceptedTurnStartedEvent: AgentStreamEvent = { + type: "turn_started", + provider: agent.provider, + turnId, + }; + yield acceptedTurnStartedEvent; for await (const event of turnStream.events(isTurnTerminalEvent)) { yield event; } @@ -2048,6 +2141,7 @@ export class AgentManager { this.runs.rememberFinalizedTurn(mutableAgent, turnId); } mutableAgent.activeForegroundTurnId = null; + this.applyActiveTurnTerminal(mutableAgent, turnId); const terminalError = mutableAgent.lastError; const shouldHoldBusyForReplacement = mutableAgent.pendingReplacement && !terminalError; let nextLifecycle: "running" | "error" | "idle"; @@ -2085,6 +2179,24 @@ export class AgentManager { } } + private openActiveTurn(agent: ActiveManagedAgent, turnId: string, startedAt: Date): void { + agent.activeTurnId = turnId; + agent.activeTurnStartedAt = startedAt; + } + + private applyActiveTurnTerminal( + agent: ActiveManagedAgent, + turnId?: string, + fromHistory = false, + ): ActiveTurnTerminalDisposition { + if (fromHistory) return "stale"; + if (!agent.activeTurnId) return "untracked"; + if (turnId && agent.activeTurnId !== turnId) return "stale"; + agent.activeTurnId = null; + agent.activeTurnStartedAt = null; + return "closed_current"; + } + async replaceAgentRun( agentId: string, prompt: AgentPromptInput, @@ -2373,6 +2485,19 @@ export class AgentManager { async rewind(agentId: string, messageId: string, mode: RewindMode): Promise { const agent = this.requireSessionAgent(agentId); + const submittedRow = this.timelineStore + .getRows(agentId) + .find( + (row) => + row.item.type === "user_message" && + row.item.messageId === messageId && + row.item.clientMessageId === messageId, + ); + if (submittedRow && !submittedRow.providerMessageId) { + throw new Error("Cannot rewind before the provider acknowledges the submitted prompt"); + } + const providerMessageId = submittedRow?.providerMessageId ?? messageId; + if (this.hasInFlightRun(agentId)) { await this.cancelAgentRunBefore(agentId, "rewind"); } @@ -2383,7 +2508,7 @@ export class AgentManager { { agentId, provider: agent.provider, messageId, mode }, "agent.rewind.start", ); - await invokeRewindCapability(agent.session, { messageId, mode }); + await invokeRewindCapability(agent.session, { messageId: providerMessageId, mode }); if (mode !== "files") { await this.hydrateTimelineFromProvider(agentId, { force: true, broadcast: true }); } @@ -2839,6 +2964,8 @@ export class AgentManager { inFlightPermissionResponses: new Set(), pendingReplacement: false, activeForegroundTurnId: null, + activeTurnId: null, + activeTurnStartedAt: null, foregroundTurnWaiters: new Set(), finalizedForegroundTurnIds: new Set(), unsubscribeSession: null, @@ -2893,6 +3020,8 @@ export class AgentManager { lifecycle: "closed", session: null, activeForegroundTurnId: null, + activeTurnId: null, + activeTurnStartedAt: null, pendingPermissions: new Map(), bufferedPermissionResolutions: new Map(), inFlightPermissionResponses: new Set(), @@ -2935,6 +3064,11 @@ export class AgentManager { }, "agent.manager.enqueue", ); + const pendingRun = this.runs.getPendingRun(agentId); + if (pendingRun && !pendingRun.started) { + pendingRun.stagedEvents.push(event); + return; + } const previous = this.sessionEventTails.get(agentId) ?? Promise.resolve(); const next = previous .catch(() => undefined) @@ -3296,14 +3430,11 @@ export class AgentManager { event: AgentStreamEvent, options?: HandleStreamEventOptions, ): Promise { - if (event.type === "timeline") { - event = { - ...event, - item: limitAgentTimelineItemContent(event.item), - }; - } - const eventTurnId = getAgentStreamEventTurnId(event); - const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId); + event = limitAgentStreamEventContent(event); + const identified = attachManagedTurnIdentity(agent, event, options?.fromHistory === true); + event = identified.event; + const eventTurnId = identified.turnId; + const isForegroundEvent = agent.activeForegroundTurnId === eventTurnId; this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent); if ( eventTurnId && @@ -3323,6 +3454,15 @@ export class AgentManager { this.agentStreamCoalescer.flushFor(agent.id); } + let terminalDisposition: ActiveTurnTerminalDisposition = "untracked"; + if (isTurnTerminalEvent(event)) { + terminalDisposition = this.applyActiveTurnTerminal( + agent, + eventTurnId, + options?.fromHistory === true, + ); + } + const flags: StreamEventFlags = { shouldDispatchEvent: true, shouldNotifyWaiters: true }; const dispatchPromise = this.dispatchStreamEventByType({ @@ -3331,21 +3471,24 @@ export class AgentManager { options, isForegroundEvent, eventTurnId, + terminalDisposition, flags, }); if (dispatchPromise) { await dispatchPromise; } - if (!options?.fromHistory && isTurnTerminalEvent(event)) { - this.runs.settleTerminalRun(agent.id, eventTurnId); - if (isForegroundEvent) { - this.finalizeForegroundTurn(agent, eventTurnId); + if (!options?.fromHistory) { + if (isTurnTerminalEvent(event)) { + this.runs.settleTerminalRun(agent.id, eventTurnId); + if (isForegroundEvent) { + this.finalizeForegroundTurn(agent, eventTurnId); + } } - } - if (!options?.fromHistory && flags.shouldDispatchEvent) { - this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() }); + if (flags.shouldDispatchEvent) { + this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() }); + } } this.traceHandleStreamEventEnd(agent, event, eventTurnId, flags); @@ -3419,9 +3562,11 @@ export class AgentManager { options: HandleStreamEventOptions | undefined; isForegroundEvent: boolean; eventTurnId: string | undefined; + terminalDisposition: ActiveTurnTerminalDisposition; flags: StreamEventFlags; }): Promise | undefined { - const { agent, event, options, isForegroundEvent, eventTurnId, flags } = params; + const { agent, event, options, isForegroundEvent, eventTurnId, terminalDisposition, flags } = + params; switch (event.type) { case "thread_started": this.onStreamThreadStarted(agent); @@ -3463,9 +3608,15 @@ export class AgentManager { this.emitState(agent); return undefined; case "timeline": - return this.onStreamTimelineEvent({ agent, event, options, isForegroundEvent, flags }); + return this.onStreamTimelineEvent({ agent, event, options, flags }); case "turn_completed": - this.onStreamTurnCompleted({ agent, event, eventTurnId, isForegroundEvent }); + this.onStreamTurnCompleted({ + agent, + event, + eventTurnId, + isForegroundEvent, + terminalDisposition, + }); return undefined; case "turn_failed": return this.onStreamTurnFailed({ @@ -3473,13 +3624,21 @@ export class AgentManager { event, eventTurnId, isForegroundEvent, + terminalDisposition, options, }); case "turn_canceled": - this.onStreamTurnCanceled({ agent, event, eventTurnId, isForegroundEvent, options }); + this.onStreamTurnCanceled({ + agent, + event, + eventTurnId, + isForegroundEvent, + terminalDisposition, + options, + }); return undefined; case "turn_started": - this.onStreamTurnStarted({ agent, eventTurnId, isForegroundEvent }); + this.onStreamTurnStarted({ agent, eventTurnId, isForegroundEvent, flags }); return undefined; case "permission_requested": this.onStreamPermissionRequested(agent, event); @@ -3508,7 +3667,6 @@ export class AgentManager { agent: ActiveManagedAgent; event: Extract; options: { fromHistory?: boolean } | undefined; - isForegroundEvent: boolean; flags: StreamEventFlags; }): Promise { const { agent, event, options, flags } = params; @@ -3519,6 +3677,16 @@ export class AgentManager { return; } + if ( + event.item.type === "user_message" && + event.item.clientMessageId && + this.reconcileSubmittedPromptEcho(agent, event.item) + ) { + flags.shouldDispatchEvent = false; + flags.shouldNotifyWaiters = false; + return; + } + if (options?.fromHistory) { this.recordTimeline( agent.id, @@ -3544,8 +3712,9 @@ export class AgentManager { event: Extract; eventTurnId: string | undefined; isForegroundEvent: boolean; + terminalDisposition: ActiveTurnTerminalDisposition; }): void { - const { agent, event, eventTurnId, isForegroundEvent } = params; + const { agent, event, eventTurnId, isForegroundEvent, terminalDisposition } = params; this.logger.trace( { agentId: agent.id, @@ -3557,6 +3726,7 @@ export class AgentManager { }, "agent.manager.turn.completed", ); + if (terminalDisposition === "stale") return; if (event.usage) { agent.lastUsage = { ...agent.lastUsage, ...event.usage }; } @@ -3564,7 +3734,12 @@ export class AgentManager { // data accumulated during streaming isn't lost when the provider omits // it from the completion event. agent.lastError = undefined; - if (!isForegroundEvent && agent.lifecycle !== "idle" && !agent.pendingReplacement) { + if ( + !isForegroundEvent && + !agent.activeForegroundTurnId && + agent.lifecycle !== "idle" && + !agent.pendingReplacement + ) { (agent as ActiveManagedAgent).lifecycle = "idle"; this.emitState(agent); } @@ -3576,9 +3751,10 @@ export class AgentManager { event: Extract; eventTurnId: string | undefined; isForegroundEvent: boolean; + terminalDisposition: ActiveTurnTerminalDisposition; options: { fromHistory?: boolean } | undefined; }): Promise { - const { agent, event, eventTurnId, isForegroundEvent, options } = params; + const { agent, event, eventTurnId, isForegroundEvent, terminalDisposition, options } = params; this.logger.warn( { agentId: agent.id, @@ -3594,7 +3770,8 @@ export class AgentManager { }, "handleStreamEvent: turn_failed", ); - if (!isForegroundEvent) { + if (terminalDisposition === "stale") return; + if (!isForegroundEvent && !agent.activeForegroundTurnId) { agent.lifecycle = "error"; } agent.lastError = event.error; @@ -3605,7 +3782,7 @@ export class AgentManager { options, ); this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Turn failed"); - if (!isForegroundEvent) { + if (!isForegroundEvent && !agent.activeForegroundTurnId) { this.emitState(agent); } } @@ -3615,13 +3792,14 @@ export class AgentManager { event: Extract; eventTurnId: string | undefined; isForegroundEvent: boolean; + terminalDisposition: ActiveTurnTerminalDisposition; options: | { fromHistory?: boolean; } | undefined; }): void { - const { agent, event, eventTurnId, isForegroundEvent, options } = params; + const { agent, event, eventTurnId, isForegroundEvent, terminalDisposition, options } = params; this.logger.trace( { agentId: agent.id, @@ -3634,12 +3812,13 @@ export class AgentManager { }, "agent.manager.turn.canceled", ); - if (!isForegroundEvent && !agent.pendingReplacement) { + if (terminalDisposition === "stale") return; + if (!isForegroundEvent && !agent.activeForegroundTurnId && !agent.pendingReplacement) { agent.lifecycle = "idle"; } agent.lastError = undefined; this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Interrupted"); - if (!isForegroundEvent) { + if (!isForegroundEvent && !agent.activeForegroundTurnId) { this.emitState(agent); } } @@ -3648,8 +3827,9 @@ export class AgentManager { agent: ActiveManagedAgent; eventTurnId: string | undefined; isForegroundEvent: boolean; + flags: StreamEventFlags; }): void { - const { agent, eventTurnId, isForegroundEvent } = params; + const { agent, eventTurnId, isForegroundEvent, flags } = params; this.logger.trace( { agentId: agent.id, @@ -3661,11 +3841,22 @@ export class AgentManager { }, "agent.manager.turn.started", ); - if (!isForegroundEvent) { - this.runs.trackAutonomousRun(agent.id, eventTurnId ?? null); - agent.lifecycle = "running"; - this.emitState(agent); + if (isForegroundEvent) { + flags.shouldDispatchEvent = false; + flags.shouldNotifyWaiters = false; + return; } + if (agent.activeForegroundTurnId) { + flags.shouldDispatchEvent = false; + flags.shouldNotifyWaiters = false; + return; + } + this.runs.trackAutonomousRun(agent.id, eventTurnId ?? null); + if (eventTurnId) { + this.openActiveTurn(agent, eventTurnId, new Date()); + } + agent.lifecycle = "running"; + this.emitState(agent); } private onStreamPermissionRequested( @@ -3720,8 +3911,9 @@ export class AgentManager { item: AgentTimelineItem, provider: AgentProvider, turnId?: string, + options?: { providerMessageId?: string }, ): AgentStreamEvent { - const row = this.recordTimeline(agentId, item); + const row = this.recordTimeline(agentId, item, options); const event: AgentStreamEvent = { type: "timeline", item, @@ -3749,6 +3941,45 @@ export class AgentManager { return event; } + private recordSubmittedPrompt( + agent: ActiveManagedAgent, + prompt: AgentPromptInput, + clientMessageId: string, + options?: { messageId?: string; providerMessageId?: string }, + ): void { + if (this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId)) { + return; + } + this.touchUpdatedAt(agent); + agent.lastUserMessageAt = new Date(); + const item: AgentTimelineItem = { + type: "user_message", + text: submittedPromptText(prompt), + clientMessageId, + ...(options?.messageId ? { messageId: options.messageId } : {}), + }; + this.recordAndDispatchTimelineItem(agent.id, item, agent.provider, undefined, options); + } + + private reconcileSubmittedPromptEcho( + agent: ActiveManagedAgent, + item: Extract, + ): AgentTimelineRow | null { + const { clientMessageId, messageId } = item; + if (!clientMessageId) return null; + const existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId); + if (!existing || existing.item.type !== "user_message") return null; + if (messageId) { + const enriched = this.timelineStore.enrichSubmittedUserMessage( + agent.id, + clientMessageId, + messageId, + ); + if (enriched) this.enqueueDurableTimelineUpdate(agent.id, enriched); + } + return existing; + } + private async appendSystemErrorTimelineMessage( agent: ActiveManagedAgent, provider: AgentProvider, @@ -3806,7 +4037,7 @@ export class AgentManager { private recordTimeline( agentId: string, item: AgentTimelineItem, - options?: { timestamp?: string }, + options?: { timestamp?: string; providerMessageId?: string }, ): AgentTimelineRow { item = limitAgentTimelineItemContent(item); const row = this.timelineStore.append(agentId, item, options); @@ -3928,6 +4159,17 @@ export class AgentManager { this.trackBackgroundTask(task); } + private enqueueDurableTimelineUpdate(agentId: string, row: AgentTimelineRow): void { + if (!this.durableTimelineStore) return; + const task = this.durableTimelineStore.updateCommittedRow(agentId, row).catch((err) => { + this.logger.error( + { err, agentId, seq: row.seq, itemType: row.item.type }, + "Failed to enrich durable timeline row", + ); + }); + this.trackBackgroundTask(task); + } + private trackBackgroundTask(task: Promise): void { this.backgroundTasks.add(task); void task.finally(() => { @@ -3996,7 +4238,11 @@ export class AgentManager { private dispatchStream( agentId: string, event: AgentStreamEvent, - metadata?: { seq?: number; epoch?: string; timestamp?: string }, + metadata?: { + seq?: number; + epoch?: string; + timestamp?: string; + }, ): void { if (event.type === "timeline") { event = { diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index c516b84f4..ffa1b5026 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -79,6 +79,8 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent currentModeId: "plan", pendingPermissions: pendingPermissionsOverride ?? new Map(), activeForegroundTurnId: activeForegroundTurnIdValue, + activeTurnId: activeForegroundTurnIdValue, + activeTurnStartedAt: lifecycle === "running" ? new Date("2025-01-01T00:00:01.000Z") : null, foregroundTurnWaiters: new Set(), unsubscribeSession: null, timeline: [], @@ -105,6 +107,13 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent }; } +it("projects the daemon-owned active turn identity", () => { + expect(toAgentPayload(createManagedAgent({ lifecycle: "running" })).activeTurn).toEqual({ + turnId: "test-turn-id", + startedAt: "2025-01-01T00:00:01.000Z", + }); +}); + function createPermission(overrides: Partial = {}): AgentPermissionRequest { const base: AgentPermissionRequest = { id: "perm-1", diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index a01c67b29..d0175b7f7 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -121,6 +121,12 @@ export function toAgentPayload( updatedAt: agent.updatedAt.toISOString(), lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null, status: agent.lifecycle, + activeTurn: agent.activeTurnId + ? { + turnId: agent.activeTurnId, + startedAt: agent.activeTurnStartedAt?.toISOString() ?? null, + } + : null, capabilities: cloneCapabilities(agent.capabilities), currentModeId: agent.currentModeId, availableModes: cloneAvailableModes(agent.availableModes), diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 22bd8cdc2..53d367416 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -41,7 +41,7 @@ export async function startAgentRun( // Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an // in-flight turn — replaceAgentRun would interrupt the running turn. The // intercept lives at this layer so it covers every prompt entrypoint. - if (agentManager.tryRunOutOfBand(agentId, prompt)) { + if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) { return { outOfBand: true }; } const shouldReplace = Boolean(options?.replaceRunning && agentManager.hasInFlightRun(agentId)); diff --git a/packages/server/src/server/agent/agent-run-state.ts b/packages/server/src/server/agent/agent-run-state.ts index e74731718..06176394a 100644 --- a/packages/server/src/server/agent/agent-run-state.ts +++ b/packages/server/src/server/agent/agent-run-state.ts @@ -13,6 +13,7 @@ export interface ForegroundTurnWaiter { export interface PendingForegroundRun { token: string; kind: "foreground"; + stagedEvents: AgentStreamEvent[]; turnId: string | null; started: boolean; settled: boolean; @@ -69,7 +70,12 @@ export class AgentRunState { return current; } - const run = createTrackedRun({ kind: "autonomous", turnId, started: true }); + const run: AutonomousAgentRun = { + ...createTrackedRunState(), + kind: "autonomous", + turnId, + started: true, + }; this.runs.set(agentId, run); return run; } @@ -259,31 +265,27 @@ export class ForegroundTurnStream { } function createPendingForegroundRun(): PendingForegroundRun { - return createTrackedRun({ kind: "foreground", turnId: null, started: false }); + return { + ...createTrackedRunState(), + kind: "foreground", + turnId: null, + started: false, + stagedEvents: [], + }; } -function createTrackedRun(input: { - kind: "foreground"; - turnId: null; - started: false; -}): PendingForegroundRun; -function createTrackedRun(input: { - kind: "autonomous"; - turnId: string | null; - started: true; -}): AutonomousAgentRun; -function createTrackedRun( - input: - | { kind: "foreground"; turnId: null; started: false } - | { kind: "autonomous"; turnId: string | null; started: true }, -): TrackedAgentRun { +function createTrackedRunState(): { + token: string; + settled: boolean; + settledPromise: Promise; + resolveSettled: () => void; +} { let resolveSettled!: () => void; const settledPromise = new Promise((resolvePromise) => { resolveSettled = resolvePromise; }); return { token: randomUUID(), - ...input, settled: false, settledPromise, resolveSettled, diff --git a/packages/server/src/server/agent/agent-timeline-store-types.ts b/packages/server/src/server/agent/agent-timeline-store-types.ts index 779a55691..7440f9b14 100644 --- a/packages/server/src/server/agent/agent-timeline-store-types.ts +++ b/packages/server/src/server/agent/agent-timeline-store-types.ts @@ -4,6 +4,7 @@ export interface AgentTimelineRow { seq: number; timestamp: string; item: AgentTimelineItem; + readonly providerMessageId?: string; } export interface AgentTimelineCursor { @@ -58,4 +59,5 @@ export interface AgentTimelineStore { getLastAssistantMessage(agentId: string): Promise; deleteAgent(agentId: string): Promise; bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise; + updateCommittedRow(agentId: string, row: AgentTimelineRow): Promise; } diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index f374b1708..2500bc19a 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -167,6 +167,35 @@ export class InMemoryAgentTimelineStore { return this.requireState(agentId).rows.map(cloneRow); } + getSubmittedUserMessage(agentId: string, clientMessageId: string): AgentTimelineRow | null { + const row = this.requireState(agentId).rows.find( + (candidate) => + candidate.item.type === "user_message" && + candidate.item.clientMessageId === clientMessageId, + ); + return row ? cloneRow(row) : null; + } + + enrichSubmittedUserMessage( + agentId: string, + clientMessageId: string, + providerMessageId: string, + ): AgentTimelineRow | null { + const state = this.requireState(agentId); + const index = state.rows.findIndex( + (candidate) => + candidate.item.type === "user_message" && + candidate.item.clientMessageId === clientMessageId, + ); + const row = state.rows[index]; + if (!row || row.item.type !== "user_message") { + return null; + } + const enriched: AgentTimelineRow = { ...row, providerMessageId }; + state.rows[index] = enriched; + return cloneRow(enriched); + } + getEpoch(agentId: string): string { return this.requireState(agentId).epoch; } @@ -235,13 +264,14 @@ export class InMemoryAgentTimelineStore { append( agentId: string, item: AgentTimelineItem, - options?: { timestamp?: string }, + options?: { timestamp?: string; providerMessageId?: string }, ): AgentTimelineRow { const state = this.requireState(agentId); const row: AgentTimelineRow = { seq: state.nextSeq, timestamp: options?.timestamp ?? new Date().toISOString(), item, + ...(options?.providerMessageId ? { providerMessageId: options.providerMessageId } : {}), }; state.nextSeq += 1; state.rows.push(row); diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index e4c29c958..53a217727 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -30,7 +30,7 @@ interface CreateAgentLifecycleDispatchDependencies { listActiveWorkspaces: () => Promise; archiveWorkspaceRecord: (workspaceId: string) => Promise; emit: (message: SessionOutboundMessage) => void; - emitAgentRemove: (agentId: string) => void; + emitAgentRemove: (agentId: string) => Promise; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; clearWorkspaceArchiving: (workspaceIds: Iterable) => void; @@ -222,7 +222,7 @@ export class CreateAgentLifecycleDispatch { ); if (options.agentId) { - this.dependencies.emitAgentRemove(options.agentId); + await this.dependencies.emitAgentRemove(options.agentId); } } } diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 49adf40a2..2f5f919eb 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -2345,6 +2345,15 @@ describe("ACPAgentSession", () => { messageId: "msg-client-1", prompt: [{ type: "text", text: "hello" }], }); + expect( + events + .filter( + (event) => + event.type === "turn_started" || + (event.type === "timeline" && event.item.type === "user_message"), + ) + .map((event) => event.type), + ).toEqual(["turn_started", "timeline"]); expect( events.filter((event) => event.type === "timeline" && event.item.type === "user_message"), ).toEqual([ diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index e4e880592..4bcb0eead 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1507,6 +1507,22 @@ describe("ClaudeAgentSession context window usage", () => { }; } + test("emits turn_started before the submitted user message", async () => { + const session = await createSessionForTurns([[]]); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("turn", { clientMessageId: "client-message-1" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(events.slice(0, 2).map((event) => event.type)).toEqual(["turn_started", "timeline"]); + expect(events[1]).toMatchObject({ + type: "timeline", + item: { type: "user_message", clientMessageId: "client-message-1" }, + }); + await session.close(); + }); + test("passes persistSession through to the Claude SDK query options", async () => { const createResultTurn = (sessionId: string) => [ { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 57235d377..06339278b 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1119,7 +1119,13 @@ describe("Codex app-server provider", () => { async () => appServer.child, ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); await session.startTurn("remember this", { clientMessageId: "client-message" }); + asInternals(session).handleNotification("turn/started", { + threadId: "thread-1", + turn: { id: "turn-1" }, + }); const userMessage = waitForNextTimelineItem(session, "user_message"); emitCodexUserMessage(appServer, { id: "codex-message", text: "remember this" }); @@ -1130,6 +1136,7 @@ describe("Codex app-server provider", () => { clientMessageId: "client-message", }, }); + expect(events.slice(0, 2).map((event) => event.type)).toEqual(["turn_started", "timeline"]); appServer.completeTurn(); await session.close(); }); @@ -2727,8 +2734,14 @@ describe("Codex app-server provider", () => { } }); - test("rejects an interrupt until Codex identifies the accepted turn", async () => { - const appServer = createFakeCodexAppServer(); + test("waits for Codex to identify an accepted turn before interrupting it", async () => { + const interruptedTurns: unknown[] = []; + const appServer = createFakeCodexAppServer({ + "turn/interrupt": async (params) => { + interruptedTurns.push(params); + return {}; + }, + }); const session = new CodexAppServerAgentSession( createConfig({ cwd: "/workspace/project" }), null, @@ -2740,11 +2753,11 @@ describe("Codex app-server provider", () => { const resultPromise = session.run("Start working."); await appServer.waitForTurnStart(); - await expect(session.interrupt()).rejects.toThrow( - "Cannot interrupt Codex before turn/started identifies the active turn", - ); - + const interruptPromise = session.interrupt(); appServer.startsTurn({ threadId: "thread-1", turnId: "turn-identified-late" }); + await interruptPromise; + + expect(interruptedTurns).toEqual([{ threadId: "thread-1", turnId: "turn-identified-late" }]); appServer.completeTurn(); await resultPromise; appServer.assertNoErrors(); @@ -2753,6 +2766,38 @@ describe("Codex app-server provider", () => { } }); + test("does not interrupt after the accepted turn terminates before identification", async () => { + const interruptedTurns: unknown[] = []; + const appServer = createFakeCodexAppServer({ + "turn/interrupt": async (params) => { + interruptedTurns.push(params); + return {}; + }, + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + try { + const resultPromise = session.run("Finish before identification."); + await appServer.waitForTurnStart(); + const interruptPromise = session.interrupt(); + appServer.completeTurn(); + + await expect(interruptPromise).rejects.toThrow( + "Cannot interrupt Codex before turn/started identifies the active turn", + ); + await resultPromise; + expect(interruptedTurns).toEqual([]); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("rejects an interrupt before Codex initializes the thread", async () => { const appServer = createFakeCodexAppServer(); const session = new CodexAppServerAgentSession( diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index fe1e83406..e17493f4e 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1988,6 +1988,7 @@ const TurnCompletedNotificationSchema = z threadId: z.string().optional(), turn: z .object({ + id: z.string().optional(), status: z.string(), error: z .object({ @@ -3108,6 +3109,11 @@ export class CodexAppServerAgentSession implements AgentSession { private currentMode: string; private currentThreadId: string | null = null; private currentTurnId: string | null = null; + private pendingForegroundTurnIdentification: { + foregroundTurnId: string; + promise: Promise; + resolve: (turnId: string | null) => void; + } | null = null; private client: CodexAppServerClient | null = null; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private nextTurnOrdinal = 0; @@ -3862,6 +3868,16 @@ export class CodexAppServerAgentSession implements AgentSession { this.activeForegroundTurnId = turnId; this.activeClientMessageId = options?.clientMessageId ?? null; this.currentTurnId = null; + this.pendingForegroundTurnIdentification?.resolve(null); + let resolveTurnIdentification!: (identifiedTurnId: string | null) => void; + const turnIdentification = new Promise((resolvePromise) => { + resolveTurnIdentification = resolvePromise; + }); + this.pendingForegroundTurnIdentification = { + foregroundTurnId: turnId, + promise: turnIdentification, + resolve: resolveTurnIdentification, + }; this.logTurnStartSummary({ turnId, @@ -3875,6 +3891,8 @@ export class CodexAppServerAgentSession implements AgentSession { await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS); return { turnId }; } catch (error) { + this.pendingForegroundTurnIdentification?.resolve(null); + this.pendingForegroundTurnIdentification = null; this.activeForegroundTurnId = null; this.activeClientMessageId = null; throw error; @@ -4264,14 +4282,26 @@ export class CodexAppServerAgentSession implements AgentSession { if (!this.client || !this.currentThreadId) { throw new Error("Cannot interrupt Codex before the active thread is initialized"); } - if (!this.currentTurnId) { + let turnId = this.currentTurnId; + const foregroundTurnId = this.activeForegroundTurnId; + const pendingIdentification = this.pendingForegroundTurnIdentification; + // turn/start is accepted before Codex publishes the native turn id. Keep the + // interrupt attached to this foreground turn until that ordered notification arrives. + if ( + !turnId && + foregroundTurnId && + pendingIdentification?.foregroundTurnId === foregroundTurnId + ) { + turnId = await pendingIdentification.promise; + } + if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) { throw new Error("Cannot interrupt Codex before turn/started identifies the active turn"); } await this.client.request( "turn/interrupt", { threadId: this.currentThreadId, - turnId: this.currentTurnId, + turnId, }, INTERRUPT_TIMEOUT_MS, ); @@ -4288,6 +4318,8 @@ export class CodexAppServerAgentSession implements AgentSession { this.subscribers.clear(); this.activeForegroundTurnId = null; this.activeClientMessageId = null; + this.pendingForegroundTurnIdentification?.resolve(null); + this.pendingForegroundTurnIdentification = null; if (this.client) { await this.client.dispose(); } @@ -5300,6 +5332,14 @@ export class CodexAppServerAgentSession implements AgentSession { return; } this.currentTurnId = parsed.turnId; + const pendingIdentification = this.pendingForegroundTurnIdentification; + if ( + pendingIdentification && + pendingIdentification.foregroundTurnId === this.activeForegroundTurnId + ) { + pendingIdentification.resolve(parsed.turnId); + this.pendingForegroundTurnIdentification = null; + } this.resetTurnTrackingState(); this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER }); } @@ -5338,6 +5378,9 @@ export class CodexAppServerAgentSession implements AgentSession { } this.activeForegroundTurnId = null; this.activeClientMessageId = null; + this.currentTurnId = null; + this.pendingForegroundTurnIdentification?.resolve(null); + this.pendingForegroundTurnIdentification = null; this.pendingSubAgentNotificationsByThreadId.clear(); this.resetTurnTrackingState(); } diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index 0e7488e9a..e9d6a10bd 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -323,7 +323,10 @@ export function createFakeCodexAppServer( child.stdout.write( `${JSON.stringify({ method: "turn/completed", - params: { threadId: params.threadId ?? "thread-1", turn: { status: "completed" } }, + params: { + threadId: params.threadId ?? "thread-1", + turn: { status: "completed" }, + }, })}\n`, ); }, diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent-rewind.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent-rewind.test.ts new file mode 100644 index 000000000..fff7965b8 --- /dev/null +++ b/packages/server/src/server/agent/providers/mock-load-test-agent-rewind.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "vitest"; + +import type { AgentSession, AgentStreamEvent } from "../agent-sdk-types.js"; +import { MockLoadTestAgentClient } from "./mock-load-test-agent.js"; + +async function createIdleSessionWithProviderUserMessage(): Promise<{ + session: AgentSession; + messageId: string; +}> { + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("Emit synthetic user message before accepting turn."); + await session.interrupt(); + + const userMessage = events.find( + (event) => event.type === "timeline" && event.item.type === "user_message", + ); + if (userMessage?.type !== "timeline" || userMessage.item.type !== "user_message") { + throw new Error("Mock provider user message was not emitted"); + } + if (!userMessage.item.messageId) { + throw new Error("Mock provider user message ID was not emitted"); + } + return { session, messageId: userMessage.item.messageId }; +} + +function requireRewindCapabilities(session: AgentSession): { + conversation: NonNullable; + files: NonNullable; + both: NonNullable; +} { + if (!session.revertConversation || !session.revertFiles || !session.revertBoth) { + throw new Error("Mock provider rewind capabilities are unavailable"); + } + return { + conversation: session.revertConversation.bind(session), + files: session.revertFiles.bind(session), + both: session.revertBoth.bind(session), + }; +} + +describe("MockLoadTestAgentSession rewind", () => { + test("rejects unknown targets and accepts a provider-owned user message", async () => { + const { session, messageId } = await createIdleSessionWithProviderUserMessage(); + const rewind = requireRewindCapabilities(session); + + await expect(rewind.conversation({ messageId: "unknown-message" })).rejects.toThrow( + "Mock rewind target unknown-message was not found in session history", + ); + await expect(rewind.files({ messageId: "unknown-message" })).rejects.toThrow( + "Mock rewind target unknown-message was not found in session history", + ); + await expect(rewind.both({ messageId: "unknown-message" })).rejects.toThrow( + "Mock rewind target unknown-message was not found in session history", + ); + + await expect(rewind.files({ messageId })).resolves.toBeUndefined(); + await expect(rewind.conversation({ messageId })).resolves.toBeUndefined(); + await expect(rewind.both({ messageId })).resolves.toBeUndefined(); + await session.close(); + }); +}); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index 98d904076..99ce0596c 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -49,6 +49,98 @@ describe("MockLoadTestAgentClient", () => { }); }); + test("rejects the configured number of prompts before starting a retry", async () => { + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + featureValues: { mockPromptRejections: 1 }, + }); + + await expect(session.startTurn("Reject this prompt.")).rejects.toThrow( + "Requested mock prompt rejection", + ); + + await expect(session.startTurn("Accept this retry.")).resolves.toEqual({ + turnId: expect.any(String), + }); + await session.interrupt(); + }); + + test("can withhold the provider user-message echo until an immediate interrupt", async () => { + vi.useFakeTimers(); + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("Withhold synthetic user message until interrupted.", { + clientMessageId: "client-message-1", + }); + await vi.advanceTimersByTimeAsync(60_000); + expect(events.some((event) => event.type === "timeline")).toBe(false); + + await session.interrupt(); + expect(events.map((event) => event.type)).toEqual(["turn_canceled"]); + }); + + test("can emit the provider user-message echo before accepting the turn", async () => { + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.startTurn("Emit synthetic user message before accepting turn.", { + clientMessageId: "client-message-1", + }); + + expect(events).toContainEqual({ + type: "timeline", + provider: "mock", + turnId: expect.any(String), + item: { + type: "user_message", + text: "Emit synthetic user message before accepting turn.", + messageId: expect.any(String), + clientMessageId: "client-message-1", + }, + }); + await session.interrupt(); + }); + + test("can place the provider echo beyond a bounded timeline tail", async () => { + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.run("Emit 205 assistant messages before synthetic user message.", { + clientMessageId: "client-message-1", + }); + + const timelineItems = events.flatMap((event) => + event.type === "timeline" ? [event.item] : [], + ); + expect(timelineItems.filter((item) => item.type === "assistant_message")).toHaveLength(205); + expect(timelineItems.at(-1)).toMatchObject({ + type: "user_message", + clientMessageId: "client-message-1", + }); + }); + test("returns schema-shaped JSON for structured branch-name generation", async () => { vi.useFakeTimers(); const client = new MockLoadTestAgentClient(); @@ -211,6 +303,32 @@ describe("MockLoadTestAgentClient", () => { }); }); + test("emits turn_started before the submitted user message", async () => { + vi.useFakeTimers(); + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => events.push(event)); + + await session.startTurn("Order the submitted prompt.", { + clientMessageId: "client-message-1", + }); + await vi.advanceTimersByTimeAsync(0); + unsubscribe(); + + expect( + events + .slice(0, 2) + .map((event) => + event.type === "timeline" ? `${event.type}:${event.item.type}` : event.type, + ), + ).toEqual(["turn_started", "timeline:user_message"]); + }); + test("emits the free-write question scenario selected by prompt", async () => { vi.useFakeTimers(); const client = new MockLoadTestAgentClient(); @@ -265,6 +383,39 @@ describe("MockLoadTestAgentClient", () => { unsubscribe(); }); + test("emits a settled assistant Markdown image path selected by prompt", async () => { + vi.useFakeTimers(); + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => events.push(event)); + const markdown = "![Fixture image](screenshots/fixture.png)"; + + const resultPromise = session.run(`Emit settled assistant image Markdown: ${markdown}`); + await vi.advanceTimersByTimeAsync(0); + + expect( + events.flatMap((event): AgentTimelineItem[] => + event.type === "timeline" && event.item.type === "assistant_message" ? [event.item] : [], + ), + ).toEqual([ + expect.objectContaining({ + type: "assistant_message", + text: markdown, + }), + ]); + await expect(resultPromise).resolves.toMatchObject({ + sessionId: session.id, + finalText: markdown, + canceled: false, + }); + unsubscribe(); + }); + test("agent manager coalesces adjacent assistant tokens into fewer messages", async () => { vi.useFakeTimers(); const workdir = mkdtempSync(join(tmpdir(), "paseo-mock-load-test-")); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 33b9ddfac..2b39fcc53 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -33,9 +33,12 @@ import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest export const MOCK_LOAD_TEST_PROVIDER_ID = "mock"; export const MOCK_LOAD_TEST_DEFAULT_MODEL_ID = "five-minute-stream"; +export const MOCK_LOAD_TEST_HANDLED_COMMAND = "/mock handled-command"; const MOCK_LOAD_TEST_MODE_ID = "load-test"; const MOCK_LOAD_TEST_DURATION_MS = 5 * 60 * 1000; const MOCK_LOAD_TEST_INTERVAL_MS = 40; +const ONE_PIXEL_PNG_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl4Kj8AAAAASUVORK5CYII="; const CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, @@ -156,6 +159,13 @@ function shouldEmitTurnFailure(prompt: AgentPromptInput): boolean { return /emit\s+(?:a\s+)?synthetic\s+turn\s+failure/i.test(promptToText(prompt)); } +function parseSettledAssistantImageMarkdown(prompt: AgentPromptInput): string | null { + const match = /^emit settled assistant image markdown:\s*(!\[[^\]\r\n]*\]\(.+\))\s*$/i.exec( + promptToText(prompt), + ); + return match?.[1] ?? null; +} + function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRequest | null { const text = promptToText(prompt); if (!/emit\s+(?:a\s+)?synthetic\s+questions?/i.test(text)) { @@ -234,6 +244,28 @@ function promptToText(prompt: AgentPromptInput): string { .trim(); } +function parseUserMessageDelayMs(prompt: AgentPromptInput): number { + const match = /delay synthetic user message by (\d+)ms/i.exec(promptToText(prompt)); + const delayMs = Number(match?.[1] ?? 0); + return Number.isSafeInteger(delayMs) ? Math.min(delayMs, 2_000) : 0; +} + +function shouldWithholdUserMessageUntilInterrupt(prompt: AgentPromptInput): boolean { + return /withhold synthetic user message until interrupted/i.test(promptToText(prompt)); +} + +function shouldEmitUserMessageBeforeTurnAcceptance(prompt: AgentPromptInput): boolean { + return /emit synthetic user message before accepting turn/i.test(promptToText(prompt)); +} + +function parseAssistantMessagesBeforeUserMessage(prompt: AgentPromptInput): number | null { + const match = /emit (\d+) assistant messages before synthetic user message/i.exec( + promptToText(prompt), + ); + const count = Number(match?.[1]); + return Number.isSafeInteger(count) && count > 0 ? Math.min(count, 500) : null; +} + function parseLargeAgentStreamPayloadPrompt( prompt: AgentPromptInput, ): LargeAgentStreamPayloadRequest | null { @@ -583,6 +615,7 @@ export class MockLoadTestAgentSession implements AgentSession { private modeId: string | null; private modelId: string | null; private readonly rewindError: string | null; + private remainingPromptRejections: number; constructor(options: { config: AgentSessionConfig; sessionId: string; logger?: Logger }) { this.id = options.sessionId; @@ -593,6 +626,13 @@ export class MockLoadTestAgentSession implements AgentSession { typeof options.config.featureValues?.mockRewindError === "string" ? options.config.featureValues.mockRewindError : null; + const requestedPromptRejections = options.config.featureValues?.mockPromptRejections; + this.remainingPromptRejections = + typeof requestedPromptRejections === "number" && + Number.isSafeInteger(requestedPromptRejections) && + requestedPromptRejections > 0 + ? requestedPromptRejections + : 0; } async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { @@ -611,6 +651,10 @@ export class MockLoadTestAgentSession implements AgentSession { if (this.activeTurn) { throw new Error("Mock load-test provider already has an active turn"); } + if (this.remainingPromptRejections > 0) { + this.remainingPromptRejections -= 1; + throw new Error("Requested mock prompt rejection"); + } const profile = resolveModelProfile(this.modelId); const turnId = randomUUID(); @@ -635,11 +679,35 @@ export class MockLoadTestAgentSession implements AgentSession { turnStarted: false, }; this.activeTurn = turn; - const userMessageId = randomUUID(); - setTimeout(() => { + const largePayload = parseLargeAgentStreamPayloadPrompt(prompt); + const stress = parseAgentStreamStressPrompt(prompt); + const questionPrompt = parseMockQuestionPrompt(prompt); + const structuredBranchName = parseStructuredBranchNamePrompt(prompt); + const settledAssistantImageMarkdown = parseSettledAssistantImageMarkdown(prompt); + const scheduleTurn = () => { + if (shouldEmitTurnFailure(prompt)) { + this.scheduleFailedTurn(turn); + } else if (structuredBranchName) { + this.scheduleSettledAssistantTurn(turn, JSON.stringify(structuredBranchName)); + } else if (settledAssistantImageMarkdown) { + this.scheduleSettledAssistantTurn(turn, settledAssistantImageMarkdown); + } else if (shouldEmitPlanApprovalPrompt(prompt)) { + this.schedulePlanApprovalTurn(turn); + } else if (questionPrompt) { + this.scheduleQuestionPromptTurn(turn, questionPrompt); + } else if (largePayload) { + this.scheduleLargePayloadTurn(turn, largePayload); + } else if (stress) { + this.scheduleStressTurn(turn, stress); + } else { + this.schedule(turn, 0); + } + }; + const emitUserMessage = () => { if (this.activeTurn?.turnId !== turnId) { return; } + this.emitTurnStarted(turn); this.emit({ type: "timeline", provider: this.provider, @@ -647,34 +715,66 @@ export class MockLoadTestAgentSession implements AgentSession { item: { type: "user_message", text: promptToText(prompt), - messageId: userMessageId, + messageId: randomUUID(), ...(options?.clientMessageId ? { clientMessageId: options.clientMessageId } : {}), }, }); - }, 0); - - const largePayload = parseLargeAgentStreamPayloadPrompt(prompt); - const stress = parseAgentStreamStressPrompt(prompt); - const questionPrompt = parseMockQuestionPrompt(prompt); - const structuredBranchName = parseStructuredBranchNamePrompt(prompt); - if (shouldEmitTurnFailure(prompt)) { - this.scheduleFailedTurn(turn); - } else if (structuredBranchName) { - this.scheduleStructuredJsonTurn(turn, structuredBranchName); - } else if (shouldEmitPlanApprovalPrompt(prompt)) { - this.schedulePlanApprovalTurn(turn); - } else if (questionPrompt) { - this.scheduleQuestionPromptTurn(turn, questionPrompt); - } else if (largePayload) { - this.scheduleLargePayloadTurn(turn, largePayload); - } else if (stress) { - this.scheduleStressTurn(turn, stress); - } else { - this.schedule(turn, 0); + }; + if (shouldEmitUserMessageBeforeTurnAcceptance(prompt)) { + emitUserMessage(); + scheduleTurn(); + return { turnId }; } + if (shouldWithholdUserMessageUntilInterrupt(prompt)) { + return { turnId }; + } + const assistantMessagesBeforeUserMessage = parseAssistantMessagesBeforeUserMessage(prompt); + if (assistantMessagesBeforeUserMessage !== null) { + turn.timer = setTimeout(async () => { + if (this.activeTurn !== turn) return; + this.emitTurnStarted(turn); + for (let index = 0; index < assistantMessagesBeforeUserMessage; index += 1) { + this.emitTimeline(turnId, { + type: "assistant_message", + text: `Synthetic pre-echo message ${index + 1}`, + messageId: `${turn.assistantMessageId}-${index + 1}`, + }); + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + if (this.activeTurn !== turn) return; + } + emitUserMessage(); + await new Promise((resolvePromise) => setImmediate(resolvePromise)); + if (this.activeTurn !== turn) return; + this.finishTurnWithText(turn, "Synthetic pre-echo stream complete"); + }, 0); + turn.timer.unref?.(); + return { turnId }; + } + const userMessageDelayMs = parseUserMessageDelayMs(prompt); + const userMessageTimer = setTimeout(() => { + emitUserMessage(); + if (userMessageDelayMs > 0) scheduleTurn(); + }, userMessageDelayMs); + userMessageTimer.unref?.(); + if (userMessageDelayMs === 0) scheduleTurn(); return { turnId }; } + tryHandleOutOfBand( + prompt: AgentPromptInput, + ): { run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise } | null { + if (prompt !== MOCK_LOAD_TEST_HANDLED_COMMAND) return null; + return { + run: async ({ emit }) => { + emit({ + type: "timeline", + provider: this.provider, + item: { type: "assistant_message", text: "Mock command handled" }, + }); + }, + }; + } + subscribe(callback: (event: AgentStreamEvent) => void): () => void { this.listeners.add(callback); return () => { @@ -781,18 +881,20 @@ export class MockLoadTestAgentSession implements AgentSession { this.listeners.clear(); } - async revertConversation(_input: { messageId: string }): Promise { + async revertConversation(input: { messageId: string }): Promise { this.failConfiguredRewind(); + this.validateRewindTarget(input.messageId); this.keepFirstUserMessageHistory(); } - async revertFiles(_input: { messageId: string }): Promise { + async revertFiles(input: { messageId: string }): Promise { this.failConfiguredRewind(); - this.keepFirstUserMessageHistory(); + this.validateRewindTarget(input.messageId); } - async revertBoth(_input: { messageId: string }): Promise { + async revertBoth(input: { messageId: string }): Promise { this.failConfiguredRewind(); + this.validateRewindTarget(input.messageId); this.keepFirstUserMessageHistory(); } @@ -807,12 +909,36 @@ export class MockLoadTestAgentSession implements AgentSession { turn.timer.unref?.(); } + private emitTurnStarted(turn: ActiveTurn): void { + if (turn.turnStarted) { + return; + } + turn.turnStarted = true; + this.emit({ + type: "turn_started", + provider: this.provider, + turnId: turn.turnId, + }); + } + private failConfiguredRewind(): void { if (this.rewindError) { throw new Error(this.rewindError); } } + private validateRewindTarget(messageId: string): void { + const isKnownUserMessage = this.history.some( + (event) => + event.type === "timeline" && + event.item.type === "user_message" && + event.item.messageId === messageId, + ); + if (!isKnownUserMessage) { + throw new Error(`Mock rewind target ${messageId} was not found in session history`); + } + } + private scheduleLargePayloadTurn( turn: ActiveTurn, largePayload: LargeAgentStreamPayloadRequest, @@ -829,11 +955,7 @@ export class MockLoadTestAgentSession implements AgentSession { return; } this.clearTurnTimer(turn); - this.emit({ - type: "turn_started", - provider: this.provider, - turnId: turn.turnId, - }); + this.emitTurnStarted(turn); this.activeTurn = null; this.emit({ type: "turn_failed", @@ -875,26 +997,21 @@ export class MockLoadTestAgentSession implements AgentSession { turn.timer.unref?.(); } - private scheduleStructuredJsonTurn(turn: ActiveTurn, result: Record): void { + private scheduleSettledAssistantTurn(turn: ActiveTurn, finalText: string): void { turn.timer = setTimeout(() => { - this.emitStructuredJsonTurn(turn, result); + this.emitSettledAssistantTurn(turn, finalText); }, 0); turn.timer.unref?.(); } - private emitStructuredJsonTurn(turn: ActiveTurn, result: Record): void { + private emitSettledAssistantTurn(turn: ActiveTurn, finalText: string): void { if (this.activeTurn !== turn) { return; } this.clearTurnTimer(turn); - this.emit({ - type: "turn_started", - provider: this.provider, - turnId: turn.turnId, - }); + this.emitTurnStarted(turn); - const finalText = JSON.stringify(result); this.emitTimeline(turn.turnId, { type: "assistant_message", text: finalText, @@ -926,11 +1043,7 @@ export class MockLoadTestAgentSession implements AgentSession { } this.clearTurnTimer(turn); - this.emit({ - type: "turn_started", - provider: this.provider, - turnId: turn.turnId, - }); + this.emitTurnStarted(turn); const request: AgentPermissionRequest = { id: `mock-plan-${turn.turnId}`, @@ -981,11 +1094,7 @@ export class MockLoadTestAgentSession implements AgentSession { } this.clearTurnTimer(turn); - this.emit({ - type: "turn_started", - provider: this.provider, - turnId: turn.turnId, - }); + this.emitTurnStarted(turn); const request: AgentPermissionRequest = { id: `mock-questions-${turn.turnId}`, @@ -1016,11 +1125,7 @@ export class MockLoadTestAgentSession implements AgentSession { } this.clearTurnTimer(turn); - this.emit({ - type: "turn_started", - provider: this.provider, - turnId: turn.turnId, - }); + this.emitTurnStarted(turn); for (let index = 0; index < stress.count; index += 1) { this.emitTimeline( @@ -1069,11 +1174,7 @@ export class MockLoadTestAgentSession implements AgentSession { } this.clearTurnTimer(turn); - this.emit({ - type: "turn_started", - provider: this.provider, - turnId: turn.turnId, - }); + this.emitTurnStarted(turn); const payload = buildRepeatedPayload(largePayload.bytes, largePayload.kind); if (largePayload.kind === "diff") { @@ -1105,9 +1206,14 @@ export class MockLoadTestAgentSession implements AgentSession { }), ); } else { + const imageBytes = Buffer.from(ONE_PIXEL_PNG_BASE64, "base64"); + const imagePayload = Buffer.concat([ + imageBytes, + Buffer.alloc(Math.max(0, largePayload.bytes - imageBytes.length)), + ]).toString("base64"); this.emitTimeline(turn.turnId, { type: "assistant_message", - text: `data:image/png;base64,${payload}`, + text: `![Synthetic image](data:image/png;base64,${imagePayload})`, messageId: turn.assistantMessageId, }); } @@ -1140,14 +1246,7 @@ export class MockLoadTestAgentSession implements AgentSession { } this.clearTurnTimer(turn); - if (!turn.turnStarted) { - turn.turnStarted = true; - this.emit({ - type: "turn_started", - provider: this.provider, - turnId: turn.turnId, - }); - } + this.emitTurnStarted(turn); const elapsedMs = Date.now() - turn.startedAt; if (elapsedMs >= turn.durationMs) { diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 4e01109be..d19771447 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -147,6 +147,7 @@ describe("OMP agent client and session", () => { { type: "user_message", text: "hello OMP", messageId: "user-1" }, { type: "assistant_message", text: "hello from OMP", messageId: "omp-assistant-1" }, ]); + expect(omp.eventTypes().slice(0, 2)).toEqual(["turn_started", "timeline"]); expect(omp.completedTurnCount()).toBe(1); }); diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index 39b4d10b4..b3efb6101 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -391,6 +391,10 @@ export class OmpHarness { return this.events.flatMap((event) => (event.type === "timeline" ? [event.item] : [])); } + eventTypes(): AgentStreamEvent["type"][] { + return this.events.map((event) => event.type); + } + async history(): Promise { const items: AgentTimelineItem[] = []; for await (const event of this.requireSession().streamHistory()) { diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index b13fe0768..a1f2959ad 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -1182,6 +1182,19 @@ describe("OpenCode adapter startTurn error handling", () => { properties: {}, }, }, + { + directory: "/tmp/test", + payload: { + type: "message.updated", + properties: { + info: { + id: "msg_user", + sessionID: "ses_unit_test", + role: "user", + }, + }, + }, + }, { directory: "/tmp/test", payload: { @@ -1243,17 +1256,25 @@ describe("OpenCode adapter startTurn error handling", () => { createTestLogger(), ); - const turn = await collectTurnEvents(streamSession(session, "hello")); + const turn = await collectTurnEvents( + streamSession(session, "hello", { clientMessageId: "client-message-1" }), + ); expect(turn.events.map((event) => event.type)).toEqual([ "turn_started", "timeline", + "timeline", "turn_completed", ]); + expect(turn.events[1]).toMatchObject({ + type: "timeline", + item: { type: "user_message", clientMessageId: "client-message-1" }, + }); expect(turn.events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([ "opencode-turn-0", "opencode-turn-0", "opencode-turn-0", + "opencode-turn-0", ]); }); diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index ee918354f..c3fad836d 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -181,6 +181,10 @@ class SessionEvents { }); } + eventTypes(): AgentStreamEvent["type"][] { + return this.events.map((event) => event.type); + } + turnCompletedEvents() { return this.events.filter( (event): event is Extract => @@ -608,6 +612,7 @@ describe("PiRpcAgentSession", () => { const fakeSession = pi.latestSession(); await session.startTurn("hello"); + fakeSession.emit({ type: "turn_start" }); fakeSession.finishSubmittedUserMessage({ id: "entry-user-1", parentId: null, @@ -619,6 +624,7 @@ describe("PiRpcAgentSession", () => { expect(events.timelineItems()).toEqual([ { type: "user_message", text: "hello", messageId: "entry-user-1" }, ]); + expect(events.eventTypes().slice(0, 2)).toEqual(["turn_started", "timeline"]); }); test("uses the Pi entry attached to a submitted prompt after resuming old history", async () => { diff --git a/packages/server/src/server/agent/timeline-projection.test.ts b/packages/server/src/server/agent/timeline-projection.test.ts index 8bfbcef83..35c315627 100644 --- a/packages/server/src/server/agent/timeline-projection.test.ts +++ b/packages/server/src/server/agent/timeline-projection.test.ts @@ -627,7 +627,63 @@ describe("selectProjectedTimelinePage", () => { }); }); - test("before page includes a wide tool whose earlier source range is before the cursor", () => { + test("before limit counts projected entries without repeating a merged assistant", () => { + const rows: AgentTimelineRow[] = [ + ...Array.from({ length: 80 }, (_, index) => ({ + seq: index + 1, + timestamp: new Date(1000 + index).toISOString(), + item: { type: "assistant_message" as const, text: "x" }, + })), + ...Array.from({ length: 4 }, (_, index) => ({ + seq: index + 81, + timestamp: new Date(2000 + index).toISOString(), + item: { type: "user_message" as const, text: `message ${index + 1}` }, + })), + ]; + + const newest = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 85, + limit: 2, + }); + const middle = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: newest.startSeq ?? 0, + limit: 2, + }); + const oldest = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: middle.startSeq ?? 0, + limit: 2, + }); + const exhausted = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: oldest.startSeq ?? 0, + limit: 2, + }); + + const pageSummaries = []; + for (const page of [newest, middle, oldest]) { + pageSummaries.push({ + entryTypes: page.entries.map((entry) => entry.item.type), + startSeq: page.startSeq, + endSeq: page.endSeq, + hasOlder: page.hasOlder, + }); + } + expect(pageSummaries).toEqual([ + { entryTypes: ["user_message", "user_message"], startSeq: 83, endSeq: 84, hasOlder: true }, + { entryTypes: ["user_message", "user_message"], startSeq: 81, endSeq: 82, hasOlder: true }, + { entryTypes: ["assistant_message"], startSeq: 1, endSeq: 80, hasOlder: false }, + ]); + expect(exhausted.entries).toEqual([]); + }); + + test("before pages place a full wide tool on the page containing its projected anchor", () => { const rows: AgentTimelineRow[] = [ toolRow(1, "running"), ...Array.from({ length: 498 }, (_, index) => ({ @@ -638,16 +694,32 @@ describe("selectProjectedTimelinePage", () => { toolRow(500, "completed"), ]; - const page = selectProjectedTimelinePage({ + const newestPage = selectProjectedTimelinePage({ rows, direction: "before", cursorSeq: 500, limit: 100, }); + const anchoredPage = selectProjectedTimelinePage({ + rows, + direction: "before", + cursorSeq: 101, + limit: 100, + }); - expect(page.entries.some((entry) => entry.item.type === "tool_call")).toBe(true); - expect(page.endSeq).toBeLessThan(500); - expect(page.hasOlder).toBe(true); + expect(newestPage.entries).toHaveLength(100); + expect(newestPage.entries.some((entry) => entry.item.type === "tool_call")).toBe(false); + expect(newestPage.startSeq).toBe(400); + expect(newestPage.endSeq).toBe(499); + expect(newestPage.hasOlder).toBe(true); + + expect(anchoredPage.entries).toHaveLength(100); + expect(anchoredPage.entries[0]?.item.type).toBe("tool_call"); + expect(anchoredPage.entries[0]?.seqStart).toBe(1); + expect(anchoredPage.entries[0]?.seqEnd).toBe(500); + expect(anchoredPage.startSeq).toBe(1); + expect(anchoredPage.endSeq).toBe(100); + expect(anchoredPage.hasOlder).toBe(false); }); test("tail page includes a wide tool when its completion is the newest seq", () => { diff --git a/packages/server/src/server/agent/timeline-projection.ts b/packages/server/src/server/agent/timeline-projection.ts index e6bd1d161..73c77f80f 100644 --- a/packages/server/src/server/agent/timeline-projection.ts +++ b/packages/server/src/server/agent/timeline-projection.ts @@ -366,16 +366,6 @@ function getTimelineBounds( return { minSeq: first.seq, maxSeq: last.seq }; } -function selectEntriesOverlappingSeqRange(input: { - entries: readonly TimelineProjectionEntry[]; - startSeq: number; - endSeq: number; -}): TimelineProjectionEntry[] { - return input.entries.filter( - (entry) => entry.seqStart <= input.endSeq && entry.seqEnd >= input.startSeq, - ); -} - function firstSourceSeqInRange( entry: TimelineProjectionEntry, startSeq: number, @@ -439,6 +429,27 @@ function selectProjectedEntriesAfter(input: { }; } +function selectProjectedEntriesBefore(input: { + entries: readonly TimelineProjectionEntry[]; + endSeq: number; + limit: number; +}): { entries: TimelineProjectionEntry[]; startSeq: number | null; hasOlder: boolean } { + // Older history follows projected display order. Lifecycle updates can move an + // entry's seqEnd without moving its display anchor, so seqStart keeps the full + // projected item on exactly one backward page. + const eligible = input.entries.filter((entry) => entry.seqStart <= input.endSeq); + const selected = + input.limit === 0 || input.limit >= eligible.length + ? eligible + : eligible.slice(eligible.length - input.limit); + + return { + entries: selected, + startSeq: selected[0]?.seqStart ?? null, + hasOlder: selected.length < eligible.length, + }; +} + export function selectProjectedTimelinePage(input: { rows: readonly AgentTimelineRow[]; bounds?: { minSeq: number; maxSeq: number }; @@ -504,11 +515,9 @@ export function selectProjectedTimelinePage(input: { }; } - let startSeq: number; - let endSeq: number; if (input.direction === "after") { const cursorSeq = input.cursorSeq ?? bounds.minSeq - 1; - startSeq = Math.max(bounds.minSeq, cursorSeq + 1); + const startSeq = Math.max(bounds.minSeq, cursorSeq + 1); const selected = selectProjectedEntriesAfter({ entries: projectedAll, rows: input.rows, @@ -523,28 +532,26 @@ export function selectProjectedTimelinePage(input: { hasOlder: startSeq > bounds.minSeq, hasNewer: selected.endSeq !== null && selected.endSeq < bounds.maxSeq, }; - } else { - const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; - endSeq = Math.min(bounds.maxSeq, cursorSeq - 1); - startSeq = limit === 0 ? bounds.minSeq : Math.max(bounds.minSeq, cursorSeq - limit); } - if (startSeq > endSeq) { + const cursorSeq = input.cursorSeq ?? bounds.maxSeq + 1; + const endSeq = Math.min(bounds.maxSeq, cursorSeq - 1); + if (endSeq < bounds.minSeq) { return { entries: [], startSeq: null, endSeq: null, - hasOlder: startSeq > bounds.minSeq, + hasOlder: false, hasNewer: endSeq < bounds.maxSeq, }; } - const entries = selectEntriesOverlappingSeqRange({ entries: projectedAll, startSeq, endSeq }); + const selected = selectProjectedEntriesBefore({ entries: projectedAll, endSeq, limit }); return { - entries, - startSeq, + entries: selected.entries, + startSeq: selected.startSeq, endSeq, - hasOlder: startSeq > bounds.minSeq, + hasOlder: selected.hasOlder, hasNewer: endSeq < bounds.maxSeq, }; } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index beee901ef..734f61932 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -1096,7 +1096,7 @@ export async function createPaseoDaemon( listActiveWorkspaces: listActiveWorkspacesExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, emit: emitExternalSessionMessage, - emitAgentRemove: () => undefined, + emitAgentRemove: async () => undefined, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, markWorkspaceArchiving: markWorkspaceArchivingExternal, clearWorkspaceArchiving: clearWorkspaceArchivingExternal, diff --git a/packages/server/src/server/messages.test.ts b/packages/server/src/server/messages.test.ts index 411d81c78..93e872b3c 100644 --- a/packages/server/src/server/messages.test.ts +++ b/packages/server/src/server/messages.test.ts @@ -4,6 +4,22 @@ import type { AgentStreamEvent } from "./agent/agent-sdk-types.js"; import { SessionInboundMessageSchema, serializeAgentStreamEvent } from "./messages.js"; describe("serializeAgentStreamEvent", () => { + test("preserves daemon turn identity on lifecycle events", () => { + expect( + serializeAgentStreamEvent({ + type: "turn_canceled", + provider: "codex", + turnId: "turn-accepted-1", + reason: "interrupted", + }), + ).toEqual({ + type: "turn_canceled", + provider: "codex", + turnId: "turn-accepted-1", + reason: "interrupted", + }); + }); + test("accepts create_agent_request env records", () => { const parsed = SessionInboundMessageSchema.parse({ type: "create_agent_request", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index a73bb6616..0e2e6e2dc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -86,11 +86,7 @@ import { setAgentModeCommand, updateAgentCommand, } from "./agent/lifecycle-command.js"; -import { - buildStoredAgentPayload, - resolveStoredAgentPayloadUpdatedAt, - toAgentPayload, -} from "./agent/agent-projections.js"; +import { buildStoredAgentPayload, toAgentPayload } from "./agent/agent-projections.js"; import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, @@ -900,7 +896,7 @@ export class Session { }); this.agentUpdates = createAgentUpdatesService({ emit: (message) => this.emit(message), - buildAgentPayload: (agent) => this.buildAgentPayload(agent), + enrichAgentPayload: (payload) => this.enrichAgentPayload(payload), buildStoredAgentPayload: (record) => this.buildStoredAgentPayload(record), isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), buildProjectPlacementForWorkspaceId: (workspaceId) => @@ -1646,22 +1642,17 @@ export class Session { }; } - private async buildAgentPayload(agent: ManagedAgent): Promise { - const storedRecord = await this.agentStorage.get(agent.id); - const title = storedRecord?.title ?? null; - const payload = toAgentPayload(agent, { title }); - const storedUpdatedAt = storedRecord ? resolveStoredAgentPayloadUpdatedAt(storedRecord) : null; - if (storedUpdatedAt) { - const liveUpdatedAt = Date.parse(payload.updatedAt); - const persistedUpdatedAt = Date.parse(storedUpdatedAt); - if (Number.isNaN(liveUpdatedAt) || persistedUpdatedAt > liveUpdatedAt) { - payload.updatedAt = storedUpdatedAt; - } - } + private async enrichAgentPayload(payload: AgentSnapshotPayload): Promise { + const storedRecord = await this.agentStorage.get(payload.id); + payload.title = storedRecord?.title ?? null; payload.archivedAt = storedRecord?.archivedAt ?? null; return payload; } + private buildAgentPayload(agent: ManagedAgent): Promise { + return this.enrichAgentPayload(toAgentPayload(agent)); + } + private buildStoredAgentPayload( record: StoredAgentRecord, registeredProviderIds = new Set(this.providerSnapshotManager.listRegisteredProviderIds()), @@ -1880,7 +1871,7 @@ export class Session { ): Promise | undefined { switch (msg.type) { case "fetch_agent_timeline_request": - return this.handleFetchAgentTimelineRequest(msg); + return this.handleFetchAgentTimelineRequest(msg, source); case "agent.provider_subagents.list.request": return this.handleProviderSubagentListRequest(msg); case "agent.provider_subagents.timeline.get.request": @@ -2371,7 +2362,7 @@ export class Session { }, }); - this.agentUpdates.removeAgent(agentId); + await this.agentUpdates.removeAgent(agentId); if (knownWorkspaceId) { await this.emitWorkspaceUpdateForWorkspaceId(knownWorkspaceId); @@ -6094,27 +6085,30 @@ export class Session { direction: AgentTimelineFetchDirection; cursor?: AgentTimelineCursor; pageLimit: number; + fullTimeline?: AgentTimelineFetchResult; }): AgentTimelineProjectionSelection { - const timeline = this.shouldUseFullTimelineForProjectedPage({ + const selectedTimeline = this.shouldUseFullTimelineForProjectedPage({ timeline: input.controlTimeline, pageLimit: input.pageLimit, }) - ? this.agentManager.fetchTimeline(input.agentId, { direction: "tail", limit: 0 }) + ? (input.fullTimeline ?? + this.agentManager.fetchTimeline(input.agentId, { direction: "tail", limit: 0 })) : input.controlTimeline; const page = selectProjectedTimelinePage({ - rows: timeline.rows, - bounds: timeline.window, + rows: selectedTimeline.rows, + bounds: selectedTimeline.window, direction: input.controlTimeline.reset ? "tail" : input.direction, ...(input.cursor ? { cursorSeq: input.cursor.seq } : {}), limit: input.pageLimit, }); return { - timeline, + timeline: selectedTimeline, entries: page.entries, startSeq: page.startSeq, endSeq: page.endSeq, - hasOlder: page.hasOlder || (page.startSeq !== null && page.startSeq > timeline.window.minSeq), + hasOlder: + page.hasOlder || (page.startSeq !== null && page.startSeq > selectedTimeline.window.minSeq), hasNewer: page.hasNewer, }; } @@ -6126,6 +6120,7 @@ export class Session { direction: AgentTimelineFetchDirection; cursor?: AgentTimelineCursor; pageLimit: number; + fullTimeline?: AgentTimelineFetchResult; }): AgentTimelineProjectionSelection { if (input.projection === "canonical") { return this.selectCanonicalTimelineProjection({ timeline: input.controlTimeline }); @@ -6136,6 +6131,7 @@ export class Session { private async handleFetchAgentTimelineRequest( msg: Extract, + source?: object, ): Promise { const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); const projection: TimelineProjectionMode = msg.projection ?? "projected"; @@ -6156,7 +6152,7 @@ export class Session { }); const agentPayload = await this.buildAgentPayload(snapshot); - const controlTimeline = this.agentManager.fetchTimeline(msg.agentId, { + const fetchedControlTimeline = this.agentManager.fetchTimeline(msg.agentId, { direction, cursor, limit: pageLimit, @@ -6164,7 +6160,7 @@ export class Session { const selectedTimeline = this.selectTimelineProjection({ agentId: msg.agentId, projection, - controlTimeline, + controlTimeline: fetchedControlTimeline, direction, ...(cursor ? { cursor } : {}), pageLimit, @@ -6178,63 +6174,73 @@ export class Session { ? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.endSeq } : null; - this.emit({ - type: "fetch_agent_timeline_response", - payload: { - requestId: msg.requestId, - agentId: msg.agentId, - agent: agentPayload, - direction, - projection, - epoch: selectedTimeline.timeline.epoch, - reset: controlTimeline.reset, - staleCursor: controlTimeline.staleCursor, - gap: controlTimeline.gap, - window: selectedTimeline.timeline.window, - startCursor, - endCursor, - hasOlder: selectedTimeline.hasOlder, - hasNewer: selectedTimeline.hasNewer, - entries: selectedTimeline.entries.map((entry) => ({ - provider: snapshot.provider, - item: entry.item, - timestamp: entry.timestamp, - seqStart: entry.seqStart, - seqEnd: entry.seqEnd, - sourceSeqRanges: entry.sourceSeqRanges, - collapsed: this.supports(CLIENT_CAPS.reasoningMergeEnum) - ? entry.collapsed - : entry.collapsed.filter((value) => value !== "reasoning_merge"), - })), - error: null, + this.emitForSource( + { + type: "fetch_agent_timeline_response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + agent: agentPayload, + direction, + projection, + epoch: selectedTimeline.timeline.epoch, + reset: fetchedControlTimeline.reset, + staleCursor: fetchedControlTimeline.staleCursor, + gap: fetchedControlTimeline.gap, + window: selectedTimeline.timeline.window, + startCursor, + endCursor, + hasOlder: selectedTimeline.hasOlder, + hasNewer: selectedTimeline.hasNewer, + entries: selectedTimeline.entries.map((entry) => ({ + provider: snapshot.provider, + item: entry.item, + timestamp: entry.timestamp, + seqStart: entry.seqStart, + seqEnd: entry.seqEnd, + sourceSeqRanges: entry.sourceSeqRanges, + collapsed: ( + source + ? this.supportsForSource(CLIENT_CAPS.reasoningMergeEnum, source) + : this.supports(CLIENT_CAPS.reasoningMergeEnum) + ) + ? entry.collapsed + : entry.collapsed.filter((value) => value !== "reasoning_merge"), + })), + error: null, + }, }, - }); + source, + ); } catch (error) { this.sessionLogger.error( { err: error, agentId: msg.agentId }, "Failed to handle fetch_agent_timeline_request", ); - this.emit({ - type: "fetch_agent_timeline_response", - payload: { - requestId: msg.requestId, - agentId: msg.agentId, - agent: null, - direction, - projection, - epoch: "", - reset: false, - staleCursor: false, - gap: false, - window: { minSeq: 0, maxSeq: 0, nextSeq: 0 }, - startCursor: null, - endCursor: null, - hasOlder: false, - hasNewer: false, - entries: [], - error: error instanceof Error ? error.message : String(error), + this.emitForSource( + { + type: "fetch_agent_timeline_response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + agent: null, + direction, + projection, + epoch: "", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 0, maxSeq: 0, nextSeq: 0 }, + startCursor: null, + endCursor: null, + hasOlder: false, + hasNewer: false, + entries: [], + error: error instanceof Error ? error.message : String(error), + }, }, - }); + source, + ); } } diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 176c50514..de33dfe2a 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -22,7 +22,7 @@ import type { AgentUpdatesService } from "./session/agent-updates/agent-updates- import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import { createTerminalManager } from "../terminal/terminal-manager.js"; -import { AgentManager } from "./agent/agent-manager.js"; +import { AgentManager, type AgentManagerEvent, type ManagedAgent } from "./agent/agent-manager.js"; import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentClient, @@ -80,6 +80,14 @@ const UNREGISTERED_CWD = path.resolve("/tmp/unregistered"); const terminalManagers: TerminalManager[] = []; +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + async function flushWorkspaceUpdateBackgroundWork(): Promise { await waitForImmediate(); } @@ -303,6 +311,7 @@ function makeManagedAgent(input: { workspaceId?: string; lifecycle: AgentSnapshotPayload["status"]; updatedAt: string; + activeTurn?: { turnId: string; startedAt: string }; }) { const now = new Date(input.updatedAt); const snapshot = makeAgent({ @@ -338,6 +347,8 @@ function makeManagedAgent(input: { unsubscribeSession: null, session: null, activeForegroundTurnId: input.lifecycle === "running" ? "turn-1" : null, + activeTurnId: input.activeTurn?.turnId ?? null, + activeTurnStartedAt: input.activeTurn ? new Date(input.activeTurn.startedAt) : null, }; } @@ -537,6 +548,8 @@ function createSessionForWorkspaceTests( onWorkspaceRecovered?: SessionOptions["onWorkspaceRecovered"]; workspaceGitService?: ReturnType; terminalManager?: TerminalManager | null; + agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown }; + agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown }; projectRegistry?: SessionOptions["projectRegistry"]; workspaceRegistry?: SessionOptions["workspaceRegistry"]; github?: ForgeService; @@ -566,6 +579,7 @@ function createSessionForWorkspaceTests( unarchiveSnapshot: async () => true, clearAgentAttention: async () => {}, notifyAgentState: () => {}, + ...options.agentManager, }); const workspaceRegistry: SessionOptions["workspaceRegistry"] = options.workspaceRegistry ?? { initialize: async () => {}, @@ -654,6 +668,7 @@ function createSessionForWorkspaceTests( }) : null, upsert: async () => {}, + ...options.agentStorage, }), projectRegistry: options.projectRegistry ?? { initialize: async () => {}, @@ -723,6 +738,113 @@ function createSessionForWorkspaceTests( return session; } +test("agent updates preserve queued live transitions across stored metadata reads", async () => { + const running = makeManagedAgent({ + id: "agent-coherent", + cwd: REPO_CWD, + workspaceId: "ws-repo-running", + lifecycle: "running", + updatedAt: "2026-07-31T10:00:01.000Z", + activeTurn: { + turnId: "turn-running", + startedAt: "2026-07-31T10:00:00.500Z", + }, + }) as unknown as ManagedAgent; + const idle = makeManagedAgent({ + id: "agent-coherent", + cwd: REPO_CWD, + workspaceId: "ws-repo-running", + lifecycle: "idle", + updatedAt: "2026-07-31T10:00:02.000Z", + }) as unknown as ManagedAgent; + running.config.model = "running-model"; + idle.config = running.config; + const stored = makeStoredAgent({ + id: "agent-coherent", + cwd: REPO_CWD, + updatedAt: "2026-07-31T10:00:03.000Z", + }); + stored.title = "Stored title"; + const storageReadStarted = deferred(); + const firstStorageRead = deferred(); + const emittedAgentUpdates: Extract< + SessionOutboundMessage, + { type: "agent_update" } + >["payload"][] = []; + const twoUpdatesEmitted = deferred(); + let storageReadCount = 0; + let forwardAgentEvent: ((event: AgentManagerEvent) => void) | null = null; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => { + if (message.type !== "agent_update") return; + emittedAgentUpdates.push(message.payload); + if (emittedAgentUpdates.length === 2) twoUpdatesEmitted.resolve(); + }, + agentManager: { + subscribe: (listener: (event: AgentManagerEvent) => void) => { + forwardAgentEvent = listener; + return () => {}; + }, + getAgent: () => idle, + }, + agentStorage: { + get: async () => { + storageReadCount += 1; + if (storageReadCount === 1) { + storageReadStarted.resolve(); + return firstStorageRead.promise; + } + return stored; + }, + }, + }); + session.projectRegistry.get = async () => + createPersistedProjectRecord({ + projectId: "proj-repo-running", + rootPath: REPO_CWD, + kind: "non_git", + displayName: "repo", + createdAt: "2026-07-31T10:00:00.000Z", + updatedAt: "2026-07-31T10:00:00.000Z", + }); + activateAgentUpdatesSubscription(session, "sub-coherent"); + if (!forwardAgentEvent) throw new Error("Agent event listener was not installed"); + + forwardAgentEvent({ type: "agent_state", agent: running }); + await storageReadStarted.promise; + idle.config.model = "idle-model"; + forwardAgentEvent({ type: "agent_state", agent: idle }); + idle.config.model = "mutated-after-queue"; + firstStorageRead.resolve(stored); + await twoUpdatesEmitted.promise; + + expect(emittedAgentUpdates).toEqual([ + expect.objectContaining({ + kind: "upsert", + agent: expect.objectContaining({ + status: "running", + activeTurn: { + turnId: "turn-running", + startedAt: "2026-07-31T10:00:00.500Z", + }, + updatedAt: "2026-07-31T10:00:01.000Z", + model: "running-model", + title: "Stored title", + }), + }), + expect.objectContaining({ + kind: "upsert", + agent: expect.objectContaining({ + status: "idle", + activeTurn: null, + updatedAt: "2026-07-31T10:00:02.000Z", + model: "idle-model", + title: "Stored title", + }), + }), + ]); +}); + test("client heartbeat clears attention for the focused terminal", async () => { const clearedTerminalIds: string[] = []; const session = createSessionForWorkspaceTests({ diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts index 3a5fa2e3c..01aba3f1f 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.test.ts @@ -16,6 +16,14 @@ import type { StoredAgentRecord } from "../../agent/agent-storage.js"; type AgentUpdatePayload = Extract["payload"]; +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + function makeAgentPayload(input: { id: string; workspaceId?: string; @@ -87,21 +95,26 @@ function buildHarness() { const workspaceUpdates: string[] = []; const loggedErrors: unknown[][] = []; const payloadById = new Map(); + const queuedPayloadBuilds: Promise[] = []; const projectByWorkspaceId = new Map(); let providerVisible: (provider: string) => boolean = () => true; let buildAgentPayloadError: Error | null = null; const service = createAgentUpdatesService({ emit: (message) => emitted.push(message), - buildAgentPayload: async (agent) => { + enrichAgentPayload: async (payload) => { + const queuedPayload = queuedPayloadBuilds.shift(); + if (queuedPayload) { + return queuedPayload; + } if (buildAgentPayloadError) { throw buildAgentPayloadError; } - const payload = payloadById.get(agent.id); - if (!payload) { - throw new Error(`no payload registered for ${agent.id}`); + const registeredPayload = payloadById.get(payload.id); + if (!registeredPayload) { + throw new Error(`no payload registered for ${payload.id}`); } - return payload; + return registeredPayload; }, buildStoredAgentPayload: (record) => { const payload = payloadById.get(record.id); @@ -142,6 +155,9 @@ function buildHarness() { failBuildAgentPayload(error: Error) { buildAgentPayloadError = error; }, + queuePayloadBuilds(...payloads: Promise[]) { + queuedPayloadBuilds.push(...payloads); + }, agentUpdates(): AgentUpdatePayload[] { return emitted .filter((message) => message.type === "agent_update") @@ -151,7 +167,26 @@ function buildHarness() { ); }, managed(id: string): ManagedAgent { - return { id } as unknown as ManagedAgent; + const payload = payloadById.get(id) ?? makeAgentPayload({ id }); + return { + ...payload, + lifecycle: payload.status, + config: { + provider: payload.provider, + cwd: payload.cwd, + }, + createdAt: new Date(payload.createdAt), + updatedAt: new Date(payload.updatedAt), + lastUserMessageAt: null, + pendingPermissions: new Map(), + attention: { + requiresAttention: payload.requiresAttention ?? false, + attentionReason: null, + attentionTimestamp: new Date(payload.updatedAt), + }, + activeTurnId: null, + activeTurnStartedAt: null, + } as unknown as ManagedAgent; }, stored(id: string): StoredAgentRecord { return { id } as unknown as StoredAgentRecord; @@ -238,6 +273,48 @@ describe("matchesAgentUpdatesFilter", () => { }); describe("forwardLiveAgent", () => { + test("emits snapshots for one agent in forward call order", async () => { + const h = buildHarness(); + h.service.beginSubscription({ subscriptionId: "sub", filter: {} }); + h.service.flushBootstrapped("sub"); + const idle = makeAgentPayload({ id: "a", workspaceId: "ws-1", status: "idle" }); + const running = makeAgentPayload({ id: "a", workspaceId: "ws-1", status: "running" }); + h.register(running); + const delayedIdleBuild = deferred(); + h.queuePayloadBuilds(delayedIdleBuild.promise, Promise.resolve(running)); + + const idleForward = h.service.forwardLiveAgent(h.managed("a")); + const runningForward = h.service.forwardLiveAgent(h.managed("a")); + await new Promise((resolve) => setImmediate(resolve)); + delayedIdleBuild.resolve(idle); + await Promise.all([idleForward, runningForward]); + + expect( + h.agentUpdates().map((update) => update.kind === "upsert" && update.agent.status), + ).toEqual(["idle", "running"]); + }); + + test("emits removal after an earlier queued upsert for the same agent", async () => { + const h = buildHarness(); + h.service.beginSubscription({ subscriptionId: "sub", filter: {} }); + h.service.flushBootstrapped("sub"); + const running = makeAgentPayload({ id: "a", workspaceId: "ws-1", status: "running" }); + h.register(running); + const delayedBuild = deferred(); + h.queuePayloadBuilds(delayedBuild.promise); + + const runningForward = h.service.forwardLiveAgent(h.managed("a")); + const removal = h.service.removeAgent("a"); + await new Promise((resolve) => setImmediate(resolve)); + delayedBuild.resolve(running); + await Promise.all([runningForward, removal]); + + expect(h.agentUpdates()).toEqual([ + { kind: "upsert", agent: expect.objectContaining({ id: "a" }), project: makeProject() }, + { kind: "remove", agentId: "a" }, + ]); + }); + test("emits an upsert for a matching agent and updates its workspace", async () => { const h = buildHarness(); h.service.beginSubscription({ subscriptionId: "sub", filter: {} }); @@ -394,7 +471,7 @@ describe("bootstrap buffering", () => { }); }); - test("skips a buffered upsert that is not newer than the snapshot", async () => { + test("skips a buffered upsert that is older than the snapshot", async () => { const h = buildHarness(); h.service.beginSubscription({ subscriptionId: "sub", filter: {} }); @@ -419,11 +496,39 @@ describe("bootstrap buffering", () => { ]); }); + test("replays a buffered live update with the same revision as the snapshot", async () => { + const h = buildHarness(); + h.service.beginSubscription({ subscriptionId: "sub", filter: {} }); + h.register( + makeAgentPayload({ + id: "equal-revision", + workspaceId: "ws-1", + status: "running", + updatedAt: "2026-03-02T00:00:00.000Z", + }), + ); + + await h.service.forwardLiveAgent(h.managed("equal-revision")); + h.service.flushBootstrapped("sub", { + snapshotUpdatedAtByAgentId: new Map([ + ["equal-revision", Date.parse("2026-03-02T00:00:00.000Z")], + ]), + }); + + expect(h.agentUpdates()).toEqual([ + { + kind: "upsert", + agent: expect.objectContaining({ id: "equal-revision", status: "running" }), + project: makeProject(), + }, + ]); + }); + test("a removed agent is always replayed, even against a snapshot", async () => { const h = buildHarness(); h.service.beginSubscription({ subscriptionId: "sub", filter: {} }); - h.service.removeAgent("a"); + await h.service.removeAgent("a"); expect(h.agentUpdates()).toEqual([]); // buffered h.service.flushBootstrapped("sub", { @@ -479,18 +584,18 @@ describe("subscription lifecycle", () => { expect(h.service.hasSubscription()).toBe(false); }); - test("removeAgent is a no-op without a subscription", () => { + test("removeAgent is a no-op without a subscription", async () => { const h = buildHarness(); - h.service.removeAgent("a"); + await h.service.removeAgent("a"); expect(h.agentUpdates()).toEqual([]); }); - test("removeAgent emits a remove for a live subscription", () => { + test("removeAgent emits a remove for a live subscription", async () => { const h = buildHarness(); h.service.beginSubscription({ subscriptionId: "sub", filter: {} }); h.service.flushBootstrapped("sub"); - h.service.removeAgent("a"); + await h.service.removeAgent("a"); expect(h.agentUpdates()).toEqual([{ kind: "remove", agentId: "a" }]); }); diff --git a/packages/server/src/server/session/agent-updates/agent-updates-service.ts b/packages/server/src/server/session/agent-updates/agent-updates-service.ts index 4630823cc..ac4f3c8d7 100644 --- a/packages/server/src/server/session/agent-updates/agent-updates-service.ts +++ b/packages/server/src/server/session/agent-updates/agent-updates-service.ts @@ -7,7 +7,7 @@ import type { } from "../../messages.js"; import type { ManagedAgent } from "../../agent/agent-manager.js"; import type { StoredAgentRecord } from "../../agent/agent-storage.js"; -import { resolveEffectiveThinkingOptionId } from "../../agent/agent-projections.js"; +import { resolveEffectiveThinkingOptionId, toAgentPayload } from "../../agent/agent-projections.js"; type AgentUpdatePayload = Extract["payload"]; type AgentUpdatesFilter = NonNullable< @@ -46,13 +46,13 @@ export interface AgentUpdatesService { hasSubscription(): boolean; forwardLiveAgent(agent: ManagedAgent): Promise; emitStoredRecord(record: StoredAgentRecord): Promise; - removeAgent(agentId: string): void; + removeAgent(agentId: string): Promise; dispose(): void; } export interface AgentUpdatesServiceDeps { emit(message: SessionOutboundMessage): void; - buildAgentPayload(agent: ManagedAgent): Promise; + enrichAgentPayload(payload: AgentSnapshotPayload): Promise; buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload; isProviderVisibleToClient(provider: string): boolean; buildProjectPlacementForWorkspaceId(workspaceId: string): Promise; @@ -150,6 +150,7 @@ function agentUpdateTargetId(update: AgentUpdatePayload): string { export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentUpdatesService { let subscription: AgentUpdatesSubscriptionState | null = null; + const liveAgentUpdateTails = new Map>(); function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void { if (payload.kind === "upsert" && !deps.isProviderVisibleToClient(payload.agent.provider)) { @@ -195,7 +196,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU const snapshotUpdatedAt = options?.snapshotUpdatedAtByAgentId?.get(payload.agent.id); if (typeof snapshotUpdatedAt === "number") { const updateUpdatedAt = Date.parse(payload.agent.updatedAt); - if (!Number.isNaN(updateUpdatedAt) && updateUpdatedAt <= snapshotUpdatedAt) { + if (!Number.isNaN(updateUpdatedAt) && updateUpdatedAt < snapshotUpdatedAt) { continue; } } @@ -218,13 +219,6 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU return subscription !== null; } - function removeAgent(agentId: string): void { - if (!subscription) { - return; - } - bufferOrEmit(subscription, { kind: "remove", agentId }); - } - async function emitStoredRecord(record: StoredAgentRecord): Promise { const payload = deps.buildStoredAgentPayload(record); const sub = subscription; @@ -264,10 +258,10 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU return payload; } - async function forwardLiveAgent(agent: ManagedAgent): Promise { + async function emitLiveAgentUpdate(payload: AgentSnapshotPayload): Promise { try { const sub = subscription; - const payload = await deps.buildAgentPayload(agent); + payload = await deps.enrichAgentPayload(payload); if (sub) { const project = payload.workspaceId ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) @@ -309,6 +303,34 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU } } + function enqueueAgentUpdate( + agentId: string, + emitUpdate: () => void | Promise, + ): Promise { + const previous = liveAgentUpdateTails.get(agentId) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(emitUpdate); + liveAgentUpdateTails.set(agentId, next); + void next.finally(() => { + if (liveAgentUpdateTails.get(agentId) === next) { + liveAgentUpdateTails.delete(agentId); + } + }); + return next; + } + + function forwardLiveAgent(agent: ManagedAgent): Promise { + const payload = toAgentPayload(agent); + return enqueueAgentUpdate(payload.id, () => emitLiveAgentUpdate(payload)); + } + + function removeAgent(agentId: string): Promise { + return enqueueAgentUpdate(agentId, () => { + if (subscription) { + bufferOrEmit(subscription, { kind: "remove", agentId }); + } + }); + } + function dispose(): void { subscription = null; } diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 6a6cef196..650e5150f 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -921,7 +921,7 @@ describe("relay external socket reconnect behavior", () => { await server.close(); }); - test("advertises stable project identity in initial server_info", async () => { + test("advertises current features in initial server_info", async () => { const server = createServer(); const socket = new MockSocket(); @@ -932,6 +932,8 @@ describe("relay external socket reconnect behavior", () => { }); expect(serverInfo.features?.stableProjectIdentity).toBe(true); + expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true); + expect(serverInfo.features?.agentTurnIdentity).toBeUndefined(); await server.close(); }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 6c5886fed..5991101fd 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1587,6 +1587,8 @@ export class VoiceAssistantWebSocketServer { forgeProviders: true, // COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12. selectiveAgentTimeline: true, + // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. + canonicalSubmittedPrompts: true, // COMPAT(stableProjectIdentity): added in v0.1.109, remove gate after 2027-01-15. stableProjectIdentity: true, // COMPAT(workspaceScriptManagement): added in v0.1.105, remove gate after 2027-01-10. diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index f5956e97e..78af3adec 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -16,6 +16,8 @@ import { import { Session, type SessionOptions } from "./session.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import type { AgentTimelineRow } from "./agent/agent-manager.js"; +import { InMemoryAgentTimelineStore } from "./agent/agent-timeline-store.js"; +import type { AgentTimelineFetchOptions } from "./agent/agent-timeline-store-types.js"; import { handleCreatePaseoWorktreeRequest } from "./worktree-session.js"; import { createPersistedProjectRecord } from "./workspace-registry.js"; @@ -87,7 +89,15 @@ interface SessionInternals { } class InMemoryAgentManager { - constructor(private readonly rows: AgentTimelineRow[]) {} + private readonly timeline = new InMemoryAgentTimelineStore(); + + constructor(rows: AgentTimelineRow[]) { + this.timeline.initialize("agent-1", { + epoch: "epoch-1", + rows, + nextSeq: (rows.at(-1)?.seq ?? 0) + 1, + }); + } getAgent() { return { @@ -133,17 +143,8 @@ class InMemoryAgentManager { }; } - fetchTimeline() { - return { - epoch: "epoch-1", - reset: false, - staleCursor: false, - gap: false, - window: { minSeq: 1, maxSeq: 3, nextSeq: 4 }, - rows: this.rows, - hasOlder: false, - hasNewer: false, - }; + fetchTimeline(_agentId: string, options?: AgentTimelineFetchOptions) { + return this.timeline.fetch("agent-1", options); } listAgents() { @@ -218,6 +219,7 @@ class InMemoryWorktreeWorkflow { function createSessionForWireCompatTest(options?: { clientCapabilities?: Record | null; messages?: SessionOutboundMessage[]; + rows?: AgentTimelineRow[]; }): Session { const messages = options?.messages ?? []; const rows: AgentTimelineRow[] = [ @@ -247,7 +249,9 @@ function createSessionForWireCompatTest(options?: { downloadTokenStore: {} as SessionOptions["downloadTokenStore"], pushTokenStore: {} as SessionOptions["pushTokenStore"], paseoHome: "/tmp/paseo-home", - agentManager: new InMemoryAgentManager(rows) as unknown as SessionOptions["agentManager"], + agentManager: new InMemoryAgentManager( + options?.rows ?? rows, + ) as unknown as SessionOptions["agentManager"], agentStorage: new EmptyAgentStorage() as unknown as SessionOptions["agentStorage"], projectRegistry: new EmptyProjectRegistry() as unknown as SessionOptions["projectRegistry"], workspaceRegistry: @@ -308,11 +312,19 @@ function createSessionForWireCompatTest(options?: { return session; } -async function emitTimelineResponse( - clientCapabilities?: Record | null, -): Promise> { +async function emitTimelineResponse(options?: { + clientCapabilities?: Record | null; + rows?: AgentTimelineRow[]; + request?: Partial< + Extract, { type: "fetch_agent_timeline_request" }> + >; +}): Promise> { const messages: SessionOutboundMessage[] = []; - const session = createSessionForWireCompatTest({ clientCapabilities, messages }); + const session = createSessionForWireCompatTest({ + clientCapabilities: options?.clientCapabilities, + rows: options?.rows, + messages, + }); const internals = session as unknown as SessionInternals; await internals.handleFetchAgentTimelineRequest({ @@ -320,6 +332,7 @@ async function emitTimelineResponse( requestId: "req-timeline", agentId: "agent-1", projection: "projected", + ...options?.request, }); const response = messages[0]; @@ -377,7 +390,7 @@ describe("wire compatibility", () => { ]); }); - test("hello parses with and without the project update capability", () => { + test("hello parses with and without current client capabilities", () => { const legacy = WSHelloMessageSchema.parse({ type: "hello", clientId: "legacy-client", @@ -389,7 +402,9 @@ describe("wire compatibility", () => { clientId: "capable-client", clientType: "mobile", protocolVersion: 1, - capabilities: { [CLIENT_CAPS.projectUpdates]: true }, + capabilities: { + [CLIENT_CAPS.projectUpdates]: true, + }, }); expect([legacy, capable]).toEqual([ @@ -404,16 +419,21 @@ describe("wire compatibility", () => { clientId: "capable-client", clientType: "mobile", protocolVersion: 1, - capabilities: { project_updates: true }, + capabilities: { + project_updates: true, + }, }, ]); }); - test("server info accepts legacy feature payloads without stable project identity", () => { + test("server info strips unknown legacy features while accepting former turn identity", () => { const parsed = ServerInfoStatusPayloadSchema.parse({ status: "server_info", serverId: "legacy-server", - features: { workspaceGithubClone: true }, + features: { + workspaceGithubClone: true, + agentTurnIdentity: true, + }, }); expect(parsed).toEqual({ @@ -421,7 +441,7 @@ describe("wire compatibility", () => { serverId: "legacy-server", hostname: null, version: null, - features: {}, + features: { agentTurnIdentity: true }, }); }); @@ -460,7 +480,7 @@ describe("wire compatibility", () => { test("preserves reasoning_merge for clients that declare the capability", async () => { const response = await emitTimelineResponse({ - [CLIENT_CAPS.reasoningMergeEnum]: true, + clientCapabilities: { [CLIENT_CAPS.reasoningMergeEnum]: true }, }); const currentParsed = FetchAgentTimelineResponseMessageSchema.parse(response);