Skip to content

[fix] Stop agent sessions breaking when open in two places [AGE-4004] - #5589

Open
ardaerzin wants to merge 4 commits into
fe-chore/agent-conversation-splitfrom
fix/5530-agent-session-multi-tab
Open

[fix] Stop agent sessions breaking when open in two places [AGE-4004]#5589
ardaerzin wants to merge 4 commits into
fe-chore/agent-conversation-splitfrom
fix/5530-agent-session-multi-tab

Conversation

@ardaerzin

@ardaerzin ardaerzin commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #5530.

Stacked on #5569 (fe-chore/agent-conversation-split), which this is rebased onto. The base is that branch so the diff shows only the three commits here. Review or merge #5569 first.

Context

Two separate bugs made an agent session unreliable whenever it was open in more than one place.

A second browser got permanently stuck (#5530). Open a session in browser B, drive it from browser A through a tool call and an approval, then refresh B mid-turn. B cached a partial transcript and no amount of refreshing ever fixed it. The durable record log had the complete turn the whole time.

The adoption guard asked "does the server have more MESSAGES than I do?". transcriptToMessages only closes a message on a done record, and it deliberately folds a paused turn into its resume. So a turn that grows in place (tool results landing, an approval round-trip completing) keeps the same message count from start to finish. The guard concluded the server was not ahead, kept the partial copy, and repeated that decision on every reload.

One browser tab could kill another's live stream. The session history, open-tab list, active tab and cached transcripts are all atomWithStorage atoms, and jotai's default storage subscribes to the window storage event. Every write in one tab replaced those records live in every other tab of the same origin. The open-tab list drives the antd Tabs items, so an incoming replacement could remove a tab and unmount its conversation, orphaning the useChat stream mid-turn. A transcript is only persisted once its stream settles, so the in-flight turn was lost. Switching browser tabs made it worse, because both session queries revalidate on window focus and the reconcile that follows writes to all three keys.

Changes

Record count instead of message count. The log is append-only and ordered, so "the server has more records than my transcript was built from" is exact. loadSessionMessages now returns the count it built from, and that watermark is persisted next to the cached messages.

Before, for a turn that paused for approval and then completed:

mid-turn snapshot:  1 user + 1 assistant   (12 records)
finished turn:      1 user + 1 assistant   (40 records)
                    ^ identical, so "server is not ahead"

After, the guard compares 40 against the stored 12 and adopts. A message-count check stays, but only as a floor: ingest lag can serve a snapshot shorter than what we render, and that must never be traded down.

Both paths in useSessionHydration now share one rule, shouldAdoptServerTranscript. The cache-miss hydration path carried the same blind spot, so fixing only the revalidate path would have left it broken. The old "local tail paused, server tail complete" special case is deleted rather than extended, since a watermark sees that growth directly.

The watermark is cleared when a turn goes live locally, because we cannot know what the runner logged for it, so the next open re-syncs from the log. Messages and watermarks can only move together: one writer sets both, and a single dropSessionMessages helper is the only deletion path. That covers the quota-eviction loop, which previously shed transcripts with no way to shed their watermarks.

Per-tab storage. The four session stores now use localStorage with subscribe removed, so a tab owns its own view. Cross-tab awareness already has a correct channel: the server reconcile on focus, plus renames pushed to the durable stream header. The localStorage push was a redundant second channel that replaced whole records instead of merging them. Storage stays shared, so a reload still picks up the last write.

A signal that something is running. A session driven from elsewhere now shows a strip above the composer, reusing the session bar's running dot so the two read as one signal. Behind it, useSessionHydration polls the record log while the backend reports a live run this browser is not driving, and the guard decides whether anything changed.

The poll chains its timeouts rather than using setInterval, since the log is large and backend-slow and a slow fetch must not stack on itself. It backs off from 15s to 60s while the log is quiet and resets on real growth. is_running is Redis-TTL'd at an hour and the runner only clears it at turn end, so a runner that dies mid-turn would otherwise leave a browser refetching the full log every 15s for that hour. Backing off rather than stopping keeps a genuinely long turn followed, since a slow tool call emits no records until it returns.

Notes

No backend changes. I checked the two behaviours this depends on. get_records returns the complete ordered log with no LIMIT and no windowing, so the count is a genuine monotonic watermark. Liveness is Redis-backed with runner heartbeats and is explicitly dropped at turn end, so the signal cannot be permanently wrong.

Caches written before this change have no watermark, which reads as 0, so they adopt the server copy once and are correct from then on. No migration needed.

Six unit tests cover the adoption rule in @agenta/entities, where the package vitest suite actually runs in CI. A regression test in transcriptToMessages.test.ts pins the property that made message counts wrong, so nobody simplifies the guard back to counting.

Worth flagging for review:

  • web/oss has no test runner yet, so the mapper test here does not run in CI. CI runs pnpm -r --if-present run test:unit and only packages define that script, so the pre-existing transcriptToMessages.test.ts has never executed either. [5528] test(frontend): run web/oss unit tests in CI #5567 adds exactly that wiring (a vitest.config.ts, the test:unit script and the devDependency), so nothing is needed here. I ran this file against [5528] test(frontend): run web/oss unit tests in CI #5567's config locally and all 11 tests pass, so it will go green once that wiring reaches this branch's release line. This is also why the adoption rule itself went into @agenta/entities, which already hosts core/liveness.ts and whose suite runs in CI today.
  • Local verification was tsc, prettier, eslint and the entities unit suite. The multi-browser flows below have not been exercised live.
  • The one-hour running TTL is long for a signal the UI treats as "is something happening right now". Nothing here depends on shortening it, and the default is shared with the TS runner via services/runner/tests/fixtures/sessions/redis_contract.json, so it is a question for the coordination-plane owner rather than a change to make in this PR.

What to QA

Needs the session flags active (AGENTA_SESSIONS_RECONSTRUCT, AGENTA_RECORDS_DURABLE, NEXT_PUBLIC_SESSIONS_LAST_MESSAGE_ONLY).

  • Open an agent session in browser A and the same session in browser B from the session history. In A, send a message that triggers a tool call and an approval. B shows the running strip above the composer instead of looking idle, and the transcript fills in on its own within a minute.
  • Refresh B mid-turn, then let the turn finish in A and refresh B again. B shows the complete turn. Before this change it stayed stuck on the partial snapshot forever.
  • Open the same agent in two tabs of one browser. Start a long run in tab A, then in tab B close a session tab or press +. Tab A keeps streaming and does not lose its turn.
  • Scroll up to read while a session is being driven from elsewhere. A background catch-up must not yank you to the bottom. If you are already at the bottom, it should follow.
  • Regression: normal single-browser use. Send a turn, reload, reopen from history, switch session tabs, and delete a session. Transcripts still paint instantly from cache and history still behaves.
  • Regression: open a session this browser has never run, from the session history. It still hydrates from the server, and a session whose history was pruned still shows the "history unavailable" notice rather than an empty chat.

The agent chat's session history, open-tab list, active tab and cached
transcripts are all `atomWithStorage` atoms. jotai's default storage
subscribes to the window `storage` event, so every write in one browser tab
replaced those records live in every other tab of the same origin.

The open-tab list drives the antd `Tabs` items, so an incoming replacement
could remove a tab and unmount its `AgentConversation`. That orphans the
`useChat` stream mid-turn, and since a transcript is only persisted once its
stream settles, the in-flight turn was lost. Switching browser tabs made it
worse: both session queries revalidate on window focus, and the reconcile
that follows writes to all three keys.

These four stores now use localStorage with `subscribe` removed, so a tab
owns its own view. Cross-tab awareness already has a correct channel: the
server reconcile on focus, plus renames pushed to the durable stream header.
The localStorage push was a redundant second channel that replaced whole
records instead of merging them.

Storage stays shared, so a reload still picks up the last write.
…004]

