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:
+52
-1
@@ -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"
|
||||
)
|
||||
Reference in New Issue
Block a user