From f655dd263017bb0568dc9d718e1d7547967f243e Mon Sep 17 00:00:00 2001 From: thirsty5034 Date: Thu, 13 Aug 2026 11:32:44 +0800 Subject: [PATCH 1/4] fix(chat): make final history persist reliable across segment jumps Long agent turns could finish in memory while the durable SQLite snapshot stayed on the initial user-only write. Final persist now retries transient failures, catches up multi-segment compaction jumps one append at a time, aligns header/segment counts before write, and only runs memory extraction after history lands. --- .../src/lib/chat/history/chatHistory.ts | 162 +++++++++++++++--- .../pages/chat/runtime/chatRunFinalization.ts | 75 +++++++- .../chat/turns/runAgentConversationTurn.ts | 7 +- .../chat/turns/runTextConversationTurn.ts | 7 +- .../chat/chat-history-persist-queue.test.mjs | 111 +++++++++++- .../test/chat/chat-stop-timing.test.mjs | 82 +++++++++ 6 files changed, 409 insertions(+), 35 deletions(-) diff --git a/crates/agent-gui/src/lib/chat/history/chatHistory.ts b/crates/agent-gui/src/lib/chat/history/chatHistory.ts index 78bd639ae..6f867073e 100644 --- a/crates/agent-gui/src/lib/chat/history/chatHistory.ts +++ b/crates/agent-gui/src/lib/chat/history/chatHistory.ts @@ -429,6 +429,23 @@ function buildChatHistoryConversationInput(params: { state, } = params; + const derivedTotalMessageCount = state.segments.reduce( + (sum, segment) => sum + resolveSegmentMessageCount(segment), + 0, + ); + const derivedTotalSegmentCount = Math.max( + state.meta.totalSegmentCount, + state.segments.length, + (getActiveSegment(state)?.segmentIndex ?? -1) + 1, + ); + // Keep the header counts aligned with the segment payloads we are about to + // write. A stale meta.totalMessageCount is a common way to trip + // "segment/message 统计不匹配" on the final persist. + const metaForPersist = { + ...state.meta, + totalSegmentCount: derivedTotalSegmentCount, + totalMessageCount: derivedTotalMessageCount, + }; return { id: conversationId, title, @@ -437,22 +454,32 @@ function buildChatHistoryConversationInput(params: { sessionId, cwd, selectedModelJson, - contextMetaJson: JSON.stringify(state.meta), + contextMetaJson: JSON.stringify(metaForPersist), activeSegmentIndex: state.meta.activeSegmentIndex, - totalSegmentCount: state.meta.totalSegmentCount, - totalMessageCount: state.meta.totalMessageCount, + totalSegmentCount: derivedTotalSegmentCount, + totalMessageCount: derivedTotalMessageCount, createdAt, updatedAt, }; } +function resolveSegmentMessageCount(segment: StoredContextSegment): number { + // Live messages are the source of truth. Fall back to messageCount only when + // the messages array is absent (defensive); an empty array means zero messages. + if (Array.isArray(segment.messages)) { + return segment.messages.length; + } + return typeof segment.messageCount === "number" ? segment.messageCount : 0; +} + function buildChatHistorySegmentInput(segment: StoredContextSegment): ChatHistorySegmentWireRecord { + const messageCount = resolveSegmentMessageCount(segment); return { segmentIndex: segment.segmentIndex, segmentId: segment.segmentId, summaryJson: segment.summary ? JSON.stringify(segment.summary) : undefined, - messagesJson: JSON.stringify(segment.messages), - messageCount: segment.messageCount, + messagesJson: JSON.stringify(segment.messages ?? []), + messageCount, startMessageId: segment.startMessageId, endMessageId: segment.endMessageId, createdAt: segment.createdAt, @@ -542,10 +569,52 @@ type PersistConversationRuntimeParams = { commitPersistenceCursor: (cursor: ConversationPersistenceCursor) => void; }; +function findSegmentByIndex( + state: ConversationViewState, + segmentIndex: number, +): StoredContextSegment | undefined { + return state.segments.find((segment) => segment.segmentIndex === segmentIndex); +} + +function conversationInputForCursor( + conversation: ChatHistoryConversationInput, + state: ConversationViewState, + activeSegmentIndex: number, +): ChatHistoryConversationInput { + // Each intermediate append must advertise the totals that will exist after + // that step lands, otherwise backend consistency checks reject the write. + const sealedThrough = state.segments.filter((segment) => segment.segmentIndex <= activeSegmentIndex); + const totalSegmentCount = Math.max(conversation.totalSegmentCount, activeSegmentIndex + 1); + const totalMessageCount = sealedThrough.reduce( + (sum, segment) => sum + resolveSegmentMessageCount(segment), + 0, + ); + let contextMetaJson = conversation.contextMetaJson; + try { + const meta = JSON.parse(conversation.contextMetaJson) as Record; + contextMetaJson = JSON.stringify({ + ...meta, + activeSegmentIndex, + totalSegmentCount, + totalMessageCount, + }); + } catch { + // Keep the original payload if meta is not JSON; header counts still win. + } + return { + ...conversation, + contextMetaJson, + activeSegmentIndex, + totalSegmentCount, + totalMessageCount, + }; +} + async function writeConversationRuntime( conversation: ChatHistoryConversationInput, cursor: ConversationPersistenceCursor | null, state: ConversationViewState, + commitPersistenceCursor: (cursor: ConversationPersistenceCursor) => void, ) { const activeSegment = getActiveSegment(state); if (!activeSegment) { @@ -556,60 +625,97 @@ async function writeConversationRuntime( if (state.segments[0]?.segmentIndex !== 0) { throw new Error("已存在的历史会话缺少持久化游标"); } - return upsertChatHistoryRaw({ + const summary = await upsertChatHistoryRaw({ ...conversation, segments: state.segments.map(buildChatHistorySegmentInput), }); + commitPersistenceCursor({ + activeSegmentIndex: activeSegment.segmentIndex, + activeSegmentId: activeSegment.segmentId, + }); + return summary; + } + + if (activeSegment.segmentIndex < cursor.activeSegmentIndex) { + throw new Error( + `不支持的历史分段回退:${cursor.activeSegmentIndex} -> ${activeSegment.segmentIndex}`, + ); } if (activeSegment.segmentIndex === cursor.activeSegmentIndex) { if (activeSegment.segmentId !== cursor.activeSegmentId) { throw new Error("活跃历史分段身份与持久化游标不一致"); } - return upsertChatHistoryActiveSegmentRaw({ + const summary = await upsertChatHistoryActiveSegmentRaw({ conversation, segment: buildChatHistorySegmentInput(activeSegment), }); + commitPersistenceCursor({ + activeSegmentIndex: activeSegment.segmentIndex, + activeSegmentId: activeSegment.segmentId, + }); + return summary; } - if (activeSegment.segmentIndex === cursor.activeSegmentIndex + 1) { - const previousSegment = state.segments.find( - (segment) => segment.segmentIndex === cursor.activeSegmentIndex, - ); + // Catch up one segment at a time when the in-memory active segment jumped + // ahead of the durable cursor (e.g. multiple compactions between persists). + // Previously this threw "不支持的历史分段跳变" and left the DB on the + // user-only snapshot after a long agent turn. + let workingCursor: ConversationPersistenceCursor = { ...cursor }; + let summary: ChatHistorySummary | null = null; + + while (workingCursor.activeSegmentIndex < activeSegment.segmentIndex) { + const previousSegment = findSegmentByIndex(state, workingCursor.activeSegmentIndex); + const nextSegment = findSegmentByIndex(state, workingCursor.activeSegmentIndex + 1); if (!previousSegment) { throw new Error("追加历史分段时缺少待封存的上一活跃分段"); } - if (previousSegment.segmentId !== cursor.activeSegmentId) { + if (!nextSegment) { + throw new Error( + `追加历史分段时缺少目标分段:${workingCursor.activeSegmentIndex + 1}`, + ); + } + if (previousSegment.segmentId !== workingCursor.activeSegmentId) { throw new Error("待封存历史分段身份与持久化游标不一致"); } - return appendChatHistorySegmentRaw({ - conversation, + + summary = await appendChatHistorySegmentRaw({ + conversation: conversationInputForCursor( + conversation, + state, + nextSegment.segmentIndex, + ), previousSegment: buildChatHistorySegmentInput(previousSegment), - segment: buildChatHistorySegmentInput(activeSegment), + segment: buildChatHistorySegmentInput(nextSegment), }); + workingCursor = { + activeSegmentIndex: nextSegment.segmentIndex, + activeSegmentId: nextSegment.segmentId, + }; + // Commit after every successful append so a later failure can resume from + // the durable frontier instead of replaying a sealed segment. + commitPersistenceCursor(workingCursor); } - throw new Error( - `不支持的历史分段跳变:${cursor.activeSegmentIndex} -> ${activeSegment.segmentIndex}`, - ); + if (activeSegment.segmentId !== workingCursor.activeSegmentId) { + throw new Error("活跃历史分段身份与持久化游标不一致"); + } + if (!summary) { + throw new Error( + `不支持的历史分段跳变:${cursor.activeSegmentIndex} -> ${activeSegment.segmentIndex}`, + ); + } + return summary; } export async function persistConversationRuntime(params: PersistConversationRuntimeParams) { return withConversationWriteLock(params.conversationId, async () => { const conversation = buildChatHistoryConversationInput(params); - const summary = await writeConversationRuntime( + return writeConversationRuntime( conversation, params.getPersistenceCursor(), params.state, + params.commitPersistenceCursor, ); - const activeSegment = getActiveSegment(params.state); - if (!activeSegment) { - throw new Error("持久化成功后缺少活跃分段"); - } - params.commitPersistenceCursor({ - activeSegmentIndex: activeSegment.segmentIndex, - activeSegmentId: activeSegment.segmentId, - }); - return summary; }); } diff --git a/crates/agent-gui/src/pages/chat/runtime/chatRunFinalization.ts b/crates/agent-gui/src/pages/chat/runtime/chatRunFinalization.ts index a647f7a2c..44efc6f80 100644 --- a/crates/agent-gui/src/pages/chat/runtime/chatRunFinalization.ts +++ b/crates/agent-gui/src/pages/chat/runtime/chatRunFinalization.ts @@ -1,5 +1,9 @@ export const CHAT_RUN_FINALIZATION_TIMEOUT_MS = 2_000; +/** Terminal history writes get a few short retries before the run is marked failed. */ +export const TERMINAL_HISTORY_PERSIST_MAX_ATTEMPTS = 3; +export const TERMINAL_HISTORY_PERSIST_RETRY_DELAY_MS = 150; + export function releaseChatRunUi(params: { clearAbortController: () => void; clearSendingState: () => void; @@ -10,6 +14,69 @@ export function releaseChatRunUi(params: { params.clearToolStatus(); } +function delay(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, Math.max(0, ms)); + }); +} + +/** + * Retry a terminal history write a few times. Transient SQLite / IPC failures + * during the final persist otherwise leave the DB stuck on the user-only + * snapshot while memory extraction still runs from in-memory state. + */ +export async function persistTerminalHistoryWithRetry( + persist: () => Promise, + options?: { + maxAttempts?: number; + retryDelayMs?: number; + sleep?: (ms: number) => Promise; + onRetry?: (error: unknown, attempt: number, maxAttempts: number) => void; + }, +): Promise { + const maxAttempts = Math.max(1, options?.maxAttempts ?? TERMINAL_HISTORY_PERSIST_MAX_ATTEMPTS); + const retryDelayMs = Math.max(0, options?.retryDelayMs ?? TERMINAL_HISTORY_PERSIST_RETRY_DELAY_MS); + const sleep = options?.sleep ?? delay; + let lastError: unknown = null; + // null = no terminal outcome yet; true = last attempt returned false; false = threw + let lastAttemptWasSoftFalse: boolean | null = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + const persisted = await persist(); + if (persisted) { + return true; + } + lastAttemptWasSoftFalse = true; + lastError = new Error("history persist returned false"); + } catch (error) { + lastAttemptWasSoftFalse = false; + lastError = error; + } + + if (attempt >= maxAttempts) { + break; + } + options?.onRetry?.(lastError, attempt, maxAttempts); + if (retryDelayMs > 0) { + await sleep(retryDelayMs * attempt); + } + } + + // Preserve the historical contract: a soft `false` from persist stays a + // boolean failure, while thrown errors remain exceptional. + if (lastAttemptWasSoftFalse) { + return false; + } + if (lastError instanceof Error) { + throw lastError; + } + if (lastError != null) { + throw new Error(String(lastError)); + } + return false; +} + export async function settleChatRunFinalization( finalization: Promise, timeoutMs = CHAT_RUN_FINALIZATION_TIMEOUT_MS, @@ -35,9 +102,15 @@ export async function settleChatRunFinalization( export async function trackTerminalHistoryPersist( persist: () => Promise, markFailed: () => void, + options?: { + maxAttempts?: number; + retryDelayMs?: number; + sleep?: (ms: number) => Promise; + onRetry?: (error: unknown, attempt: number, maxAttempts: number) => void; + }, ): Promise { try { - const persisted = await persist(); + const persisted = await persistTerminalHistoryWithRetry(persist, options); if (!persisted) { markFailed(); } diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts index 92dbc6d2b..5e9769028 100644 --- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts @@ -1252,7 +1252,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP applyConversationState(completedState); freezeGatewayFinalProjection(completedState, true); settleLiveTranscript(transcriptStore); - await persistConversationWithHistorySync({ + const historyPersisted = await persistConversationWithHistorySync({ conversationId, sessionId, providerId, @@ -1263,7 +1263,10 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP createdAt, titlePromise, }); - if (!showSilentMemoryExtraction && shouldRunMemoryExtraction) { + // Memory extraction reads the in-memory final state. Only run it after the + // durable history write succeeds so we never keep "memory has the answer, + // chat history only has the user prompt" after a failed final persist. + if (historyPersisted && !showSilentMemoryExtraction && shouldRunMemoryExtraction) { void runPostTurnMemoryExtraction(); } } diff --git a/crates/agent-gui/src/pages/chat/turns/runTextConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runTextConversationTurn.ts index e1135668c..c62e64a7e 100644 --- a/crates/agent-gui/src/pages/chat/turns/runTextConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runTextConversationTurn.ts @@ -427,7 +427,7 @@ export async function runTextConversationTurn(params: RunTextConversationTurnPar settleLiveTranscript(transcriptStore); hookLifecycle.ensureMessageEnded(); hookLifecycle.endAgent(); - await persistConversationWithHistorySync({ + const historyPersisted = await persistConversationWithHistorySync({ conversationId, sessionId, providerId, @@ -438,7 +438,10 @@ export async function runTextConversationTurn(params: RunTextConversationTurnPar createdAt, titlePromise, }); - if (shouldRunMemoryExtraction) { + // Only extract memory after durable history lands; otherwise memory can + // retain the answer while a failed final persist leaves chat history on the + // user-only snapshot. + if (historyPersisted && shouldRunMemoryExtraction) { const currentMemoryExtractionModel: MemoryExtractionModelConfig = { providerId, model, diff --git a/crates/agent-gui/test/chat/chat-history-persist-queue.test.mjs b/crates/agent-gui/test/chat/chat-history-persist-queue.test.mjs index cb18497a8..0c4ef691e 100644 --- a/crates/agent-gui/test/chat/chat-history-persist-queue.test.mjs +++ b/crates/agent-gui/test/chat/chat-history-persist-queue.test.mjs @@ -43,15 +43,33 @@ function loadChatHistory(invoke) { return loader.loadModule("src/lib/chat/history/chatHistory.ts"); } +function placeholderMessages(count, prefix = "m") { + return Array.from({ length: count }, (_, offset) => ({ + role: "user", + id: `${prefix}-${offset + 1}`, + content: `${prefix}-${offset + 1}`, + timestamp: 1000 + offset, + })); +} + function segment(index, overrides = {}) { + const messageCount = + typeof overrides.messageCount === "number" + ? overrides.messageCount + : Array.isArray(overrides.messages) + ? overrides.messages.length + : 0; + const messages = + overrides.messages ?? + placeholderMessages(messageCount, `seg${index}`); return { segmentIndex: index, segmentId: `seg-${index}`, - messages: [], - messageCount: 0, createdAt: 100 + index, updatedAt: 100 + index, ...overrides, + messages, + messageCount: messages.length, }; } @@ -226,6 +244,95 @@ test("failed persist does not advance the cursor and the next persist retries th assert.deepEqual(cursorCommits, [persistenceCursor(seg1Grown)]); }); +test("final persist catches up multiple segment jumps one append at a time", async () => { + const recorder = createInvokeRecorder(); + const chatHistory = loadChatHistory(recorder.invoke); + const segA = segment(0, { messageCount: 2, endMessageId: "a-2" }); + const segB = segment(1, { messageCount: 1, endMessageId: "b-1" }); + const segC = segment(2, { messageCount: 3, endMessageId: "c-3" }); + const cursorRef = { current: persistenceCursor(segA) }; + const cursorCommits = []; + + const task = chatHistory.persistConversationRuntime( + persistParams({ + conversationId: "conv-multi-jump", + cursorRef, + cursorCommits, + state: buildState([segA, segB, segC], 2), + }), + ); + await flush(); + + assert.equal(recorder.calls.length, 1); + assert.equal(recorder.calls[0].cmd, "chat_history_append_segment"); + assert.equal(recorder.calls[0].args.input.previousSegment.segmentId, "seg-0"); + assert.equal(recorder.calls[0].args.input.segment.segmentId, "seg-1"); + assert.equal(recorder.calls[0].args.input.conversation.activeSegmentIndex, 1); + assert.equal(recorder.calls[0].args.input.conversation.totalSegmentCount, 3); + assert.equal(recorder.calls[0].args.input.conversation.totalMessageCount, 3); + + await resolveCall(recorder.calls[0], "conv-multi-jump", 40); + await flush(); + + assert.deepEqual(cursorRef.current, persistenceCursor(segB)); + assert.equal(recorder.calls.length, 2); + assert.equal(recorder.calls[1].cmd, "chat_history_append_segment"); + assert.equal(recorder.calls[1].args.input.previousSegment.segmentId, "seg-1"); + assert.equal(recorder.calls[1].args.input.segment.segmentId, "seg-2"); + assert.equal(recorder.calls[1].args.input.conversation.activeSegmentIndex, 2); + assert.equal(recorder.calls[1].args.input.conversation.totalMessageCount, 6); + + await resolveCall(recorder.calls[1], "conv-multi-jump", 41); + await task; + + assert.deepEqual(cursorRef.current, persistenceCursor(segC)); + assert.deepEqual(cursorCommits, [persistenceCursor(segB), persistenceCursor(segC)]); +}); + +test("partial multi-segment catch-up resumes from the durable cursor frontier", async () => { + const recorder = createInvokeRecorder(); + const chatHistory = loadChatHistory(recorder.invoke); + const segA = segment(0, { messageCount: 1, endMessageId: "a-1" }); + const segB = segment(1, { messageCount: 1, endMessageId: "b-1" }); + const segC = segment(2, { messageCount: 1, endMessageId: "c-1" }); + const cursorRef = { current: persistenceCursor(segA) }; + const cursorCommits = []; + + const first = chatHistory.persistConversationRuntime( + persistParams({ + conversationId: "conv-resume", + cursorRef, + cursorCommits, + state: buildState([segA, segB, segC], 2), + }), + ); + await flush(); + await resolveCall(recorder.calls[0], "conv-resume", 50); + await flush(); + assert.deepEqual(cursorRef.current, persistenceCursor(segB)); + recorder.calls[1].deferred.reject(new Error("db locked")); + await assert.rejects(first, /db locked/); + assert.deepEqual(cursorRef.current, persistenceCursor(segB)); + assert.deepEqual(cursorCommits, [persistenceCursor(segB)]); + + const second = chatHistory.persistConversationRuntime( + persistParams({ + conversationId: "conv-resume", + cursorRef, + cursorCommits, + state: buildState([segA, segB, segC], 2), + }), + ); + await flush(); + assert.equal(recorder.calls.length, 3); + assert.equal(recorder.calls[2].cmd, "chat_history_append_segment"); + assert.equal(recorder.calls[2].args.input.previousSegment.segmentId, "seg-1"); + assert.equal(recorder.calls[2].args.input.segment.segmentId, "seg-2"); + await resolveCall(recorder.calls[2], "conv-resume", 51); + await second; + assert.deepEqual(cursorRef.current, persistenceCursor(segC)); +}); + test("persistence cursor selects explicit initial active and append transitions", async () => { const recorder = createInvokeRecorder(); const chatHistory = loadChatHistory(recorder.invoke); diff --git a/crates/agent-gui/test/chat/chat-stop-timing.test.mjs b/crates/agent-gui/test/chat/chat-stop-timing.test.mjs index c58408455..3d5c42d14 100644 --- a/crates/agent-gui/test/chat/chat-stop-timing.test.mjs +++ b/crates/agent-gui/test/chat/chat-stop-timing.test.mjs @@ -607,6 +607,7 @@ test("terminal history persistence marks both false results and thrown errors", "src/pages/chat/runtime/chatRunFinalization.ts", ); let failures = 0; + const noRetry = { maxAttempts: 1, retryDelayMs: 0 }; assert.equal( await trackTerminalHistoryPersist( @@ -614,6 +615,7 @@ test("terminal history persistence marks both false results and thrown errors", () => { failures += 1; }, + noRetry, ), false, ); @@ -625,8 +627,88 @@ test("terminal history persistence marks both false results and thrown errors", () => { failures += 1; }, + noRetry, ), /history database unavailable/, ); assert.equal(failures, 2); }); + +test("terminal history persistence retries transient failures before succeeding", async () => { + const loader = createTsModuleLoader(); + const { persistTerminalHistoryWithRetry, trackTerminalHistoryPersist } = loader.loadModule( + "src/pages/chat/runtime/chatRunFinalization.ts", + ); + const attempts = []; + const sleeps = []; + + const persisted = await persistTerminalHistoryWithRetry( + async () => { + attempts.push("try"); + if (attempts.length < 3) { + throw new Error(`transient-${attempts.length}`); + } + return true; + }, + { + maxAttempts: 3, + retryDelayMs: 5, + sleep: async (ms) => { + sleeps.push(ms); + }, + }, + ); + + assert.equal(persisted, true); + assert.equal(attempts.length, 3); + assert.deepEqual(sleeps, [5, 10]); + + let failures = 0; + let tries = 0; + assert.equal( + await trackTerminalHistoryPersist( + async () => { + tries += 1; + return tries >= 2; + }, + () => { + failures += 1; + }, + { + maxAttempts: 3, + retryDelayMs: 0, + }, + ), + true, + ); + assert.equal(tries, 2); + assert.equal(failures, 0); +}); + +test("terminal history persistence exhausts retries then marks failure", async () => { + const loader = createTsModuleLoader(); + const { trackTerminalHistoryPersist } = loader.loadModule( + "src/pages/chat/runtime/chatRunFinalization.ts", + ); + let failures = 0; + let tries = 0; + + await assert.rejects( + trackTerminalHistoryPersist( + async () => { + tries += 1; + throw new Error("still busy"); + }, + () => { + failures += 1; + }, + { + maxAttempts: 3, + retryDelayMs: 0, + }, + ), + /still busy/, + ); + assert.equal(tries, 3); + assert.equal(failures, 1); +}); From 0a6e98a4eaf69a4b11468cff2af49cd988ac9b95 Mon Sep 17 00:00:00 2001 From: thirsty5034 Date: Thu, 13 Aug 2026 11:47:49 +0800 Subject: [PATCH 2/4] style(chat): biome format history persist fix --- .../agent-gui/src/lib/chat/history/chatHistory.ts | 14 +++++--------- .../src/pages/chat/runtime/chatRunFinalization.ts | 5 ++++- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/crates/agent-gui/src/lib/chat/history/chatHistory.ts b/crates/agent-gui/src/lib/chat/history/chatHistory.ts index 6f867073e..beb2420b5 100644 --- a/crates/agent-gui/src/lib/chat/history/chatHistory.ts +++ b/crates/agent-gui/src/lib/chat/history/chatHistory.ts @@ -583,7 +583,9 @@ function conversationInputForCursor( ): ChatHistoryConversationInput { // Each intermediate append must advertise the totals that will exist after // that step lands, otherwise backend consistency checks reject the write. - const sealedThrough = state.segments.filter((segment) => segment.segmentIndex <= activeSegmentIndex); + const sealedThrough = state.segments.filter( + (segment) => segment.segmentIndex <= activeSegmentIndex, + ); const totalSegmentCount = Math.max(conversation.totalSegmentCount, activeSegmentIndex + 1); const totalMessageCount = sealedThrough.reduce( (sum, segment) => sum + resolveSegmentMessageCount(segment), @@ -671,20 +673,14 @@ async function writeConversationRuntime( throw new Error("追加历史分段时缺少待封存的上一活跃分段"); } if (!nextSegment) { - throw new Error( - `追加历史分段时缺少目标分段:${workingCursor.activeSegmentIndex + 1}`, - ); + throw new Error(`追加历史分段时缺少目标分段:${workingCursor.activeSegmentIndex + 1}`); } if (previousSegment.segmentId !== workingCursor.activeSegmentId) { throw new Error("待封存历史分段身份与持久化游标不一致"); } summary = await appendChatHistorySegmentRaw({ - conversation: conversationInputForCursor( - conversation, - state, - nextSegment.segmentIndex, - ), + conversation: conversationInputForCursor(conversation, state, nextSegment.segmentIndex), previousSegment: buildChatHistorySegmentInput(previousSegment), segment: buildChatHistorySegmentInput(nextSegment), }); diff --git a/crates/agent-gui/src/pages/chat/runtime/chatRunFinalization.ts b/crates/agent-gui/src/pages/chat/runtime/chatRunFinalization.ts index 44efc6f80..0ff26fb39 100644 --- a/crates/agent-gui/src/pages/chat/runtime/chatRunFinalization.ts +++ b/crates/agent-gui/src/pages/chat/runtime/chatRunFinalization.ts @@ -35,7 +35,10 @@ export async function persistTerminalHistoryWithRetry( }, ): Promise { const maxAttempts = Math.max(1, options?.maxAttempts ?? TERMINAL_HISTORY_PERSIST_MAX_ATTEMPTS); - const retryDelayMs = Math.max(0, options?.retryDelayMs ?? TERMINAL_HISTORY_PERSIST_RETRY_DELAY_MS); + const retryDelayMs = Math.max( + 0, + options?.retryDelayMs ?? TERMINAL_HISTORY_PERSIST_RETRY_DELAY_MS, + ); const sleep = options?.sleep ?? delay; let lastError: unknown = null; // null = no terminal outcome yet; true = last attempt returned false; false = threw From 0a44d8ac839bb642df92a3d24e420303589bd524 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 13 Aug 2026 14:58:49 +0800 Subject: [PATCH 3/4] fix(chat): anchor persisted history totals on meta, not in-memory segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior fix derived header total_segment_count/total_message_count by summing the in-memory segments. After a conversation is reopened from history, state.segments holds only the loaded active segment while sealed segments live solely in SQLite, so that sum undercounts. Rust's verify_chat_history_consistency compares the header against COUNT/SUM over all rows in the same transaction and rejects every persist — reproducing the exact history loss this PR targets, and now deterministically. Anchor final-persist header totals on state.meta (maintained incrementally by appendMessagesToConversation and reconciled by normalizeConversationState, so it already matches the durable segment sum). For intermediate catch-up appends, derive each step's totals from the final header minus the not-yet-appended in-memory segments, and set totalSegmentCount to activeSegmentIndex + 1 so it equals stored_count + 1 as the append precondition demands. Add regression coverage for reopened conversations (active-segment upsert and multi-segment catch-up) whose meta totals exceed the in-memory segment sum; fix the multi-jump assertion to expect the per-step segment count. --- .../src/lib/chat/history/chatHistory.ts | 52 ++++++------- .../chat/chat-history-persist-queue.test.mjs | 74 ++++++++++++++++++- 2 files changed, 94 insertions(+), 32 deletions(-) diff --git a/crates/agent-gui/src/lib/chat/history/chatHistory.ts b/crates/agent-gui/src/lib/chat/history/chatHistory.ts index beb2420b5..11b78e19e 100644 --- a/crates/agent-gui/src/lib/chat/history/chatHistory.ts +++ b/crates/agent-gui/src/lib/chat/history/chatHistory.ts @@ -429,23 +429,11 @@ function buildChatHistoryConversationInput(params: { state, } = params; - const derivedTotalMessageCount = state.segments.reduce( - (sum, segment) => sum + resolveSegmentMessageCount(segment), - 0, - ); - const derivedTotalSegmentCount = Math.max( - state.meta.totalSegmentCount, - state.segments.length, - (getActiveSegment(state)?.segmentIndex ?? -1) + 1, - ); - // Keep the header counts aligned with the segment payloads we are about to - // write. A stale meta.totalMessageCount is a common way to trip - // "segment/message 统计不匹配" on the final persist. - const metaForPersist = { - ...state.meta, - totalSegmentCount: derivedTotalSegmentCount, - totalMessageCount: derivedTotalMessageCount, - }; + // Header totals must stay anchored on state.meta. After a conversation is + // reopened from history, state.segments only holds the active segment while + // meta.totalMessageCount still counts every sealed row in SQLite — summing + // the in-memory segments would undercount and trip the backend segment-sum + // consistency check on every persist. return { id: conversationId, title, @@ -454,10 +442,10 @@ function buildChatHistoryConversationInput(params: { sessionId, cwd, selectedModelJson, - contextMetaJson: JSON.stringify(metaForPersist), + contextMetaJson: JSON.stringify(state.meta), activeSegmentIndex: state.meta.activeSegmentIndex, - totalSegmentCount: derivedTotalSegmentCount, - totalMessageCount: derivedTotalMessageCount, + totalSegmentCount: state.meta.totalSegmentCount, + totalMessageCount: state.meta.totalMessageCount, createdAt, updatedAt, }; @@ -581,16 +569,20 @@ function conversationInputForCursor( state: ConversationViewState, activeSegmentIndex: number, ): ChatHistoryConversationInput { - // Each intermediate append must advertise the totals that will exist after - // that step lands, otherwise backend consistency checks reject the write. - const sealedThrough = state.segments.filter( - (segment) => segment.segmentIndex <= activeSegmentIndex, - ); - const totalSegmentCount = Math.max(conversation.totalSegmentCount, activeSegmentIndex + 1); - const totalMessageCount = sealedThrough.reduce( - (sum, segment) => sum + resolveSegmentMessageCount(segment), - 0, - ); + // Each intermediate append must advertise exactly the totals that exist + // after that step lands: the backend append precondition requires + // totalSegmentCount == stored count + 1, and the consistency check compares + // the header against COUNT/SUM over all rows inside the same transaction. + // + // Subtract the not-yet-appended in-memory segments from the final header + // total instead of re-summing in-memory segments from zero: after a + // conversation is reopened from history, the sealed rows before the loaded + // active segment exist only in SQLite. + const pendingBeyondStep = state.segments + .filter((segment) => segment.segmentIndex > activeSegmentIndex) + .reduce((sum, segment) => sum + resolveSegmentMessageCount(segment), 0); + const totalSegmentCount = activeSegmentIndex + 1; + const totalMessageCount = Math.max(0, conversation.totalMessageCount - pendingBeyondStep); let contextMetaJson = conversation.contextMetaJson; try { const meta = JSON.parse(conversation.contextMetaJson) as Record; diff --git a/crates/agent-gui/test/chat/chat-history-persist-queue.test.mjs b/crates/agent-gui/test/chat/chat-history-persist-queue.test.mjs index 0c4ef691e..5d40678fe 100644 --- a/crates/agent-gui/test/chat/chat-history-persist-queue.test.mjs +++ b/crates/agent-gui/test/chat/chat-history-persist-queue.test.mjs @@ -73,7 +73,7 @@ function segment(index, overrides = {}) { }; } -function buildState(segments, activeSegmentIndex) { +function buildState(segments, activeSegmentIndex, metaOverrides = {}) { return { meta: { schemaVersion: 3, @@ -81,6 +81,7 @@ function buildState(segments, activeSegmentIndex) { activeSegmentIndex: segments[activeSegmentIndex].segmentIndex, totalSegmentCount: segments.length, totalMessageCount: segments.reduce((sum, item) => sum + item.messageCount, 0), + ...metaOverrides, }, segments, transcript: { @@ -268,7 +269,9 @@ test("final persist catches up multiple segment jumps one append at a time", asy assert.equal(recorder.calls[0].args.input.previousSegment.segmentId, "seg-0"); assert.equal(recorder.calls[0].args.input.segment.segmentId, "seg-1"); assert.equal(recorder.calls[0].args.input.conversation.activeSegmentIndex, 1); - assert.equal(recorder.calls[0].args.input.conversation.totalSegmentCount, 3); + // 中间步必须是"该步落库后"的精确值:后端 append 前置校验要求 + // totalSegmentCount == 现有值 + 1,一致性校验按全表 COUNT/SUM 比对。 + assert.equal(recorder.calls[0].args.input.conversation.totalSegmentCount, 2); assert.equal(recorder.calls[0].args.input.conversation.totalMessageCount, 3); await resolveCall(recorder.calls[0], "conv-multi-jump", 40); @@ -280,6 +283,7 @@ test("final persist catches up multiple segment jumps one append at a time", asy assert.equal(recorder.calls[1].args.input.previousSegment.segmentId, "seg-1"); assert.equal(recorder.calls[1].args.input.segment.segmentId, "seg-2"); assert.equal(recorder.calls[1].args.input.conversation.activeSegmentIndex, 2); + assert.equal(recorder.calls[1].args.input.conversation.totalSegmentCount, 3); assert.equal(recorder.calls[1].args.input.conversation.totalMessageCount, 6); await resolveCall(recorder.calls[1], "conv-multi-jump", 41); @@ -333,6 +337,72 @@ test("partial multi-segment catch-up resumes from the durable cursor frontier", assert.deepEqual(cursorRef.current, persistenceCursor(segC)); }); +// 从历史重开的会话(openInitial → buildConversationStateFromWindow)内存里 +// 只有活跃段,meta 计数仍覆盖 SQLite 中全部封存行。header 若改为对内存段 +// 求和会少算封存段,被后端全表 SUM 一致性校验拒绝。 +test("reopened conversation keeps full header totals on active-segment persist", async () => { + const recorder = createInvokeRecorder(); + const chatHistory = loadChatHistory(recorder.invoke); + // SQLite: seg0-2 共 270 条已封存;内存只载入活跃段 seg3(22 条)。 + const segActive = segment(3, { messageCount: 22, endMessageId: "seg3-22" }); + const cursorRef = { current: persistenceCursor(segActive) }; + const state = buildState([segActive], 0, { + totalSegmentCount: 4, + totalMessageCount: 292, + }); + + const task = chatHistory.persistConversationRuntime( + persistParams({ conversationId: "conv-reopened", cursorRef, state }), + ); + await flush(); + + assert.equal(recorder.calls.length, 1); + assert.equal(recorder.calls[0].cmd, "chat_history_upsert_active_segment"); + const conversation = recorder.calls[0].args.input.conversation; + assert.equal(conversation.activeSegmentIndex, 3); + assert.equal(conversation.totalSegmentCount, 4); + assert.equal(conversation.totalMessageCount, 292); + assert.equal(recorder.calls[0].args.input.segment.messageCount, 22); + + await resolveCall(recorder.calls[0], "conv-reopened", 60); + await task; + assert.deepEqual(cursorRef.current, persistenceCursor(segActive)); +}); + +test("reopened conversation catch-up append anchors totals on the full history", async () => { + const recorder = createInvokeRecorder(); + const chatHistory = loadChatHistory(recorder.invoke); + // 重开后 run 中发生一次压缩:内存 = [seg3(24), seg4(1)],SQLite 另有 + // seg0-2 共 270 条。追赶 append 的 header 必须含全部封存行。 + const segLoaded = segment(3, { messageCount: 24, endMessageId: "seg3-24" }); + const segNew = segment(4, { messageCount: 1, endMessageId: "seg4-1" }); + const cursorRef = { current: persistenceCursor(segLoaded) }; + const cursorCommits = []; + const state = buildState([segLoaded, segNew], 1, { + totalSegmentCount: 5, + totalMessageCount: 295, + }); + + const task = chatHistory.persistConversationRuntime( + persistParams({ conversationId: "conv-reopened-jump", cursorRef, cursorCommits, state }), + ); + await flush(); + + assert.equal(recorder.calls.length, 1); + assert.equal(recorder.calls[0].cmd, "chat_history_append_segment"); + assert.equal(recorder.calls[0].args.input.previousSegment.segmentId, "seg-3"); + assert.equal(recorder.calls[0].args.input.segment.segmentId, "seg-4"); + const conversation = recorder.calls[0].args.input.conversation; + assert.equal(conversation.activeSegmentIndex, 4); + assert.equal(conversation.totalSegmentCount, 5); + assert.equal(conversation.totalMessageCount, 295); + + await resolveCall(recorder.calls[0], "conv-reopened-jump", 61); + await task; + assert.deepEqual(cursorRef.current, persistenceCursor(segNew)); + assert.deepEqual(cursorCommits, [persistenceCursor(segNew)]); +}); + test("persistence cursor selects explicit initial active and append transitions", async () => { const recorder = createInvokeRecorder(); const chatHistory = loadChatHistory(recorder.invoke); From 9ca9ad92f74c8484114c299c0e454b4086fce53f Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Thu, 13 Aug 2026 15:55:33 +0800 Subject: [PATCH 4/4] fix(chat): persist agent dev history before memory Agent Dev memory extraction can write durable memory before the final chat snapshot lands, leaving memory ahead of a user-only history when persistence fails. Persist the completed answer first, gate extraction on that success, then persist any render-only extraction status separately. --- .../chat/turns/runAgentConversationTurn.ts | 61 +++-- .../agent-turn-cancelled-history.test.mjs | 240 ++++++++++++++++++ 2 files changed, 276 insertions(+), 25 deletions(-) diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts index 5e9769028..af3be90fa 100644 --- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts @@ -1111,7 +1111,37 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP }); }; - if (showSilentMemoryExtraction && shouldRunMemoryExtraction) { + const persistCompletedState = (state: ConversationViewState) => + persistConversationWithHistorySync({ + conversationId, + sessionId, + providerId, + model, + cwd: conversationCwd, + state, + fallbackTitle, + createdAt, + titlePromise, + }); + + const pendingTerminalAssistantMeta = pendingTerminalAssistantMetaRef.current; + if (pendingTerminalAssistantMeta) { + commitAssistantRoundMeta( + pendingTerminalAssistantMeta.assistant, + pendingTerminalAssistantMeta.round, + ); + } + hookLifecycle.endAgent(); + + applyConversationState(finalState); + freezeGatewayFinalProjection(finalState, true); + settleLiveTranscript(transcriptStore); + const historyPersisted = await persistCompletedState(finalState); + + // Memory extraction reads the in-memory final state. Only run it after the + // durable history write succeeds so we never keep "memory has the answer, + // chat history only has the user prompt" after a failed final persist. + if (historyPersisted && showSilentMemoryExtraction && shouldRunMemoryExtraction) { const extraction = await runPostTurnMemoryExtraction({ roundOffset: memoryRoundOffset, onTurnStart: (round) => { @@ -1241,31 +1271,12 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP ); } } - const pendingTerminalAssistantMeta = pendingTerminalAssistantMetaRef.current; - if (pendingTerminalAssistantMeta) { - commitAssistantRoundMeta( - pendingTerminalAssistantMeta.assistant, - pendingTerminalAssistantMeta.round, - ); + if (completedState !== finalState) { + applyConversationState(completedState); + freezeGatewayFinalProjection(completedState, true); + settleLiveTranscript(transcriptStore); + await persistCompletedState(completedState); } - hookLifecycle.endAgent(); - applyConversationState(completedState); - freezeGatewayFinalProjection(completedState, true); - settleLiveTranscript(transcriptStore); - const historyPersisted = await persistConversationWithHistorySync({ - conversationId, - sessionId, - providerId, - model, - cwd: conversationCwd, - state: completedState, - fallbackTitle, - createdAt, - titlePromise, - }); - // Memory extraction reads the in-memory final state. Only run it after the - // durable history write succeeds so we never keep "memory has the answer, - // chat history only has the user prompt" after a failed final persist. if (historyPersisted && !showSilentMemoryExtraction && shouldRunMemoryExtraction) { void runPostTurnMemoryExtraction(); } diff --git a/crates/agent-gui/test/chat/agent-turn-cancelled-history.test.mjs b/crates/agent-gui/test/chat/agent-turn-cancelled-history.test.mjs index cb089ba96..566021d3a 100644 --- a/crates/agent-gui/test/chat/agent-turn-cancelled-history.test.mjs +++ b/crates/agent-gui/test/chat/agent-turn-cancelled-history.test.mjs @@ -106,6 +106,13 @@ async function replayCancelledHistoryScenario(params) { } let runAssistantWithToolsScenario = replayCancelledHistoryScenario; +let memoryExtractionRequestScenario = async () => ({ + ok: true, + acceptedCount: 0, + rejectedCount: 0, + writtenSlugs: [], + emittedMessages: [], +}); const loader = createTsModuleLoader({ mocks: { @@ -135,6 +142,9 @@ const loader = createTsModuleLoader({ [memoryExtractionPath]: { memoryExtraction: { noteTurnBoundary() {}, + requestExtraction(params) { + return memoryExtractionRequestScenario(params); + }, }, }, [fileToolStatePath]: { @@ -160,7 +170,9 @@ function noOp() {} function createHookLifecycle() { return { startAgent: noOp, + endAgent: noOp, startTurn: noOp, + endTurn: noOp, ensureMessageEnded: noOp, assistantMessageCompleted: noOp, toolExecutionStarted: noOp, @@ -168,6 +180,234 @@ function createHookLifecycle() { }; } +function createCompletedAgentDevTurnParams({ + state, + applyConversationState = noOp, + persistConversationWithHistorySync, + gatewayTokens = [], +}) { + const userStop = new AbortController(); + return { + providerId: "codex", + model: "gpt-5", + runtime: {}, + runtimeModel: { + provider: "codex", + api: "openai-responses", + id: "gpt-5", + }, + selectedModel: { customProviderId: "codex", model: "gpt-5" }, + effectiveWorkdir: "C:/workspace", + effectiveSkillsEnabled: false, + showSilentMemoryExtraction: true, + agentTemplates: [], + getMcpSettings: () => ({ servers: [], selected: [] }), + sessionId: "session-1", + taskStateStore: { + runId: "run-1", + getState: () => undefined, + async commitState() {}, + }, + conversationId: "conversation-agent-dev", + fallbackTitle: "title", + createdAt: 1, + titlePromise: null, + transcriptStore: {}, + gatewayBridgeEvents: { + hasForwardedText: () => false, + queueToken(text, meta) { + gatewayTokens.push({ text, meta }); + }, + queueEvent: noOp, + queueToolStatus: noOp, + }, + hookLifecycle: createHookLifecycle(), + conversationDebugLogger: { enabled: false, logResult: noOp }, + getNextConversationState: () => state, + applyConversationState, + buildPreparedContext: (currentState) => ({ + systemPrompt: "", + messages: currentState.segments.flatMap((segment) => segment.messages), + }), + compaction: { + async maybeCompactPreSend() {}, + beginRequest: noOp, + observeContextMessages: () => 0, + shouldProtectMidStream: () => false, + async compactDuringRun() { + return { context: null, shouldDisableProtection: false }; + }, + }, + cancellation: { + userStop, + deriveScope() { + return { controller: new AbortController(), release: noOp }; + }, + }, + resetLiveTranscript: noOp, + settleLiveTranscript: noOp, + batchLiveRoundsUpdate: noOp, + updateToolStatus: noOp, + updateRetryAttempts: noOp, + updatePersistableAgentProgress: noOp, + commitVisibleAbortedConversation: () => false, + freezeGatewayFinalProjection: noOp, + persistConversationWithHistorySync, + }; +} + +test("agent dev skips memory extraction when final history persistence fails", async () => { + const finalAssistant = { + ...abortedAssistant, + content: [{ type: "text", text: "durable answer" }], + stopReason: "stop", + }; + runAssistantWithToolsScenario = async (params) => { + params.onTurnStart?.(1); + params.onAssistantMessage?.(finalAssistant, 1); + return { + assistant: finalAssistant, + messages: [finalAssistant], + emittedMessages: [finalAssistant], + }; + }; + + try { + for (const failure of ["false", "throw"]) { + const state = conversationState.createConversationStateFromContext({ + systemPrompt: "", + messages: [], + }); + const order = []; + memoryExtractionRequestScenario = async () => { + order.push("memory-extraction"); + return { + ok: true, + acceptedCount: 0, + rejectedCount: 0, + writtenSlugs: [], + emittedMessages: [], + }; + }; + const run = runAgentConversationTurn( + createCompletedAgentDevTurnParams({ + state, + async persistConversationWithHistorySync() { + order.push("history-failed"); + if (failure === "throw") { + throw new Error("history unavailable"); + } + return false; + }, + }), + ); + + if (failure === "throw") { + await assert.rejects(run, /history unavailable/); + } else { + await run; + } + assert.deepEqual(order, ["history-failed"]); + } + } finally { + runAssistantWithToolsScenario = replayCancelledHistoryScenario; + memoryExtractionRequestScenario = async () => ({ + ok: true, + acceptedCount: 0, + rejectedCount: 0, + writtenSlugs: [], + emittedMessages: [], + }); + } +}); + +test("agent dev persists the answer before memory extraction and its visible status", async () => { + const finalAssistant = { + ...abortedAssistant, + content: [{ type: "text", text: "durable answer" }], + stopReason: "stop", + }; + const extractionAssistant = { + ...finalAssistant, + provider: "liveagent", + api: "liveagent-memory", + model: "gpt-5", + content: [{ type: "text", text: "Memory updated" }], + timestamp: 5, + }; + const state = conversationState.createConversationStateFromContext({ + systemPrompt: "", + messages: [], + }); + const order = []; + const persistedStates = []; + const appliedStates = []; + runAssistantWithToolsScenario = async (params) => { + params.onTurnStart?.(1); + params.onAssistantMessage?.(finalAssistant, 1); + return { + assistant: finalAssistant, + messages: [finalAssistant], + emittedMessages: [finalAssistant], + }; + }; + memoryExtractionRequestScenario = async (params) => { + order.push("memory-extraction"); + params.visibleEvents?.onTurnStart?.(2); + params.visibleEvents?.onTextDelta?.("Memory updated", 2); + params.visibleEvents?.onAssistantMessage?.(extractionAssistant, 2); + return { + ok: true, + acceptedCount: 1, + rejectedCount: 0, + writtenSlugs: ["user-preference"], + emittedMessages: [extractionAssistant], + }; + }; + + try { + await runAgentConversationTurn( + createCompletedAgentDevTurnParams({ + state, + applyConversationState(nextState) { + appliedStates.push(nextState); + }, + async persistConversationWithHistorySync(params) { + order.push(`history-${persistedStates.length + 1}`); + persistedStates.push(params.state); + return true; + }, + }), + ); + } finally { + runAssistantWithToolsScenario = replayCancelledHistoryScenario; + memoryExtractionRequestScenario = async () => ({ + ok: true, + acceptedCount: 0, + rejectedCount: 0, + writtenSlugs: [], + emittedMessages: [], + }); + } + + assert.deepEqual(order, ["history-1", "memory-extraction", "history-2"]); + assert.equal(persistedStates.length, 2); + assert.notEqual(persistedStates[0], persistedStates[1]); + assert.equal( + persistedStates[0].transcript.items.at(-1).rounds.some( + (round) => round.meta?.contextRelevant === false, + ), + false, + ); + assert.equal( + persistedStates[1].transcript.items.at(-1).rounds.some( + (round) => round.meta?.contextRelevant === false, + ), + true, + ); + assert.equal(appliedStates.at(-1), persistedStates[1]); +}); + test("agent turn preserves suppressed parent Agent trace for cancellation persistence", async () => { let liveRounds = []; const progressUpdates = [];