A browser that opened a session while another browser was mid-turn cached a
partial transcript, and no amount of refreshing ever fixed it. The durable
record log had the complete turn the whole time.

The adoption guard asked "does the server have more MESSAGES than I do?".
`transcriptToMessages` only closes a message on a `done` record and folds a
paused turn into its resume, so a turn that grows in place (tool results
landing, an approval round-trip completing) keeps the same message count from
start to finish. The guard concluded the server was not ahead and kept the
partial copy, on every subsequent reload.

The record log is append-only and ordered, so record count is an exact test.
`loadSessionMessages` now returns the count it built the transcript from, and
that watermark is persisted next to the cached messages. The guard adopts when
the log grew past the watermark, with a message-count floor so ingest lag can
never trade a longer local transcript for a shorter server one.

Both guards now share one rule (`shouldAdoptServerTranscript`): the cache-miss
hydration path carried the same blind spot and would have stayed broken if only
the revalidate path were fixed. The old "local tail paused, server tail
complete" special case is deleted rather than extended, since a watermark sees
that growth directly.

The watermark is cleared when a turn goes live locally (we cannot know what the
runner logged for it), so the next open re-syncs from the log. That clearing
effect must stay declared above the persist effect; effects fire in declaration
order, and that is what stops a locally-extended transcript being filed under a
server watermark.

Messages and watermarks can only move together: one writer sets both, and a
single `dropSessionMessages` helper is now the only deletion path, covering the
quota-eviction loop that previously shed transcripts with no way to shed their
watermarks.

Caches written before this change have no watermark, which reads as 0, so they
adopt the server copy once and are correct from then on.
A session being driven from another browser or tab gave no sign anything was
happening. It looked identical to an idle one, so the only feedback was the
transcript silently not changing.

