fix(desktop): re-bind open pane to rebuilt runtime after model switch

A mid-conversation model/provider switch rebuilds the agent runtime. The
rebuilt runtime emits session.info (and all later events) under a NEW
explicit session_id while the pane still holds the dead one as its
active id — isActiveEvent is false for the same conversation from that
moment on, so view-scoped updates stop and the chat freezes until a
full resume (#93942 scenario B; backend even logs 'client should resume
the stored session', but the client never does).

Fix: when a session.info event lineage-matches the selected conversation
(sessionMatchesStoredId over stored_session_id) but carries a different
runtime id, adopt the new runtime id as the active session id — keeping
the durable selection untouched — so every subsequent isActiveEvent gate
keeps matching without a resume. Guarded: the old runtime must show no
live turn (not busy/awaiting/streaming) or the adoption is refused, so
an overlapping manual switch can never split one conversation across
two panes.

The existing compression-rotation path does not cover this case: it
fires when the SAME runtime's stored id rotates, while a rebuild
produces a NEW runtime with a NEW stored id.

Together with #94255 (tile reconcile on sessions.changed), closes
#93942.

Regression tests verified failing pre-fix on 41447a6d70.
This commit is contained in:
beplee
2026-08-25 08:17:29 +07:00
committed by Teknium
parent 7cfed25370
commit ec8ca8f2cb
2 changed files with 125 additions and 1 deletions
@@ -4,12 +4,14 @@ import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
import { followActiveSessionCwd } from '@/store/projects'
import {
$activeSessionId,
$currentCwd,
$currentModel,
$currentProvider,
$selectedStoredSessionId,
$sessions,
sessionMatchesStoredId,
setActiveSessionId,
setCurrentBranch,
setCurrentCwdTransient,
setCurrentFastMode,
@@ -65,6 +67,48 @@ function sessionInfoDescribesSelectedSession(storedSessionId: string | undefined
.some(session => sessionMatchesStoredId(session, infoStoredSessionId) && sessionMatchesStoredId(session, selected))
}
/**
* Adopt a rebuilt runtime back into the open pane (#93942 scenario B).
*
* A mid-conversation model/provider switch rebuilds the agent runtime: the
* new runtime emits `session.info` (and every later event) under a NEW
* explicit session_id while the pane still holds the dead one as its active
* id — so `isActiveEvent` is false for the same conversation and the view
* stops receiving live updates until a full resume. When an incoming
* `session.info` lineage-matches the selected conversation but carries a
* different runtime id, and the OLD runtime shows no live turn (not busy,
* not streaming), re-bind: adopt the new id as the active session id,
* keeping the durable selection untouched. A live turn on the old runtime
* (overlap window during a manual switch) refuses the adoption.
*/
function maybeRebindPaneToRebuiltRuntime(ctx: GatewayEventContext): boolean {
const { deps, explicitSid, isActiveEvent, payload } = ctx
if (!explicitSid || isActiveEvent || typeof payload?.stored_session_id !== 'string') {
return false
}
const selected = $selectedStoredSessionId.get()
if (!selected || !sessionInfoDescribesSelectedSession(payload.stored_session_id)) {
return false
}
const activeId = $activeSessionId.get()
const oldState = activeId ? deps.sessionStateByRuntimeIdRef.current.get(activeId) : undefined
// Only a dead old runtime may be adopted over: hijacking a streaming turn
// would split one conversation's events across two panes.
if (oldState?.busy || oldState?.awaitingResponse || oldState?.streamId) {
return false
}
setActiveSessionId(explicitSid)
ctx.deps.activeSessionIdRef.current = explicitSid
return true
}
/** session.info / session.usage / session.title. */
export function handleSessionInfoEvent(ctx: GatewayEventContext): boolean {
const { deps, event, payload, sessionId, explicitSid, isActiveEvent, occurredAt, fromActiveSource } = ctx
@@ -81,9 +125,16 @@ export function handleSessionInfoEvent(ctx: GatewayEventContext): boolean {
} = deps
if (event.type === 'session.info') {
// A rebuilt runtime (mid-conversation model/provider switch) speaks under
// a NEW session_id. Before scoping anything by isActiveEvent, check
// whether this event is the rebuilt runtime announcing itself for the
// conversation already on screen — if so, re-bind the pane so every
// subsequent isActiveEvent gate keeps matching (#93942 scenario B).
const rebound = maybeRebindPaneToRebuiltRuntime(ctx)
// Apply session-scoped fields when the event targets the active
// session, OR when it's a global broadcast and we have no session.
const apply = explicitSid ? isActiveEvent : !activeSessionIdRef.current
const apply = (explicitSid ? isActiveEvent : !activeSessionIdRef.current) || rebound
const statePatch = sessionInfoStatePatch(payload)
const hasStatePatch = hasSessionInfoStatePatch(statePatch)
const modelChanged = typeof payload?.model === 'string'
@@ -0,0 +1,73 @@
"""Regression tests for #93942 slice 2 — open chat pane never follows a runtime
rebuild after a mid-conversation model switch.
Mechanism (traced on 41447a6d70):
* A mid-conversation model/provider switch rebuilds the agent runtime. The
rebuilt runtime emits its ``session.info`` (and all later stream events)
under a NEW explicit ``session_id``; the old id is dead.
* The pane's identity is the pair ``(activeSessionIdRef, selectedStoredSessionIdRef)``.
After the rebuild, incoming events carry an explicit sid that no longer
equals ``activeSessionIdRef.current``, so ``isActiveEvent`` is False for every
subsequent event of the SAME conversation — view-scoped side effects stop,
and the pane keeps listening on a dead runtime until a full resume.
* Existing machinery covers compression rotation only:
``ensureSessionState`` fires the stored-id rotation signal when the SAME
runtime's stored id rotates — but a model-switch rebuild produces a NEW
runtime with a NEW stored id, which is invisible to that path.
Fix contract: when a ``session.info`` event's lineage
(``sessionMatchesStoredId`` on ``stored_session_id``) matches the currently
selected conversation but its runtime id differs from the active one, re-bind
the pane: adopt the new runtime id as the active session id (keeping the same
durable selection), so live events keep flowing to the view without a resume.
Guarded to only fire when the old runtime is dead (no busy/streaming state) so
an overlapping turn is never hijacked mid-flight.
Together with #94255 (slice 1), this closes #93942.
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] / "apps" / "desktop" / "src"
def _session_info_source() -> str:
return (
ROOT / "app" / "session" / "hooks" / "use-message-stream" / "gateway-event" / "session-info.ts"
).read_text(encoding="utf-8")
def test_session_info_rebinds_pane_to_rebuilt_runtime():
"""A session.info whose lineage matches the selected conversation but whose
runtime id differs from the active one must trigger the re-bind path."""
src = _session_info_source()
# The fix adds a lineage-checked re-bind call in handleSessionInfoEvent,
# distinct from the cwd-claim helper (#71254). Pre-fix there is exactly one
# sessionInfoDescribesSelectedSession use site (cwd claim); post-fix a
# second consumer exists for the re-bind.
uses = len(re.findall(r"sessionInfoDescribesSelectedSession\(", src))
assert uses >= 3, (
"pre-fix state: session.info lineage matching is used only for the cwd "
"claim; a rebuilt runtime (model switch) under a new session_id is "
"never adopted back into the open pane (#93942 scenario B)"
)
def test_rebind_is_guarded_against_live_turns():
"""The re-bind must not hijack a conversation while its OLD runtime is
still streaming/busy — only a dead runtime may be adopted over."""
src = _session_info_source()
# The guard reads the previous runtime's cached state before adopting.
m = re.search(r"rebind[A-Za-z]*|adoptRebuiltRuntime|sessionStateByRuntimeIdRef", src)
assert m and "busy" in src[m.start() : m.start() + 2000] or "state?.busy" in src, (
"pre-fix state: no guarded adoption path exists; the re-bind must check "
"the outgoing runtime's busy/streaming state before switching"
)