Edit/Regenerate with useStream adds message at end instead of replacing

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:

  1. Stream completes → eagerly fetch N checkpoints from getHistory
  2. Build the entire branch tree
  3. Precompute metadata for ALL messages
  4. 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

Thanks you! I will apply that, howerver it could be interessing to add these indication in the doc for branching Branching chat - Docs by LangChain

True, I’ll try to update the docs :slight_smile:

hello, I have a question about your solution, how can I display a branch switcher on very long conversation? Because I can fetch the metadata when a user edit a message, but if the message is already edited, we don’t fetch it’s metadata unless the user do an action on this message, so we don’t know its branch no? If we want to display a branch switcher, we are forced to get all of its checkpoint history to get the messages metadata and then discover if it contain a branch or not.

Hi @Jourdelune

could you create a separate post for this topic, please? For the others so they can search stuff