Hi @Jourdelune
The fetchStateHistory: { limit: 100 } workaround fetches 100 checkpoints after every single message - even when the user never clicks edit. In a production app this is wasteful. The getHistory API already supports pagination and filtering; the SDK just doesn’t leverage it for branching.
The problem with eager fetching
Your current useStream flow:
- Stream completes → eagerly fetch N checkpoints from
getHistory - Build the entire branch tree
- Precompute metadata for ALL messages
- User might click edit (usually they don’t)
This means every message turn pays the cost of a large history fetch, even though edit/regenerate is a rare user action. With limit: 200 and a deep agent, that’s 200 checkpoint states transferred on every single turn.
The getHistory API already supports what you need
Looking at the JS SDK client (libs/sdk/src/client.ts, line 1361-1384):
async getHistory<ValuesType>(
threadId: string,
options?: {
limit?: number; // how many checkpoints to fetch
before?: Config; // pagination cursor - fetch older checkpoints
metadata?: Metadata; // filter by checkpoint metadata (e.g., source)
checkpoint?: Partial<Omit<Checkpoint, "thread_id">>;
}
): Promise<ThreadState<ValuesType>[]>
And the Python SDK (langgraph_sdk/_async/threads.py, line 635-676):
async def get_history(
self,
thread_id: str,
*,
limit: int = 10,
before: str | Checkpoint | None = None, # pagination
metadata: Mapping[str, Any] | None = None, # filter
checkpoint: Checkpoint | None = None,
) -> list[ThreadState]
Three capabilities that the useStream hook currently doesn’t use:
| Capability | API parameter | What it enables |
|---|---|---|
| Pagination | before |
Fetch older checkpoints page by page, only when needed |
| Metadata filter | metadata |
Filter by { source: "input" } to get only run entry points (~1 per turn instead of ~6) |
| Single lookup | getState(threadId, checkpoint) |
Fetch one specific checkpoint by ID |
Practical approach: small eager window + on-demand fallback
Keep a small fetchStateHistory limit for recent branch navigation, and paginate backwards only when editing older messages. The useStream hook exposes client directly, so you can call the API yourself:
<script setup lang="ts">
import { useStream } from "@langchain/vue";
let threadId: string | undefined;
const {
messages,
submit,
getMessagesMetadata,
setBranch,
isLoading,
client, // ← exposed by useStream
} = useStream({
assistantId: "agent",
apiUrl: "http://localhost:2024",
fetchStateHistory: { limit: 30 }, // small window for recent branch nav
onThreadId: (id) => { threadId = id; },
});
async function handleEdit(msg: any, index: number, newText: string) {
if (isLoading.value) return;
// 1. Try precomputed metadata first (covers recent messages)
const metadata = getMessagesMetadata(msg, index);
let checkpoint = metadata?.firstSeenState?.parent_checkpoint;
// 2. If not found, paginate backwards to find it
if (!checkpoint && threadId && msg.id) {
checkpoint = await findCheckpointForMessage(threadId, msg.id);
}
if (!checkpoint) {
console.warn('Could not find checkpoint for message', msg.id);
return;
}
submit({ messages: [{ ...msg, content: newText }] }, { checkpoint });
}
async function handleRegenerate(msg: any, index: number) {
if (isLoading.value) return;
const metadata = getMessagesMetadata(msg, index);
let checkpoint = metadata?.firstSeenState?.parent_checkpoint;
if (!checkpoint && threadId && msg.id) {
checkpoint = await findCheckpointForMessage(threadId, msg.id);
}
if (!checkpoint) return;
submit(undefined, { checkpoint });
}
/**
* Paginate through thread history to find the checkpoint
* where a message first appeared, then return its parent.
*/
async function findCheckpointForMessage(
tid: string,
messageId: string
): Promise<any | null> {
let before: any = undefined;
while (true) {
const page = await client.threads.getHistory(tid, {
limit: 20,
before,
});
if (page.length === 0) break;
// Search for the oldest checkpoint containing this message
// (history is returned newest-first, so findLast = earliest match in page)
const found = [...page].reverse().find((state: any) =>
state.values?.messages?.some((m: any) => m.id === messageId)
);
if (found) {
return found.parent_checkpoint;
}
// Move cursor to the oldest checkpoint in this page
before = page.at(-1)?.checkpoint;
}
return null;
}
</script>
<template>
<div>
<div v-for="(msg, i) in messages.value" :key="msg.id ?? i">
<p>{{ msg.content }}</p>
<!-- Branch navigation for recent messages (precomputed) -->
<template v-if="getMessagesMetadata(msg, i)?.branchOptions">
<button
@click="() => {
const meta = getMessagesMetadata(msg, i);
if (!meta?.branchOptions || !meta.branch) return;
const idx = meta.branchOptions.indexOf(meta.branch);
const prev = meta.branchOptions[idx - 1];
if (prev) setBranch(prev);
}"
>Prev</button>
<span>
{{
(() => {
const meta = getMessagesMetadata(msg, i);
if (!meta?.branchOptions || !meta.branch) return '';
const idx = meta.branchOptions.indexOf(meta.branch);
return `${idx + 1} / ${meta.branchOptions.length}`;
})()
}}
</span>
<button
@click="() => {
const meta = getMessagesMetadata(msg, i);
if (!meta?.branchOptions || !meta.branch) return;
const idx = meta.branchOptions.indexOf(meta.branch);
const next = meta.branchOptions[idx + 1];
if (next) setBranch(next);
}"
>Next</button>
</template>
<button
v-if="msg.type === 'human'"
:disabled="isLoading.value"
@click="handleEdit(msg, i, 'Edited: ' + msg.content)"
>Edit</button>
<button
v-if="msg.type === 'ai'"
:disabled="isLoading.value"
@click="handleRegenerate(msg, i)"
>Regenerate</button>
</div>
<button
:disabled="isLoading.value"
@click="submit({ messages: [{ type: 'human', content: 'Hello' }] })"
>Send</button>
</div>
</template>
Why this is better
| Approach | History fetched per message | Edit old messages | Scales to long conversations |
|---|---|---|---|
fetchStateHistory: true (default, limit 10) |
10 checkpoints | Fails after ~2 turns (deep agent) | No |
fetchStateHistory: { limit: 200 } |
200 checkpoints | Works for ~33 turns | Partially |
| Small window + on-demand pagination | 30 checkpoints + only on edit | Works for any message | Yes |
The on-demand approach:
- Normal messages cost less - only 30 checkpoints fetched (vs 200)
- Edit always works - pagination finds the checkpoint no matter how old the message
- Cost is pay-per-use - the expensive pagination only runs when the user actually clicks edit, which is rare