diff --git a/cli/src/App.tsx b/cli/src/App.tsx index 9ed673ae..c561efd6 100644 --- a/cli/src/App.tsx +++ b/cli/src/App.tsx @@ -493,7 +493,14 @@ export default function App() { const renderState = createEventRenderState(); for (;;) { const result = await api.pollTask(taskId, offset); - offset = result.next_offset; + // 事件窗口缺口检测(协议 §5.2):中间事件已被裁剪,提示用户并对齐窗口继续 + const windowStart = Number(result.window_start ?? 0); + if (offset > 0 && offset < windowStart) { + addItem({ kind: 'system', body: `部分实时输出已被裁剪(窗口起点 ${windowStart}),以最终结果为准` }); + offset = windowStart; + } else { + offset = result.next_offset; + } for (const event of result.events || []) { if (event.type === 'tool_approval_required') { const approval = event.data?.approval || event.data; diff --git a/cli/src/api.ts b/cli/src/api.ts index 65964d65..0038c718 100644 --- a/cli/src/api.ts +++ b/cli/src/api.ts @@ -17,6 +17,8 @@ export type TaskPollResult = { conversation_id?: string; events: Array<{ idx: number; type: string; data: any; ts?: number }>; next_offset: number; + // 事件窗口当前最小 idx(协议 §5.2):offset 落后于此值说明中间事件已被裁剪 + window_start?: number; }; export class ApiClient { diff --git a/static/src/app/methods/taskPolling/lifecycle.ts b/static/src/app/methods/taskPolling/lifecycle.ts index 79ac1640..44ba29ae 100644 --- a/static/src/app/methods/taskPolling/lifecycle.ts +++ b/static/src/app/methods/taskPolling/lifecycle.ts @@ -392,6 +392,21 @@ export const lifecycleMethods = { if (this._rebuildingFromScratch) break; this.handleRuntimeQueueSync(eventData); break; + case 'event_window_gap': + // 事件窗口缺口(协议 §5.2/§5.4):中间事件已被服务端裁剪, + // 以会话 JSON 为权威强制重读历史对账。重建期间历史本就是新加载的,跳过。 + if (this._rebuildingFromScratch) break; + debugLog('[TaskPolling] 事件窗口缺口,触发会话快照对账:', eventData); + if ((window as any).__vueApp?.uiPushToast) { + (window as any).__vueApp.uiPushToast({ + title: t('stores.eventWindowGap'), + message: t('stores.eventWindowGapReload'), + type: 'warning', + duration: 5000 + }); + } + this.fetchAndDisplayHistory({ force: true }); + break; default: debugLog(`[TaskPolling] 未知事件类型: ${eventType}`); diff --git a/static/src/locales/en-US/stores.ts b/static/src/locales/en-US/stores.ts index 0f9bdf7e..5000adb9 100644 --- a/static/src/locales/en-US/stores.ts +++ b/static/src/locales/en-US/stores.ts @@ -60,6 +60,8 @@ export default { fetchTaskListFailed: 'Failed to fetch task list', pollingFluctuation: 'Polling fluctuation', pollingUnstableRetry: 'Message updates are temporarily unstable, retrying automatically', + eventWindowGap: 'Sync gap detected', + eventWindowGapReload: 'Some live updates were trimmed; resyncing conversation content', requestFailed: 'Request failed', fileTreeUnavailableHostMode: 'File tree unavailable in host mode', unlimited: 'Unlimited', diff --git a/static/src/locales/zh-CN/stores.ts b/static/src/locales/zh-CN/stores.ts index 4426adaf..7a078913 100644 --- a/static/src/locales/zh-CN/stores.ts +++ b/static/src/locales/zh-CN/stores.ts @@ -60,6 +60,8 @@ export default { fetchTaskListFailed: '获取任务列表失败', pollingFluctuation: '轮询波动', pollingUnstableRetry: '消息更新暂时不稳定,正在自动重试', + eventWindowGap: '消息同步缺口', + eventWindowGapReload: '部分实时更新已被裁剪,正在重新同步对话内容', requestFailed: '请求失败', fileTreeUnavailableHostMode: '宿主机模式下文件树不可用', unlimited: '未限制', diff --git a/static/src/stores/task.ts b/static/src/stores/task.ts index 3888b2f0..4ec6c534 100644 --- a/static/src/stores/task.ts +++ b/static/src/stores/task.ts @@ -258,6 +258,34 @@ export const useTaskStore = defineStore('task', { let sawTaskCompleteEvent = false; let sawTaskStoppedEvent = false; + // 事件窗口缺口检测(协议 §5.2):请求 offset 落后于窗口起点,说明中间事件 + // 已被裁剪,禁止当作“无新事件”静默吞掉——通知上层走会话快照对账(§5.4), + // 本批返回的事件(idx >= window_start)是连续段,游标照常推进。 + const windowStart = Number(data?.window_start ?? 0); + if (fromOffset > 0 && fromOffset < windowStart) { + debugLog('[Task] 事件窗口缺口:', { from: fromOffset, windowStart }); + taskPollDiag('task-poll-window-gap', { + requestId, + taskId, + from: fromOffset, + windowStart, + nextOffset: data?.next_offset + }); + try { + eventHandler({ + type: 'event_window_gap', + data: { + task_id: data?.task_id || this.currentTaskId, + conversation_id: data?.conversation_id || null, + from_offset: fromOffset, + window_start: windowStart + } + }); + } catch (err) { + console.warn('[Task] 处理 event_window_gap 失败:', err); + } + } + // 处理新事件 if (data.events && data.events.length > 0) { debugLog(`[Task] 收到 ${data.events.length} 个新事件`);