fix: 修复嵌套 iframe 污染 embed 会话与协助卡后流式显示异常
顶层窗口通过 topOwned 标记自愈老版嵌套页写脏的 embed.session;协助卡提交后避免冻结 checkpoint 覆盖实时 SSE,并仅在服务端回显新 human 消息时清除乐观气泡。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
eba3008482
commit
33639e5b89
5 files changed
+528
-96
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user