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:
1 parent
3715af3138
commit
b164e70541
3 files changed
+138
-135
No files matched your search
@@ -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> {
|
||||||
|
|||||||
Reference in new issue
Block a user