diff --git a/apps/website/content/docs/langgraph/guides/time-travel.mdx b/apps/website/content/docs/langgraph/guides/time-travel.mdx
index c73b7ccf4..7012e1ba7 100644
--- a/apps/website/content/docs/langgraph/guides/time-travel.mdx
+++ b/apps/website/content/docs/langgraph/guides/time-travel.mdx
@@ -36,7 +36,7 @@ The example resolves its connection details at runtime from the host that serves
### Reading the checkpoint history
-The component injects the agent and derives the timeline from it. `langGraphHistory()` is LangGraph's own view of the thread: an array of `ThreadState` snapshots. `selectedIndex` is local interface state, holding the row the user clicked last.
+The component injects the agent and derives the timeline from it. `langGraphHistory()` is LangGraph's own view of the thread: an array of `ThreadState` snapshots. `selectedCheckpointId` is local interface state, holding the checkpoint the user clicked last. New checkpoints can move existing rows, so selection follows the identifier across history refreshes.
diff --git a/cockpit/langgraph/time-travel/angular/e2e/fixtures/time-travel.json b/cockpit/langgraph/time-travel/angular/e2e/fixtures/time-travel.json
index b72a2018a..dcc32ca70 100644
--- a/cockpit/langgraph/time-travel/angular/e2e/fixtures/time-travel.json
+++ b/cockpit/langgraph/time-travel/angular/e2e/fixtures/time-travel.json
@@ -7,6 +7,14 @@
"response": {
"content": "Hi! How can I help you today?\n\nQuick note: every response here is saved as a checkpoint snapshot. If you want to explore a different path later, you can inspect the conversation history and branch from any previous checkpoint using stream.setBranch(checkpointId) to create an alternate timeline."
}
+ },
+ {
+ "match": { "userMessage": "Continue the original path." },
+ "response": { "content": "This reply belongs only to the original path." }
+ },
+ {
+ "match": { "userMessage": "Try a different approach from here." },
+ "response": { "content": "This reply belongs to the checkpoint fork." }
}
]
-}
\ No newline at end of file
+}
diff --git a/cockpit/langgraph/time-travel/angular/e2e/time-travel.spec.ts b/cockpit/langgraph/time-travel/angular/e2e/time-travel.spec.ts
index 51303817d..09bf1da67 100644
--- a/cockpit/langgraph/time-travel/angular/e2e/time-travel.spec.ts
+++ b/cockpit/langgraph/time-travel/angular/e2e/time-travel.spec.ts
@@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test';
import { submitAndWaitForResponse } from '@threadplane-internal/e2e-harness';
+import type { ThreadState } from '@langchain/langgraph-sdk';
test('time-travel: hello prompt produces assistant turn', async ({ page }) => {
const bubble = await submitAndWaitForResponse(page, 'Hello');
@@ -7,3 +8,80 @@ test('time-travel: hello prompt produces assistant turn', async ({ page }) => {
// finalized (data-streaming="false") and is present in the DOM.
await expect(bubble).toBeVisible();
});
+
+test('time-travel: fork starts at the selected checkpoint, not the thread tip', async ({
+ page,
+}) => {
+ const firstRun = page.waitForRequest(
+ (request) =>
+ request.method() === 'POST' &&
+ new URL(request.url()).pathname.endsWith('/runs/stream')
+ );
+ await submitAndWaitForResponse(page, 'Hello');
+ const stateUrl = (await firstRun).url().replace(/\/runs\/stream$/, '/state');
+ const readState = async (): Promise<
+ ThreadState<{
+ messages: { id: string; content: string }[];
+ }>
+ > => {
+ const response = await page.request.get(stateUrl);
+ expect(response.ok()).toBe(true);
+ return response.json();
+ };
+ await expect
+ .poll(async () => (await readState()).values.messages.length)
+ .toBe(2);
+ const original = await readState();
+ const checkpointId = original.checkpoint.checkpoint_id;
+ if (!checkpointId)
+ throw new Error('Completed first turn did not produce a checkpoint');
+
+ await page
+ .getByRole('textbox', { name: /message|prompt/i })
+ .fill('Continue the original path.');
+ await page.getByRole('button', { name: /send message/i }).click();
+ await expect(
+ page
+ .locator('chat-message[data-role="assistant"][data-streaming="false"]')
+ .last()
+ ).toContainText('This reply belongs only to the original path.');
+ await expect
+ .poll(async () => (await readState()).values.messages.length)
+ .toBe(4);
+
+ const forkRun = page.waitForRequest(
+ (request) =>
+ request.method() === 'POST' &&
+ new URL(request.url()).pathname.endsWith('/runs/stream')
+ );
+ await page
+ .locator('.row')
+ .filter({ hasText: checkpointId })
+ .getByRole('button', { name: 'Fork', exact: true })
+ .click();
+ const body = (await forkRun).postDataJSON();
+ expect(body.checkpoint).toEqual({
+ checkpoint_id: checkpointId,
+ checkpoint_ns: '',
+ checkpoint_map: {},
+ });
+ expect(body).not.toHaveProperty('checkpoint_id');
+ await expect(
+ page
+ .locator('chat-message[data-role="assistant"][data-streaming="false"]')
+ .last()
+ ).toContainText('This reply belongs to the checkpoint fork.');
+
+ // The real server must preserve the first turn and exclude the later original
+ // turn. Merely observing a completed reply would pass with the broken alias.
+ await expect
+ .poll(async () =>
+ (await readState()).values.messages.map((message) => message.content)
+ )
+ .toEqual([
+ ...original.values.messages.map((message) => message.content),
+ 'Try a different approach from here.',
+ 'This reply belongs to the checkpoint fork.',
+ ]);
+ await expect(page.locator('.row--active .id')).toHaveText(checkpointId);
+});
diff --git a/cockpit/langgraph/time-travel/angular/src/app/time-travel.component.ts b/cockpit/langgraph/time-travel/angular/src/app/time-travel.component.ts
index 7bc007547..c5bf6877b 100644
--- a/cockpit/langgraph/time-travel/angular/src/app/time-travel.component.ts
+++ b/cockpit/langgraph/time-travel/angular/src/app/time-travel.component.ts
@@ -155,10 +155,16 @@ import { ExampleChatLayoutComponent } from '@threadplane/example-layouts';
No checkpoints yet. Send a message to begin.
}
- @for (state of checkpoints(); track $index; let i = $index) {
-
+ @for (state of checkpoints(); track state.checkpoint?.checkpoint_id ?? $index; let i = $index) {
+