From 9917e1a07036c3c7473bef1996d7678696788842 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 15 Oct 2026 14:02:00 +0800 Subject: [PATCH 01/19] fix: follow runtime session identity after model fallback Co-authored-by: Qwen-Coder --- app/api/sessions/[id]/route.ts | 6 +++++- hooks/useAgentSession.ts | 17 +++++++++++++++-- lib/rpc-manager.ts | 12 +++++++++--- lib/rpc-session-info.test.mjs | 28 +++++++++++++++++++++++++++- 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts index f8e3e4dfb7..6b6f05d548 100644 --- a/app/api/sessions/[id]/route.ts +++ b/app/api/sessions/[id]/route.ts @@ -44,6 +44,10 @@ export async function GET( const deferThinking = searchParams.has("deferThinking"); const deferToolResultImages = searchParams.has("deferMedia"); const context = buildSessionContext(entries, leafId, { deferThinking, deferToolResultImages }); + // A runtime fallback can differ from the last persisted model selector. + if (liveRpc?.inner.model) { + context.model = { provider: liveRpc.inner.model.provider, modelId: liveRpc.inner.model.id }; + } const contextUsage = await getHistoricalContextUsage(entries, leafId); const totalActiveMs = computeSessionTotalActiveMs(entries); const header = sm.getHeader(); @@ -72,7 +76,7 @@ export async function GET( } : null; return NextResponse.json({ - sessionId: id, + sessionId: sm.getSessionId(), filePath, info, leafId, diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 90a5a6b2e9..bf325f25af 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -76,6 +76,8 @@ interface LastAssistantTextResponse { } type AgentStateResponse = { + sessionId?: string; + model?: { provider: string; id: string }; contextUsage?: { percent: number | null; contextWindow: number; tokens: number | null } | null; systemPrompt?: string; thinkingLevel?: string; @@ -513,6 +515,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (!res.ok) throw new Error(`HTTP ${res.status}`); const d = await res.json() as SessionData; if (sessionIdRef.current !== sid) return null; + if (d.sessionId !== sid && onSessionForked) { + onSessionForked(d.sessionId); + return null; + } setData(d); setActiveLeafId(d.leafId); setMessages(d.context.messages); @@ -559,7 +565,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } finally { if (showLoading && !messagesLoaded) setLoading(false); } - }, []); + }, [onSessionForked]); const loadContext = useCallback(async (sid: string, leafId: string | null) => { try { @@ -1103,11 +1109,18 @@ export function useAgentSession(opts: UseAgentSessionOptions) { || sessionIdRef.current !== sid || promptRunIdRef.current !== runId ) return; + if (data.state?.sessionId && data.state.sessionId !== sid && onSessionForked) { + onSessionForked(data.state.sessionId); + return; + } + if (data.state?.model && !modelSwitchPendingRef.current) { + setCurrentModelOverride({ provider: data.state.model.provider, modelId: data.state.model.id }); + } if (data.state?.contextUsage !== undefined) setContextUsage(data.state.contextUsage ?? null); } catch { // The next refresh retries; state reconciliation owns error recovery. } - }, []); + }, [onSessionForked]); // Context usage changes after each model/tool turn, not only when the prompt // settles. Poll the lightweight state endpoint while the session is active diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index a67728475c..56f4f42323 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -1592,7 +1592,13 @@ function trackStartingSession(cwd: string): () => void { } export function getRpcSession(sessionId: string): AgentSessionWrapper | undefined { - return getRegistry().get(sessionId); + const registry = getRegistry(); + const registered = registry.get(sessionId); + if (registered?.isAlive()) return registered; + // The SDK can fork in place during model fallback. Keep the original key + // for in-flight requests, but also accept the wrapper's current identity. + return [...registry.values()].find((session) => session.isAlive() && session.sessionId === sessionId) + ?? registered; } function runtimeMessageText(entry: SessionMessageEntry): string { @@ -1747,8 +1753,8 @@ export async function startRpcSession( const registry = getRegistry(); const locks = getLocks(); - const existing = registry.get(sessionId); - if (existing?.isAlive()) return { session: existing, realSessionId: sessionId }; + const existing = getRpcSession(sessionId); + if (existing?.isAlive()) return { session: existing, realSessionId: existing.sessionId }; const inflight = locks.get(sessionId); if (inflight) return inflight; diff --git a/lib/rpc-session-info.test.mjs b/lib/rpc-session-info.test.mjs index 469a3c5b18..2ac64eb25e 100644 --- a/lib/rpc-session-info.test.mjs +++ b/lib/rpc-session-info.test.mjs @@ -9,7 +9,7 @@ const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false, }); -const { getRpcSessionInfos } = await jiti.import("./rpc-manager.ts"); +const { getRpcSession, getRpcSessionInfos, startRpcSession } = await jiti.import("./rpc-manager.ts"); function makeRuntimeSession({ id, filePath, running, entries }) { const timestamp = "2026-08-12T01:02:03.000Z"; @@ -91,3 +91,29 @@ test("keeps an idle runtime visible once its JSONL file exists", (t) => { assert.equal(infos.length, 1); assert.equal(infos[0].transient, false); }); + +test("reuses a live wrapper after the SDK changes its session ID", async (t) => { + const previousRegistry = globalThis.__ompSessions; + let id = "original-session"; + let alive = true; + const wrapper = { + get sessionId() { return id; }, + isAlive: () => alive, + }; + globalThis.__ompSessions = new Map([[id, wrapper]]); + t.after(() => { globalThis.__ompSessions = previousRegistry; }); + + id = "fallback-session"; + assert.equal(getRpcSession(id), wrapper); + assert.equal(getRpcSession("original-session"), wrapper); + // Returning here must not open a second transcript or create another agent. + const resumed = await startRpcSession(id, "/missing/session.jsonl", undefined); + assert.equal(resumed.session, wrapper); + assert.equal(resumed.realSessionId, id); + const original = await startRpcSession("original-session", "/missing/session.jsonl", undefined); + assert.equal(original.realSessionId, id); + assert.equal(globalThis.__ompSessions.size, 1); + + alive = false; + assert.equal(getRpcSession(id), undefined); +}); From c4da3e38cac8c106cbd94d19c6270b767d0d412d Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 15 Oct 2026 14:59:05 +0800 Subject: [PATCH 02/19] fix: retain live session aliases across repeated fallbacks Co-authored-by: Qwen-Coder --- app/api/sessions/[id]/route.ts | 2 +- app/api/sessions/runtime-route.test.mjs | 4 +++- lib/rpc-manager.ts | 18 +++++++++++++++--- lib/rpc-session-info.test.mjs | 10 +++++++++- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts index 6b6f05d548..5f68a245e5 100644 --- a/app/api/sessions/[id]/route.ts +++ b/app/api/sessions/[id]/route.ts @@ -76,7 +76,7 @@ export async function GET( } : null; return NextResponse.json({ - sessionId: sm.getSessionId(), + sessionId: liveRpc?.sessionId ?? sm.getSessionId(), filePath, info, leafId, diff --git a/app/api/sessions/runtime-route.test.mjs b/app/api/sessions/runtime-route.test.mjs index 4699b3be5a..8ba530e599 100644 --- a/app/api/sessions/runtime-route.test.mjs +++ b/app/api/sessions/runtime-route.test.mjs @@ -44,6 +44,7 @@ test("live agent state is available before the session file is persisted", () => test("live detail and state routes work without a persisted JSONL file", async (t) => { const previousRegistry = globalThis.__ompSessions; const id = "live-route-test"; + const runtimeId = "live-route-after-fallback"; const timestamp = "2026-08-12T01:02:03.000Z"; const entry = { type: "message", @@ -65,7 +66,7 @@ test("live detail and state routes work without a persisted JSONL file", async ( isRunning: () => true, inner: { sessionManager }, sessionFile: sessionManager.getSessionFile(), - sessionId: id, + sessionId: runtimeId, cwd: "/tmp", send: async () => ({ isStreaming: true }), }]]); @@ -85,6 +86,7 @@ test("live detail and state routes work without a persisted JSONL file", async ( const detail = await detailResponse.json(); assert.equal(detailResponse.status, 200); + assert.equal(detail.sessionId, runtimeId); assert.equal(detail.info.transient, true); assert.deepEqual(detail.context.messages.map((message) => message.content), ["hello live"]); assert.equal(stateResponse.status, 200); diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index 56f4f42323..635342c4e2 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -1545,6 +1545,7 @@ export class AgentSessionWrapper { declare global { var __ompSessions: Map | undefined; + var __ompSessionAliases: WeakMap> | undefined; var __ompStartLocks: Map> | undefined; var __ompStartingSessionCwds: Map | undefined; var __ompRunningListeners: Set<(ids: string[]) => void> | undefined; @@ -1593,11 +1594,22 @@ function trackStartingSession(cwd: string): () => void { export function getRpcSession(sessionId: string): AgentSessionWrapper | undefined { const registry = getRegistry(); + const aliases = globalThis.__ompSessionAliases ??= new WeakMap>(); + // The SDK can change a wrapper's ID repeatedly during fallback. Remember + // observed identities without duplicating wrappers in the registry; the weak + // keys release their alias sets when a destroyed wrapper is collected. + for (const [registeredId, session] of registry) { + if (!session.isAlive()) continue; + let ids = aliases.get(session); + if (!ids) { + ids = new Set([registeredId]); + aliases.set(session, ids); + } + ids.add(session.sessionId); + } const registered = registry.get(sessionId); if (registered?.isAlive()) return registered; - // The SDK can fork in place during model fallback. Keep the original key - // for in-flight requests, but also accept the wrapper's current identity. - return [...registry.values()].find((session) => session.isAlive() && session.sessionId === sessionId) + return [...registry.values()].find((session) => session.isAlive() && aliases.get(session)?.has(sessionId)) ?? registered; } diff --git a/lib/rpc-session-info.test.mjs b/lib/rpc-session-info.test.mjs index 2ac64eb25e..d50a90bc24 100644 --- a/lib/rpc-session-info.test.mjs +++ b/lib/rpc-session-info.test.mjs @@ -92,7 +92,7 @@ test("keeps an idle runtime visible once its JSONL file exists", (t) => { assert.equal(infos[0].transient, false); }); -test("reuses a live wrapper after the SDK changes its session ID", async (t) => { +test("reuses one live wrapper across successive SDK session ID changes", async (t) => { const previousRegistry = globalThis.__ompSessions; let id = "original-session"; let alive = true; @@ -112,8 +112,16 @@ test("reuses a live wrapper after the SDK changes its session ID", async (t) => assert.equal(resumed.realSessionId, id); const original = await startRpcSession("original-session", "/missing/session.jsonl", undefined); assert.equal(original.realSessionId, id); + id = "second-fallback-session"; + assert.equal(getRpcSession("fallback-session"), wrapper); + assert.equal(getRpcSession("original-session"), wrapper); + assert.equal(getRpcSession(id), wrapper); + const resumedAgain = await startRpcSession("fallback-session", "/missing/session.jsonl", undefined); + assert.equal(resumedAgain.session, wrapper); + assert.equal(resumedAgain.realSessionId, id); assert.equal(globalThis.__ompSessions.size, 1); alive = false; assert.equal(getRpcSession(id), undefined); + assert.equal(getRpcSession("fallback-session"), undefined); }); From 2fe183fdc32715caa936ab1b1fd80375627a7f30 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 15 Oct 2026 15:48:08 +0800 Subject: [PATCH 03/19] fix: migrate live session keys without redirecting history Co-authored-by: Qwen-Coder --- app/api/agent/[id]/events/route.ts | 2 +- app/api/sessions/runtime-route.test.mjs | 1 + hooks/useAgentSession.ts | 43 ++++++++++----- lib/rpc-manager.ts | 50 ++++++++++------- lib/rpc-session-info.test.mjs | 72 +++++++++++++++---------- 5 files changed, 108 insertions(+), 60 deletions(-) diff --git a/app/api/agent/[id]/events/route.ts b/app/api/agent/[id]/events/route.ts index dfb0620a8e..f077668fdf 100644 --- a/app/api/agent/[id]/events/route.ts +++ b/app/api/agent/[id]/events/route.ts @@ -51,7 +51,7 @@ export async function GET( }; // Send initial connected event - encode({ type: "connected", sessionId: id }); + encode({ type: "connected", sessionId: session.sessionId }); const unsubscribe = session.onEvent((event) => { const clientEvent = toClientEvent(event); diff --git a/app/api/sessions/runtime-route.test.mjs b/app/api/sessions/runtime-route.test.mjs index 8ba530e599..071714e5fa 100644 --- a/app/api/sessions/runtime-route.test.mjs +++ b/app/api/sessions/runtime-route.test.mjs @@ -67,6 +67,7 @@ test("live detail and state routes work without a persisted JSONL file", async ( inner: { sessionManager }, sessionFile: sessionManager.getSessionFile(), sessionId: runtimeId, + syncSessionIdentity: () => {}, cwd: "/tmp", send: async () => ({ isStreaming: true }), }]]); diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index bf325f25af..31bb2af906 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -497,6 +497,15 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } satisfies SessionStatsInfo; }, [messages, sessionStatsOverride, contextUsage, data?.filePath, data?.totalActiveMs, session?.id, session?.name]); + const followSessionIdentity = useCallback((nextId: string) => { + if (nextId === sessionIdRef.current) return; + // Async sends awaiting the SSE handshake must use the new ID immediately; + // waiting for the parent to remount leaves captured IDs pointing at history. + sessionIdRef.current = nextId; + eventSourceSessionIdRef.current = nextId; + onSessionForked?.(nextId); + }, [onSessionForked]); + const loadSession = useCallback(async (sid: string, showLoading = false, includeState = false) => { let messagesLoaded = false; try { @@ -516,7 +525,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const d = await res.json() as SessionData; if (sessionIdRef.current !== sid) return null; if (d.sessionId !== sid && onSessionForked) { - onSessionForked(d.sessionId); + followSessionIdentity(d.sessionId); return null; } setData(d); @@ -565,7 +574,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } finally { if (showLoading && !messagesLoaded) setLoading(false); } - }, [onSessionForked]); + }, [followSessionIdentity, onSessionForked]); const loadContext = useCallback(async (sid: string, leafId: string | null) => { try { @@ -1110,7 +1119,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { || promptRunIdRef.current !== runId ) return; if (data.state?.sessionId && data.state.sessionId !== sid && onSessionForked) { - onSessionForked(data.state.sessionId); + followSessionIdentity(data.state.sessionId); return; } if (data.state?.model && !modelSwitchPendingRef.current) { @@ -1120,7 +1129,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } catch { // The next refresh retries; state reconciliation owns error recovery. } - }, [onSessionForked]); + }, [followSessionIdentity, onSessionForked]); // Context usage changes after each model/tool turn, not only when the prompt // settles. Poll the lightweight state endpoint while the session is active @@ -1167,6 +1176,12 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const handleAgentEvent = useCallback((event: AgentEvent) => { switch (event.type) { + case "connected": + case "session_changed": + if (typeof event.sessionId === "string" && event.sessionId !== sessionIdRef.current) { + followSessionIdentity(event.sessionId); + } + break; case "agent_start": cancelEventStreamGrace(); sdkAgentActiveRef.current = true; @@ -1436,7 +1451,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { handleExtensionUiRequest(event as ExtensionUiRequest); break; } - }, [addNotice, cancelEventStreamGrace, handleExtensionUiRequest, loadSession, notifyPromptStage, onAgentEnd, refreshContextUsage, scheduleEventStreamClose, settleUiStage]); + }, [addNotice, cancelEventStreamGrace, handleExtensionUiRequest, loadSession, notifyPromptStage, onAgentEnd, followSessionIdentity, refreshContextUsage, scheduleEventStreamClose, settleUiStage]); handleAgentEventRef.current = handleAgentEvent; const handleSend = useCallback(async (message: string, images?: AttachedImage[]) => { @@ -1493,9 +1508,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) { await sendAgentCommand(sid, { type: "set_model", provider: selectedModel.provider, modelId: selectedModel.modelId }); } } - await ensureEventsConnected(sid); + await ensureEventsConnected(sessionIdRef.current ?? sid); + sentSessionId = sessionIdRef.current ?? sid; promptRequestStarted = true; - await sendAgentCommand(sid, { + await sendAgentCommand(sentSessionId, { type: "prompt", message, ...(piImages?.length ? { images: piImages } : {}), @@ -1503,10 +1519,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) { promoteNewSession(1, message); } } else if (session) { - sentSessionId = session.id; - await ensureEventsConnected(session.id); + await ensureEventsConnected(sessionIdRef.current ?? session.id); + sentSessionId = sessionIdRef.current ?? session.id; promptRequestStarted = true; - await sendAgentCommand(session.id, { + await sendAgentCommand(sentSessionId, { type: "prompt", message, ...(piImages?.length ? { images: piImages } : {}), @@ -1890,8 +1906,9 @@ export function useAgentSession(opts: UseAgentSessionOptions) { setAgentRunning(true); setAgentPhase({ kind: "running_command" }); try { - await ensureEventsConnected(sid); - const result = await sendAgentCommand<{ cancelled?: boolean }>(sid, { + await ensureEventsConnected(sessionIdRef.current ?? sid); + const currentSid = sessionIdRef.current ?? sid; + const result = await sendAgentCommand<{ cancelled?: boolean }>(currentSid, { type: "handoff", ...(args ? { customInstructions: args } : {}), }); @@ -1901,7 +1918,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { // omp 18 hands off in place, so the session keeps its id and gains // a handoff compaction entry: reload this transcript rather than // navigating to a replacement session. - if (await loadSession(sid, true)) promoteNewSession(); + if (await loadSession(currentSid, true)) promoteNewSession(); return complete({ handled: true, message: "Context handed off and compacted in place" }); } finally { agentRunningRef.current = false; diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index 635342c4e2..bb467294ae 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -724,7 +724,26 @@ export class AgentSessionWrapper { } + syncSessionIdentity(): void { + const registry = globalThis.__ompSessions; + const currentId = this.sessionId; + if (!registry || registry.get(currentId) === this) return; + let moved = false; + for (const [id, session] of registry) { + if (session !== this) continue; + registry.delete(id); + moved = true; + } + if (!moved) return; + registry.set(currentId, this); + if (this.sessionFile) cacheSessionPath(currentId, this.sessionFile); + invalidateSessionListCache(); + // Existing streams still hold this wrapper after its registry key moves. + for (const listener of this.listeners) listener({ type: "session_changed", sessionId: currentId }); + } + private emit(event: AgentEvent): void { + this.syncSessionIdentity(); for (const l of this.listeners) l(event); } @@ -1188,6 +1207,11 @@ export class AgentSessionWrapper { void this.inner.dispose?.(); } finally { try { + // Runtime fallback can move this wrapper away from its startup ID. + // Remove only this instance, preserving separately reopened sessions. + for (const [id, session] of globalThis.__ompSessions ?? []) { + if (session === this) globalThis.__ompSessions?.delete(id); + } this.onDestroyCallback?.(); } finally { notifyRunningChange(); @@ -1545,7 +1569,6 @@ export class AgentSessionWrapper { declare global { var __ompSessions: Map | undefined; - var __ompSessionAliases: WeakMap> | undefined; var __ompStartLocks: Map> | undefined; var __ompStartingSessionCwds: Map | undefined; var __ompRunningListeners: Set<(ids: string[]) => void> | undefined; @@ -1594,23 +1617,15 @@ function trackStartingSession(cwd: string): () => void { export function getRpcSession(sessionId: string): AgentSessionWrapper | undefined { const registry = getRegistry(); - const aliases = globalThis.__ompSessionAliases ??= new WeakMap>(); - // The SDK can change a wrapper's ID repeatedly during fallback. Remember - // observed identities without duplicating wrappers in the registry; the weak - // keys release their alias sets when a destroyed wrapper is collected. - for (const [registeredId, session] of registry) { - if (!session.isAlive()) continue; - let ids = aliases.get(session); - if (!ids) { - ids = new Set([registeredId]); - aliases.set(session, ids); - } - ids.add(session.sessionId); - } const registered = registry.get(sessionId); - if (registered?.isAlive()) return registered; - return [...registry.values()].find((session) => session.isAlive() && aliases.get(session)?.has(sessionId)) - ?? registered; + if (registered?.isAlive() && registered.sessionId === sessionId) return registered; + const session = registered?.isAlive() ? registered : [...registry.values()].find( + (candidate) => candidate.isAlive() && candidate.sessionId === sessionId, + ); + // Return the moved wrapper to the discovering request once. Its old ID is + // then free to reopen the original transcript rather than redirect forever. + session?.syncSessionIdentity(); + return session ?? registered; } function runtimeMessageText(entry: SessionMessageEntry): string { @@ -1885,7 +1900,6 @@ export async function startRpcSession( const realSessionFile = inner.sessionFile as string | undefined; if (realSessionFile) cacheSessionPath(realSessionId, realSessionFile); - wrapper.onDestroy(() => registry.delete(realSessionId)); registry.set(realSessionId, wrapper); wrapper.beginExtensionBinding({ forceEmptySystemPrompt: toolNames?.length === 0 }); diff --git a/lib/rpc-session-info.test.mjs b/lib/rpc-session-info.test.mjs index d50a90bc24..ca2b066579 100644 --- a/lib/rpc-session-info.test.mjs +++ b/lib/rpc-session-info.test.mjs @@ -9,7 +9,7 @@ const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false, }); -const { getRpcSession, getRpcSessionInfos, startRpcSession } = await jiti.import("./rpc-manager.ts"); +const { AgentSessionWrapper, getRpcSession, getRpcSessionInfos, startRpcSession } = await jiti.import("./rpc-manager.ts"); function makeRuntimeSession({ id, filePath, running, entries }) { const timestamp = "2026-08-12T01:02:03.000Z"; @@ -92,36 +92,52 @@ test("keeps an idle runtime visible once its JSONL file exists", (t) => { assert.equal(infos[0].transient, false); }); -test("reuses one live wrapper across successive SDK session ID changes", async (t) => { +test("moves a live wrapper across successive IDs without redirecting historical sessions", async (t) => { const previousRegistry = globalThis.__ompSessions; - let id = "original-session"; - let alive = true; - const wrapper = { - get sessionId() { return id; }, - isAlive: () => alive, - }; - globalThis.__ompSessions = new Map([[id, wrapper]]); - t.after(() => { globalThis.__ompSessions = previousRegistry; }); + const inner = { sessionId: "original-session", isBashRunning: false }; + const eventBus = { on: () => () => {} }; + const wrapper = new AgentSessionWrapper(inner, eventBus); + const events = []; + wrapper.onEvent((event) => events.push(event)); + let historical; + globalThis.__ompSessions = new Map([[inner.sessionId, wrapper]]); + t.after(() => { + wrapper.destroy(); + historical?.destroy(); + globalThis.__ompSessions = previousRegistry; + }); - id = "fallback-session"; - assert.equal(getRpcSession(id), wrapper); - assert.equal(getRpcSession("original-session"), wrapper); - // Returning here must not open a second transcript or create another agent. - const resumed = await startRpcSession(id, "/missing/session.jsonl", undefined); + inner.sessionId = "fallback-session"; + assert.equal(getRpcSession(inner.sessionId), wrapper); + assert.equal(getRpcSession("original-session"), undefined); + assert.equal(getRpcSession(inner.sessionId), wrapper); + assert.deepEqual([...globalThis.__ompSessions.keys()], [inner.sessionId]); + // Reusing the current ID must not create a second agent or open a transcript. + const resumed = await startRpcSession(inner.sessionId, "/missing/session.jsonl", undefined); assert.equal(resumed.session, wrapper); - assert.equal(resumed.realSessionId, id); - const original = await startRpcSession("original-session", "/missing/session.jsonl", undefined); - assert.equal(original.realSessionId, id); - id = "second-fallback-session"; - assert.equal(getRpcSession("fallback-session"), wrapper); - assert.equal(getRpcSession("original-session"), wrapper); - assert.equal(getRpcSession(id), wrapper); - const resumedAgain = await startRpcSession("fallback-session", "/missing/session.jsonl", undefined); - assert.equal(resumedAgain.session, wrapper); - assert.equal(resumedAgain.realSessionId, id); - assert.equal(globalThis.__ompSessions.size, 1); + assert.equal(resumed.realSessionId, inner.sessionId); - alive = false; - assert.equal(getRpcSession(id), undefined); + inner.sessionId = "second-fallback-session"; + assert.equal(getRpcSession("fallback-session"), wrapper); assert.equal(getRpcSession("fallback-session"), undefined); + assert.equal(getRpcSession("original-session"), undefined); + assert.equal(getRpcSession(inner.sessionId), wrapper); + assert.deepEqual([...globalThis.__ompSessions.keys()], [inner.sessionId]); + + // A second tab reopening B gets its own wrapper, not C's transcript. + historical = new AgentSessionWrapper({ sessionId: "fallback-session", isBashRunning: false }, eventBus); + globalThis.__ompSessions.set(historical.sessionId, historical); + assert.equal(getRpcSession("fallback-session"), historical); + const reopened = await startRpcSession("fallback-session", "/missing/session.jsonl", undefined); + assert.equal(reopened.session, historical); + assert.equal(reopened.realSessionId, "fallback-session"); + + wrapper.destroy(); + assert.equal(getRpcSession("second-fallback-session"), undefined); + assert.equal(getRpcSession("fallback-session"), historical); + assert.deepEqual([...globalThis.__ompSessions.keys()], ["fallback-session"]); + assert.deepEqual(events.filter((event) => event.type === "session_changed"), [ + { type: "session_changed", sessionId: "fallback-session" }, + { type: "session_changed", sessionId: "second-fallback-session" }, + ]); }); From 87c63e4773d8067872b53bd238dd606a204d1b42 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 15 Oct 2026 17:10:01 +0800 Subject: [PATCH 04/19] fix: preserve live session continuity across missed identity events Use a stable runtime handle for active commands, events and polling while keeping historical session IDs independent. Retry expired handles only after an explicit negative acknowledgement. Co-authored-by: Qwen-Coder --- app/api/agent/[id]/events/route.ts | 2 +- app/api/agent/[id]/route.ts | 6 ++ app/api/agent/new/route.ts | 2 + components/AppShell.tsx | 5 +- components/ChatWindow.tsx | 2 +- hooks/useAgentSession.test.mjs | 6 +- hooks/useAgentSession.ts | 147 +++++++++++++++++------------ lib/agent-client.test.mjs | 28 +++++- lib/agent-client.ts | 10 +- lib/rpc-manager.ts | 6 +- lib/rpc-session-info.test.mjs | 27 ++++-- lib/types.ts | 2 + 12 files changed, 168 insertions(+), 75 deletions(-) diff --git a/app/api/agent/[id]/events/route.ts b/app/api/agent/[id]/events/route.ts index f077668fdf..8c33d62458 100644 --- a/app/api/agent/[id]/events/route.ts +++ b/app/api/agent/[id]/events/route.ts @@ -51,7 +51,7 @@ export async function GET( }; // Send initial connected event - encode({ type: "connected", sessionId: session.sessionId }); + encode({ type: "connected", sessionId: session.sessionId, runtimeId: session.runtimeId }); const unsubscribe = session.onEvent((event) => { const clientEvent = toClientEvent(event); diff --git a/app/api/agent/[id]/route.ts b/app/api/agent/[id]/route.ts index ea7debda2e..6b4022abb0 100644 --- a/app/api/agent/[id]/route.ts +++ b/app/api/agent/[id]/route.ts @@ -31,6 +31,12 @@ export async function POST( return NextResponse.json({ success: true, data: result }); } + if (id.startsWith("runtime:")) { + return NextResponse.json({ + error: "Runtime session expired", code: "runtime_expired", accepted: false, + }, { status: 404 }); + } + const filePath = await resolveSessionPath(id); if (!filePath) { return NextResponse.json({ diff --git a/app/api/agent/new/route.ts b/app/api/agent/new/route.ts index 60adfb2ddb..44d8a9eda7 100644 --- a/app/api/agent/new/route.ts +++ b/app/api/agent/new/route.ts @@ -87,6 +87,7 @@ export async function POST(req: Request) { return NextResponse.json({ success: true, sessionId: realSessionId, + runtimeId: session.runtimeId, data: null, model: state.model ? { provider: state.model.provider, modelId: state.model.id } @@ -102,6 +103,7 @@ export async function POST(req: Request) { return NextResponse.json({ success: true, sessionId: realSessionId, + runtimeId: session.runtimeId, data: result, model: state.model ? { provider: state.model.provider, modelId: state.model.id } diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 731c40415d..7bc302fb89 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -453,7 +453,7 @@ export function AppShell() { } } setNewSessionCwd(null); - setSelectedSession(session); + setSelectedSession({ ...session, runtimeId: undefined }); setSessionKey((k) => k + 1); setSystemPrompt(null); setInitialSessionRestored(true); @@ -621,7 +621,7 @@ export function AppShell() { setExplorerRefreshKey((k) => k + 1); }, []); - const handleSessionForked = useCallback((newSessionId: string) => { + const handleSessionForked = useCallback((newSessionId: string, runtimeId?: string) => { invalidateWorkspaceRestore(); setRefreshKey((k) => k + 1); setSessionKey((k) => k + 1); @@ -629,6 +629,7 @@ export function AppShell() { setSelectedSession((prev) => ({ ...(prev ?? { path: "", cwd: "", created: "", modified: "", messageCount: 0, firstMessage: "" }), id: newSessionId, + runtimeId, transient: false, })); hydrateSelectedSession(newSessionId); diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 4176205669..de0b168ca8 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -44,7 +44,7 @@ interface Props { onAgentEnd?: () => void; onAttentionNeeded?: (request: BlockingExtensionUiRequest) => void; onSessionCreated?: (session: SessionInfo) => void; - onSessionForked?: (newSessionId: string) => void; + onSessionForked?: (newSessionId: string, runtimeId?: string) => void; modelsRefreshKey?: number; chatInputRef?: React.RefObject; onBranchDataChange?: (tree: SessionTreeNode[], activeLeafId: string | null, onLeafChange: (leafId: string | null) => void) => void; diff --git a/hooks/useAgentSession.test.mjs b/hooks/useAgentSession.test.mjs index 182ca53e66..8795e07e23 100644 --- a/hooks/useAgentSession.test.mjs +++ b/hooks/useAgentSession.test.mjs @@ -39,7 +39,7 @@ test("keeps the session event stream open through the idle grace window", () => assert.match(source, /const EVENT_STREAM_IDLE_GRACE_MS = 30_000/); assert.match(graceSource, /setTimeout\(\(\) => void checkServerIdle\(\), EVENT_STREAM_IDLE_GRACE_MS\)/); - assert.match(graceSource, /fetch\(`\/api\/agent\/\$\{encodeURIComponent\(sid\)\}`\)/); + assert.match(graceSource, /fetch\(`\/api\/agent\/\$\{encodeURIComponent\(runtimeIdRef\.current \?\? sid\)\}`\)/); assert.match(graceSource, /closeEvents\(\)/); assert.match(finishSource, /scheduleEventStreamClose\(sid\)/); assert.doesNotMatch(finishSource, /closeEvents\(\)/); @@ -170,7 +170,7 @@ test("/handoff forwards the focus text verbatim and keeps the UI busy", () => { assert.match(handoffCaseSource, /complete\(\{ handled: true, error: "Handoff cancelled" \}\)/); assert.doesNotMatch(handoffCaseSource, /execute_slash_command/); assert.doesNotMatch(handoffCaseSource, /type: "prompt"/); - const connectIndex = handoffCaseSource.indexOf("await ensureEventsConnected(sid)"); + const connectIndex = handoffCaseSource.indexOf("await ensureEventsConnected(sessionIdRef.current ?? sid)"); const dispatchIndex = handoffCaseSource.indexOf('type: "handoff"'); assert.ok(connectIndex >= 0); assert.ok(dispatchIndex > connectIndex); @@ -191,7 +191,7 @@ test("/handoff reloads the same session after an in-place compaction", () => { // reloads this transcript instead of navigating to a replacement session. assert.doesNotMatch(handoffCaseSource, /newSessionId/); assert.doesNotMatch(handoffCaseSource, /onSessionForked/); - assert.match(handoffCaseSource, /if \(await loadSession\(sid, true\)\) promoteNewSession\(\);/); + assert.match(handoffCaseSource, /if \(await loadSession\(currentSid, true\)\) promoteNewSession\(\);/); // Every branch returns handled so the command never reaches the SDK // fallback — the busy state is torn down in a finally block and the case // yields to the default bridge with a return. diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 31bb2af906..8ab8593dad 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -76,6 +76,7 @@ interface LastAssistantTextResponse { } type AgentStateResponse = { + runtimeId?: string; sessionId?: string; model?: { provider: string; id: string }; contextUsage?: { percent: number | null; contextWindow: number; tokens: number | null } | null; @@ -172,7 +173,7 @@ export interface UseAgentSessionOptions { onAgentEnd?: () => void; onAttentionNeeded?: (request: BlockingExtensionUiRequest) => void; onSessionCreated?: (session: SessionInfo) => void; - onSessionForked?: (newSessionId: string) => void; + onSessionForked?: (newSessionId: string, runtimeId?: string) => void; modelsRefreshKey?: number; chatInputRef?: React.RefObject; onBranchDataChange?: (tree: SessionTreeNode[], activeLeafId: string | null, onLeafChange: (leafId: string | null) => void) => void; @@ -423,6 +424,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const eventStreamGraceGenerationRef = useRef(0); const eventStreamGraceActiveRef = useRef(false); const sessionIdRef = useRef(session?.id ?? null); + const runtimeIdRef = useRef(session?.runtimeId ?? null); const agentRunningRef = useRef(false); const sdkAgentActiveRef = useRef(false); const rpcPromptPendingRef = useRef(false); @@ -497,15 +499,26 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } satisfies SessionStatsInfo; }, [messages, sessionStatsOverride, contextUsage, data?.filePath, data?.totalActiveMs, session?.id, session?.name]); - const followSessionIdentity = useCallback((nextId: string) => { + const followSessionIdentity = useCallback((nextId: string, runtimeId?: string) => { + if (runtimeId) runtimeIdRef.current = runtimeId; if (nextId === sessionIdRef.current) return; // Async sends awaiting the SSE handshake must use the new ID immediately; // waiting for the parent to remount leaves captured IDs pointing at history. sessionIdRef.current = nextId; eventSourceSessionIdRef.current = nextId; - onSessionForked?.(nextId); + onSessionForked?.(nextId, runtimeIdRef.current ?? undefined); }, [onSessionForked]); + const followRuntimeState = useCallback((state: AgentStateResponse | undefined, requestedId: string) => { + if (sessionIdRef.current !== requestedId) return true; + if (state?.runtimeId) runtimeIdRef.current = state.runtimeId; + if (state?.sessionId && state.sessionId !== requestedId) { + followSessionIdentity(state.sessionId, state.runtimeId); + return true; + } + return false; + }, [followSessionIdentity]); + const loadSession = useCallback(async (sid: string, showLoading = false, includeState = false) => { let messagesLoaded = false; try { @@ -543,12 +556,16 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (!includeState) return null; try { - const stateRes = await fetch(`/api/sessions/${encodeURIComponent(sid)}/state`); + const stateRes = await fetch(runtimeIdRef.current + ? `/api/agent/${encodeURIComponent(runtimeIdRef.current)}` + : `/api/sessions/${encodeURIComponent(sid)}/state`); if (!stateRes.ok) throw new Error(`HTTP ${stateRes.status}`); const agentState = await stateRes.json() as { running: boolean; state?: AgentStateResponse }; if (sessionIdRef.current !== sid) return null; const liveState = agentState.state; + if (!agentState.running) runtimeIdRef.current = null; + if (followRuntimeState(liveState, sid)) return agentState; if (liveState) { if (liveState.contextUsage !== undefined) setContextUsage(liveState.contextUsage ?? null); if (liveState.systemPrompt !== undefined) setSystemPrompt(liveState.systemPrompt ?? null); @@ -574,7 +591,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } finally { if (showLoading && !messagesLoaded) setLoading(false); } - }, [followSessionIdentity, onSessionForked]); + }, [followSessionIdentity, followRuntimeState, onSessionForked]); const loadContext = useCallback(async (sid: string, leafId: string | null) => { try { @@ -597,7 +614,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const loadTools = useCallback(async (sid: string) => { try { - const tools = await sendAgentCommand(sid, { type: "get_tools" }); + const tools = await sendAgentCommand(sid, { type: "get_tools" }, runtimeIdRef); if (tools) { const { getPresetFromTools } = await import("@/lib/tool-presets"); setToolPresetState(getPresetFromTools(tools)); @@ -613,6 +630,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { newSessionPromotedRef.current = true; onSessionCreated?.({ id: sid, + runtimeId: runtimeIdRef.current ?? undefined, path: "", cwd: newSessionCwd, name: undefined, @@ -652,12 +670,14 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (!res.ok) throw new Error(`HTTP ${res.status}`); const result = await res.json() as { sessionId: string; + runtimeId?: string; model?: SelectedModel | null; thinkingLevel?: ThinkingLevelOption; configuredThinkingLevel?: ThinkingLevelOption; }; const realId = result.sessionId; sessionIdRef.current = realId; + runtimeIdRef.current = result.runtimeId ?? null; if (result.model && newSessionModelOverrideRef.current === selectedModel) { setPendingModel(result.model); if (!selectedModel) setNewSessionDefaultModel(result.model); @@ -688,7 +708,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } setSlashCommandsLoading(true); try { - const data = await sendAgentCommand(sid, { type: "get_commands" }); + const data = await sendAgentCommand(sid, { type: "get_commands" }, runtimeIdRef); const commands = data?.commands ?? []; setSlashCommands(commands); return commands; @@ -719,7 +739,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const connectEvents = useCallback((sid: string): Promise => { closeEvents(); - const es = new EventSource(`/api/agent/${encodeURIComponent(sid)}/events`); + const es = new EventSource(`/api/agent/${encodeURIComponent(runtimeIdRef.current ?? sid)}/events`); eventSourceRef.current = es; eventSourceSessionIdRef.current = sid; @@ -777,6 +797,14 @@ export function useAgentSession(opts: UseAgentSessionOptions) { }, [closeEvents]); const ensureEventsConnected = useCallback(async (sid: string) => { + if (runtimeIdRef.current) { + const stateRes = await fetch(`/api/agent/${encodeURIComponent(runtimeIdRef.current)}`); + if (stateRes.ok) { + const state = await stateRes.json() as { running?: boolean; state?: AgentStateResponse }; + if (!state.running) runtimeIdRef.current = null; + else followRuntimeState(state.state, sid); + } + } const current = eventSourceRef.current; if (current && eventSourceSessionIdRef.current === sid) { if (current.readyState === EventSource.OPEN) return; @@ -794,7 +822,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (eventConnectionAttemptRef.current?.source === result.source) eventConnectionAttemptRef.current = null; result.source.close(); throw new EventStreamConnectionError(result.status); - }, [connectEvents]); + }, [connectEvents, followRuntimeState]); const respondToExtensionUi = useCallback(async ( request: ExtensionUiDialogRequest, @@ -808,7 +836,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { type: "extension_ui_response", id: request.id, ...response, - }); + }, runtimeIdRef); } catch (e) { console.error("Failed to send extension UI response:", e); } @@ -822,7 +850,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { type: "extension_ui_input", id: request.id, data, - }); + }, runtimeIdRef); } catch (e) { console.error("Failed to send extension custom UI input:", e); } @@ -937,7 +965,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { ) return; try { - const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`); + const res = await fetch(`/api/agent/${encodeURIComponent(runtimeIdRef.current ?? sid)}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json() as { running?: boolean; state?: AgentStateResponse }; if ( @@ -947,6 +975,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { ) return; const state = data.state; + if (followRuntimeState(state, sid)) return; setSubagents((current) => mergeSubagentSnapshots(current, state?.subagents ?? [])); const promptActive = Boolean( data.running @@ -993,7 +1022,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { }; eventStreamGraceTimerRef.current = setTimeout(() => void checkServerIdle(), EVENT_STREAM_IDLE_GRACE_MS); - }, [cancelEventStreamGrace, closeEvents]); + }, [cancelEventStreamGrace, closeEvents, followRuntimeState]); const finishPromptWithoutStream = useCallback(async (sid: string | null = sessionIdRef.current, runId = promptRunIdRef.current) => { // Bail out before loadSession too: a stale finish for a previous run @@ -1025,10 +1054,11 @@ export function useAgentSession(opts: UseAgentSessionOptions) { while (agentRunningRef.current && Date.now() - startedAt < PROMPT_SETTLE_MAX_MS) { if (runId !== undefined && promptRunIdRef.current !== runId) return; try { - const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`); + const res = await fetch(`/api/agent/${encodeURIComponent(runtimeIdRef.current ?? sid)}`); if (res.ok) { const data = await res.json() as { running?: boolean; state?: AgentStateResponse }; const state = data.state; + if (followRuntimeState(state, sid)) return; if (!data.running || !state || (!state.isStreaming && !state.isPromptRunning)) { await finishPromptWithoutStream(sid, runId); return; @@ -1039,7 +1069,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } await delay(PROMPT_SETTLE_POLL_MS); } - }, [finishPromptWithoutStream]); + }, [finishPromptWithoutStream, followRuntimeState]); const waitForBashSettlement = useCallback(async (sid: string) => { const recoveryId = bashRecoveryIdRef.current + 1; @@ -1052,9 +1082,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) { ) { await delay(BASH_STATE_RECONCILE_MS); try { - const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`); + const res = await fetch(`/api/agent/${encodeURIComponent(runtimeIdRef.current ?? sid)}`); if (!res.ok) continue; const data = await res.json() as { state?: AgentStateResponse }; + if (followRuntimeState(data.state, sid)) return; if (data.state?.isBashRunning) continue; await loadSession(sid); @@ -1067,7 +1098,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { // Keep polling while the page is mounted; network recovery is transparent. } } - }, [loadSession]); + }, [loadSession, followRuntimeState]); // Reconcile client streaming state with the server. When SSE events are // missed (network drop, mobile tab backgrounded, half-open connection), @@ -1078,7 +1109,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (!agentRunningRef.current) return; const runId = promptRunIdRef.current; try { - const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`); + const res = await fetch(`/api/agent/${encodeURIComponent(runtimeIdRef.current ?? sid)}`); if (!res.ok) return; const data = await res.json() as { running?: boolean; state?: AgentStateResponse }; // A slow response can straddle a run boundary (previous run finished @@ -1086,6 +1117,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { // flight) — everything in it is stale, drop it. if (promptRunIdRef.current !== runId) return; const state = data.state; + if (followRuntimeState(state, sid)) return; // Mirror compaction state unconditionally: a missed compaction_end // would otherwise leave the "Stop compaction" UI stuck. No state // (wrapper destroyed) means nothing is compacting. @@ -1105,12 +1137,12 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } catch { // Network still down — the next poll / visibility / online tick retries. } - }, [finishPromptWithoutStream]); + }, [finishPromptWithoutStream, followRuntimeState]); const refreshContextUsage = useCallback(async (sid: string, runId = promptRunIdRef.current) => { const requestId = contextUsageRequestIdRef.current + 1; contextUsageRequestIdRef.current = requestId; try { - const res = await fetch(`/api/agent/${encodeURIComponent(sid)}`); + const res = await fetch(`/api/agent/${encodeURIComponent(runtimeIdRef.current ?? sid)}`); if (!res.ok) return; const data = await res.json() as { state?: AgentStateResponse }; if ( @@ -1118,10 +1150,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { || sessionIdRef.current !== sid || promptRunIdRef.current !== runId ) return; - if (data.state?.sessionId && data.state.sessionId !== sid && onSessionForked) { - followSessionIdentity(data.state.sessionId); - return; - } + if (followRuntimeState(data.state, sid)) return; if (data.state?.model && !modelSwitchPendingRef.current) { setCurrentModelOverride({ provider: data.state.model.provider, modelId: data.state.model.id }); } @@ -1129,7 +1158,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } catch { // The next refresh retries; state reconciliation owns error recovery. } - }, [followSessionIdentity, onSessionForked]); + }, [followRuntimeState]); // Context usage changes after each model/tool turn, not only when the prompt // settles. Poll the lightweight state endpoint while the session is active @@ -1178,8 +1207,8 @@ export function useAgentSession(opts: UseAgentSessionOptions) { switch (event.type) { case "connected": case "session_changed": - if (typeof event.sessionId === "string" && event.sessionId !== sessionIdRef.current) { - followSessionIdentity(event.sessionId); + if (typeof event.sessionId === "string") { + followSessionIdentity(event.sessionId, typeof event.runtimeId === "string" ? event.runtimeId : undefined); } break; case "agent_start": @@ -1199,10 +1228,12 @@ export function useAgentSession(opts: UseAgentSessionOptions) { setRetryInfo(null); dispatch({ type: "end" }); if (sessionIdRef.current) { - loadSession(sessionIdRef.current); - fetch(`/api/agent/${encodeURIComponent(sessionIdRef.current)}`) + const sid = sessionIdRef.current; + loadSession(sid); + fetch(`/api/agent/${encodeURIComponent(runtimeIdRef.current ?? sid)}`) .then((r) => r.json()) .then((d: { state?: AgentStateResponse }) => { + if (followRuntimeState(d.state, sid)) return; if (d.state?.contextUsage !== undefined) setContextUsage(d.state.contextUsage ?? null); if (d.state?.systemPrompt !== undefined) setSystemPrompt(d.state.systemPrompt ?? null); if (d.state?.extensionStatuses !== undefined) setExtensionStatuses(d.state.extensionStatuses ?? []); @@ -1451,7 +1482,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { handleExtensionUiRequest(event as ExtensionUiRequest); break; } - }, [addNotice, cancelEventStreamGrace, handleExtensionUiRequest, loadSession, notifyPromptStage, onAgentEnd, followSessionIdentity, refreshContextUsage, scheduleEventStreamClose, settleUiStage]); + }, [addNotice, cancelEventStreamGrace, handleExtensionUiRequest, loadSession, notifyPromptStage, onAgentEnd, followSessionIdentity, followRuntimeState, refreshContextUsage, scheduleEventStreamClose, settleUiStage]); handleAgentEventRef.current = handleAgentEvent; const handleSend = useCallback(async (message: string, images?: AttachedImage[]) => { @@ -1505,7 +1536,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (selectedModel) { setPendingModel(selectedModel); if (existingSid) { - await sendAgentCommand(sid, { type: "set_model", provider: selectedModel.provider, modelId: selectedModel.modelId }); + await sendAgentCommand(sid, { type: "set_model", provider: selectedModel.provider, modelId: selectedModel.modelId }, runtimeIdRef); } } await ensureEventsConnected(sessionIdRef.current ?? sid); @@ -1515,7 +1546,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { type: "prompt", message, ...(piImages?.length ? { images: piImages } : {}), - }); + }, runtimeIdRef); promoteNewSession(1, message); } } else if (session) { @@ -1526,7 +1557,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { type: "prompt", message, ...(piImages?.length ? { images: piImages } : {}), - }); + }, runtimeIdRef); } if (isSlashCommandPrompt && sentSessionId) { void waitForPromptSettlement(sentSessionId, promptRunId); @@ -1584,7 +1615,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { type: "bash", command, excludeFromContext, - }); + }, runtimeIdRef); await loadSession(sid); promoteNewSession(1, inputText); } catch (e) { @@ -1604,14 +1635,14 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (!sid) return; if (bashRunningRef.current) { try { - await sendAgentCommand(sid, { type: "abort_bash" }); + await sendAgentCommand(sid, { type: "abort_bash" }, runtimeIdRef); } catch (e) { console.error("Failed to abort bash:", e); } return; } try { - await sendAgentCommand(sid, { type: "abort" }); + await sendAgentCommand(sid, { type: "abort" }, runtimeIdRef); } catch (e) { console.error("Failed to abort:", e); } @@ -1632,7 +1663,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const result = await sendAgentCommand<{ cancelled?: boolean; newSessionId?: string }>(sid, { type: "fork", ...(entryId ? { entryId } : {}), - }); + }, runtimeIdRef); const { cancelled, newSessionId } = result ?? {}; if (!cancelled && newSessionId) { onSessionForked?.(newSessionId); @@ -1651,7 +1682,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (bashRunningRef.current) return; const sid = sessionIdRef.current; if (!sid) return; - sendAgentCommand(sid, { type: "navigate_tree", targetId: entryId }).catch(() => {}); + sendAgentCommand(sid, { type: "navigate_tree", targetId: entryId }, runtimeIdRef).catch(() => {}); setActiveLeafId(entryId); await loadContext(sid, entryId); }, [loadContext]); @@ -1663,7 +1694,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (!sid) return; await loadContext(sid, leafId); if (leafId) { - sendAgentCommand(sid, { type: "navigate_tree", targetId: leafId }).catch(() => {}); + sendAgentCommand(sid, { type: "navigate_tree", targetId: leafId }, runtimeIdRef).catch(() => {}); } }, [loadContext]); @@ -1676,7 +1707,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current; if (!sid) return; try { - await sendAgentCommand(sid, { type: "set_model", provider, modelId }); + await sendAgentCommand(sid, { type: "set_model", provider, modelId }, runtimeIdRef); } catch (e) { console.error("Failed to set model:", e); } @@ -1690,7 +1721,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { setCurrentModelOverride(target); setModelSwitching(true); try { - await sendAgentCommand(sid, { type: "set_model", provider, modelId }); + await sendAgentCommand(sid, { type: "set_model", provider, modelId }, runtimeIdRef); // Pi persists model_change synchronously. Reload the canonical session so // the model, thinking level, and active leaf all advance together. modelSwitchPendingRef.current = false; @@ -1739,7 +1770,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const sid = sessionIdRef.current ?? (isNew ? await ensuringNewSessionRef.current : null); if (!sid) return; try { - await sendAgentCommand(sid, { type: "set_role_model", role }); + await sendAgentCommand(sid, { type: "set_role_model", role }, runtimeIdRef); if (!isNew) setCurrentModelOverride({ provider: resolved.provider, modelId: resolved.modelId }); } catch (e) { console.error("Failed to set role model:", e); @@ -1753,7 +1784,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { setCompactError(null); setCompactResult(null); try { - const result = await sendAgentCommand(sid, { type: "compact" }); + const result = await sendAgentCommand(sid, { type: "compact" }, runtimeIdRef); setCompactResult(readCompactResult(result, "manual")); await loadSession(sid, true); } catch (e) { @@ -1821,7 +1852,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const result = await sendAgentCommand(sid, { type: "compact", ...(args ? { customInstructions: args } : {}), - }); + }, runtimeIdRef); setCompactResult(readCompactResult(result, "manual")); if (await loadSession(sid, true)) promoteNewSession(); return complete({ handled: true, message: "Compacted context" }); @@ -1829,7 +1860,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { case "reload": { if (!sid) return complete({ handled: true, error: "No active session to reload" }); - await sendAgentCommand(sid, { type: "reload" }); + await sendAgentCommand(sid, { type: "reload" }, runtimeIdRef); await Promise.all([ loadSession(sid, false, true), loadTools(sid), @@ -1842,14 +1873,14 @@ export function useAgentSession(opts: UseAgentSessionOptions) { case "name": { if (!sid) return complete({ handled: true, error: "No active session to name" }); if (!args) return complete({ handled: true, error: "Usage: /name " }); - await sendAgentCommand(sid, { type: "set_session_name", name: args }); + await sendAgentCommand(sid, { type: "set_session_name", name: args }, runtimeIdRef); if (await loadSession(sid)) promoteNewSession(); return complete({ handled: true, message: `Session renamed to ${args}` }); } case "session": { if (!sid) return complete({ handled: true, error: "No active session" }); - const stats = await sendAgentCommand(sid, { type: "get_session_stats" }); + const stats = await sendAgentCommand(sid, { type: "get_session_stats" }, runtimeIdRef); if (stats) { setSessionStatsOverride(stats); } @@ -1859,7 +1890,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { case "copy": { if (!sid) return complete({ handled: true, error: "No active session" }); - const data = await sendAgentCommand(sid, { type: "get_last_assistant_text" }); + const data = await sendAgentCommand(sid, { type: "get_last_assistant_text" }, runtimeIdRef); const textToCopy = data?.text ?? ""; if (!textToCopy) return complete({ handled: true, error: "No assistant message to copy" }); await navigator.clipboard.writeText(textToCopy); @@ -1877,7 +1908,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { case "goal": { if (!sid) return complete({ handled: true, error: "No active session" }); - const result = await sendAgentCommand(sid, { type: "goal", args }); + const result = await sendAgentCommand(sid, { type: "goal", args }, runtimeIdRef); setGoalStatus(result?.status ?? null); if (result?.error) return complete({ handled: true, error: result.error }); // `/goal show` output belongs in the transcript, not a toast that @@ -1911,7 +1942,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const result = await sendAgentCommand<{ cancelled?: boolean }>(currentSid, { type: "handoff", ...(args ? { customInstructions: args } : {}), - }); + }, runtimeIdRef); if (!result || result.cancelled) { return complete({ handled: true, error: "Handoff cancelled" }); } @@ -1937,7 +1968,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { }>(sid, { type: "execute_slash_command", message: text, - }); + }, runtimeIdRef); if (!result?.handled) return { handled: false }; const output = result.output?.filter((line) => line.trim()).join("\n\n") ?? ""; if (output) appendCommandOutput(output); @@ -1966,7 +1997,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { type: "steer", message, ...(piImages?.length ? { images: piImages } : {}), - }); + }, runtimeIdRef); } catch (e) { console.error("Failed to steer:", e); } @@ -1986,7 +2017,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { message, streamingBehavior: behavior, ...(piImages?.length ? { images: piImages } : {}), - }); + }, runtimeIdRef); } catch (e) { console.error("Failed to queue prompt:", e); } @@ -2001,7 +2032,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { type: "follow_up", message, ...(piImages?.length ? { images: piImages } : {}), - }); + }, runtimeIdRef); } catch (e) { console.error("Failed to follow up:", e); } @@ -2011,7 +2042,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const sid = sessionIdRef.current; if (!sid) return; try { - await sendAgentCommand(sid, { type: "abort_compaction" }); + await sendAgentCommand(sid, { type: "abort_compaction" }, runtimeIdRef); } catch (e) { console.error("Failed to abort compaction:", e); } @@ -2021,7 +2052,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const sid = sessionIdRef.current; if (!sid) return; try { - const result = await sendAgentCommand<{ steering?: string[]; followUp?: string[] }>(sid, { type: "clear_queue" }); + const result = await sendAgentCommand<{ steering?: string[]; followUp?: string[] }>(sid, { type: "clear_queue" }, runtimeIdRef); // clearQueue also emits an empty queue_update, but that only reaches us // while SSE is connected — clear locally so idle recalls update the UI. setQueuedMessages({ steering: [], followUp: [] }); @@ -2044,7 +2075,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current; if (!sid) return; try { - await sendAgentCommand(sid, { type: "set_thinking_level", level }); + await sendAgentCommand(sid, { type: "set_thinking_level", level }, runtimeIdRef); await loadSession(sid); } catch (e) { console.error("Failed to set thinking level:", e); @@ -2059,7 +2090,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current; if (!sid) return; try { - await sendAgentCommand(sid, { type: "set_tools", toolNames }); + await sendAgentCommand(sid, { type: "set_tools", toolNames }, runtimeIdRef); } catch (e) { console.error("Failed to set tools:", e); } diff --git a/lib/agent-client.test.mjs b/lib/agent-client.test.mjs index 5d7d0f4268..d46ac78eb3 100644 --- a/lib/agent-client.test.mjs +++ b/lib/agent-client.test.mjs @@ -37,12 +37,15 @@ test("agent command HTTP rejections are distinguishable from transport failures" ); const transportError = new TypeError("connection reset"); + const runtime = { current: "runtime:live" }; + let transportAttempts = 0; globalThis.fetch = async () => { + transportAttempts += 1; throw transportError; }; await assert.rejects( - sendAgentCommand("session-id", { type: "prompt", message: "hello" }), + sendAgentCommand("session-id", { type: "prompt", message: "hello" }, runtime), (error) => { assert.equal(error, transportError); assert.equal(error instanceof AgentCommandError, false); @@ -50,6 +53,8 @@ test("agent command HTTP rejections are distinguishable from transport failures" return true; }, ); + assert.equal(transportAttempts, 1); + assert.equal(runtime.current, "runtime:live"); }); test("only an explicit negative prompt acknowledgement is definitive", () => { @@ -62,3 +67,24 @@ test("only an explicit negative prompt acknowledgement is definitive", () => { false, ); }); + + +test("an explicitly expired runtime retries the canonical session once", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { globalThis.fetch = originalFetch; }); + const requests = []; + const runtime = { current: "runtime:expired" }; + const command = { type: "set_model", provider: "provider", modelId: "model" }; + globalThis.fetch = async (url, options) => { + requests.push({ url, body: JSON.parse(options.body) }); + return requests.length === 1 + ? Response.json({ error: "Runtime session expired", code: "runtime_expired", accepted: false }, { status: 404 }) + : Response.json({ success: true, data: { updated: true } }); + }; + assert.deepEqual(await sendAgentCommand("canonical-session", command, runtime), { updated: true }); + assert.deepEqual(requests, [ + { url: "/api/agent/runtime%3Aexpired", body: command }, + { url: "/api/agent/canonical-session", body: command }, + ]); + assert.equal(runtime.current, null); +}); diff --git a/lib/agent-client.ts b/lib/agent-client.ts index e30e7b8510..cc99f121c8 100644 --- a/lib/agent-client.ts +++ b/lib/agent-client.ts @@ -28,8 +28,10 @@ export function isPromptRejectedError(error: unknown): error is AgentCommandErro export async function sendAgentCommand( sessionId: string, command: Record, + runtime?: { current: string | null }, ): Promise { - const res = await fetch(`/api/agent/${encodeURIComponent(sessionId)}`, { + const runtimeId = runtime?.current; + const res = await fetch(`/api/agent/${encodeURIComponent(runtimeId ?? sessionId)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(command), @@ -42,6 +44,12 @@ export async function sendAgentCommand( accepted?: boolean; }; if (!res.ok || body.error) { + // Retry only when the server guarantees that the expired handle executed nothing. + if (runtimeId && runtime?.current === runtimeId && res.status === 404 + && body.code === "runtime_expired" && body.accepted === false) { + runtime.current = null; + return sendAgentCommand(sessionId, command); + } throw new AgentCommandError( body.error ?? `HTTP ${res.status}`, res.status, diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index bb467294ae..052b2032a3 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -280,6 +280,7 @@ export async function getAvailableSlashCommands(session: AgentSessionLike): Prom // ============================================================================ export class AgentSessionWrapper { + readonly runtimeId = `runtime:${randomUUID()}`; private listeners: EventListener[] = []; private pendingUiResponses = new Map(); private pendingUiRequests = new Map(); @@ -739,7 +740,7 @@ export class AgentSessionWrapper { if (this.sessionFile) cacheSessionPath(currentId, this.sessionFile); invalidateSessionListCache(); // Existing streams still hold this wrapper after its registry key moves. - for (const listener of this.listeners) listener({ type: "session_changed", sessionId: currentId }); + for (const listener of this.listeners) listener({ type: "session_changed", sessionId: currentId, runtimeId: this.runtimeId }); } private emit(event: AgentEvent): void { @@ -855,6 +856,7 @@ export class AgentSessionWrapper { const contextUsage = this.inner.getContextUsage(); return { sessionId: this.inner.sessionId, + runtimeId: this.runtimeId, sessionFile: this.inner.sessionFile ?? "", isStreaming: this.inner.isStreaming, isPromptRunning: this.promptRunning, @@ -1620,7 +1622,7 @@ export function getRpcSession(sessionId: string): AgentSessionWrapper | undefine const registered = registry.get(sessionId); if (registered?.isAlive() && registered.sessionId === sessionId) return registered; const session = registered?.isAlive() ? registered : [...registry.values()].find( - (candidate) => candidate.isAlive() && candidate.sessionId === sessionId, + (candidate) => candidate.isAlive() && (candidate.sessionId === sessionId || candidate.runtimeId === sessionId), ); // Return the moved wrapper to the discovering request once. Its old ID is // then free to reopen the original transcript rather than redirect forever. diff --git a/lib/rpc-session-info.test.mjs b/lib/rpc-session-info.test.mjs index ca2b066579..b31e59e26c 100644 --- a/lib/rpc-session-info.test.mjs +++ b/lib/rpc-session-info.test.mjs @@ -98,7 +98,8 @@ test("moves a live wrapper across successive IDs without redirecting historical const eventBus = { on: () => () => {} }; const wrapper = new AgentSessionWrapper(inner, eventBus); const events = []; - wrapper.onEvent((event) => events.push(event)); + const disconnect = wrapper.onEvent((event) => events.push(event)); + const runtimeId = wrapper.runtimeId; let historical; globalThis.__ompSessions = new Map([[inner.sessionId, wrapper]]); t.after(() => { @@ -132,12 +133,26 @@ test("moves a live wrapper across successive IDs without redirecting historical assert.equal(reopened.session, historical); assert.equal(reopened.realSessionId, "fallback-session"); - wrapper.destroy(); + assert.deepEqual(events.filter((event) => event.type === "session_changed"), [ + { type: "session_changed", sessionId: "fallback-session", runtimeId }, + { type: "session_changed", sessionId: "second-fallback-session", runtimeId }, + ]); + // A disconnected tab can recover the next identity without aliasing B or C. + disconnect(); + inner.sessionId = "disconnected-fallback-session"; + wrapper.syncSessionIdentity(); assert.equal(getRpcSession("second-fallback-session"), undefined); + assert.equal(getRpcSession(runtimeId), wrapper); + assert.equal(getRpcSession(runtimeId).sessionId, inner.sessionId); + const recovered = await startRpcSession(runtimeId, "/missing/session.jsonl", undefined); + assert.equal(recovered.session, wrapper); + assert.equal(recovered.realSessionId, inner.sessionId); + assert.equal(getRpcSession("fallback-session"), historical); + assert.equal(events.filter((event) => event.type === "session_changed").length, 2); + + wrapper.destroy(); + assert.equal(getRpcSession(runtimeId), undefined); + assert.equal(getRpcSession(inner.sessionId), undefined); assert.equal(getRpcSession("fallback-session"), historical); assert.deepEqual([...globalThis.__ompSessions.keys()], ["fallback-session"]); - assert.deepEqual(events.filter((event) => event.type === "session_changed"), [ - { type: "session_changed", sessionId: "fallback-session" }, - { type: "session_changed", sessionId: "second-fallback-session" }, - ]); }); diff --git a/lib/types.ts b/lib/types.ts index d8f5a09acd..13c89b55d7 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -392,6 +392,8 @@ export interface SessionTreeNode { } export interface SessionInfo { + /** Client continuation handle; never a persisted transcript ID or history alias. */ + runtimeId?: string; path: string; id: string; cwd: string; From c6a37630b977eedaf006fb4be5c3d2a9faa65435 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 15 Oct 2026 17:27:58 +0800 Subject: [PATCH 05/19] fix: track sidebar completion by runtime identity Include stable runtime identities in activity snapshots so fallback relocations do not mark historical sessions unread or play premature completion tones. Co-authored-by: Qwen-Coder --- app/api/agent/running/route.ts | 4 ++-- app/api/sessions/route.ts | 4 ++-- components/SessionSidebar.tsx | 33 +++++++++++++++++++++++---------- lib/rpc-manager.ts | 13 +++++++++++++ lib/rpc-session-info.test.mjs | 18 +++++++++++++++++- 5 files changed, 57 insertions(+), 15 deletions(-) diff --git a/app/api/agent/running/route.ts b/app/api/agent/running/route.ts index ecde5b7b64..4cab9dd7ad 100644 --- a/app/api/agent/running/route.ts +++ b/app/api/agent/running/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; -import { getRunningRpcSessionIds } from "@/lib/rpc-manager"; +import { getRpcSessionActivitySnapshot } from "@/lib/rpc-manager"; export const dynamic = "force-dynamic"; // GET /api/agent/running - Lightweight snapshot for visible-tab polling. export async function GET() { return NextResponse.json( - { runningSessionIds: getRunningRpcSessionIds() }, + getRpcSessionActivitySnapshot(), { headers: { "Cache-Control": "no-store" } }, ); } diff --git a/app/api/sessions/route.ts b/app/api/sessions/route.ts index ebb0c18863..4de6baff46 100644 --- a/app/api/sessions/route.ts +++ b/app/api/sessions/route.ts @@ -4,7 +4,7 @@ import { listAllSessions, mergeSessionLists, } from "@/lib/session-reader"; -import { getRpcSessionInfos, getRunningRpcSessionIds } from "@/lib/rpc-manager"; +import { getRpcSessionInfos, getRpcSessionActivitySnapshot } from "@/lib/rpc-manager"; import { readArchivedIds } from "@/lib/session-archive"; export const dynamic = "force-dynamic"; @@ -21,7 +21,7 @@ export async function GET(req: Request) { const sessions = mergeSessionLists(persistedSessions, runtimeSessions) .filter((s) => (showArchived ? archived.has(s.id) : !archived.has(s.id))); return NextResponse.json( - { sessions, runningSessionIds: getRunningRpcSessionIds() }, + { sessions, ...getRpcSessionActivitySnapshot() }, { headers: { "Cache-Control": "no-store" } }, ); } catch (error) { diff --git a/components/SessionSidebar.tsx b/components/SessionSidebar.tsx index 39f082c5b3..07aa42b8b9 100644 --- a/components/SessionSidebar.tsx +++ b/components/SessionSidebar.tsx @@ -481,9 +481,13 @@ export function SessionSidebar({ selectedSessionId, optimisticSession, onSelectS const [changesCollapsed, setChangesCollapsed] = useState(true); const [sessionRefreshDone, setSessionRefreshDone] = useState(false); const [explorerRefreshDone, setExplorerRefreshDone] = useState(false); - const [runningSessionIds, setRunningSessionIds] = useState>(() => new Set()); + const [activitySnapshot, setActivitySnapshot] = useState<{ + runningSessionIds?: string[]; + sessionIdsByRuntimeId?: Record; + }>({}); + const runningSessionIds = useMemo(() => new Set(activitySnapshot.runningSessionIds ?? []), [activitySnapshot]); const [unreadSessionIds, setUnreadSessionIds] = useState>(() => loadUnreadSessionIds()); - const previousRunningSessionIdsRef = useRef>(new Set()); + const previousActivitySnapshotRef = useRef(activitySnapshot); // Once polling has delivered a snapshot it is the source of truth for // running state; late /api/sessions responses must not overwrite it. const runningPollAuthoritativeRef = useRef(false); @@ -503,12 +507,12 @@ export function SessionSidebar({ selectedSessionId, optimisticSession, onSelectS cache: "no-store", }); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json() as { sessions: SessionInfo[]; runningSessionIds?: string[] }; + const data = await res.json() as { sessions: SessionInfo[]; runningSessionIds?: string[]; sessionIdsByRuntimeId?: Record }; setAllSessions(data.sessions); // Treat the fetched running set as an initial fallback only. Once the // lightweight poll is live, a slow session-list fetch cannot overwrite it. if (!runningPollAuthoritativeRef.current) { - setRunningSessionIds(new Set(data.runningSessionIds ?? [])); + setActivitySnapshot({ runningSessionIds: data.runningSessionIds, sessionIdsByRuntimeId: data.sessionIdsByRuntimeId }); } // Drop unread markers for sessions that no longer exist (e.g. deleted). const existingIds = new Set(data.sessions.map((s) => s.id)); @@ -584,10 +588,10 @@ export function SessionSidebar({ selectedSessionId, optimisticSession, onSelectS signal: current.signal, }); if (!res.ok) return; - const data = await res.json() as { runningSessionIds?: string[] }; + const data = await res.json() as { runningSessionIds?: string[]; sessionIdsByRuntimeId?: Record }; if (stopped || controller !== current) return; runningPollAuthoritativeRef.current = true; - setRunningSessionIds(new Set(data.runningSessionIds ?? [])); + setActivitySnapshot({ runningSessionIds: data.runningSessionIds, sessionIdsByRuntimeId: data.sessionIdsByRuntimeId }); } catch { // Keep the last known state; the next visible-tab poll retries. } finally { @@ -617,8 +621,17 @@ export function SessionSidebar({ selectedSessionId, optimisticSession, onSelectS }, []); useEffect(() => { - const previous = previousRunningSessionIdsRef.current; - const completedInBackground = [...previous].filter((id) => !runningSessionIds.has(id) && id !== selectedSessionId); + const previousSnapshot = previousActivitySnapshotRef.current; + const previous = new Set(previousSnapshot.runningSessionIds ?? []); + // Compare the same live wrapper across fallback IDs before reporting completion. + const previousRuntimeIds = new Map(Object.entries(previousSnapshot.sessionIdsByRuntimeId ?? {}) + .map(([runtimeId, sessionId]) => [sessionId, runtimeId])); + const completedInBackground = [...previous].flatMap((id) => { + const runtimeId = previousRuntimeIds.get(id); + const currentId = (runtimeId && activitySnapshot.sessionIdsByRuntimeId?.[runtimeId]) || id; + return !runningSessionIds.has(currentId) && id !== selectedSessionId && currentId !== selectedSessionId + ? [currentId] : []; + }); const newlyRunning = [...runningSessionIds].filter((id) => !previous.has(id)); if (completedInBackground.length > 0 || newlyRunning.length > 0) { @@ -639,8 +652,8 @@ export function SessionSidebar({ selectedSessionId, optimisticSession, onSelectS onBackgroundTaskDone?.(); } - previousRunningSessionIdsRef.current = runningSessionIds; - }, [runningSessionIds, selectedSessionId, allSessions, loadSessions, onBackgroundTaskDone]); + previousActivitySnapshotRef.current = activitySnapshot; + }, [activitySnapshot, runningSessionIds, selectedSessionId, allSessions, loadSessions, onBackgroundTaskDone]); useEffect(() => { if (!selectedSessionId) return; diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index 052b2032a3..dbdac65417 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -1713,6 +1713,19 @@ export async function destroyRpcSessionsForCwd(cwd: string): Promise { return sessions.length; } +// Include idle wrappers so a run that changes ID and finishes between polls +// is attributed to its final transcript rather than its historical ID. +export function getRpcSessionActivitySnapshot() { + const runningSessionIds = new Set(); + const sessionIdsByRuntimeId: Record = {}; + for (const session of getRegistry().values()) { + if (!session.isAlive()) continue; + sessionIdsByRuntimeId[session.runtimeId] = session.sessionId; + if (session.isRunning()) runningSessionIds.add(session.sessionId); + } + return { runningSessionIds: [...runningSessionIds], sessionIdsByRuntimeId }; +} + export function getRunningRpcSessionIds(): string[] { const ids = new Set(); for (const [sessionId, session] of getRegistry()) { diff --git a/lib/rpc-session-info.test.mjs b/lib/rpc-session-info.test.mjs index b31e59e26c..55fd2ef31a 100644 --- a/lib/rpc-session-info.test.mjs +++ b/lib/rpc-session-info.test.mjs @@ -9,7 +9,7 @@ const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false, }); -const { AgentSessionWrapper, getRpcSession, getRpcSessionInfos, startRpcSession } = await jiti.import("./rpc-manager.ts"); +const { AgentSessionWrapper, getRpcSession, getRpcSessionInfos, getRpcSessionActivitySnapshot, startRpcSession } = await jiti.import("./rpc-manager.ts"); function makeRuntimeSession({ id, filePath, running, entries }) { const timestamp = "2026-08-12T01:02:03.000Z"; @@ -100,6 +100,8 @@ test("moves a live wrapper across successive IDs without redirecting historical const events = []; const disconnect = wrapper.onEvent((event) => events.push(event)); const runtimeId = wrapper.runtimeId; + let running = true; + wrapper.isRunning = () => running; let historical; globalThis.__ompSessions = new Map([[inner.sessionId, wrapper]]); t.after(() => { @@ -108,7 +110,21 @@ test("moves a live wrapper across successive IDs without redirecting historical globalThis.__ompSessions = previousRegistry; }); + assert.deepEqual(getRpcSessionActivitySnapshot(), { + runningSessionIds: ["original-session"], + sessionIdsByRuntimeId: { [runtimeId]: "original-session" }, + }); inner.sessionId = "fallback-session"; + assert.deepEqual(getRpcSessionActivitySnapshot(), { + runningSessionIds: ["fallback-session"], + sessionIdsByRuntimeId: { [runtimeId]: "fallback-session" }, + }); + running = false; + assert.deepEqual(getRpcSessionActivitySnapshot(), { + runningSessionIds: [], + sessionIdsByRuntimeId: { [runtimeId]: "fallback-session" }, + }); + running = true; assert.equal(getRpcSession(inner.sessionId), wrapper); assert.equal(getRpcSession("original-session"), undefined); assert.equal(getRpcSession(inner.sessionId), wrapper); From 97a71f223fabaf3fb5d6d7eff12265a5c403035e Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 15 Oct 2026 17:47:18 +0800 Subject: [PATCH 06/19] fix: preserve selection and drafts during runtime relocation Ignore identity responses after the hook unmounts and rekey composer drafts before notifying the parent of a runtime identity change. Co-authored-by: Qwen-Coder --- hooks/useAgentSession.ts | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 8ab8593dad..6c7ff8c456 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useRef, useEffect, useMemo, useReducer } from "react"; +import { useState, useCallback, useRef, useEffect, useLayoutEffect, useMemo, useReducer } from "react"; import type { AgentMessage, BlockingExtensionUiRequest, @@ -339,6 +339,7 @@ export interface ChatInputHandle { prependText: (text: string) => void; addImages: (files: File[]) => void; restoreSubmission: (text: string, images?: Array<{ data: string; mimeType: string }>, targetDraftKey?: string) => void; + rekeyDraft?: (previousKey: string, nextKey: string) => void; } export interface AttachedImage { @@ -425,6 +426,13 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const eventStreamGraceActiveRef = useRef(false); const sessionIdRef = useRef(session?.id ?? null); const runtimeIdRef = useRef(session?.runtimeId ?? null); + const mountedRef = useRef(false); + const identityDraftKeyRef = useRef(undefined); + useLayoutEffect(() => { + mountedRef.current = true; + identityDraftKeyRef.current = session?.id ?? (newSessionCwd ? `new:${newSessionCwd}` : undefined); + return () => { mountedRef.current = false; }; + }, [session?.id, newSessionCwd]); const agentRunningRef = useRef(false); const sdkAgentActiveRef = useRef(false); const rpcPromptPendingRef = useRef(false); @@ -500,17 +508,22 @@ export function useAgentSession(opts: UseAgentSessionOptions) { }, [messages, sessionStatsOverride, contextUsage, data?.filePath, data?.totalActiveMs, session?.id, session?.name]); const followSessionIdentity = useCallback((nextId: string, runtimeId?: string) => { + if (!mountedRef.current) return; if (runtimeId) runtimeIdRef.current = runtimeId; if (nextId === sessionIdRef.current) return; // Async sends awaiting the SSE handshake must use the new ID immediately; // waiting for the parent to remount leaves captured IDs pointing at history. + if (identityDraftKeyRef.current) { + opts.chatInputRef?.current?.rekeyDraft?.(identityDraftKeyRef.current, nextId); + } + identityDraftKeyRef.current = nextId; sessionIdRef.current = nextId; eventSourceSessionIdRef.current = nextId; onSessionForked?.(nextId, runtimeIdRef.current ?? undefined); - }, [onSessionForked]); + }, [onSessionForked, opts.chatInputRef]); const followRuntimeState = useCallback((state: AgentStateResponse | undefined, requestedId: string) => { - if (sessionIdRef.current !== requestedId) return true; + if (!mountedRef.current || sessionIdRef.current !== requestedId) return true; if (state?.runtimeId) runtimeIdRef.current = state.runtimeId; if (state?.sessionId && state.sessionId !== requestedId) { followSessionIdentity(state.sessionId, state.runtimeId); @@ -536,7 +549,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } if (!res.ok) throw new Error(`HTTP ${res.status}`); const d = await res.json() as SessionData; - if (sessionIdRef.current !== sid) return null; + if (!mountedRef.current || sessionIdRef.current !== sid) return null; if (d.sessionId !== sid && onSessionForked) { followSessionIdentity(d.sessionId); return null; @@ -561,7 +574,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { : `/api/sessions/${encodeURIComponent(sid)}/state`); if (!stateRes.ok) throw new Error(`HTTP ${stateRes.status}`); const agentState = await stateRes.json() as { running: boolean; state?: AgentStateResponse }; - if (sessionIdRef.current !== sid) return null; + if (!mountedRef.current || sessionIdRef.current !== sid) return null; const liveState = agentState.state; if (!agentState.running) runtimeIdRef.current = null; From 1af9df72449f54f2f9d0d3fc57b3472aa87625be Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 15 Oct 2026 18:08:51 +0800 Subject: [PATCH 07/19] fix: retain final session identity and follow live runs in place Keep bounded runtime continuation records after teardown, retry only confirmed final identities, and preserve the hook and its pending handshake and completion state across runtime relocations. Co-authored-by: Qwen-Coder --- app/api/agent/[id]/route.ts | 6 ++++-- components/AppShell.tsx | 2 +- hooks/useAgentSession.ts | 34 ++++++++++++++++++++++++++-------- lib/agent-client.test.mjs | 20 ++++++++++++++++---- lib/agent-client.ts | 10 ++++++---- lib/rpc-manager.ts | 9 +++++++++ lib/rpc-session-info.test.mjs | 3 ++- 7 files changed, 64 insertions(+), 20 deletions(-) diff --git a/app/api/agent/[id]/route.ts b/app/api/agent/[id]/route.ts index 6b4022abb0..e8147a3359 100644 --- a/app/api/agent/[id]/route.ts +++ b/app/api/agent/[id]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { resolveSessionPath } from "@/lib/session-reader"; -import { startRpcSession, getRpcSession } from "@/lib/rpc-manager"; +import { startRpcSession, getRpcSession, getFinalRpcSessionId } from "@/lib/rpc-manager"; import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security"; // POST /api/agent/[id] - Send a command to an existing session @@ -34,6 +34,7 @@ export async function POST( if (id.startsWith("runtime:")) { return NextResponse.json({ error: "Runtime session expired", code: "runtime_expired", accepted: false, + sessionId: getFinalRpcSessionId(id), }, { status: 404 }); } @@ -72,7 +73,8 @@ export async function GET( try { const session = getRpcSession(id); if (!session || !session.isAlive()) { - return NextResponse.json({ running: false }); + const sessionId = getFinalRpcSessionId(id); + return NextResponse.json({ running: false, ...(sessionId ? { state: { sessionId } } : {}) }); } const state = await session.send({ type: "get_state" }); diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 7bc302fb89..2ccd20c059 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -624,7 +624,7 @@ export function AppShell() { const handleSessionForked = useCallback((newSessionId: string, runtimeId?: string) => { invalidateWorkspaceRestore(); setRefreshKey((k) => k + 1); - setSessionKey((k) => k + 1); + if (!runtimeId) setSessionKey((k) => k + 1); setNewSessionCwd(null); setSelectedSession((prev) => ({ ...(prev ?? { path: "", cwd: "", created: "", modified: "", messageCount: 0, firstMessage: "" }), diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 6c7ff8c456..17661b6be1 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -425,7 +425,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const eventStreamGraceGenerationRef = useRef(0); const eventStreamGraceActiveRef = useRef(false); const sessionIdRef = useRef(session?.id ?? null); - const runtimeIdRef = useRef(session?.runtimeId ?? null); + const runtimeIdRef: { current: string | null; onSessionId?: (id: string) => void } = useRef(session?.runtimeId ?? null); const mountedRef = useRef(false); const identityDraftKeyRef = useRef(undefined); useLayoutEffect(() => { @@ -522,12 +522,13 @@ export function useAgentSession(opts: UseAgentSessionOptions) { onSessionForked?.(nextId, runtimeIdRef.current ?? undefined); }, [onSessionForked, opts.chatInputRef]); + runtimeIdRef.onSessionId = (id) => followSessionIdentity(id); + const followRuntimeState = useCallback((state: AgentStateResponse | undefined, requestedId: string) => { if (!mountedRef.current || sessionIdRef.current !== requestedId) return true; if (state?.runtimeId) runtimeIdRef.current = state.runtimeId; if (state?.sessionId && state.sessionId !== requestedId) { followSessionIdentity(state.sessionId, state.runtimeId); - return true; } return false; }, [followSessionIdentity]); @@ -577,8 +578,8 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (!mountedRef.current || sessionIdRef.current !== sid) return null; const liveState = agentState.state; - if (!agentState.running) runtimeIdRef.current = null; if (followRuntimeState(liveState, sid)) return agentState; + if (!agentState.running && liveState?.sessionId) runtimeIdRef.current = null; if (liveState) { if (liveState.contextUsage !== undefined) setContextUsage(liveState.contextUsage ?? null); if (liveState.systemPrompt !== undefined) setSystemPrompt(liveState.systemPrompt ?? null); @@ -814,8 +815,12 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const stateRes = await fetch(`/api/agent/${encodeURIComponent(runtimeIdRef.current)}`); if (stateRes.ok) { const state = await stateRes.json() as { running?: boolean; state?: AgentStateResponse }; - if (!state.running) runtimeIdRef.current = null; - else followRuntimeState(state.state, sid); + if (followRuntimeState(state.state, sid)) throw new Error("Session is no longer active"); + if (!state.running) { + if (!state.state?.sessionId) throw new Error("Runtime identity expired; reopen the intended session"); + runtimeIdRef.current = null; + } + sid = sessionIdRef.current ?? sid; } } const current = eventSourceRef.current; @@ -989,6 +994,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const state = data.state; if (followRuntimeState(state, sid)) return; + sid = sessionIdRef.current ?? sid; setSubagents((current) => mergeSubagentSnapshots(current, state?.subagents ?? [])); const promptActive = Boolean( data.running @@ -1040,11 +1046,12 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const finishPromptWithoutStream = useCallback(async (sid: string | null = sessionIdRef.current, runId = promptRunIdRef.current) => { // Bail out before loadSession too: a stale finish for a previous run // must not overwrite the messages of the run currently streaming. - if (promptRunIdRef.current !== runId) return; + if (!mountedRef.current || promptRunIdRef.current !== runId) return; + sid = sessionIdRef.current ?? sid; try { if (sid) await loadSession(sid); } finally { - if (promptRunIdRef.current !== runId) return; + if (!mountedRef.current || promptRunIdRef.current !== runId) return; const promptWasPending = rpcPromptPendingRef.current; const agentWasActive = sdkAgentActiveRef.current; rpcPromptPendingRef.current = false; @@ -1072,6 +1079,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const data = await res.json() as { running?: boolean; state?: AgentStateResponse }; const state = data.state; if (followRuntimeState(state, sid)) return; + sid = sessionIdRef.current ?? sid; if (!data.running || !state || (!state.isStreaming && !state.isPromptRunning)) { await finishPromptWithoutStream(sid, runId); return; @@ -1099,6 +1107,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (!res.ok) continue; const data = await res.json() as { state?: AgentStateResponse }; if (followRuntimeState(data.state, sid)) return; + sid = sessionIdRef.current ?? sid; if (data.state?.isBashRunning) continue; await loadSession(sid); @@ -1131,6 +1140,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (promptRunIdRef.current !== runId) return; const state = data.state; if (followRuntimeState(state, sid)) return; + sid = sessionIdRef.current ?? sid; // Mirror compaction state unconditionally: a missed compaction_end // would otherwise leave the "Stop compaction" UI stuck. No state // (wrapper destroyed) means nothing is compacting. @@ -1605,7 +1615,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { opts.chatInputRef?.current?.restoreSubmission?.( message, images?.map(({ data, mimeType }) => ({ data, mimeType })), - session?.id ?? sentSessionId ?? undefined, + sessionIdRef.current ?? sentSessionId ?? session?.id ?? undefined, ); optimisticUserMessageKeyRef.current = null; setAgentRunning(false); @@ -2158,6 +2168,14 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } }, [setAutoFollow]); + const displayedSessionIdRef = useRef(session?.id); + useEffect(() => { + if (session?.id && session.id !== displayedSessionIdRef.current) { + displayedSessionIdRef.current = session.id; + void loadSession(session.id); + } + }, [session?.id, loadSession]); + // Load session on mount useEffect(() => { if (session) { diff --git a/lib/agent-client.test.mjs b/lib/agent-client.test.mjs index d46ac78eb3..6ebae55a85 100644 --- a/lib/agent-client.test.mjs +++ b/lib/agent-client.test.mjs @@ -69,22 +69,34 @@ test("only an explicit negative prompt acknowledgement is definitive", () => { }); -test("an explicitly expired runtime retries the canonical session once", async (t) => { +test("an explicitly expired runtime retries its confirmed final session once", async (t) => { const originalFetch = globalThis.fetch; t.after(() => { globalThis.fetch = originalFetch; }); const requests = []; - const runtime = { current: "runtime:expired" }; + const relocations = []; + const runtime = { current: "runtime:expired", onSessionId: (id) => relocations.push(id) }; const command = { type: "set_model", provider: "provider", modelId: "model" }; globalThis.fetch = async (url, options) => { requests.push({ url, body: JSON.parse(options.body) }); return requests.length === 1 - ? Response.json({ error: "Runtime session expired", code: "runtime_expired", accepted: false }, { status: 404 }) + ? Response.json({ error: "Runtime session expired", code: "runtime_expired", accepted: false, sessionId: "final-session" }, { status: 404 }) : Response.json({ success: true, data: { updated: true } }); }; assert.deepEqual(await sendAgentCommand("canonical-session", command, runtime), { updated: true }); assert.deepEqual(requests, [ { url: "/api/agent/runtime%3Aexpired", body: command }, - { url: "/api/agent/canonical-session", body: command }, + { url: "/api/agent/final-session", body: command }, ]); assert.equal(runtime.current, null); + assert.deepEqual(relocations, ["final-session"]); + + requests.length = 0; + runtime.current = "runtime:unknown"; + globalThis.fetch = async (url) => { + requests.push(url); + return Response.json({ error: "Runtime session expired", code: "runtime_expired", accepted: false }, { status: 404 }); + }; + await assert.rejects(sendAgentCommand("stale-session", command, runtime), isPromptRejectedError); + assert.deepEqual(requests, ["/api/agent/runtime%3Aunknown"]); + assert.equal(runtime.current, "runtime:unknown"); }); diff --git a/lib/agent-client.ts b/lib/agent-client.ts index cc99f121c8..0162dc95df 100644 --- a/lib/agent-client.ts +++ b/lib/agent-client.ts @@ -21,14 +21,14 @@ export class AgentCommandError extends Error { export function isPromptRejectedError(error: unknown): error is AgentCommandError { return error instanceof AgentCommandError - && error.code === "prompt_rejected" + && (error.code === "prompt_rejected" || error.code === "runtime_expired") && error.accepted === false; } export async function sendAgentCommand( sessionId: string, command: Record, - runtime?: { current: string | null }, + runtime?: { current: string | null; onSessionId?: (id: string) => void }, ): Promise { const runtimeId = runtime?.current; const res = await fetch(`/api/agent/${encodeURIComponent(runtimeId ?? sessionId)}`, { @@ -42,13 +42,15 @@ export async function sendAgentCommand( error?: string; code?: string; accepted?: boolean; + sessionId?: string; }; if (!res.ok || body.error) { // Retry only when the server guarantees that the expired handle executed nothing. if (runtimeId && runtime?.current === runtimeId && res.status === 404 - && body.code === "runtime_expired" && body.accepted === false) { + && body.code === "runtime_expired" && body.accepted === false && body.sessionId) { + runtime.onSessionId?.(body.sessionId); runtime.current = null; - return sendAgentCommand(sessionId, command); + return sendAgentCommand(body.sessionId, command); } throw new AgentCommandError( body.error ?? `HTTP ${res.status}`, diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index dbdac65417..f6287920b5 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -1195,6 +1195,10 @@ export class AgentSessionWrapper { destroy(): void { if (!this._alive) return; this._alive = false; + const finalIds = globalThis.__ompFinalSessionIds ??= new Map(); + finalIds.set(this.runtimeId, this.sessionId); + // A bounded continuation record, never a historical-session alias. + while (finalIds.size > 1024) finalIds.delete(finalIds.keys().next().value!); if (this.idleTimer) clearTimeout(this.idleTimer); if (this.inner.isBashRunning) this.inner.abortBash(); this.unsubscribe?.(); @@ -1571,6 +1575,7 @@ export class AgentSessionWrapper { declare global { var __ompSessions: Map | undefined; + var __ompFinalSessionIds: Map | undefined; var __ompStartLocks: Map> | undefined; var __ompStartingSessionCwds: Map | undefined; var __ompRunningListeners: Set<(ids: string[]) => void> | undefined; @@ -1617,6 +1622,10 @@ function trackStartingSession(cwd: string): () => void { }; } +export function getFinalRpcSessionId(runtimeId: string): string | undefined { + return globalThis.__ompFinalSessionIds?.get(runtimeId); +} + export function getRpcSession(sessionId: string): AgentSessionWrapper | undefined { const registry = getRegistry(); const registered = registry.get(sessionId); diff --git a/lib/rpc-session-info.test.mjs b/lib/rpc-session-info.test.mjs index 55fd2ef31a..7de7348cb0 100644 --- a/lib/rpc-session-info.test.mjs +++ b/lib/rpc-session-info.test.mjs @@ -9,7 +9,7 @@ const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false, }); -const { AgentSessionWrapper, getRpcSession, getRpcSessionInfos, getRpcSessionActivitySnapshot, startRpcSession } = await jiti.import("./rpc-manager.ts"); +const { AgentSessionWrapper, getRpcSession, getRpcSessionInfos, getRpcSessionActivitySnapshot, getFinalRpcSessionId, startRpcSession } = await jiti.import("./rpc-manager.ts"); function makeRuntimeSession({ id, filePath, running, entries }) { const timestamp = "2026-08-12T01:02:03.000Z"; @@ -168,6 +168,7 @@ test("moves a live wrapper across successive IDs without redirecting historical wrapper.destroy(); assert.equal(getRpcSession(runtimeId), undefined); + assert.equal(getFinalRpcSessionId(runtimeId), inner.sessionId); assert.equal(getRpcSession(inner.sessionId), undefined); assert.equal(getRpcSession("fallback-session"), historical); assert.deepEqual([...globalThis.__ompSessions.keys()], ["fallback-session"]); From da4e305b47fd3d4c2e1ddc3f3c024391c1bcac23 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 15 Oct 2026 18:27:18 +0800 Subject: [PATCH 08/19] fix: carry runtime identity through settings and completion Share live handles with settings reloads, pass canonical IDs to completion callbacks, and preserve teardown relocations in sidebar activity snapshots. Co-authored-by: Qwen-Coder --- components/AppShell.tsx | 9 ++++++--- components/ChatWindow.tsx | 6 +++--- components/PluginsConfig.tsx | 6 ++++-- components/SettingsConfig.tsx | 13 +++++++------ hooks/useAgentSession.test.mjs | 2 +- hooks/useAgentSession.ts | 19 +++++++++++-------- lib/rpc-manager.ts | 2 +- lib/rpc-session-info.test.mjs | 4 ++++ 8 files changed, 37 insertions(+), 24 deletions(-) diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 2ccd20c059..118dbafeaa 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -553,13 +553,15 @@ export function AppShell() { } }, [handleSelectSession]); - const handleAgentEnd = useCallback(() => { + const handleAgentEnd = useCallback((completedSessionId?: string) => { setRefreshKey((k) => k + 1); setExplorerRefreshKey((k) => k + 1); - if (selectedSession) hydrateSelectedSession(selectedSession.id); + const targetSession = selectedSession + ? { ...selectedSession, id: completedSessionId ?? selectedSession.id } + : null; + if (targetSession) hydrateSelectedSession(targetSession.id); if (!shouldShowBrowserNotification()) return; - const targetSession = selectedSession; deliverSessionNotification({ targetSession, title: targetSession?.name ?? translate("i18n.sessionComplete"), @@ -1815,6 +1817,7 @@ export function AppShell() { setSettingsConfigOpen(false)} onModelsChanged={() => setModelsRefreshKey((key) => key + 1)} onReloaded={() => setSessionKey((key) => key + 1)} diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index de0b168ca8..d9c6a3c0eb 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -41,7 +41,7 @@ import { interface Props { session: SessionInfo | null; newSessionCwd: string | null; - onAgentEnd?: () => void; + onAgentEnd?: (sessionId?: string) => void; onAttentionNeeded?: (request: BlockingExtensionUiRequest) => void; onSessionCreated?: (session: SessionInfo) => void; onSessionForked?: (newSessionId: string, runtimeId?: string) => void; @@ -203,11 +203,11 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onAttentionNeed const soundEnabledRef = useRef(soundEnabled); soundEnabledRef.current = soundEnabled; const soundedExtensionDialogIdRef = useRef(null); - const wrappedOnAgentEnd = useCallback(() => { + const wrappedOnAgentEnd = useCallback((sessionId?: string) => { if (soundEnabledRef.current) { playDoneSoundRef.current(); } - onAgentEnd?.(); + onAgentEnd?.(sessionId); }, [onAgentEnd]); // 稳定化 onEditContent 引用,配合 React.memo 防止历史消息重渲染 diff --git a/components/PluginsConfig.tsx b/components/PluginsConfig.tsx index ce45041268..5d7382ece0 100644 --- a/components/PluginsConfig.tsx +++ b/components/PluginsConfig.tsx @@ -615,12 +615,14 @@ function PackageDetail({ export function PluginsConfig({ cwd, sessionId, + runtimeId, onClose, onReloaded, embedded = false, }: { cwd: string; sessionId: string | null; + runtimeId?: string; onClose: () => void; onReloaded?: () => void; embedded?: boolean; @@ -741,7 +743,7 @@ export function PluginsConfig({ setActionError(null); setActionMessage(null); try { - await sendAgentCommand(sessionId, { type: "reload" }); + await sendAgentCommand(sessionId, { type: "reload" }, { current: runtimeId ?? null }); onReloaded?.(); await loadPlugins(); setActionMessage("Session reloaded."); @@ -750,7 +752,7 @@ export function PluginsConfig({ } finally { setBusyKey(null); } - }, [loadPlugins, onReloaded, sessionId]); + }, [loadPlugins, onReloaded, runtimeId, sessionId]); const addBusy = busyKey?.startsWith("install:") ?? false; diff --git a/components/SettingsConfig.tsx b/components/SettingsConfig.tsx index 475f536a10..0415f8ac79 100644 --- a/components/SettingsConfig.tsx +++ b/components/SettingsConfig.tsx @@ -26,6 +26,7 @@ type SettingsSection = "models" | "themes" | "skills" | "plugins" | "mcp" | "acc interface SettingsConfigProps { cwd?: string | null; sessionId: string | null; + runtimeId?: string; initialSection?: SettingsSection; onClose: () => void; onModelsChanged?: () => void; @@ -187,7 +188,7 @@ function ThemePreview({ palette }: { palette: WebThemePalette }) { /** Settings the web client applies itself, without reloading the agent. */ const DISPLAY_SETTING_PATHS = new Set(["hideThinkingBlock"]); -export function SettingsConfig({ cwd, sessionId, initialSection = "models", onClose, onModelsChanged, onReloaded }: SettingsConfigProps) { +export function SettingsConfig({ cwd, sessionId, runtimeId, initialSection = "models", onClose, onModelsChanged, onReloaded }: SettingsConfigProps) { const [section, setSection] = useState(initialSection); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); @@ -257,7 +258,7 @@ export function SettingsConfig({ cwd, sessionId, initialSection = "models", onCl if (!sessionId) return; setReloading(true); try { - await sendAgentCommand(sessionId, { type: "reload" }); + await sendAgentCommand(sessionId, { type: "reload" }, { current: runtimeId ?? null }); setNeedsReload(false); onReloaded?.(); } catch (error) { @@ -265,7 +266,7 @@ export function SettingsConfig({ cwd, sessionId, initialSection = "models", onCl } finally { setReloading(false); } - }, [onReloaded, sessionId]); + }, [onReloaded, runtimeId, sessionId]); const selectedTab = settings?.tabs.find((tab) => tab.id === activeTab); const pageTitle = query.trim() ? `Search · ${filteredFields.length}` : selectedTab?.label ?? "Settings"; @@ -340,14 +341,14 @@ export function SettingsConfig({ cwd, sessionId, initialSection = "models", onCl
- {query.trim() ? renderGenericSettings() : section === "models" ? : section === "themes" ? renderThemeSection() : section === "skills" && cwd ? : section === "plugins" && cwd ? : section === "mcp" ? : section === "access" ? : renderGenericSettings()} + {query.trim() ? renderGenericSettings() : section === "models" ? : section === "themes" ? renderThemeSection() : section === "skills" && cwd ? : section === "plugins" && cwd ? : section === "mcp" ? : section === "access" ? : renderGenericSettings()}
); } -function McpSettings({ cwd, sessionId, onReloaded }: { cwd?: string | null; sessionId?: string | null; onReloaded?: () => void }) { +function McpSettings({ cwd, sessionId, runtimeId, onReloaded }: { cwd?: string | null; sessionId?: string | null; runtimeId?: string; onReloaded?: () => void }) { const [data, setData] = useState(null); const [scope, setScope] = useState<"user" | "project">("user"); const [selected, setSelected] = useState(0); @@ -423,7 +424,7 @@ function McpSettings({ cwd, sessionId, onReloaded }: { cwd?: string | null; sess if (!response.ok || result.error) throw new Error(result.error ?? `HTTP ${response.status}`); await load(); if (sessionId) { - await sendAgentCommand(sessionId, { type: "reload" }); + await sendAgentCommand(sessionId, { type: "reload" }, { current: runtimeId ?? null }); onReloaded?.(); } } catch (caught) { diff --git a/hooks/useAgentSession.test.mjs b/hooks/useAgentSession.test.mjs index 8795e07e23..1bf2fcce6a 100644 --- a/hooks/useAgentSession.test.mjs +++ b/hooks/useAgentSession.test.mjs @@ -46,7 +46,7 @@ test("keeps the session event stream open through the idle grace window", () => assert.doesNotMatch(agentEndSource, /closeEvents\(\)/); assert.match(agentStartSource, /cancelEventStreamGrace\(\)/); assert.match(agentSettledSource, /scheduleEventStreamClose\(sid\)/); - assert.match(agentSettledSource, /onAgentEnd\?\.\(\)/); + assert.match(agentSettledSource, /onAgentEnd\?\.\(sessionIdRef\.current \?\? undefined\)/); assert.match(promptDoneSource, /notifyPromptStage\(runId\)/); assert.match(promptDoneSource, /scheduleEventStreamClose\(sid\)/); assert.match(sendSource, /const definitivelyRejected = !promptRequestStarted \|\| isPromptRejectedError\(e\)/); diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 17661b6be1..ab365437a2 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -170,7 +170,7 @@ export type BuiltinSlashCommandResult = export interface UseAgentSessionOptions { session: SessionInfo | null; newSessionCwd: string | null; - onAgentEnd?: () => void; + onAgentEnd?: (sessionId?: string) => void; onAttentionNeeded?: (request: BlockingExtensionUiRequest) => void; onSessionCreated?: (session: SessionInfo) => void; onSessionForked?: (newSessionId: string, runtimeId?: string) => void; @@ -509,10 +509,14 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const followSessionIdentity = useCallback((nextId: string, runtimeId?: string) => { if (!mountedRef.current) return; + const handleChanged = Boolean(runtimeId && runtimeId !== runtimeIdRef.current); if (runtimeId) runtimeIdRef.current = runtimeId; - if (nextId === sessionIdRef.current) return; + if (nextId === sessionIdRef.current) { + if (handleChanged) onSessionForked?.(nextId, runtimeId); + return; + } // Async sends awaiting the SSE handshake must use the new ID immediately; - // waiting for the parent to remount leaves captured IDs pointing at history. + // React state alone leaves pending callbacks pointing at historical IDs. if (identityDraftKeyRef.current) { opts.chatInputRef?.current?.rekeyDraft?.(identityDraftKeyRef.current, nextId); } @@ -526,8 +530,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const followRuntimeState = useCallback((state: AgentStateResponse | undefined, requestedId: string) => { if (!mountedRef.current || sessionIdRef.current !== requestedId) return true; - if (state?.runtimeId) runtimeIdRef.current = state.runtimeId; - if (state?.sessionId && state.sessionId !== requestedId) { + if (state?.sessionId) { followSessionIdentity(state.sessionId, state.runtimeId); } return false; @@ -966,7 +969,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const notifyPromptStage = useCallback((runId: number) => { if (notifiedPromptRunIdRef.current === runId) return false; notifiedPromptRunIdRef.current = runId; - onAgentEnd?.(); + onAgentEnd?.(sessionIdRef.current ?? undefined); return true; }, [onAgentEnd]); @@ -1061,7 +1064,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (promptWasPending) { notifyPromptStage(runId); } else if (agentWasActive && wasRunning) { - onAgentEnd?.(); + onAgentEnd?.(sessionIdRef.current ?? undefined); } if (sid) scheduleEventStreamClose(sid); } @@ -1285,7 +1288,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { void loadSession(sid); scheduleEventStreamClose(sid); } - if (wasRunning) onAgentEnd?.(); + if (wasRunning) onAgentEnd?.(sessionIdRef.current ?? undefined); break; } case "prompt_done": diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index f6287920b5..f040ed8202 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -1726,7 +1726,7 @@ export async function destroyRpcSessionsForCwd(cwd: string): Promise { // is attributed to its final transcript rather than its historical ID. export function getRpcSessionActivitySnapshot() { const runningSessionIds = new Set(); - const sessionIdsByRuntimeId: Record = {}; + const sessionIdsByRuntimeId: Record = Object.fromEntries(globalThis.__ompFinalSessionIds ?? []); for (const session of getRegistry().values()) { if (!session.isAlive()) continue; sessionIdsByRuntimeId[session.runtimeId] = session.sessionId; diff --git a/lib/rpc-session-info.test.mjs b/lib/rpc-session-info.test.mjs index 7de7348cb0..5899d68d04 100644 --- a/lib/rpc-session-info.test.mjs +++ b/lib/rpc-session-info.test.mjs @@ -94,6 +94,8 @@ test("keeps an idle runtime visible once its JSONL file exists", (t) => { test("moves a live wrapper across successive IDs without redirecting historical sessions", async (t) => { const previousRegistry = globalThis.__ompSessions; + const previousFinalIds = globalThis.__ompFinalSessionIds; + globalThis.__ompFinalSessionIds = new Map(); const inner = { sessionId: "original-session", isBashRunning: false }; const eventBus = { on: () => () => {} }; const wrapper = new AgentSessionWrapper(inner, eventBus); @@ -108,6 +110,7 @@ test("moves a live wrapper across successive IDs without redirecting historical wrapper.destroy(); historical?.destroy(); globalThis.__ompSessions = previousRegistry; + globalThis.__ompFinalSessionIds = previousFinalIds; }); assert.deepEqual(getRpcSessionActivitySnapshot(), { @@ -169,6 +172,7 @@ test("moves a live wrapper across successive IDs without redirecting historical wrapper.destroy(); assert.equal(getRpcSession(runtimeId), undefined); assert.equal(getFinalRpcSessionId(runtimeId), inner.sessionId); + assert.equal(getRpcSessionActivitySnapshot().sessionIdsByRuntimeId[runtimeId], inner.sessionId); assert.equal(getRpcSession(inner.sessionId), undefined); assert.equal(getRpcSession("fallback-session"), historical); assert.deepEqual([...globalThis.__ompSessions.keys()], ["fallback-session"]); From 8b74a8ab69a83aabe3067148b1d05e351e7ed8fe Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 15 Oct 2026 18:48:15 +0800 Subject: [PATCH 09/19] fix: retain per-tab session continuation through refresh Remember the active runtime handle in sessionStorage for refresh restoration while explicit history selection and new-session actions clear it. Co-authored-by: Qwen-Coder --- components/AppShell.tsx | 8 +++++--- hooks/useAgentSession.ts | 3 +++ lib/workspace-memory.test.mjs | 17 +++++++++++++++++ lib/workspace-memory.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 118dbafeaa..7ce3adcee1 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -27,7 +27,7 @@ import { showBrowserNotification, } from "@/lib/browser-notifications"; import { getInitialNavigation } from "@/lib/initial-navigation"; -import { clearLastOpen, getLastOpenSession, setLastOpenSession } from "@/lib/workspace-memory"; +import { clearLastOpen, getLastOpenSession, setLastOpenSession, getSessionContinuation, clearSessionContinuation } from "@/lib/workspace-memory"; import { getDefaultRightPanelWidth, getRightPanelMaxWidth, @@ -374,7 +374,7 @@ export function AppShell() { // present: useAgentSession loads content in a mount-only effect, so // the null-session welcome mount from the switch would never load // the restored session's messages. - setSelectedSession(s); + setSelectedSession({ ...s, runtimeId: getSessionContinuation(s.id) }); setSessionKey((k) => k + 1); if (new URLSearchParams(window.location.search).get("session") !== s.id) { router.replace(`?session=${encodeURIComponent(s.id)}`, { scroll: false }); @@ -453,7 +453,8 @@ export function AppShell() { } } setNewSessionCwd(null); - setSelectedSession({ ...session, runtimeId: undefined }); + if (!isRestore) clearSessionContinuation(); + setSelectedSession({ ...session, runtimeId: isRestore ? getSessionContinuation(session.id) : undefined }); setSessionKey((k) => k + 1); setSystemPrompt(null); setInitialSessionRestored(true); @@ -472,6 +473,7 @@ export function AppShell() { }, [invalidateWorkspaceRestore, router, isMobile, selectedSession]); const handleNewSession = useCallback((_sessionId: string, cwd: string) => { + clearSessionContinuation(); invalidateWorkspaceRestore(); setSelectedSession(null); setNewSessionCwd(cwd); diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index ab365437a2..dcb5c6b54f 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -16,6 +16,7 @@ import { isBlockingExtensionUiRequest } from "@/lib/browser-notifications"; import { normalizeToolCalls } from "@/lib/normalize"; import { stripAnsi } from "@/lib/ansi"; import { isPromptRejectedError, sendAgentCommand } from "@/lib/agent-client"; +import { rememberSessionContinuation } from "@/lib/workspace-memory"; import { getToolNamesForPreset, type ToolEntry } from "@/lib/tool-presets"; import type { ContextUsage, GoalStatusInfo, SessionStatsInfo, SlashCommandInfo } from "@/lib/omp-types"; import type { ModelRoleAssignment } from "@/lib/api-types"; @@ -511,6 +512,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (!mountedRef.current) return; const handleChanged = Boolean(runtimeId && runtimeId !== runtimeIdRef.current); if (runtimeId) runtimeIdRef.current = runtimeId; + if (runtimeIdRef.current) rememberSessionContinuation(nextId, runtimeIdRef.current); if (nextId === sessionIdRef.current) { if (handleChanged) onSessionForked?.(nextId, runtimeId); return; @@ -695,6 +697,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const realId = result.sessionId; sessionIdRef.current = realId; runtimeIdRef.current = result.runtimeId ?? null; + if (result.runtimeId) rememberSessionContinuation(realId, result.runtimeId); if (result.model && newSessionModelOverrideRef.current === selectedModel) { setPendingModel(result.model); if (!selectedModel) setNewSessionDefaultModel(result.model); diff --git a/lib/workspace-memory.test.mjs b/lib/workspace-memory.test.mjs index cfaf2f6e2b..38463b1e98 100644 --- a/lib/workspace-memory.test.mjs +++ b/lib/workspace-memory.test.mjs @@ -8,6 +8,9 @@ const { setLastOpenSession, clearLastOpen, workspaceKeyOf, + getSessionContinuation, + rememberSessionContinuation, + clearSessionContinuation, } = await jiti.import("./workspace-memory.ts"); function createStorage(initial = {}) { @@ -123,3 +126,17 @@ test("workspaceKeyOf prefers projectRoot over cwd", () => { assert.equal(workspaceKeyOf({ cwd: "/plain/dir", projectRoot: null }), "/plain/dir"); assert.equal(workspaceKeyOf({ cwd: "/plain/dir" }), "/plain/dir"); }); + + +test("a tab restores only its active runtime and explicit history selection clears it", () => { + const tab = createStorage(); + rememberSessionContinuation("session-a", "runtime:live", tab); + assert.equal(getSessionContinuation("session-a", tab), "runtime:live"); + assert.equal(getSessionContinuation("session-b", tab), undefined); + assert.equal(getSessionContinuation("session-a", createStorage()), undefined); + rememberSessionContinuation("session-b", "runtime:live", tab); + assert.equal(getSessionContinuation("session-a", tab), undefined); + assert.equal(getSessionContinuation("session-b", tab), "runtime:live"); + clearSessionContinuation(tab); + assert.equal(getSessionContinuation("session-b", tab), undefined); +}); diff --git a/lib/workspace-memory.ts b/lib/workspace-memory.ts index f063fa0aff..85dad015cd 100644 --- a/lib/workspace-memory.ts +++ b/lib/workspace-memory.ts @@ -96,3 +96,29 @@ export function workspaceKeyOf(session: { }): string { return session.projectRoot ?? session.cwd; } + + +// A single per-tab continuation survives refresh without aliasing history in +// other tabs. Explicit session selection clears it before opening history. +const CONTINUATION_KEY = "pi-web:active-runtime"; + +function getTabStorage(): StorageLike | null { + if (typeof window === "undefined") return null; + try { return window.sessionStorage; } catch { return null; } +} + +export function getSessionContinuation(sessionId: string, storage: StorageLike | null = getTabStorage()): string | undefined { + try { + const record = JSON.parse(storage?.getItem(CONTINUATION_KEY) ?? "null"); + return record?.sessionId === sessionId && typeof record.runtimeId === "string" && record.runtimeId.startsWith("runtime:") + ? record.runtimeId : undefined; + } catch { return undefined; } +} + +export function rememberSessionContinuation(sessionId: string, runtimeId: string, storage: StorageLike | null = getTabStorage()): void { + try { storage?.setItem(CONTINUATION_KEY, JSON.stringify({ sessionId, runtimeId })); } catch { /* Storage is optional. */ } +} + +export function clearSessionContinuation(storage: StorageLike | null = getTabStorage()): void { + try { storage?.removeItem(CONTINUATION_KEY); } catch { /* Storage is optional. */ } +} From b93b11b8fd8f8d8fc7d1188f657a366d85f6c40b Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 15 Oct 2026 19:07:39 +0800 Subject: [PATCH 10/19] fix: clear confirmed expired continuations and allow reopening Clear only the matching dead runtime record after final identity recovery and treat reselecting a row with a continuation as an explicit historical reopen. Co-authored-by: Qwen-Coder --- components/AppShell.tsx | 9 ++++----- hooks/useAgentSession.ts | 7 ++++++- lib/agent-client.ts | 3 +++ lib/workspace-memory.test.mjs | 4 +++- lib/workspace-memory.ts | 7 +++++-- 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 7ce3adcee1..4cee26fb66 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -439,15 +439,14 @@ export function AppShell() { // project switch and the just-selected session is cleared. activeProjectRootRef.current = session.projectRoot ?? session.cwd; invalidateWorkspaceRestore(); - // Re-clicking the already-open session must not remount the chat and - // re-run the full load/positioning cycle. Only skip when the effective - // cwd context already matches — otherwise a pending cwd move still needs - // the full re-select flow. + // A current row with a continuation handle can explicitly reopen history, + // including recovery from a handle unknown after a server restart. + // Keep ordinary same-session clicks as a no-op. if (!isRestore && selectedSession) { const sameProject = (selectedSession.projectRoot ?? selectedSession.cwd) === (session.projectRoot ?? session.cwd); - if (selectedSession.id === session.id && sameProject) { + if (selectedSession.id === session.id && sameProject && !selectedSession.runtimeId) { if (isMobile) setSidebarOpen(false); return; } diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index dcb5c6b54f..1cd72659a8 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -16,7 +16,7 @@ import { isBlockingExtensionUiRequest } from "@/lib/browser-notifications"; import { normalizeToolCalls } from "@/lib/normalize"; import { stripAnsi } from "@/lib/ansi"; import { isPromptRejectedError, sendAgentCommand } from "@/lib/agent-client"; -import { rememberSessionContinuation } from "@/lib/workspace-memory"; +import { rememberSessionContinuation, clearSessionContinuation } from "@/lib/workspace-memory"; import { getToolNamesForPreset, type ToolEntry } from "@/lib/tool-presets"; import type { ContextUsage, GoalStatusInfo, SessionStatsInfo, SlashCommandInfo } from "@/lib/omp-types"; import type { ModelRoleAssignment } from "@/lib/api-types"; @@ -533,7 +533,12 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const followRuntimeState = useCallback((state: AgentStateResponse | undefined, requestedId: string) => { if (!mountedRef.current || sessionIdRef.current !== requestedId) return true; if (state?.sessionId) { + const expiredRuntimeId = !state.runtimeId ? runtimeIdRef.current : null; followSessionIdentity(state.sessionId, state.runtimeId); + if (expiredRuntimeId) { + clearSessionContinuation(undefined, expiredRuntimeId); + runtimeIdRef.current = null; + } } return false; }, [followSessionIdentity]); diff --git a/lib/agent-client.ts b/lib/agent-client.ts index 0162dc95df..7a5916705f 100644 --- a/lib/agent-client.ts +++ b/lib/agent-client.ts @@ -1,3 +1,5 @@ +import { clearSessionContinuation } from "./workspace-memory"; + // Client-side helper for POST /api/agent/[id]. // // Every /api/agent/[id] route returns one of: @@ -49,6 +51,7 @@ export async function sendAgentCommand( if (runtimeId && runtime?.current === runtimeId && res.status === 404 && body.code === "runtime_expired" && body.accepted === false && body.sessionId) { runtime.onSessionId?.(body.sessionId); + clearSessionContinuation(undefined, runtimeId); runtime.current = null; return sendAgentCommand(body.sessionId, command); } diff --git a/lib/workspace-memory.test.mjs b/lib/workspace-memory.test.mjs index 38463b1e98..314a0ac1aa 100644 --- a/lib/workspace-memory.test.mjs +++ b/lib/workspace-memory.test.mjs @@ -137,6 +137,8 @@ test("a tab restores only its active runtime and explicit history selection clea rememberSessionContinuation("session-b", "runtime:live", tab); assert.equal(getSessionContinuation("session-a", tab), undefined); assert.equal(getSessionContinuation("session-b", tab), "runtime:live"); - clearSessionContinuation(tab); + clearSessionContinuation(tab, "runtime:older"); + assert.equal(getSessionContinuation("session-b", tab), "runtime:live"); + clearSessionContinuation(tab, "runtime:live"); assert.equal(getSessionContinuation("session-b", tab), undefined); }); diff --git a/lib/workspace-memory.ts b/lib/workspace-memory.ts index 85dad015cd..251dc6ffcc 100644 --- a/lib/workspace-memory.ts +++ b/lib/workspace-memory.ts @@ -119,6 +119,9 @@ export function rememberSessionContinuation(sessionId: string, runtimeId: string try { storage?.setItem(CONTINUATION_KEY, JSON.stringify({ sessionId, runtimeId })); } catch { /* Storage is optional. */ } } -export function clearSessionContinuation(storage: StorageLike | null = getTabStorage()): void { - try { storage?.removeItem(CONTINUATION_KEY); } catch { /* Storage is optional. */ } +export function clearSessionContinuation(storage: StorageLike | null = getTabStorage(), expectedRuntimeId?: string): void { + try { + if (expectedRuntimeId && JSON.parse(storage?.getItem(CONTINUATION_KEY) ?? "null")?.runtimeId !== expectedRuntimeId) return; + storage?.removeItem(CONTINUATION_KEY); + } catch { /* Storage is optional. */ } } From 46b746fe52a87c6932b9face2c46955312e34add Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Thu, 15 Oct 2026 19:28:37 +0800 Subject: [PATCH 11/19] fix: synchronize expired runtime handles across session callers Publish cleared runtime identities before retrying, propagate external expiry safely to the parent and hook, and route subagent transcript polling through the live handle. Co-authored-by: Qwen-Coder --- components/AppShell.tsx | 18 +++++++++++++++--- components/ChatWindow.tsx | 2 +- components/PluginsConfig.tsx | 6 ++++-- components/SettingsConfig.tsx | 14 ++++++++------ components/SubagentPanel.tsx | 14 ++++++++++---- hooks/useAgentSession.ts | 29 +++++++++++++++-------------- lib/agent-client.test.mjs | 7 +++++-- lib/agent-client.ts | 6 +++--- 8 files changed, 61 insertions(+), 35 deletions(-) diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 4cee26fb66..a03bcf061d 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -190,6 +190,8 @@ export function AppShell() { const autoNameTimerRef = useRef | null>(null); const activeSessionIdRef = useRef(selectedSession?.id ?? null); activeSessionIdRef.current = selectedSession?.id ?? null; + const activeRuntimeIdRef = useRef(selectedSession?.runtimeId); + activeRuntimeIdRef.current = selectedSession?.runtimeId; const handleSessionStatsChange = useCallback((stats: SessionStatsInfo | null) => { setSessionStats(stats); }, []); @@ -624,21 +626,28 @@ export function AppShell() { setExplorerRefreshKey((k) => k + 1); }, []); - const handleSessionForked = useCallback((newSessionId: string, runtimeId?: string) => { + const handleSessionForked = useCallback((newSessionId: string, runtimeId?: string | null) => { invalidateWorkspaceRestore(); setRefreshKey((k) => k + 1); - if (!runtimeId) setSessionKey((k) => k + 1); + if (runtimeId === undefined) setSessionKey((k) => k + 1); setNewSessionCwd(null); setSelectedSession((prev) => ({ ...(prev ?? { path: "", cwd: "", created: "", modified: "", messageCount: 0, firstMessage: "" }), id: newSessionId, - runtimeId, + runtimeId: runtimeId ?? undefined, transient: false, })); hydrateSelectedSession(newSessionId); router.replace(`?session=${encodeURIComponent(newSessionId)}`, { scroll: false }); }, [invalidateWorkspaceRestore, router, hydrateSelectedSession]); + const handleRuntimeExpired = useCallback((sessionId: string, expiredRuntimeId: string) => { + if (activeRuntimeIdRef.current !== expiredRuntimeId) return; + activeRuntimeIdRef.current = undefined; + if (activeSessionIdRef.current) chatInputRef.current?.rekeyDraft(activeSessionIdRef.current, sessionId); + handleSessionForked(sessionId, null); + }, [handleSessionForked]); + const handleInitialRestoreDone = useCallback(() => { setInitialSessionRestored(true); }, []); @@ -1710,6 +1719,8 @@ export function AppShell() {
@@ -1819,6 +1830,7 @@ export function AppShell() { cwd={projectTrustCwd} sessionId={selectedSession?.id ?? null} runtimeId={selectedSession?.runtimeId} + onRuntimeExpired={handleRuntimeExpired} onClose={() => setSettingsConfigOpen(false)} onModelsChanged={() => setModelsRefreshKey((key) => key + 1)} onReloaded={() => setSessionKey((key) => key + 1)} diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index d9c6a3c0eb..ff6ac6b8ea 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -44,7 +44,7 @@ interface Props { onAgentEnd?: (sessionId?: string) => void; onAttentionNeeded?: (request: BlockingExtensionUiRequest) => void; onSessionCreated?: (session: SessionInfo) => void; - onSessionForked?: (newSessionId: string, runtimeId?: string) => void; + onSessionForked?: (newSessionId: string, runtimeId?: string | null) => void; modelsRefreshKey?: number; chatInputRef?: React.RefObject; onBranchDataChange?: (tree: SessionTreeNode[], activeLeafId: string | null, onLeafChange: (leafId: string | null) => void) => void; diff --git a/components/PluginsConfig.tsx b/components/PluginsConfig.tsx index 5d7382ece0..8b8bdbf738 100644 --- a/components/PluginsConfig.tsx +++ b/components/PluginsConfig.tsx @@ -616,6 +616,7 @@ export function PluginsConfig({ cwd, sessionId, runtimeId, + onRuntimeExpired, onClose, onReloaded, embedded = false, @@ -623,6 +624,7 @@ export function PluginsConfig({ cwd: string; sessionId: string | null; runtimeId?: string; + onRuntimeExpired?: (sessionId: string, expiredRuntimeId: string) => void; onClose: () => void; onReloaded?: () => void; embedded?: boolean; @@ -743,7 +745,7 @@ export function PluginsConfig({ setActionError(null); setActionMessage(null); try { - await sendAgentCommand(sessionId, { type: "reload" }, { current: runtimeId ?? null }); + await sendAgentCommand(sessionId, { type: "reload" }, { current: runtimeId ?? null, onSessionId: onRuntimeExpired }); onReloaded?.(); await loadPlugins(); setActionMessage("Session reloaded."); @@ -752,7 +754,7 @@ export function PluginsConfig({ } finally { setBusyKey(null); } - }, [loadPlugins, onReloaded, runtimeId, sessionId]); + }, [loadPlugins, onReloaded, onRuntimeExpired, runtimeId, sessionId]); const addBusy = busyKey?.startsWith("install:") ?? false; diff --git a/components/SettingsConfig.tsx b/components/SettingsConfig.tsx index 0415f8ac79..25e4a95f1f 100644 --- a/components/SettingsConfig.tsx +++ b/components/SettingsConfig.tsx @@ -27,6 +27,7 @@ interface SettingsConfigProps { cwd?: string | null; sessionId: string | null; runtimeId?: string; + onRuntimeExpired?: (sessionId: string, expiredRuntimeId: string) => void; initialSection?: SettingsSection; onClose: () => void; onModelsChanged?: () => void; @@ -188,7 +189,7 @@ function ThemePreview({ palette }: { palette: WebThemePalette }) { /** Settings the web client applies itself, without reloading the agent. */ const DISPLAY_SETTING_PATHS = new Set(["hideThinkingBlock"]); -export function SettingsConfig({ cwd, sessionId, runtimeId, initialSection = "models", onClose, onModelsChanged, onReloaded }: SettingsConfigProps) { +export function SettingsConfig({ cwd, sessionId, runtimeId, onRuntimeExpired, initialSection = "models", onClose, onModelsChanged, onReloaded }: SettingsConfigProps) { const [section, setSection] = useState(initialSection); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); @@ -258,7 +259,7 @@ export function SettingsConfig({ cwd, sessionId, runtimeId, initialSection = "mo if (!sessionId) return; setReloading(true); try { - await sendAgentCommand(sessionId, { type: "reload" }, { current: runtimeId ?? null }); + await sendAgentCommand(sessionId, { type: "reload" }, { current: runtimeId ?? null, onSessionId: onRuntimeExpired }); setNeedsReload(false); onReloaded?.(); } catch (error) { @@ -266,7 +267,7 @@ export function SettingsConfig({ cwd, sessionId, runtimeId, initialSection = "mo } finally { setReloading(false); } - }, [onReloaded, runtimeId, sessionId]); + }, [onReloaded, onRuntimeExpired, runtimeId, sessionId]); const selectedTab = settings?.tabs.find((tab) => tab.id === activeTab); const pageTitle = query.trim() ? `Search · ${filteredFields.length}` : selectedTab?.label ?? "Settings"; @@ -341,14 +342,15 @@ export function SettingsConfig({ cwd, sessionId, runtimeId, initialSection = "mo
- {query.trim() ? renderGenericSettings() : section === "models" ? : section === "themes" ? renderThemeSection() : section === "skills" && cwd ? : section === "plugins" && cwd ? : section === "mcp" ? : section === "access" ? : renderGenericSettings()} + {query.trim() ? renderGenericSettings() : section === "models" ? : section === "themes" ? renderThemeSection() : section === "skills" && cwd ? : section === "plugins" && cwd ? : section === "mcp" ? : section === "access" ? : renderGenericSettings()}
); } -function McpSettings({ cwd, sessionId, runtimeId, onReloaded }: { cwd?: string | null; sessionId?: string | null; runtimeId?: string; onReloaded?: () => void }) { +function McpSettings({ cwd, sessionId, runtimeId, onRuntimeExpired, onReloaded }: { cwd?: string | null; sessionId?: string | null; runtimeId?: string; + onRuntimeExpired?: (sessionId: string, expiredRuntimeId: string) => void; onReloaded?: () => void }) { const [data, setData] = useState(null); const [scope, setScope] = useState<"user" | "project">("user"); const [selected, setSelected] = useState(0); @@ -424,7 +426,7 @@ function McpSettings({ cwd, sessionId, runtimeId, onReloaded }: { cwd?: string | if (!response.ok || result.error) throw new Error(result.error ?? `HTTP ${response.status}`); await load(); if (sessionId) { - await sendAgentCommand(sessionId, { type: "reload" }, { current: runtimeId ?? null }); + await sendAgentCommand(sessionId, { type: "reload" }, { current: runtimeId ?? null, onSessionId: onRuntimeExpired }); onReloaded?.(); } } catch (caught) { diff --git a/components/SubagentPanel.tsx b/components/SubagentPanel.tsx index eb0d9ac4c0..c41dae6db7 100644 --- a/components/SubagentPanel.tsx +++ b/components/SubagentPanel.tsx @@ -70,11 +70,15 @@ function subagentActivity(subagent: SubagentSnapshot, t: (key: string, params?: function SubagentDetail({ sessionId, + runtimeId, + onRuntimeExpired, cwd, subagent, onBack, }: { sessionId: string; + runtimeId?: string; + onRuntimeExpired?: (sessionId: string, expiredRuntimeId: string) => void; cwd?: string; subagent: SubagentSnapshot; onBack: () => void; @@ -104,7 +108,9 @@ function SubagentDetail({ type: "get_subagent_messages", subagentId: subagent.id, fromByte: nextByte, - }); + }, { current: runtimeId ?? null, onSessionId: (id, expiredId) => { + if (!disposed) onRuntimeExpired?.(id, expiredId); + } }); if (disposed) return; const chunk = result.entries.flatMap((entry, index) => entry.type === "message" && entry.message @@ -130,7 +136,7 @@ function SubagentDetail({ disposed = true; clearInterval(interval); }; - }, [active, sessionId, subagent.id]); + }, [active, sessionId, runtimeId, onRuntimeExpired, subagent.id]); useEffect(() => { if (!followTailRef.current) return; @@ -269,7 +275,7 @@ function SubagentRow({ subagent, selected, onSelect }: { subagent: SubagentSnaps ); } -export function SubagentPanel({ sessionId, cwd, subagents }: { sessionId: string | null; cwd?: string; subagents: SubagentSnapshot[] }) { +export function SubagentPanel({ sessionId, runtimeId, onRuntimeExpired, cwd, subagents }: { sessionId: string | null; runtimeId?: string; onRuntimeExpired?: (sessionId: string, expiredRuntimeId: string) => void; cwd?: string; subagents: SubagentSnapshot[] }) { const { t } = useI18n(); const [collapsed, setCollapsed] = useState(false); const [selectedId, setSelectedId] = useState(null); @@ -305,7 +311,7 @@ export function SubagentPanel({ sessionId, cwd, subagents }: { sessionId: string }} > {selected ? ( - setSelectedId(null)} /> + setSelectedId(null)} /> ) : ( <>
- {query.trim() ? renderGenericSettings() : section === "models" ? : section === "themes" ? renderThemeSection() : section === "skills" && cwd ? : section === "plugins" && cwd ? : section === "mcp" ? : section === "access" ? : renderGenericSettings()} + {query.trim() ? renderGenericSettings() : section === "models" ? : section === "themes" ? renderThemeSection() : section === "skills" && cwd ? : section === "plugins" && cwd ? : section === "mcp" ? : section === "access" ? : renderGenericSettings()}
); } -function McpSettings({ cwd, sessionId, runtimeId, onRuntimeExpired, onReloaded }: { cwd?: string | null; sessionId?: string | null; runtimeId?: string; - onRuntimeExpired?: (sessionId: string, expiredRuntimeId: string) => void; onReloaded?: () => void }) { +function McpSettings({ cwd, sessionId, runtimeId, onRuntimeIdentity, onReloaded }: { cwd?: string | null; sessionId?: string | null; runtimeId?: string; + onRuntimeIdentity?: RuntimeSessionBinding["onIdentity"]; onReloaded?: (runtimeId: string | null) => void }) { const [data, setData] = useState(null); const [scope, setScope] = useState<"user" | "project">("user"); const [selected, setSelected] = useState(0); @@ -426,8 +427,9 @@ function McpSettings({ cwd, sessionId, runtimeId, onRuntimeExpired, onReloaded } if (!response.ok || result.error) throw new Error(result.error ?? `HTTP ${response.status}`); await load(); if (sessionId) { - await sendAgentCommand(sessionId, { type: "reload" }, { current: runtimeId ?? null, onSessionId: onRuntimeExpired }); - onReloaded?.(); + const binding = { current: runtimeId ?? null, onIdentity: onRuntimeIdentity }; + await sendAgentCommand(sessionId, { type: "reload" }, binding); + onReloaded?.(binding.current); } } catch (caught) { setError(caught instanceof Error ? caught.message : String(caught)); diff --git a/components/SubagentPanel.tsx b/components/SubagentPanel.tsx index c41dae6db7..1acf7dd416 100644 --- a/components/SubagentPanel.tsx +++ b/components/SubagentPanel.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { AgentMessage, SubagentSnapshot, ToolResultMessage } from "@/lib/types"; import { useI18n } from "@/hooks/useI18n"; import { normalizeToolCalls } from "@/lib/normalize"; -import { sendAgentCommand } from "@/lib/agent-client"; +import { sendAgentCommand, type RuntimeSessionBinding } from "@/lib/agent-client"; import { MessageView } from "./MessageView"; type SubagentTranscriptEntry = { @@ -71,14 +71,14 @@ function subagentActivity(subagent: SubagentSnapshot, t: (key: string, params?: function SubagentDetail({ sessionId, runtimeId, - onRuntimeExpired, + onRuntimeIdentity, cwd, subagent, onBack, }: { sessionId: string; runtimeId?: string; - onRuntimeExpired?: (sessionId: string, expiredRuntimeId: string) => void; + onRuntimeIdentity?: RuntimeSessionBinding["onIdentity"]; cwd?: string; subagent: SubagentSnapshot; onBack: () => void; @@ -108,8 +108,9 @@ function SubagentDetail({ type: "get_subagent_messages", subagentId: subagent.id, fromByte: nextByte, - }, { current: runtimeId ?? null, onSessionId: (id, expiredId) => { - if (!disposed) onRuntimeExpired?.(id, expiredId); + }, { current: runtimeId ?? null, onIdentity: (identity, request) => { + if (disposed) return false; + return onRuntimeIdentity?.(identity, request); } }); if (disposed) return; const chunk = result.entries.flatMap((entry, index) => @@ -136,7 +137,7 @@ function SubagentDetail({ disposed = true; clearInterval(interval); }; - }, [active, sessionId, runtimeId, onRuntimeExpired, subagent.id]); + }, [active, sessionId, runtimeId, onRuntimeIdentity, subagent.id]); useEffect(() => { if (!followTailRef.current) return; @@ -275,7 +276,7 @@ function SubagentRow({ subagent, selected, onSelect }: { subagent: SubagentSnaps ); } -export function SubagentPanel({ sessionId, runtimeId, onRuntimeExpired, cwd, subagents }: { sessionId: string | null; runtimeId?: string; onRuntimeExpired?: (sessionId: string, expiredRuntimeId: string) => void; cwd?: string; subagents: SubagentSnapshot[] }) { +export function SubagentPanel({ sessionId, runtimeId, onRuntimeIdentity, cwd, subagents }: { sessionId: string | null; runtimeId?: string; onRuntimeIdentity?: RuntimeSessionBinding["onIdentity"]; cwd?: string; subagents: SubagentSnapshot[] }) { const { t } = useI18n(); const [collapsed, setCollapsed] = useState(false); const [selectedId, setSelectedId] = useState(null); @@ -311,7 +312,7 @@ export function SubagentPanel({ sessionId, runtimeId, onRuntimeExpired, cwd, sub }} > {selected ? ( - setSelectedId(null)} /> + setSelectedId(null)} /> ) : ( <>