There is no push channel to browsers today: the runner publishes every event to
Redis, but the only consumer is the ingest worker that writes them to the DB.
True live following would need an SSE endpoint with per-session auth, which is
out of scope here.

Instead the conversation now polls the durable record log while the backend
reports a live run that this browser is not driving, and the adoption guard
decides whether anything changed. A strip above the composer says so, reusing
the session bar's `running` dot so the two read as one signal.

The poll chains its timeouts rather than using setInterval, since the log is
large and backend-slow and a slow fetch must not stack requests on itself. It
backs off from 15s to 60s while the log is quiet and resets on real growth:
`is_running` is Redis-TTL'd at an hour and the runner only clears it at turn
end, so a runner that dies mid-turn would otherwise leave a browser refetching
the full log every 15s for that hour. Backing off rather than stopping keeps a
genuinely long turn followed, since a slow tool call emits no records until it
returns.

Background adoptions do not jump to the live edge unless the reader is already
there, so a catch-up cannot yank someone who scrolled up to read.
@linear-code

linear-code Bot commented Jul 30, 2026

Copy link
Copy Markdown

AGE-4004

@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview Jul 30, 2026 4:17pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added a status indicator when a chat session is actively running in another browser.
    • Improved cross-browser session updates so completed activity is reflected automatically.
    • Enhanced transcript synchronization to preserve in-progress responses while adopting newer server history.
  • Bug Fixes

    • Prevented transcript message counts from changing unexpectedly as a turn progresses.
    • Improved recovery and resynchronization when session history is updated elsewhere.
  • Tests

    • Added coverage for transcript growth, synchronization, and live-streaming scenarios.

Walkthrough

Session transcript hydration now tracks durable record-count watermarks, applies a centralized server-adoption policy, polls sessions running elsewhere, and displays that state in the composer dock. Persisted transcript cleanup also removes associated watermarks.

Changes

Transcript adoption and validation

Layer / File(s) Summary
Transcript loading and adoption policy
web/oss/src/components/AgentChatSlice/assets/loadSession.ts, web/packages/agenta-entities/src/session/core/transcriptAdoption.ts, web/packages/agenta-entities/tests/unit/*, web/oss/src/components/AgentChatSlice/assets/transcriptToMessages.test.ts
Loaded transcripts now include durable record counts, while adoption requires server record growth, sufficient message coverage, and no active local stream. Tests cover paused-turn growth, watermark resync, and veto conditions.

Session persistence

Layer / File(s) Summary
Per-session watermark persistence and cleanup
web/oss/src/components/AgentChatSlice/state/sessions.ts
Session storage uses per-tab persistence, stores record-count watermarks beside messages, and removes both values during deletion, reconciliation, reset, or quota eviction.

Hydration and remote following

Layer / File(s) Summary
Hydration, adoption, and remote-run polling
web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts, web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts
Hydration and revalidation share one adoption callback, persistence carries record counts, and liveness-triggered polling adopts durable updates while a session runs in another browser.

Composer status

Layer / File(s) Summary
Running-elsewhere status surface
web/oss/src/components/AgentChatSlice/components/RunningElsewhereStrip.tsx, web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx, web/oss/src/components/AgentChatSlice/AgentConversation.tsx
Session liveness flows to the composer dock, which conditionally renders an accessible pulsing status strip when the dock chrome is visible.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BrowserB
  participant useSessionHydration
  participant sessionLivenessAtomFamily
  participant loadSessionMessages
  participant shouldAdoptServerTranscript
  BrowserB->>useSessionHydration: open session
  useSessionHydration->>sessionLivenessAtomFamily: read session liveness
  sessionLivenessAtomFamily-->>useSessionHydration: running elsewhere
  useSessionHydration->>loadSessionMessages: poll durable transcript
  loadSessionMessages->>shouldAdoptServerTranscript: compare record watermark
  shouldAdoptServerTranscript-->>useSessionHydration: adopt updated transcript
  useSessionHydration-->>BrowserB: render current transcript and status
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address #5530 by adding a running indicator, polling, and durable-record adoption so stale refreshes converge.
Out of Scope Changes check ✅ Passed No obvious unrelated code was added; the storage, polling, and UI changes all support the session-staleness fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly matches the main fix: preventing agent sessions from breaking when opened in multiple places.
Description check ✅ Passed The description is detailed and directly describes the multi-browser/tab transcript, polling, and storage fixes in the PR.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/5530-agent-session-multi-tab

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ardaerzin

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@ardaerzin
ardaerzin marked this pull request as ready for review July 30, 2026 17:16
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. Bug Report Something isn't working Frontend labels Jul 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Preview URL https://gateway-production-ba7e.up.railway.app/w
Image tag pr-5589-45f5997
Status Failed
Railway logs Open logs
Logs View workflow run
Updated at 2026-07-30T17:39:57.721Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Report Something isn't working Frontend size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant