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:
john
2026-06-30 00:27:15 +08:00
parent 742aee7148
commit 98721371a4
31 changed files with 1440 additions and 141 deletions
+73
View File
@@ -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,
-1
View File
@@ -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]);
+21 -13
View File
@@ -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
View File
@@ -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
View File
@@ -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[] {