From 9d5d7996d4fa72a759e363d0e69ef07b8f65610b Mon Sep 17 00:00:00 2001 From: jfilipiuk Date: Tue, 16 Jun 2026 12:06:37 +0200 Subject: [PATCH] fix: render full thread history in chat (#8) Long threads were truncated, hiding the original prompt and early agent reasoning. Three layered causes, fixed together: - Read messages from threads.get (persisted record) instead of threads.getState (graph checkpoint, which the backend windows for agent memory). Still call getState in parallel for run-status fields. - Prefer the polled snapshot over stream.messages once the run has settled; stream length can be inflated by sub-agent noise from streamSubgraphs. - Skip the subgraph-namespace filter for human messages; getMessagesMetadata can return stale subgraph tags for prompts from a prior run. --- src/app/components/ChatInterface.tsx | 20 +++++++++++------ src/app/hooks/useChat.ts | 33 ++++++++++++++++++++++------ 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/app/components/ChatInterface.tsx b/src/app/components/ChatInterface.tsx index 9142453..8feed45 100644 --- a/src/app/components/ChatInterface.tsx +++ b/src/app/components/ChatInterface.tsx @@ -686,6 +686,19 @@ export const ChatInterface = React.memo( // aren't in thread state anyway.) const seenAsyncUpdates = new Set(); const visibleMessages = messages.filter((message: Message) => { + // Humans are always user-typed (or our injected async-update pills) — + // never sub-agent noise. Run their checks first so a stale subgraph + // namespace on a previous-run human (left over in stream metadata) + // can't silently drop the original prompt. + if (message.type === "human") { + const key = asyncUpdateMessageKey( + extractStringFromMessageContent(message) + ); + if (!key) return true; + if (seenAsyncUpdates.has(key)) return false; + seenAsyncUpdates.add(key); + return true; + } const meta = stream.getMessagesMetadata(message)?.streamMetadata; const ns = meta?.["langgraph_checkpoint_ns"]; if (typeof ns === "string" && ns.includes("|")) return false; @@ -696,13 +709,6 @@ export const ChatInterface = React.memo( // never persisted in `messages`. Drop it here; the stable summary is // surfaced from `_summarization_event` as a collapsible block instead. if (isSummarizationMessage(message)) return false; - if (message.type !== "human") return true; - const key = asyncUpdateMessageKey( - extractStringFromMessageContent(message) - ); - if (!key) return true; - if (seenAsyncUpdates.has(key)) return false; - seenAsyncUpdates.add(key); return true; }); const completedToolCallIds = new Set(); diff --git a/src/app/hooks/useChat.ts b/src/app/hooks/useChat.ts index 59f01ee..db05f91 100644 --- a/src/app/hooks/useChat.ts +++ b/src/app/hooks/useChat.ts @@ -231,13 +231,24 @@ export function useChat({ const attempt = async () => { tries += 1; try { - const state = (await client.threads.getState(threadId)) as { - tasks?: Array<{ interrupts?: unknown[] }>; - next?: unknown[]; - values?: { messages?: Message[] }; - }; + // `getState` returns the GRAPH CHECKPOINT state — which the backend + // windows/compacts for memory, so its `values.messages` is only the + // recent slice. `threads.get` returns the persisted THREAD RECORD with + // the full message history. We need both: state for run status + // (`next` / `tasks` / `interrupts`), record for the messages the UI + // displays. Done in parallel to keep the round trip tight. + const [state, threadRecord] = await Promise.all([ + client.threads.getState(threadId) as Promise<{ + tasks?: Array<{ interrupts?: unknown[] }>; + next?: unknown[]; + values?: { messages?: Message[] }; + }>, + client.threads.get(threadId) as Promise<{ + values?: { messages?: Message[] }; + }>, + ]); if (cancelled || recoveryRunRef.current !== recoveryRunId) return; - const msgs = state.values?.messages; + const msgs = threadRecord.values?.messages; const pending = latestTaskInterrupt(state.tasks); const stillPending = Array.isArray(state.next) && state.next.length > 0; const safePending = normalizePendingInterrupt(pending); @@ -319,13 +330,21 @@ export function useChat({ // the blank live version (the bug where the answer only appears after a manual // refresh). The equal-count/more-text rule is gated on `!isLoading` so a // mid-stream poll snapshot never flickers over the actively updating stream. + // + // Once the run has settled AND we have a snapshot, ALWAYS prefer the snapshot. + // `stream.messages` can carry subgraph noise (streamSubgraphs: true) plus + // stale per-message metadata from earlier runs, inflating its length above the + // persisted main-thread state. A pure `>` compare against that bloated count + // would keep us on the stream — which makes the downstream subgraph-namespace + // filter (ChatInterface.processedMessages) drop legitimate main-thread + // history that's only tagged subgraph in stale stream metadata. const messages = (() => { if (!fetchedMessages || fetchedThreadId !== threadId) return stream.messages; if (fetchedInterrupt) return fetchedMessages; + if (!stream.isLoading) return fetchedMessages; if (fetchedMessages.length > stream.messages.length) return fetchedMessages; if ( - !stream.isLoading && fetchedMessages.length === stream.messages.length && totalTextLength(fetchedMessages) > totalTextLength(stream.messages) ) {