diff --git a/crates/agent-gui/src/lib/chat/history/chatHistory.ts b/crates/agent-gui/src/lib/chat/history/chatHistory.ts index 78bd639ae..11b78e19e 100644 --- a/crates/agent-gui/src/lib/chat/history/chatHistory.ts +++ b/crates/agent-gui/src/lib/chat/history/chatHistory.ts @@ -429,6 +429,11 @@ function buildChatHistoryConversationInput(params: { state, } = params; + // 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, @@ -446,13 +451,23 @@ function buildChatHistoryConversationInput(params: { }; } +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 +557,58 @@ 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 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; + 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 +619,91 @@ 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..0ff26fb39 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,72 @@ 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 +105,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..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,29 +1271,13 @@ 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); - await persistConversationWithHistorySync({ - conversationId, - sessionId, - providerId, - model, - cwd: conversationCwd, - state: completedState, - fallbackTitle, - createdAt, - titlePromise, - }); - if (!showSilentMemoryExtraction && shouldRunMemoryExtraction) { + 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/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 = []; 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..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 @@ -43,19 +43,37 @@ 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, }; } -function buildState(segments, activeSegmentIndex) { +function buildState(segments, activeSegmentIndex, metaOverrides = {}) { return { meta: { schemaVersion: 3, @@ -63,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: { @@ -226,6 +245,164 @@ 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); + // 中间步必须是"该步落库后"的精确值:后端 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); + 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.totalSegmentCount, 3); + 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)); +}); + +// 从历史重开的会话(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); 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); +});