fix: unlock send immediately after SSE close

Normal Q&A no longer waits on GET /runs before the next message. Probe run status only on 409 or dropped streams.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-30 08:48:53 +08:00
1 parent 3715af3138
commit b164e70541
3 files changed
+138 -135

No files matched your search

+94 -135
View File
@@ -31,6 +31,7 @@ import {
import { import {
collectHumanMessageIds, collectHumanMessageIds,
hasServerEchoedOptimisticHuman, hasServerEchoedOptimisticHuman,
isStaleStreamFinish,
mergeStreamDisplayValues, mergeStreamDisplayValues,
shouldDiscardTerminalOverlay, shouldDiscardTerminalOverlay,
} from "./stream-display"; } from "./stream-display";
@@ -190,11 +191,9 @@ function getRunMetadataStorage(): {
/** /**
* SDK ``useStream`` stores the in-flight run id at ``lg:stream:{threadId}`` * SDK ``useStream`` stores the in-flight run id at ``lg:stream:{threadId}``
* but **deletes that key on stream onSuccess**. A premature SSE close * but **deletes that key on stream onSuccess**. We keep a second key the SDK
* (proxy timeout / false-positive disconnect) looks like success, so the * never touches so a 409 / dropped-SSE exception can still rejoin. Normal
* reconnect key vanishes while the backend run is still occupying the * SSE close clears it immediately — do not wait for ``runs.get``.
* thread. We keep a second key the SDK never touches and only clear it
* after ``runs.get`` confirms a terminal status.
*/ */
const ACTIVE_RUN_STORAGE_PREFIX = "df:active-run:"; const ACTIVE_RUN_STORAGE_PREFIX = "df:active-run:";
@@ -276,36 +275,18 @@ export async function stopThreadRun(
/** /**
* 协助卡提交前:若上一轮还在流式(弱模型出卡后偶发未停干净),先取消服务端 run * 协助卡提交前:若上一轮还在流式(弱模型出卡后偶发未停干净),先取消服务端 run
* 并断开本地 SSE,避免 409「已有活跃 run」或用户点了选项却发不出去。 * 并断开本地 SSE,避免 409「已有活跃 run」或用户点了选项却发不出去。
*
* 不在发送前 GET /runs —— 协助卡出现时图多半已经结束,为兜底去查会让每次
* 点选项都卡住。未在流式则直接发;真遇到 409 再走 confirmOrRejoin。
*/ */
export async function stopActiveThreadBeforeClarificationReply( export async function stopActiveThreadBeforeClarificationReply(
threadId: string | null | undefined, threadId: string | null | undefined,
thread: { isLoading?: boolean; stop: () => void | Promise<void> }, thread: { isLoading?: boolean; stop: () => void | Promise<void> },
): Promise<void> { ): Promise<void> {
let storedRunId: string | null = null; if (!thread.isLoading) {
if (threadId && typeof window !== "undefined") { if (threadId) clearActiveRunId(threadId);
try { return;
storedRunId =
normalizeStoredRunId(
window.sessionStorage.getItem(`lg:stream:${threadId}`),
) || readActiveRunId(threadId);
} catch {
storedRunId = null;
}
} }
// 协助卡出现时图已经 jump_to=end,run 多半已是 terminal。再 stop()/cancel
// 会再触发 onFinish → getState overlay,把刚发出的用户回复冻住、整段攒着出。
if (storedRunId && threadId) {
try {
const run = await getAPIClient().runs.get(threadId, storedRunId);
if (run && !isInflightRunStatus(run.status)) {
clearActiveRunId(threadId);
return;
}
} catch {
/* 404 / 网络:按下面的 best-effort stop 走 */
}
}
if (!thread.isLoading && !storedRunId) return;
try { try {
await stopThreadRun(threadId); await stopThreadRun(threadId);
} catch { } catch {
@@ -320,7 +301,7 @@ export async function stopActiveThreadBeforeClarificationReply(
await new Promise((resolve) => setTimeout(resolve, 120)); await new Promise((resolve) => setTimeout(resolve, 120));
} }
function getStreamErrorMessage(error: unknown): string { function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) { if (typeof error === "string" && error.trim()) {
return error; return error;
} }
@@ -340,21 +321,21 @@ function getStreamErrorMessage(error: unknown): string {
return nestedError; return nestedError;
} }
} }
return "Request failed."; return "Request failed.";
} }
/** /**
* React production error #185 is the minified form of "Maximum update depth * React production error #185 is the minified form of "Maximum update depth
* exceeded". It is a client render-loop guard, not a backend/model failure, * exceeded". It is a client render-loop guard, not a backend/model failure,
* so it must never be surfaced as the answer's stream error. * so it must never be surfaced as the answer's stream error.
*/ */
function isReactMaximumUpdateDepthError(message: string): boolean { function isReactMaximumUpdateDepthError(message: string): boolean {
return ( return (
/maximum update depth exceeded/i.test(message) || /maximum update depth exceeded/i.test(message) ||
/minified react error\s*#185\b/i.test(message) || /minified react error\s*#185\b/i.test(message) ||
/react\.dev\/errors\/185\b/i.test(message) /react\.dev\/errors\/185\b/i.test(message)
); );
} }
const TERMINAL_RUN_STATUSES = new Set([ const TERMINAL_RUN_STATUSES = new Set([
"success", "success",
@@ -363,12 +344,9 @@ const TERMINAL_RUN_STATUSES = new Set([
"interrupted", "interrupted",
]); ]);
// A completed server run normally sends a terminal SSE event. In local // Only probe /runs after an *exception* path has already set streamHoldOpen
// development, however, a Vite hot update, a proxy disconnect, or a brief // (dropped SSE, 409, React #185). Normal Q&A must not GET /runs on finish or
// network interruption can make the browser miss that final event while the // every 5s — that made every send wait for a status round-trip.
// backend continues (and successfully completes) the run. Do not probe on
// every render: only treat the stream as suspicious after it has been quiet
// for a while, then reconcile it from the authoritative run/state endpoints.
const STREAM_RECONCILIATION_IDLE_MS = 12_000; const STREAM_RECONCILIATION_IDLE_MS = 12_000;
const STREAM_RECONCILIATION_POLL_MS = 5_000; const STREAM_RECONCILIATION_POLL_MS = 5_000;
// A run can legitimately be quiet while a long tool call / slow model write is // A run can legitimately be quiet while a long tool call / slow model write is
@@ -608,14 +586,9 @@ export function useThreadStream({
"terminal" | "rejoined" | "unknown" "terminal" | "rejoined" | "unknown"
> >
>(async () => "unknown"); >(async () => "unknown");
// Keep the send button in "stop" until we confirm the backend run is // Held only on exception recovery (dropped SSE / 409). Normal turns rely
// terminal. A premature SSE close must not look like the turn ended. // on SDK `isLoading` so the send button unlocks as soon as SSE closes.
const [streamHoldOpen, setStreamHoldOpen] = useState( const [streamHoldOpen, setStreamHoldOpen] = useState(false);
() =>
Boolean(
threadId && typeof window !== "undefined" && readActiveRunId(threadId),
),
);
const sendInFlightRef = useRef(false); const sendInFlightRef = useRef(false);
const sendEpochRef = useRef(0); const sendEpochRef = useRef(0);
// Filled after useStream; onFinish/onError may read it asynchronously. // Filled after useStream; onFinish/onError may read it asynchronously.
@@ -687,17 +660,17 @@ export function useThreadStream({
runMetadataStorageRef.current = getRunMetadataStorage(); runMetadataStorageRef.current = getRunMetadataStorage();
} }
const thread = useStream<AgentThreadState>({ const thread = useStream<AgentThreadState>({
client: getAPIClient(isMock), client: getAPIClient(isMock),
assistantId: assistantId ?? "lead_agent", assistantId: assistantId ?? "lead_agent",
threadId: onStreamThreadId, threadId: onStreamThreadId,
// The SDK's current implementation falls back to synchronous external- // The SDK's current implementation falls back to synchronous external-
// store notifications when this option is omitted. A network chunk can // store notifications when this option is omitted. A network chunk can
// contain dozens of token events, causing nested React renders and the // contain dozens of token events, causing nested React renders and the
// intermittent production-only React #185 error. Coalesce notifications // intermittent production-only React #185 error. Coalesce notifications
// to one visual update per frame; stream data itself is not dropped. // to one visual update per frame; stream data itself is not dropped.
throttle: 16, throttle: 16,
reconnectOnMount: runMetadataStorageRef.current reconnectOnMount: runMetadataStorageRef.current
? () => runMetadataStorageRef.current! ? () => runMetadataStorageRef.current!
: false, : false,
fetchStateHistory: { limit: 1 }, fetchStateHistory: { limit: 1 },
@@ -707,7 +680,6 @@ export function useThreadStream({
if (meta.run_id) { if (meta.run_id) {
activeRunIdRef.current = meta.run_id; activeRunIdRef.current = meta.run_id;
persistActiveRunId(meta.thread_id, meta.run_id); persistActiveRunId(meta.thread_id, meta.run_id);
setStreamHoldOpen(true);
} }
const metadataAgentId = context.agent_id ?? context.agent_name; const metadataAgentId = context.agent_id ?? context.agent_name;
const extraMetadata = const extraMetadata =
@@ -862,21 +834,21 @@ export function useThreadStream({
toast(e.message); toast(e.message);
} }
}, },
onError(error) { onError(error) {
markStreamActivity(); markStreamActivity();
const message = getStreamErrorMessage(error); const message = getStreamErrorMessage(error);
// A synchronous SDK store notification used to let React's update-depth // A synchronous SDK store notification used to let React's update-depth
// guard escape into the stream promise. Do not present that internal UI // guard escape into the stream promise. Do not present that internal UI
// exception as a model failure. Reconcile/rejoin on the next task so the // exception as a model failure. Reconcile/rejoin on the next task so the
// backend run can continue and the current answer is preserved. // backend run can continue and the current answer is preserved.
if (isReactMaximumUpdateDepthError(message)) { if (isReactMaximumUpdateDepthError(message)) {
window.setTimeout(() => { window.setTimeout(() => {
setStreamHoldOpen(true); setStreamHoldOpen(true);
void confirmOrRejoinRef.current("error"); void confirmOrRejoinRef.current("error");
}, 0); }, 0);
return; return;
} }
// Replacing a stalled local SSE reader aborts the in-flight fetch. That // Replacing a stalled local SSE reader aborts the in-flight fetch. That
// is expected and must not look like a user-facing "network" failure. // is expected and must not look like a user-facing "network" failure.
if (isAbortLikeError(error)) { if (isAbortLikeError(error)) {
return; return;
@@ -944,34 +916,30 @@ export function useThreadStream({
} }
toast.error(message); toast.error(message);
}, },
onFinish(state) { onFinish(state, runMeta) {
lastStreamActivityAtRef.current = Date.now(); lastStreamActivityAtRef.current = Date.now();
// Do not trust a clean SSE close as "the run is done". streamResumable // Trust a clean SSE close. Querying /runs here blocked every send for
// chats keep the backend run alive on disconnect; the SDK still fires // ~5s to paper over a rare premature disconnect; that case is handled
// onFinish and would otherwise restore the send button. // when the next send hits 409 (or onError for a dropped stream).
// if (
// If a newer turn is already streaming (common: 用户协助卡提交后,上一轮 isStaleStreamFinish({
// interrupt 的迟到 onFinish 才到), do not freeze/rejoin — that would finishingRunId: runMeta?.run_id,
// steal the live SSE and dump tokens only at the end. currentRunId: activeRunIdRef.current,
if (threadStreamRef.current?.isLoading || sendInFlightRef.current) { })
) {
return; return;
} }
setStreamHoldOpen(true); const finThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
void confirmOrRejoinRef.current("finish").then((result) => { if (finThreadId) {
if (result === "rejoined") { clearActiveRunId(finThreadId);
return; finalizeResearchReportStream(finThreadId);
} }
if (threadStreamRef.current?.isLoading || sendInFlightRef.current) { activeRunIdRef.current = null;
return; setStreamHoldOpen(false);
} if (state?.values) {
setStreamHoldOpen(false);
// run 真正终结(非 rejoin):若深度研究报告流仍 active(tool_finished
// 帧丢失/被取消),标记中断,避免进度条永久转圈。
const finThreadId = threadIdRef.current ?? onStreamThreadId ?? null;
if (finThreadId) finalizeResearchReportStream(finThreadId);
listeners.current.onFinish?.(state.values); listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); }
}); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
}, },
}); });
@@ -1009,14 +977,6 @@ export function useThreadStream({
if (streamReconciliationInFlightRef.current && reason === "poll") { if (streamReconciliationInFlightRef.current && reason === "poll") {
return "unknown"; return "unknown";
} }
// Stale finish/error from a prior interrupt must not joinStream over a
// turn the user already started (协助卡提交后的实时流会被打断并攒批)。
if (
(reason === "finish" || reason === "error") &&
(sendInFlightRef.current || threadStreamRef.current?.isLoading)
) {
return "rejoined";
}
streamReconciliationInFlightRef.current = true; streamReconciliationInFlightRef.current = true;
const epochAtStart = sendEpochRef.current; const epochAtStart = sendEpochRef.current;
try { try {
@@ -1192,11 +1152,10 @@ export function useThreadStream({
if (dropped && idleFor < 800) { if (dropped && idleFor < 800) {
return; return;
} }
const stored = activeRunIdRef.current ?? readActiveRunId(activeThreadId); // Do not probe just because a run id is stored. Only the exception
if (!stored && !streamHoldOpen && threadStreamRef.current?.isLoading) { // path sets streamHoldOpen; polling otherwise made every turn wait
return; // on GET /runs before the user could send again.
} if (!streamHoldOpen) {
if (!stored && !streamHoldOpen) {
return; return;
} }
await confirmOrRejoin("poll"); await confirmOrRejoin("poll");
@@ -1705,20 +1664,20 @@ export function useThreadStream({
thread.isLoading, thread.isLoading,
) ?? thread.values; ) ?? thread.values;
const mergedMessages = const mergedMessages =
Array.isArray(mergedValues?.messages) Array.isArray(mergedValues?.messages)
? (mergedValues.messages as Message[]) ? (mergedValues.messages as Message[])
: thread.messages; : thread.messages;
const visibleThreadError = const visibleThreadError =
thread.error && thread.error &&
isReactMaximumUpdateDepthError(getStreamErrorMessage(thread.error)) isReactMaximumUpdateDepthError(getStreamErrorMessage(thread.error))
? undefined ? undefined
: thread.error; : thread.error;
const mergedThread = { const mergedThread = {
...thread, ...thread,
error: visibleThreadError, error: visibleThreadError,
values: mergedValues, values: mergedValues,
messages: messages:
optimisticMessages.length > 0 optimisticMessages.length > 0
@@ -5,6 +5,7 @@ import type { Message } from "@langchain/langgraph-sdk";
import { import {
collectHumanMessageIds, collectHumanMessageIds,
hasServerEchoedOptimisticHuman, hasServerEchoedOptimisticHuman,
isStaleStreamFinish,
mergeStreamDisplayValues, mergeStreamDisplayValues,
shouldDiscardTerminalOverlay, shouldDiscardTerminalOverlay,
} from "./stream-display"; } from "./stream-display";
@@ -28,6 +29,30 @@ test("live streaming ignores frozen checkpoint messages so tokens can paint", ()
assert.deepEqual(afterStream?.messages, overrides.messages); assert.deepEqual(afterStream?.messages, overrides.messages);
}); });
test("late onFinish of a prior run is stale once a newer run is live", () => {
assert.equal(
isStaleStreamFinish({
finishingRunId: "old-run",
currentRunId: "new-run",
}),
true,
);
assert.equal(
isStaleStreamFinish({
finishingRunId: "run-1",
currentRunId: "run-1",
}),
false,
);
assert.equal(
isStaleStreamFinish({
finishingRunId: "run-1",
currentRunId: null,
}),
false,
);
});
test("a later send discards the previous run's getState overlay", () => { test("a later send discards the previous run's getState overlay", () => {
assert.equal( assert.equal(
shouldDiscardTerminalOverlay({ shouldDiscardTerminalOverlay({
@@ -51,6 +51,25 @@ export function shouldDiscardTerminalOverlay(opts: {
return false; return false;
} }
/**
* True when an SSE onFinish belongs to a *previous* run, after the user
* already started a newer turn (协助卡提交是典型场景).
*
* Do **not** use `sendInFlight` / `isLoading` for this: `submit()` stays
* pending until the current stream ends, so those flags are still true on a
* normal same-turn finish and would skip unlock for every Q&A.
*/
export function isStaleStreamFinish(opts: {
finishingRunId?: string | null;
currentRunId?: string | null;
}): boolean {
return Boolean(
opts.finishingRunId &&
opts.currentRunId &&
opts.finishingRunId !== opts.currentRunId,
);
}
export function collectHumanMessageIds( export function collectHumanMessageIds(
...groups: Array<Message[] | undefined> ...groups: Array<Message[] | undefined>
): Set<string> { ): Set<string> {