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.
This commit is contained in:
jfilipiuk
2026-06-16 12:06:37 +02:00
committed by GitHub
parent 99d1085e6a
commit 9d5d7996d4
2 changed files with 39 additions and 14 deletions
+13 -7
View File
@@ -686,6 +686,19 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
// aren't in thread state anyway.)
const seenAsyncUpdates = new Set<string>();
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<ChatInterfaceProps>(
// 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<string>();
+26 -7
View File
@@ -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)
) {