fix(mindspace): edit_file 落盘、Finish 聊天 merge 与回归守卫
- finish-sync 支持 edit_file 覆盖 public HTML - Finish 同步 merge 本地流式消息,剥离 agent 内部前缀 - 新增 verify:mindspace-publish-guards 与 AGENTS.md 跨工具说明 - 发版脚本接入回归门禁;103 runtime 发布含备份回退 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2217,6 +2217,79 @@ type SubscribeOptions = {
|
||||
onBalance?: (update: BalanceUpdate) => void;
|
||||
};
|
||||
|
||||
export function subscribeAgentRunEvents(
|
||||
runId: string,
|
||||
onRun: (run: AgentRun) => void,
|
||||
onError: (error: Error) => void,
|
||||
): () => void {
|
||||
let closed = false;
|
||||
let activeController: AbortController | null = null;
|
||||
|
||||
const run = async (controller: AbortController) => {
|
||||
while (!closed && !controller.signal.aborted) {
|
||||
try {
|
||||
const res = await fetch(`${API}${AGENT_RUNS_PATH}/${encodeURIComponent(runId)}/events`, {
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
credentials: 'same-origin',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
notifyUnauthorized();
|
||||
throw new ApiError(401, 'SSE 未授权');
|
||||
}
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
throw new ApiError(res.status, `SSE failed: ${res.status}`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (!closed && !controller.signal.aborted) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const chunks = buffer.split('\n\n');
|
||||
buffer = chunks.pop() ?? '';
|
||||
|
||||
for (const chunk of chunks) {
|
||||
let eventName = 'message';
|
||||
let data = '';
|
||||
for (const line of chunk.split('\n')) {
|
||||
if (line.startsWith('event:')) eventName = line.slice(6).trim();
|
||||
if (line.startsWith('data:')) data = line.slice(5).trim();
|
||||
}
|
||||
if (!data) continue;
|
||||
if (eventName === 'run') {
|
||||
const payload = JSON.parse(data) as { run: AgentRun };
|
||||
onRun(payload.run);
|
||||
continue;
|
||||
}
|
||||
if (eventName === 'error') {
|
||||
const payload = JSON.parse(data) as { message?: string };
|
||||
throw new Error(payload.message || '后台任务失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted || closed) return;
|
||||
onError(err instanceof Error ? err : new Error(String(err)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
activeController = new AbortController();
|
||||
void run(activeController);
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
activeController?.abort();
|
||||
};
|
||||
}
|
||||
|
||||
export function subscribeSessionEvents(
|
||||
sessionId: string,
|
||||
onEvent: (event: SessionEvent) => void,
|
||||
|
||||
@@ -950,7 +950,6 @@ export function MindSpaceView({
|
||||
prevChatStateRef.current = chatState;
|
||||
const wasBusy = previous === 'streaming' || previous === 'waiting';
|
||||
if (!wasBusy || chatState !== 'idle' || !selectedPageId || previewMode || pageFullscreenPreviewOpen) return;
|
||||
setPageRefreshTrigger((value) => value + 1);
|
||||
void refreshPagesSilently();
|
||||
}, [chatState, pageFullscreenPreviewOpen, previewMode, selectedPageId]);
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
confirmTool,
|
||||
createAgentRun,
|
||||
forkMindSpacePageEditSession,
|
||||
getAgentRun,
|
||||
getMindSpace,
|
||||
loadSessionDetail,
|
||||
resumeSession,
|
||||
subscribeAgentRunEvents,
|
||||
uploadMindSpaceAsset,
|
||||
subscribeSessionEvents,
|
||||
} from '../api/client';
|
||||
@@ -28,19 +28,27 @@ import {
|
||||
pushMessage,
|
||||
} from '../utils/message';
|
||||
|
||||
const AGENT_RUN_POLL_DELAY_MS = 1200;
|
||||
const AGENT_RUN_MAX_POLLS = 100;
|
||||
|
||||
async function waitForAgentRun(runId: string) {
|
||||
for (let attempt = 0; attempt < AGENT_RUN_MAX_POLLS; attempt += 1) {
|
||||
const run = await getAgentRun(runId);
|
||||
if (run.status === 'succeeded') return run;
|
||||
if (run.status === 'failed') {
|
||||
throw new Error(run.error || '后台任务失败,请稍后重试');
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, AGENT_RUN_POLL_DELAY_MS));
|
||||
}
|
||||
throw new Error('后台任务仍在排队,请稍后刷新会话查看结果');
|
||||
return await new Promise((resolve, reject) => {
|
||||
const unsubscribe = subscribeAgentRunEvents(
|
||||
runId,
|
||||
(run) => {
|
||||
if (run.status === 'succeeded') {
|
||||
unsubscribe();
|
||||
resolve(run);
|
||||
return;
|
||||
}
|
||||
if (run.status === 'failed') {
|
||||
unsubscribe();
|
||||
reject(new Error(run.error || '后台任务失败,请稍后重试'));
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
unsubscribe();
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function buildPageEditSummary(messages: Message[], pageTitle: string): string {
|
||||
|
||||
+98
-37
@@ -7,7 +7,6 @@ import {
|
||||
confirmTool,
|
||||
createAgentRun,
|
||||
deleteChatSession,
|
||||
getAgentRun,
|
||||
getMindSpace,
|
||||
getMe,
|
||||
listNotifications,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
rememberProjectContext,
|
||||
uploadMindSpaceAsset,
|
||||
resumeSession,
|
||||
subscribeAgentRunEvents,
|
||||
subscribeNotificationEvents,
|
||||
subscribeSessionEvents,
|
||||
syncUserMemory,
|
||||
@@ -45,13 +45,13 @@ import type {
|
||||
import { buildContextPrefix } from '../utils/mindspaceChatContext';
|
||||
import { buildUserAddressPrefix } from '../utils/userAddress';
|
||||
import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs';
|
||||
import { mergeConversationSnapshot } from '../../chat-finish-sync.mjs';
|
||||
import {
|
||||
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
|
||||
} from '../utils/imageUpload';
|
||||
import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards';
|
||||
import {
|
||||
buildUserMessage,
|
||||
mergeConversationSnapshot,
|
||||
normalizeConversationMessages,
|
||||
getDisplayText,
|
||||
getToolConfirmation,
|
||||
@@ -69,21 +69,31 @@ import {
|
||||
|
||||
const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
|
||||
const REPLY_RECOVERY_SYNC_DELAYS_MS = [1500, 5000, 12000];
|
||||
const AGENT_RUN_POLL_DELAY_MS = 1200;
|
||||
const AGENT_RUN_MAX_POLLS = 100;
|
||||
|
||||
const FINISH_SYNC_RETRY_DELAYS_MS = [500, 1500, 3000];
|
||||
const ACTIVE_REQUEST_MISSING_GRACE_MS = 2500;
|
||||
export { INSUFFICIENT_BALANCE_NOTICE };
|
||||
|
||||
async function waitForAgentRun(runId: string) {
|
||||
for (let attempt = 0; attempt < AGENT_RUN_MAX_POLLS; attempt += 1) {
|
||||
const run = await getAgentRun(runId);
|
||||
if (run.status === 'succeeded') return run;
|
||||
if (run.status === 'failed') {
|
||||
throw new Error(run.error || '后台任务失败,请稍后重试');
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, AGENT_RUN_POLL_DELAY_MS));
|
||||
}
|
||||
throw new Error('后台任务仍在排队,请稍后刷新会话查看结果');
|
||||
return await new Promise((resolve, reject) => {
|
||||
const unsubscribe = subscribeAgentRunEvents(
|
||||
runId,
|
||||
(run) => {
|
||||
if (run.status === 'succeeded') {
|
||||
unsubscribe();
|
||||
resolve(run);
|
||||
return;
|
||||
}
|
||||
if (run.status === 'failed') {
|
||||
unsubscribe();
|
||||
reject(new Error(run.error || '后台任务失败,请稍后重试'));
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
unsubscribe();
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function mergeMessagePages(older: Message[], current: Message[]): Message[] {
|
||||
@@ -137,6 +147,7 @@ export function useTKMindChat(
|
||||
const seenNotificationIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const activeRequestId = useRef<string | null>(null);
|
||||
const activeRequestMissingTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
|
||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||
const connectTokenRef = useRef(0);
|
||||
const messagesRef = useRef<Message[]>([]);
|
||||
@@ -154,6 +165,12 @@ export function useTKMindChat(
|
||||
const onUserUpdateRef = useRef(onUserUpdate);
|
||||
const chatImageCategoryIdRef = useRef<string | null>(null);
|
||||
|
||||
const clearActiveRequestMissingTimer = useCallback(() => {
|
||||
if (!activeRequestMissingTimerRef.current) return;
|
||||
window.clearTimeout(activeRequestMissingTimerRef.current);
|
||||
activeRequestMissingTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const dismissNotice = useCallback(() => {
|
||||
const currentNotification = activeNotification;
|
||||
setNotice(null);
|
||||
@@ -519,24 +536,50 @@ export function useTKMindChat(
|
||||
}, [resolveChatImageUploadCategoryId]);
|
||||
|
||||
const syncSessionMessages = useCallback(async (sessionId: string) => {
|
||||
// REGRESSION GUARD: Finish may arrive before Goose persists assistant turns.
|
||||
// Never blind-replace with server snapshot — merge + retry (see chat-finish-sync.mjs).
|
||||
try {
|
||||
const localMessages = messagesRef.current;
|
||||
const currentLoadedCount = Math.max(
|
||||
messageHistoryLoadedCountRef.current,
|
||||
messagesRef.current.length,
|
||||
localMessages.length,
|
||||
appConfig.sessionMessagePageSize,
|
||||
);
|
||||
const detail = await loadSessionDetail(sessionId, undefined, {
|
||||
before: 0,
|
||||
limit: currentLoadedCount,
|
||||
});
|
||||
const knownSession =
|
||||
sessionRef.current?.id === sessionId
|
||||
? sessionRef.current
|
||||
: sessionsRef.current.find((item) => item.id === sessionId);
|
||||
const hints = {
|
||||
messageCount: Math.max(knownSession?.message_count ?? 0, localMessages.length),
|
||||
updatedAt: knownSession?.updated_at,
|
||||
};
|
||||
const historyQuery = { before: 0, limit: currentLoadedCount };
|
||||
|
||||
const fetchDetail = () =>
|
||||
loadSessionDetail(sessionId, hints, historyQuery);
|
||||
|
||||
let detail = await fetchDetail();
|
||||
let nextMessages = mergeConversationSnapshot(localMessages, detail.messages);
|
||||
|
||||
for (const delay of FINISH_SYNC_RETRY_DELAYS_MS) {
|
||||
if (detail.messages.length >= localMessages.length) break;
|
||||
await new Promise((resolve) => window.setTimeout(resolve, delay));
|
||||
if (sessionRef.current?.id !== sessionId) return;
|
||||
detail = await fetchDetail();
|
||||
nextMessages = mergeConversationSnapshot(localMessages, detail.messages);
|
||||
}
|
||||
|
||||
setSessions((prev) => prependUnique(prev, toSessionSummary(detail.session)));
|
||||
if (sessionRef.current?.id !== sessionId) return;
|
||||
messagesRef.current = detail.messages;
|
||||
messageHistoryLoadedCountRef.current = detail.messages.length;
|
||||
messageHistoryTotalRef.current = Math.max(Number(detail.page.total ?? detail.messages.length), detail.messages.length);
|
||||
setMessageHistoryHasMore(detail.messages.length < messageHistoryTotalRef.current);
|
||||
messagesRef.current = nextMessages;
|
||||
messageHistoryLoadedCountRef.current = nextMessages.length;
|
||||
messageHistoryTotalRef.current = Math.max(
|
||||
Number(detail.page.total ?? nextMessages.length),
|
||||
nextMessages.length,
|
||||
);
|
||||
setMessageHistoryHasMore(nextMessages.length < messageHistoryTotalRef.current);
|
||||
setMessageHistoryTotal(messageHistoryTotalRef.current);
|
||||
setMessages(detail.messages);
|
||||
setMessages(nextMessages);
|
||||
setSession((current) => (current?.id === sessionId ? detail.session : current));
|
||||
} catch (err) {
|
||||
console.warn('Session final sync failed:', err);
|
||||
@@ -550,6 +593,7 @@ export function useTKMindChat(
|
||||
const raw = event as SessionEvent & { chat_request_id?: string; request_id?: string };
|
||||
const eventRequestId = raw.chat_request_id ?? raw.request_id;
|
||||
if (eventRequestId && eventRequestId !== requestId) return;
|
||||
clearActiveRequestMissingTimer();
|
||||
|
||||
switch (event.type) {
|
||||
case 'Message': {
|
||||
@@ -674,7 +718,7 @@ export function useTKMindChat(
|
||||
return;
|
||||
}
|
||||
},
|
||||
[rememberRecentContext, syncSessionMessages],
|
||||
[clearActiveRequestMissingTimer, rememberRecentContext, syncSessionMessages],
|
||||
);
|
||||
|
||||
const subscribeToSession = useCallback(
|
||||
@@ -687,15 +731,25 @@ export function useTKMindChat(
|
||||
if (event.type === 'ActiveRequests') {
|
||||
if (!rid && event.request_ids.length > 0) {
|
||||
// SSE reconnected while agent was running — adopt the active request.
|
||||
clearActiveRequestMissingTimer();
|
||||
activeRequestId.current = event.request_ids[0];
|
||||
setChatState('streaming');
|
||||
} else if (rid && event.request_ids.includes(rid)) {
|
||||
clearActiveRequestMissingTimer();
|
||||
} else if (rid && !event.request_ids.includes(rid)) {
|
||||
// Our request is no longer active — agent finished while SSE was paused.
|
||||
// Sync the final state from the server.
|
||||
activeRequestId.current = null;
|
||||
setChatState('idle');
|
||||
setPendingTool(null);
|
||||
void syncSessionMessages(sessionId);
|
||||
// Goose can briefly report no active request between tool phases.
|
||||
// Confirm the absence before turning the UI idle, otherwise MindSpace
|
||||
// refreshes the page while tools are still mutating it.
|
||||
if (!activeRequestMissingTimerRef.current) {
|
||||
activeRequestMissingTimerRef.current = window.setTimeout(() => {
|
||||
activeRequestMissingTimerRef.current = null;
|
||||
if (activeRequestId.current !== rid) return;
|
||||
activeRequestId.current = null;
|
||||
setChatState('idle');
|
||||
setPendingTool(null);
|
||||
void syncSessionMessages(sessionId);
|
||||
}, ACTIVE_REQUEST_MISSING_GRACE_MS);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -729,13 +783,14 @@ export function useTKMindChat(
|
||||
},
|
||||
);
|
||||
},
|
||||
[notifyInsufficientBalance, processEvent],
|
||||
[clearActiveRequestMissingTimer, notifyInsufficientBalance, processEvent],
|
||||
);
|
||||
|
||||
const resetSessionView = useCallback(() => {
|
||||
connectTokenRef.current += 1;
|
||||
unsubscribeRef.current?.();
|
||||
unsubscribeRef.current = null;
|
||||
clearActiveRequestMissingTimer();
|
||||
setError(null);
|
||||
setPendingTool(null);
|
||||
activeRequestId.current = null;
|
||||
@@ -748,7 +803,7 @@ export function useTKMindChat(
|
||||
setMessageHistoryLoadingMore(false);
|
||||
setMessages([]);
|
||||
setChatState('connecting');
|
||||
}, []);
|
||||
}, [clearActiveRequestMissingTimer]);
|
||||
|
||||
const connectSession = useCallback(
|
||||
async (
|
||||
@@ -759,6 +814,7 @@ export function useTKMindChat(
|
||||
const token = ++connectTokenRef.current;
|
||||
unsubscribeRef.current?.();
|
||||
unsubscribeRef.current = null;
|
||||
clearActiveRequestMissingTimer();
|
||||
|
||||
if (showLoading) {
|
||||
setChatState('loading');
|
||||
@@ -802,7 +858,7 @@ export function useTKMindChat(
|
||||
|
||||
subscribeToSession(sessionId);
|
||||
},
|
||||
[subscribeToSession],
|
||||
[clearActiveRequestMissingTimer, subscribeToSession],
|
||||
);
|
||||
|
||||
const ensureProvider = useCallback(async (sessionId: string) => {
|
||||
@@ -942,9 +998,10 @@ export function useTKMindChat(
|
||||
cancelled = true;
|
||||
connectTokenRef.current += 1;
|
||||
unsubscribeRef.current?.();
|
||||
clearActiveRequestMissingTimer();
|
||||
};
|
||||
// Re-run when the signed-in user changes so session storage stays per-user.
|
||||
}, [ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, user?.id]);
|
||||
}, [clearActiveRequestMissingTimer, ensureProvider, loadProjectMemory, notifyInsufficientBalance, refreshSessions, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibility = () => {
|
||||
@@ -1083,6 +1140,7 @@ export function useTKMindChat(
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setChatState('error');
|
||||
}
|
||||
clearActiveRequestMissingTimer();
|
||||
activeRequestId.current = null;
|
||||
}
|
||||
},
|
||||
@@ -1091,6 +1149,7 @@ export function useTKMindChat(
|
||||
session,
|
||||
chatState,
|
||||
grantedSkills,
|
||||
clearActiveRequestMissingTimer,
|
||||
subscribeToSession,
|
||||
ensureProvider,
|
||||
loadProjectMemory,
|
||||
@@ -1104,10 +1163,11 @@ export function useTKMindChat(
|
||||
try {
|
||||
await cancelRequest(session.id, activeRequestId.current);
|
||||
} finally {
|
||||
clearActiveRequestMissingTimer();
|
||||
activeRequestId.current = null;
|
||||
setChatState('idle');
|
||||
}
|
||||
}, [session]);
|
||||
}, [clearActiveRequestMissingTimer, session]);
|
||||
|
||||
const approveTool = useCallback(
|
||||
async (allow: boolean) => {
|
||||
@@ -1125,6 +1185,7 @@ export function useTKMindChat(
|
||||
connectTokenRef.current += 1;
|
||||
unsubscribeRef.current?.();
|
||||
unsubscribeRef.current = null;
|
||||
clearActiveRequestMissingTimer();
|
||||
clearStoredSessionId(userRef.current?.id);
|
||||
activeRequestId.current = null;
|
||||
messagesRef.current = [];
|
||||
@@ -1139,7 +1200,7 @@ export function useTKMindChat(
|
||||
setError(null);
|
||||
setPendingTool(null);
|
||||
setChatState('idle');
|
||||
}, [chatState]);
|
||||
}, [chatState, clearActiveRequestMissingTimer]);
|
||||
|
||||
const refreshProjectMemory = useCallback(async () => {
|
||||
if (!session || chatState === 'streaming' || chatState === 'waiting') return;
|
||||
|
||||
+20
-9
@@ -1,5 +1,7 @@
|
||||
import type { Message, MessageContent } from '../types';
|
||||
import { mergeMessageContent } from '../../message-stream.mjs';
|
||||
import { mergeConversationSnapshot as mergeConversationSnapshotCore } from '../../chat-finish-sync.mjs';
|
||||
import { deriveUserFacingText } from '../../conversation-display.mjs';
|
||||
import { stripUserAddressPrefix } from './userAddress';
|
||||
|
||||
const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/;
|
||||
@@ -153,30 +155,39 @@ export function normalizeConversationMessages(messages: Message[]): Message[] {
|
||||
* Merge an authoritative server conversation snapshot into the currently
|
||||
* displayed messages without ever erasing what the user can already see.
|
||||
*
|
||||
* Used by UpdateConversation SSE and Finish sync. Implementation lives in
|
||||
* chat-finish-sync.mjs (shared with regression tests — do not inline elsewhere).
|
||||
*
|
||||
* `UpdateConversation` snapshots arrive on every SSE (re)connect — which on
|
||||
* mobile happens constantly via the page visibility cycle. A mid-turn snapshot
|
||||
* can be shorter than the live view (e.g. assistant messages not yet flagged
|
||||
* userVisible), so a blind replace would blank an active conversation even
|
||||
* though the backend session is alive and persisted. We therefore treat the
|
||||
* snapshot as authoritative for content/order but keep any locally-displayed
|
||||
* messages the snapshot hasn't caught up to yet (optimistic / in-flight tail).
|
||||
* The authoritative full reload on `Finish` corrects any drift afterwards.
|
||||
* though the backend session is alive and persisted. Finish sync also merges
|
||||
* because Goose may persist assistant turns after the Finish event.
|
||||
*/
|
||||
export function mergeConversationSnapshot(current: Message[], incoming: Message[]): Message[] {
|
||||
if (incoming.length === 0) return current;
|
||||
const incomingIds = new Set(incoming.map((message) => message.id).filter(Boolean));
|
||||
const localOnly = current.filter((message) => message.id && !incomingIds.has(message.id));
|
||||
return localOnly.length ? [...incoming, ...localOnly] : incoming;
|
||||
return mergeConversationSnapshotCore(current, incoming) as Message[];
|
||||
}
|
||||
|
||||
export function getDisplayText(message: Message): string {
|
||||
// REGRESSION GUARD: never show agent-only routing/skill prefixes in the chat UI.
|
||||
if (message.role === 'user') {
|
||||
if ('displayText' in message.metadata && message.metadata.displayText != null) {
|
||||
return stripImageUrlLines(message.metadata.displayText);
|
||||
}
|
||||
const raw = message.content
|
||||
.filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text')
|
||||
.map((c) => c.text)
|
||||
.join('\n');
|
||||
return stripImageUrlLines(deriveUserFacingText(raw));
|
||||
}
|
||||
if ('displayText' in message.metadata) {
|
||||
return stripImageUrlLines(message.metadata.displayText ?? '');
|
||||
}
|
||||
const systemText = getSystemNotificationText(message);
|
||||
if (systemText) return systemText;
|
||||
const visible = getVisibleText(message);
|
||||
return message.role === 'user' ? stripImageUrlLines(stripUserAddressPrefix(visible)) : visible;
|
||||
return visible;
|
||||
}
|
||||
|
||||
export function pushMessage(messages: Message[], incoming: Message): Message[] {
|
||||
|
||||
Reference in New Issue
Block a user