From ec8ca8f2cbc4438c16731a76de67c548aa204d46 Mon Sep 17 00:00:00 2001 From: beplee Date: Tue, 25 Aug 2026 08:17:29 +0700 Subject: [PATCH] fix(desktop): re-bind open pane to rebuilt runtime after model switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../gateway-event/session-info.ts | 53 +++++++++++++- tests/desktop/test_bots_chat_stream_rekey.py | 73 +++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 tests/desktop/test_bots_chat_stream_rekey.py diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/session-info.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/session-info.ts index ef66a3b22a..3e39bb872b 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/session-info.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/session-info.ts @@ -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' diff --git a/tests/desktop/test_bots_chat_stream_rekey.py b/tests/desktop/test_bots_chat_stream_rekey.py new file mode 100644 index 0000000000..046498e72e --- /dev/null +++ b/tests/desktop/test_bots_chat_stream_rekey.py @@ -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" + )