diff --git a/frontend/src/core/threads/hooks.ts b/frontend/src/core/threads/hooks.ts index 4418a9e26d..f21835d90c 100644 --- a/frontend/src/core/threads/hooks.ts +++ b/frontend/src/core/threads/hooks.ts @@ -221,6 +221,24 @@ export function mergeMessages( ]); } +export function mergeThreadValues( + previousValues: Partial | undefined, + nextValues: Partial | undefined, +): Partial | undefined { + if (!previousValues) { + return nextValues; + } + if (!nextValues) { + return previousValues; + } + + return { + ...previousValues, + ...nextValues, + todos: nextValues.todos ?? previousValues.todos, + }; +} + function getMessagesAfterBaseline( messages: Message[], baselineMessageIds: ReadonlySet, @@ -559,6 +577,8 @@ export function useThreadStream({ const latestMessageCountsRef = useRef({ humanMessageCount }); const sendInFlightRef = useRef(false); const messagesRef = useRef([]); + const valuesRef = useRef | undefined>(thread.values); + const valuesThreadIdRef = useRef(threadId ?? null); const summarizedRef = useRef>(null); // Track human message count before sending to prevent clearing optimistic // messages before the server's human message arrives (e.g. when AI messages @@ -838,9 +858,17 @@ export function useThreadStream({ // Merge history, live stream, and optimistic messages for display // History messages may overlap with thread.messages; thread.messages take precedence + const activeThreadId = onStreamThreadId ?? threadId ?? null; + if (valuesThreadIdRef.current !== activeThreadId) { + valuesThreadIdRef.current = activeThreadId; + valuesRef.current = thread.values; + } + const mergedValues = mergeThreadValues(valuesRef.current, thread.values); + valuesRef.current = mergedValues; const mergedThread = { ...thread, messages: mergedMessages, + values: mergedValues, } as typeof thread; return { diff --git a/frontend/tests/unit/core/threads/message-merge.test.ts b/frontend/tests/unit/core/threads/message-merge.test.ts index 3a1f22cce3..4d359b30eb 100644 --- a/frontend/tests/unit/core/threads/message-merge.test.ts +++ b/frontend/tests/unit/core/threads/message-merge.test.ts @@ -10,6 +10,7 @@ import { getVisibleOptimisticMessages, MAX_CONSECUTIVE_EMPTY_RUN_LOADS, mergeMessages, + mergeThreadValues, runMessagesPageHasMore, shouldAutoContinueOnEmptyRun, } from "@/core/threads/hooks"; @@ -486,3 +487,35 @@ test("shouldAutoContinueOnEmptyRun input must use the post-filter visible count, expect(shouldAutoContinueOnEmptyRun(filteredVisibleCount, 0)).toBe(true); expect(shouldAutoContinueOnEmptyRun(rawPageSize, 0)).toBe(false); }); + +test("mergeThreadValues preserves todos when a later state omits them", () => { + expect( + mergeThreadValues( + { + title: "Thread", + todos: [{ content: "Keep me", status: "in_progress" }], + }, + { + title: "Thread", + }, + ), + ).toEqual({ + title: "Thread", + todos: [{ content: "Keep me", status: "in_progress" }], + }); +}); + +test("mergeThreadValues allows explicit todo clearing", () => { + expect( + mergeThreadValues( + { + todos: [{ content: "Done", status: "completed" }], + }, + { + todos: [], + }, + ), + ).toEqual({ + todos: [], + }); +});