feat(web): isolate stream runtime by session and turn

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-11 13:20:13 +08:00
co-authored by Cursor
parent 0dbfc32603
commit 29cbd5a6ad
2 changed files with 191 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
import test from 'node:test';
import assert from 'node:assert/strict';
test('turn runtime: session A stream survives session B switch', () => {
type TurnRuntime = {
clientTurnId: string;
sessionId: string;
status: string;
controller: { aborted: boolean };
};
const store = new Map<string, Map<string, TurnRuntime>>();
const register = (rt: TurnRuntime) => {
if (!store.has(rt.sessionId)) store.set(rt.sessionId, new Map());
store.get(rt.sessionId)!.set(rt.clientTurnId, rt);
};
const isSessionThinking = (sessionId: string) => {
const m = store.get(sessionId);
if (!m) return false;
for (const rt of m.values()) {
if (rt.status === 'pending' || rt.status === 'streaming') return true;
}
return false;
};
register({
clientTurnId: 'turn-a',
sessionId: 'session-A',
status: 'streaming',
controller: { aborted: false },
});
assert.equal(isSessionThinking('session-A'), true);
assert.equal(isSessionThinking('session-B'), false);
register({
clientTurnId: 'turn-b',
sessionId: 'session-B',
status: 'pending',
controller: { aborted: false },
});
assert.equal(isSessionThinking('session-A'), true);
assert.equal(isSessionThinking('session-B'), true);
});
+145
View File
@@ -0,0 +1,145 @@
import { useCallback, useRef, useSyncExternalStore } from 'react';
export type TurnRuntimeStatus =
| 'pending'
| 'streaming'
| 'completed'
| 'failed'
| 'cancelled';
export interface TurnRuntime {
clientTurnId: string;
sessionId: string;
userMsgId: string;
asstId: string;
status: TurnRuntimeStatus;
controller: AbortController;
startedAt: number;
}
type Listener = () => void;
/** 按 sessionId → clientTurnId 隔离流运行态 */
export function useTurnRuntime(activeSessionId: string) {
const storeRef = useRef({
turns: new Map<string, Map<string, TurnRuntime>>(),
listeners: new Set<Listener>(),
});
const subscribe = useCallback((listener: Listener) => {
storeRef.current.listeners.add(listener);
return () => {
storeRef.current.listeners.delete(listener);
};
}, []);
const notify = useCallback(() => {
for (const l of storeRef.current.listeners) {
l();
}
}, []);
const getSessionMap = useCallback((sessionId: string) => {
const store = storeRef.current;
if (!store.turns.has(sessionId)) {
store.turns.set(sessionId, new Map());
}
return store.turns.get(sessionId)!;
}, []);
const registerTurn = useCallback(
(runtime: TurnRuntime) => {
const sessionMap = getSessionMap(runtime.sessionId);
// 同 session 新 turn 开始前,仅中止该 session 内仍在 pending/streaming 的旧 turn
for (const [turnId, existing] of sessionMap) {
if (
turnId !== runtime.clientTurnId &&
(existing.status === 'pending' || existing.status === 'streaming')
) {
existing.controller.abort();
existing.status = 'cancelled';
}
}
sessionMap.set(runtime.clientTurnId, runtime);
notify();
},
[getSessionMap, notify],
);
const updateTurnStatus = useCallback(
(sessionId: string, clientTurnId: string, status: TurnRuntimeStatus) => {
const rt = getSessionMap(sessionId).get(clientTurnId);
if (rt) {
rt.status = status;
notify();
}
},
[getSessionMap, notify],
);
const removeTurn = useCallback(
(sessionId: string, clientTurnId: string) => {
getSessionMap(sessionId).delete(clientTurnId);
notify();
},
[getSessionMap, notify],
);
const abortTurn = useCallback(
(sessionId: string, clientTurnId: string) => {
const rt = getSessionMap(sessionId).get(clientTurnId);
if (rt) {
rt.controller.abort();
rt.status = 'cancelled';
notify();
}
},
[getSessionMap, notify],
);
const abortActiveSessionTurns = useCallback(() => {
const sessionMap = getSessionMap(activeSessionId);
for (const rt of sessionMap.values()) {
if (rt.status === 'pending' || rt.status === 'streaming') {
rt.controller.abort();
rt.status = 'cancelled';
}
}
notify();
}, [activeSessionId, getSessionMap, notify]);
const isSessionThinking = useCallback(
(sessionId: string) => {
const sessionMap = storeRef.current.turns.get(sessionId);
if (!sessionMap) return false;
for (const rt of sessionMap.values()) {
if (rt.status === 'pending' || rt.status === 'streaming') return true;
}
return false;
},
[],
);
const isThinking = useSyncExternalStore(
subscribe,
() => isSessionThinking(activeSessionId),
() => false,
);
const getTurn = useCallback(
(sessionId: string, clientTurnId: string) =>
storeRef.current.turns.get(sessionId)?.get(clientTurnId),
[],
);
return {
registerTurn,
updateTurnStatus,
removeTurn,
abortTurn,
abortActiveSessionTurns,
isThinking,
isSessionThinking,
getTurn,
};
}