fix: 修复嵌套 iframe 污染 embed 会话与协助卡后流式显示异常

顶层窗口通过 topOwned 标记自愈老版嵌套页写脏的 embed.session;协助卡提交后避免冻结 checkpoint 覆盖实时 SSE,并仅在服务端回显新 human 消息时清除乐观气泡。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-26 16:28:10 +08:00
1 parent eba3008482
commit 33639e5b89
5 files changed
+528 -96

No files matched your search

+115 -27
View File
@@ -24,6 +24,12 @@ import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import type { AgentThread, AgentThreadState } from "./types";
import {
collectHumanMessageIds,
hasServerEchoedOptimisticHuman,
mergeStreamDisplayValues,
shouldDiscardTerminalOverlay,
} from "./stream-display";
import {
isAbortLikeError,
isTransientStreamTransportError,
@@ -282,6 +288,19 @@ export async function stopActiveThreadBeforeClarificationReply(
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 {
await stopThreadRun(threadId);
@@ -580,6 +599,16 @@ export function useThreadStream({
threadId && typeof window !== "undefined" && readActiveRunId(threadId),
),
);
const sendInFlightRef = useRef(false);
const sendEpochRef = useRef(0);
// Filled after useStream; onFinish/onError may read it asynchronously.
const threadStreamRef = useRef<{
isLoading: boolean;
stop: () => void | Promise<void>;
joinStream: (runId: string) => Promise<unknown>;
messages: Message[];
values?: AgentThreadState;
} | null>(null);
const markStreamActivity = useCallback(() => {
lastStreamActivityAtRef.current = Date.now();
@@ -867,11 +896,21 @@ export function useThreadStream({
// Do not trust a clean SSE close as "the run is done". streamResumable
// chats keep the backend run alive on disconnect; the SDK still fires
// onFinish and would otherwise restore the send button.
//
// If a newer turn is already streaming (common: 用户协助卡提交后,上一轮
// interrupt 的迟到 onFinish 才到), do not freeze/rejoin — that would
// steal the live SSE and dump tokens only at the end.
if (threadStreamRef.current?.isLoading || sendInFlightRef.current) {
return;
}
setStreamHoldOpen(true);
void confirmOrRejoinRef.current("finish").then((result) => {
if (result === "rejoined") {
return;
}
if (threadStreamRef.current?.isLoading || sendInFlightRef.current) {
return;
}
setStreamHoldOpen(false);
listeners.current.onFinish?.(state.values);
void queryClient.invalidateQueries({ queryKey: ["threads", "search"] });
@@ -885,7 +924,6 @@ export function useThreadStream({
const [threadValueOverrides, setThreadValueOverrides] = useState<
Partial<AgentThreadState>
>({});
const sendInFlightRef = useRef(false);
// Carries the ask time of an in-flight send until the server-assigned human
// message id shows up, so we can bind the timestamp to the real id (see the
// reconciliation effect below). ``known`` is the set of human ids that
@@ -893,13 +931,12 @@ export function useThreadStream({
const pendingAskTimeRef = useRef<{ ts: string; known: Set<string> } | null>(
null,
);
// Human ids known at optimistic-send time; used to clear optimistic only when
// a *new* server human appears (not when length bumps from tool/checkpoint).
const optimisticKnownHumanIdsRef = useRef<Set<string>>(new Set());
// Track message count before sending so we know when server has responded
const prevMsgCountRef = useRef(thread.messages.length);
// Keep a ref to the latest `thread` so the threadId-change effect can stop
// an in-flight stream without re-running on every render that produces a
// new `thread` identity.
const threadStreamRef = useRef(thread);
threadStreamRef.current = thread;
const confirmOrRejoin = useCallback(
@@ -915,18 +952,39 @@ export function useThreadStream({
if (streamReconciliationInFlightRef.current && reason === "poll") {
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;
const epochAtStart = sendEpochRef.current;
try {
const client = getAPIClient(isMock);
let runId = activeRunIdRef.current ?? readActiveRunId(tid);
const terminalRunId = runId;
const applyTerminalState = async (status: string) => {
const finalState = await client.threads.getState(tid);
if (
shouldDiscardTerminalOverlay({
epochAtStart,
currentEpoch: sendEpochRef.current,
sendInFlight: sendInFlightRef.current,
terminalRunId,
currentRunId: activeRunIdRef.current,
})
) {
return;
}
clearActiveRunId(tid);
activeRunIdRef.current = null;
setStreamHoldOpen(false);
streamReconnectAttemptsRef.current = 0;
streamReconnectFailureToastShownRef.current = false;
const finalState = await client.threads.getState(tid);
setThreadValueOverrides(
finalState.values as Partial<AgentThreadState>,
);
@@ -985,7 +1043,7 @@ export function useThreadStream({
const streamAboutToEnd = reason === "finish" || reason === "error";
if (
threadStreamRef.current.isLoading &&
threadStreamRef.current?.isLoading &&
!streamAboutToEnd &&
reason !== "conflict"
) {
@@ -1013,6 +1071,9 @@ export function useThreadStream({
streamReconnectAttemptsRef.current += 1;
lastStreamActivityAtRef.current = Date.now();
const stream = threadStreamRef.current;
if (!stream) {
return "unknown";
}
runMetadataStorageRef.current?.removeItem(`lg:stream:${tid}`);
void Promise.resolve(stream.stop())
.catch(() => undefined)
@@ -1042,6 +1103,9 @@ export function useThreadStream({
lastStreamActivityAtRef.current = Date.now();
const stream = threadStreamRef.current;
if (!stream) {
return "unknown";
}
void stream.joinStream(runId).catch(() => {
/* poll retries while the run stays non-terminal */
});
@@ -1063,7 +1127,7 @@ export function useThreadStream({
let disposed = false;
const reconcileRun = async () => {
if (disposed) return;
const dropped = !threadStreamRef.current.isLoading;
const dropped = !threadStreamRef.current?.isLoading;
const idleFor = Date.now() - lastStreamActivityAtRef.current;
if (!dropped && idleFor < STREAM_RECONCILIATION_IDLE_MS) {
return;
@@ -1072,7 +1136,7 @@ export function useThreadStream({
return;
}
const stored = activeRunIdRef.current ?? readActiveRunId(activeThreadId);
if (!stored && !streamHoldOpen && threadStreamRef.current.isLoading) {
if (!stored && !streamHoldOpen && threadStreamRef.current?.isLoading) {
return;
}
if (!stored && !streamHoldOpen) {
@@ -1120,7 +1184,9 @@ export function useThreadStream({
}
startedRef.current = false;
sendInFlightRef.current = false;
sendEpochRef.current += 1;
prevMsgCountRef.current = 0;
optimisticKnownHumanIdsRef.current = new Set();
activeRunIdRef.current = null;
setStreamHoldOpen(false);
setOptimisticMessages([]);
@@ -1207,15 +1273,22 @@ export function useThreadStream({
threadValueOverrides.title_provisional,
]);
// Clear optimistic when server messages arrive (count increases)
// Clear optimistic only when the server echoes a *new* human message.
// After 用户协助, a late checkpoint / tool result can bump length without
// the new answer yet — clearing then makes the bubble flash and vanish.
useEffect(() => {
if (optimisticMessages.length === 0) {
return;
}
if (
optimisticMessages.length > 0 &&
thread.messages.length > prevMsgCountRef.current
hasServerEchoedOptimisticHuman(
thread.messages,
optimisticKnownHumanIdsRef.current,
)
) {
setOptimisticMessages([]);
}
}, [thread.messages.length, optimisticMessages.length]);
}, [thread.messages, optimisticMessages.length]);
// Bind a just-sent question's ask time to the server-assigned human message
// id once it appears. Persists to localStorage so the time shows on every
@@ -1248,22 +1321,36 @@ export function useThreadStream({
return;
}
sendInFlightRef.current = true;
sendEpochRef.current += 1;
// Drop frozen checkpoint messages so the new SSE can paint live tokens.
// Keep title / title_provisional overlays from the prior reconcile.
setThreadValueOverrides((prev) => {
if (!("messages" in prev)) {
return prev;
}
const { messages: _dropped, ...rest } = prev;
return rest;
});
setStreamHoldOpen(false);
const text = message.text.trim();
// Wall-clock time the question was asked. Stored on the human message so
// the chat can show "几点提问" and have it survive a reload (it round-trips
// through the checkpoint inside additional_kwargs, same as `files`).
const clientTs = new Date().toISOString();
const knownHumanIds = collectHumanMessageIds(
thread.messages,
Array.isArray(thread.values?.messages)
? (thread.values.messages as Message[])
: undefined,
);
// Remember which human messages already exist so the reconciliation
// effect can bind ``clientTs`` to the new one once the server returns it.
pendingAskTimeRef.current = {
ts: clientTs,
known: new Set(
thread.messages
.filter((m) => m.type === "human" && m.id)
.map((m) => m.id as string),
),
known: knownHumanIds,
};
optimisticKnownHumanIdsRef.current = knownHumanIds;
// Capture current count before showing optimistic messages
prevMsgCountRef.current = thread.messages.length;
@@ -1535,7 +1622,7 @@ export function useThreadStream({
persistActiveRunId(tid, runId);
}
try {
await Promise.resolve(threadStreamRef.current.stop());
await Promise.resolve(threadStreamRef.current?.stop());
} catch {
/* local abort is best-effort */
}
@@ -1549,17 +1636,18 @@ export function useThreadStream({
}
}, [isMock]);
// Merge thread with optimistic messages for display
// Merge thread with optimistic messages for display. While a live SSE is
// open, never let a stale getState snapshot's `messages` win — that is what
// made 协助卡提交后「发送中」攒到结束才一口气出字。
const mergedValues =
Object.keys(threadValueOverrides).length > 0
? {
...thread.values,
...threadValueOverrides,
}
: thread.values;
mergeStreamDisplayValues(
thread.values as AgentThreadState | undefined,
threadValueOverrides,
thread.isLoading,
) ?? thread.values;
const mergedMessages =
Array.isArray(mergedValues.messages)
Array.isArray(mergedValues?.messages)
? (mergedValues.messages as Message[])
: thread.messages;