@truefoundry/assistant-ui-runtime 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truefoundry/assistant-ui-runtime",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "TrueFoundry Gateway agent runtime adapter for assistant-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -18,8 +18,8 @@ import type {
18
18
  import { ROOT_THREAD_ID } from "./constants.js";
19
19
  import { collectPendingToolResponses } from "./collectPending.js";
20
20
  import {
21
- buildSnapshotBeforeTurnIndex,
22
21
  buildSnapshotFromSessionEvents,
22
+ buildSnapshotThroughTurn,
23
23
  buildTurnAssistantContent,
24
24
  buildUserMessageContent,
25
25
  buildUserMessageFromTurnInput,
@@ -28,6 +28,7 @@ import {
28
28
  prependOlderSessionHistory,
29
29
  projectSessionMessages,
30
30
  repositoryItemsFromMessages,
31
+ resolveGatewayBranchPreviousTurnIdForTurn,
31
32
  streamTurnEvents,
32
33
  turnStreamUpdateToAssistantMessage,
33
34
  } from "./convertTurnMessages.js";
@@ -135,22 +136,68 @@ function mockTurn(
135
136
  };
136
137
  }
137
138
 
139
+ /** Mirror runtime listTurns normalization: ASC stays, DESC (or tied) reverses. */
140
+ function toOldestFirstTurns(turns: TurnFixture[]): TurnFixture[] {
141
+ const chain = turns.filter((turn) => turn.state.status !== "running");
142
+ let newestFirst = chain.length > 1;
143
+ for (let i = 1; i < chain.length; i++) {
144
+ const newer = chain[i];
145
+ const older = chain[i - 1];
146
+ if (newer == null || older == null) {
147
+ continue;
148
+ }
149
+ const delta = Date.parse(newer.createdAt) - Date.parse(older.createdAt);
150
+ if (delta < 0) {
151
+ newestFirst = true;
152
+ break;
153
+ }
154
+ if (delta > 0) {
155
+ newestFirst = false;
156
+ break;
157
+ }
158
+ }
159
+ return newestFirst ? [...chain].reverse() : [...chain];
160
+ }
161
+
162
+ /** Mirrors the host's ancestor walk: parent pointers only, sibling branches excluded. */
163
+ function ancestorChainFromTurns(
164
+ turns: TurnFixture[],
165
+ anchorTurnId: string,
166
+ ): TurnFixture[] {
167
+ const turnsById = new Map(turns.map((turn) => [turn.id, turn]));
168
+ const chain: TurnFixture[] = [];
169
+ const seen = new Set<string>();
170
+ let cursor: string | null | undefined = anchorTurnId;
171
+ while (cursor != null && !seen.has(cursor)) {
172
+ seen.add(cursor);
173
+ const turn = turnsById.get(cursor);
174
+ if (turn == null) {
175
+ break;
176
+ }
177
+ if (turn.state.status !== "running") {
178
+ chain.unshift(turn);
179
+ }
180
+ cursor = turn.previousTurnId;
181
+ }
182
+ return chain;
183
+ }
184
+
138
185
  /**
139
- * Builds session-level event items from per-turn mocks — newest-first turns,
140
- * running turns excluded (matches session.listEvents API contract).
186
+ * Builds session-level event items from per-turn mocks — accepts ASC or DESC
187
+ * listTurns order; running turns excluded (matches session.listEvents). An
188
+ * anchor selects that turn's ancestor chain, not a chronological prefix.
141
189
  */
142
190
  function sessionEventItemsFromTurns(
143
- turnsNewestFirst: TurnFixture[],
191
+ turns: TurnFixture[],
144
192
  lastTurnId?: string,
145
193
  ): SessionEventItem[] {
146
- let chain = turnsNewestFirst.filter((turn) => turn.state.status !== "running");
147
- if (lastTurnId != null) {
148
- const anchorIndex = chain.findIndex((turn) => turn.id === lastTurnId);
149
- chain = anchorIndex === -1 ? [] : chain.slice(anchorIndex);
150
- }
194
+ const chain =
195
+ lastTurnId == null
196
+ ? toOldestFirstTurns(turns)
197
+ : ancestorChainFromTurns(turns, lastTurnId);
151
198
 
152
199
  const items: SessionEventItem[] = [];
153
- for (const turn of [...chain].reverse()) {
200
+ for (const turn of chain) {
154
201
  items.push({
155
202
  turnId: turn.id,
156
203
  event: {
@@ -160,6 +207,9 @@ function sessionEventItemsFromTurns(
160
207
  input: turn.input,
161
208
  state: { status: "running" },
162
209
  createdAt: turn.createdAt,
210
+ ...(turn.previousTurnId === undefined
211
+ ? {}
212
+ : { previousTurnId: turn.previousTurnId }),
163
213
  },
164
214
  });
165
215
  for (const event of turn.events) {
@@ -180,10 +230,18 @@ function sessionEventItemsFromTurns(
180
230
 
181
231
  function mockServerWithTurns(turns: TurnFixture[]): AgentChatServer {
182
232
  const eventsByTurnId = new Map(turns.map((turn) => [turn.id, turn.events]));
233
+ const turnsById = new Map(turns.map((turn) => [turn.id, turn]));
183
234
  return {
184
235
  listTurns: async ({ limit }: { limit?: number } = {}) => ({
185
236
  data: limit != null ? turns.slice(0, limit) : turns,
186
237
  }),
238
+ getTurn: async ({ turnId }: { turnId: string }) => {
239
+ const turn = turnsById.get(turnId);
240
+ if (turn == null) {
241
+ throw new Error(`Turn ${turnId} not found`);
242
+ }
243
+ return turn;
244
+ },
187
245
  listEvents: async (opts: { lastTurnId?: string } = {}) => {
188
246
  const items = sessionEventItemsFromTurns(turns, opts.lastTurnId);
189
247
  const newestFirst = [...items].reverse();
@@ -2434,7 +2492,7 @@ describe("buildSnapshotFromSessionEvents", () => {
2434
2492
  expect(progressSnapshots).toEqual([1, 2]);
2435
2493
  });
2436
2494
 
2437
- it("only inspects the newest listTurns page for a running turn", async () => {
2495
+ it("falls back to listTurns({ limit: 1 }) when listEvents omits the running tip", async () => {
2438
2496
  const listTurns = vi.fn(async () => ({
2439
2497
  data: [
2440
2498
  {
@@ -2460,6 +2518,300 @@ describe("buildSnapshotFromSessionEvents", () => {
2460
2518
  expect(snapshot.pendingUser?.content).toBe("now");
2461
2519
  });
2462
2520
 
2521
+ it("attaches an open tip from events when listTurns is oldest-first and clears answered ask-user", async () => {
2522
+ // Trueforge listTurns is ASC: limit:1 returns the oldest done turn even
2523
+ // while a later tip is still running. Tip detection must use the open
2524
+ // turn.created in listEvents (which includes running turns).
2525
+ const oldestDoneTurn = {
2526
+ id: "t-pause",
2527
+ sessionId: SESSION_ID,
2528
+ state: {
2529
+ status: "done",
2530
+ requiredActions: [
2531
+ {
2532
+ id: "resp-req-1",
2533
+ type: "tool.response_required",
2534
+ threadId: ROOT_THREAD_ID,
2535
+ createdAt,
2536
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
2537
+ },
2538
+ ],
2539
+ completedAt: createdAt,
2540
+ },
2541
+ input: [{ type: "user.message", content: "please research" }],
2542
+ createdAt,
2543
+ } as unknown as Turn;
2544
+
2545
+ const runningTip = {
2546
+ id: "t-running",
2547
+ sessionId: SESSION_ID,
2548
+ state: { status: "running" },
2549
+ input: [
2550
+ {
2551
+ type: "user.tool_response",
2552
+ threadId: ROOT_THREAD_ID,
2553
+ toolCallId: "question-1",
2554
+ content: "AI-native / LLM-powered SaaS",
2555
+ },
2556
+ ],
2557
+ createdAt,
2558
+ } as unknown as Turn;
2559
+
2560
+ const items: SessionEventItem[] = [
2561
+ {
2562
+ turnId: "t-pause",
2563
+ event: {
2564
+ type: "turn.created",
2565
+ id: "evt-c-pause",
2566
+ turnId: "t-pause",
2567
+ input: oldestDoneTurn.input,
2568
+ state: { status: "running" },
2569
+ createdAt,
2570
+ },
2571
+ },
2572
+ {
2573
+ turnId: "t-pause",
2574
+ event: modelMessage({
2575
+ id: "model-1",
2576
+ threadId: ROOT_THREAD_ID,
2577
+ content: "One calibration question:",
2578
+ toolCalls: [
2579
+ {
2580
+ id: "question-1",
2581
+ type: "function",
2582
+ function: {
2583
+ name: "ask_user_question",
2584
+ arguments: JSON.stringify({
2585
+ question: "Which direction should I focus on?",
2586
+ options: [
2587
+ "General B2B SaaS",
2588
+ "AI-native / LLM-powered SaaS",
2589
+ ],
2590
+ }),
2591
+ },
2592
+ toolInfo: {
2593
+ type: "truefoundry-system",
2594
+ name: "ask_user_question",
2595
+ },
2596
+ },
2597
+ ],
2598
+ }),
2599
+ },
2600
+ {
2601
+ turnId: "t-pause",
2602
+ event: responseRequired({
2603
+ id: "resp-req-1",
2604
+ threadId: ROOT_THREAD_ID,
2605
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
2606
+ }),
2607
+ },
2608
+ {
2609
+ turnId: "t-pause",
2610
+ event: {
2611
+ type: "turn.done",
2612
+ id: "evt-d-pause",
2613
+ state: oldestDoneTurn.state as TurnDoneEvent["state"],
2614
+ createdAt,
2615
+ } as TurnDoneEvent,
2616
+ },
2617
+ {
2618
+ turnId: "t-running",
2619
+ event: {
2620
+ type: "turn.created",
2621
+ id: "evt-c-running",
2622
+ turnId: "t-running",
2623
+ previousTurnId: "t-pause",
2624
+ input: runningTip.input,
2625
+ state: { status: "running" },
2626
+ createdAt,
2627
+ },
2628
+ },
2629
+ {
2630
+ turnId: "t-running",
2631
+ event: modelMessage({
2632
+ id: "model-2",
2633
+ threadId: ROOT_THREAD_ID,
2634
+ content: "Locked in — starting deep research.",
2635
+ }),
2636
+ },
2637
+ ];
2638
+
2639
+ const listTurns = vi.fn(async () => ({
2640
+ data: [oldestDoneTurn],
2641
+ nextPageToken: "eyJvZmZzZXQiOjF9",
2642
+ }));
2643
+ const getTurn = vi.fn(async () => runningTip);
2644
+ const server = {
2645
+ listTurns,
2646
+ getTurn,
2647
+ listEvents: sessionEventsPage(items),
2648
+ listTurnEvents: async () => ({ data: [] }),
2649
+ } as unknown as AgentChatServer;
2650
+
2651
+ const snapshot = await buildSnapshotFromSessionEvents(server, SESSION_ID);
2652
+
2653
+ expect(getTurn).toHaveBeenCalledWith({
2654
+ sessionId: SESSION_ID,
2655
+ turnId: "t-running",
2656
+ });
2657
+ expect(listTurns).not.toHaveBeenCalled();
2658
+ expect(snapshot.runningTurn?.id).toBe("t-running");
2659
+ expect(snapshot.unstable_resume).toBe(true);
2660
+
2661
+ const messages = projectSessionMessages(snapshot);
2662
+ expect(collectPendingToolResponses(messages)).toHaveLength(0);
2663
+
2664
+ const assistant = messages.find((message) => message.role === "assistant");
2665
+ expect(assistant).toBeDefined();
2666
+ const toolCall = assistant?.content.find(
2667
+ (part) => part.type === "tool-call" && part.toolCallId === "question-1",
2668
+ );
2669
+ expect(toolCall).toMatchObject({
2670
+ type: "tool-call",
2671
+ toolCallId: "question-1",
2672
+ result: "AI-native / LLM-powered SaaS",
2673
+ });
2674
+ if (toolCall?.type === "tool-call") {
2675
+ expect(toolCall.interrupt).toBeUndefined();
2676
+ }
2677
+ });
2678
+
2679
+ it("keeps an answered ask-user answered when the open tip finished before getTurn", async () => {
2680
+ // The tip can complete between listEvents and getTurn. There is nothing
2681
+ // left to resume, but its user.tool_response input never reached the
2682
+ // fold via ingestion (no turn.done in the window), so it must still be
2683
+ // applied — otherwise the answered prompt resurfaces as pending.
2684
+ const finishedTip = {
2685
+ id: "t-tip",
2686
+ sessionId: SESSION_ID,
2687
+ state: { status: "done", requiredActions: [], completedAt: createdAt },
2688
+ input: [
2689
+ {
2690
+ type: "user.tool_response",
2691
+ threadId: ROOT_THREAD_ID,
2692
+ toolCallId: "question-1",
2693
+ content: "AI-native / LLM-powered SaaS",
2694
+ },
2695
+ ],
2696
+ createdAt,
2697
+ } as unknown as Turn;
2698
+
2699
+ const items: SessionEventItem[] = [
2700
+ {
2701
+ turnId: "t-pause",
2702
+ event: {
2703
+ type: "turn.created",
2704
+ id: "evt-c-pause",
2705
+ turnId: "t-pause",
2706
+ input: [{ type: "user.message", content: "please research" }],
2707
+ state: { status: "running" },
2708
+ createdAt,
2709
+ },
2710
+ },
2711
+ {
2712
+ turnId: "t-pause",
2713
+ event: modelMessage({
2714
+ id: "model-1",
2715
+ threadId: ROOT_THREAD_ID,
2716
+ content: "One calibration question:",
2717
+ toolCalls: [
2718
+ {
2719
+ id: "question-1",
2720
+ type: "function",
2721
+ function: {
2722
+ name: "ask_user_question",
2723
+ arguments: JSON.stringify({
2724
+ question: "Which direction should I focus on?",
2725
+ options: [
2726
+ "General B2B SaaS",
2727
+ "AI-native / LLM-powered SaaS",
2728
+ ],
2729
+ }),
2730
+ },
2731
+ toolInfo: {
2732
+ type: "truefoundry-system",
2733
+ name: "ask_user_question",
2734
+ },
2735
+ },
2736
+ ],
2737
+ }),
2738
+ },
2739
+ {
2740
+ turnId: "t-pause",
2741
+ event: responseRequired({
2742
+ id: "resp-req-1",
2743
+ threadId: ROOT_THREAD_ID,
2744
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
2745
+ }),
2746
+ },
2747
+ {
2748
+ turnId: "t-pause",
2749
+ event: {
2750
+ type: "turn.done",
2751
+ id: "evt-d-pause",
2752
+ state: {
2753
+ status: "done",
2754
+ requiredActions: [
2755
+ {
2756
+ id: "resp-req-1",
2757
+ type: "tool.response_required",
2758
+ threadId: ROOT_THREAD_ID,
2759
+ createdAt,
2760
+ toolCalls: [
2761
+ { id: "question-1", sourceEventId: "model-1" },
2762
+ ],
2763
+ },
2764
+ ],
2765
+ completedAt: createdAt,
2766
+ },
2767
+ createdAt,
2768
+ } as unknown as TurnDoneEvent,
2769
+ },
2770
+ {
2771
+ turnId: "t-tip",
2772
+ event: {
2773
+ type: "turn.created",
2774
+ id: "evt-c-tip",
2775
+ turnId: "t-tip",
2776
+ previousTurnId: "t-pause",
2777
+ input: finishedTip.input,
2778
+ state: { status: "running" },
2779
+ createdAt,
2780
+ },
2781
+ },
2782
+ ];
2783
+
2784
+ const listTurns = vi.fn(async () => ({ data: [], nextPageToken: undefined }));
2785
+ const getTurn = vi.fn(async () => finishedTip);
2786
+ const server = {
2787
+ listTurns,
2788
+ getTurn,
2789
+ listEvents: sessionEventsPage(items),
2790
+ listTurnEvents: async () => ({ data: [] }),
2791
+ } as unknown as AgentChatServer;
2792
+
2793
+ const snapshot = await buildSnapshotFromSessionEvents(server, SESSION_ID);
2794
+
2795
+ expect(snapshot.runningTurn).toBeUndefined();
2796
+ expect(snapshot.unstable_resume).toBeFalsy();
2797
+
2798
+ const messages = projectSessionMessages(snapshot);
2799
+ expect(collectPendingToolResponses(messages)).toHaveLength(0);
2800
+
2801
+ const assistant = messages.find((message) => message.role === "assistant");
2802
+ const toolCall = assistant?.content.find(
2803
+ (part) => part.type === "tool-call" && part.toolCallId === "question-1",
2804
+ );
2805
+ expect(toolCall).toMatchObject({
2806
+ type: "tool-call",
2807
+ toolCallId: "question-1",
2808
+ result: "AI-native / LLM-powered SaaS",
2809
+ });
2810
+ if (toolCall?.type === "tool-call") {
2811
+ expect(toolCall.interrupt).toBeUndefined();
2812
+ }
2813
+ });
2814
+
2463
2815
  it("loads only the newest event page and exposes an older-history cursor", async () => {
2464
2816
  const makeTurnItems = (id: string, text: string): SessionEventItem[] => [
2465
2817
  {
@@ -2530,40 +2882,46 @@ describe("buildSnapshotFromSessionEvents", () => {
2530
2882
  });
2531
2883
  });
2532
2884
 
2533
- describe("buildSnapshotBeforeTurnIndex", () => {
2534
- it("rewinds via session.listEvents({ lastTurnId }) excluding the branch turn", async () => {
2535
- const t1 = mockTurn({
2536
- id: "t1",
2537
- createdAt,
2538
- input: [{ type: "user.message", content: "first" }],
2539
- events: [
2540
- modelMessage({ id: "m1", threadId: ROOT_THREAD_ID, content: "reply 1" }),
2885
+ describe("buildSnapshotThroughTurn", () => {
2886
+ /** Linear session t1 -> t2 -> t3, oldest-first. */
2887
+ function linearTurns(): TurnFixture[] {
2888
+ return [
2889
+ mockTurn({
2890
+ id: "t1",
2891
+ createdAt: "2026-01-01T00:00:00.000Z",
2892
+ previousTurnId: null,
2893
+ input: [{ type: "user.message", content: "first" }],
2894
+ events: [
2895
+ modelMessage({ id: "m1", threadId: ROOT_THREAD_ID, content: "reply 1" }),
2541
2896
  ],
2542
- });
2543
- const t2 = mockTurn({
2544
- id: "t2",
2545
- createdAt,
2546
- input: [{ type: "user.message", content: "second" }],
2547
- events: [
2548
- modelMessage({ id: "m2", threadId: ROOT_THREAD_ID, content: "reply 2" }),
2897
+ }),
2898
+ mockTurn({
2899
+ id: "t2",
2900
+ createdAt: "2026-01-01T00:01:00.000Z",
2901
+ previousTurnId: "t1",
2902
+ input: [{ type: "user.message", content: "second" }],
2903
+ events: [
2904
+ modelMessage({ id: "m2", threadId: ROOT_THREAD_ID, content: "reply 2" }),
2549
2905
  ],
2550
- });
2551
- const t3 = mockTurn({
2552
- id: "t3",
2553
- createdAt,
2554
- input: [{ type: "user.message", content: "third" }],
2555
- events: [
2556
- modelMessage({ id: "m3", threadId: ROOT_THREAD_ID, content: "reply 3" }),
2906
+ }),
2907
+ mockTurn({
2908
+ id: "t3",
2909
+ createdAt: "2026-01-01T00:02:00.000Z",
2910
+ previousTurnId: "t2",
2911
+ input: [{ type: "user.message", content: "third" }],
2912
+ events: [
2913
+ modelMessage({ id: "m3", threadId: ROOT_THREAD_ID, content: "reply 3" }),
2557
2914
  ],
2558
- });
2915
+ }),
2916
+ ];
2917
+ }
2559
2918
 
2560
- // listTurns is newest-first.
2561
- const server = mockServerWithTurns([t3, t2, t1]);
2562
- const snapshot = await buildSnapshotBeforeTurnIndex(server, SESSION_ID, 2);
2919
+ it("rewinds to the anchor's ancestor chain, inclusive", async () => {
2920
+ const server = mockServerWithTurns(linearTurns());
2921
+ const snapshot = await buildSnapshotThroughTurn(server, SESSION_ID, "t2");
2563
2922
 
2564
2923
  expect(snapshot.turns.map((turn) => turn.id)).toEqual(["t1", "t2"]);
2565
- const messages = projectSessionMessages(snapshot);
2566
- expect(messages.map((message) => message.id)).toEqual([
2924
+ expect(projectSessionMessages(snapshot).map((message) => message.id)).toEqual([
2567
2925
  "t1-user",
2568
2926
  "t1-assistant",
2569
2927
  "t2-user",
@@ -2571,12 +2929,116 @@ describe("buildSnapshotBeforeTurnIndex", () => {
2571
2929
  ]);
2572
2930
  });
2573
2931
 
2574
- it("returns an empty snapshot when branching from the first turn", async () => {
2575
- const server = mockServerWithTurns([
2576
- mockTurn({ id: "t1", createdAt }),
2577
- ]);
2578
- const snapshot = await buildSnapshotBeforeTurnIndex(server, SESSION_ID, 0);
2932
+ it("returns an empty snapshot for a null anchor (branching from a root turn)", async () => {
2933
+ const server = mockServerWithTurns(linearTurns());
2934
+ const snapshot = await buildSnapshotThroughTurn(server, SESSION_ID, null);
2579
2935
  expect(snapshot.turns).toHaveLength(0);
2580
2936
  });
2937
+
2938
+ it("ignores listTurns page order", async () => {
2939
+ const asc = linearTurns();
2940
+ const ascSnapshot = await buildSnapshotThroughTurn(
2941
+ mockServerWithTurns(asc),
2942
+ SESSION_ID,
2943
+ "t2",
2944
+ );
2945
+ const descSnapshot = await buildSnapshotThroughTurn(
2946
+ mockServerWithTurns([...asc].reverse()),
2947
+ SESSION_ID,
2948
+ "t2",
2949
+ );
2950
+
2951
+ expect(descSnapshot.turns.map((turn) => turn.id)).toEqual(
2952
+ ascSnapshot.turns.map((turn) => turn.id),
2953
+ );
2954
+ expect(descSnapshot.turns.map((turn) => turn.id)).toEqual(["t1", "t2"]);
2955
+ });
2956
+
2957
+ it("drops sibling turns left behind by an earlier rerun", async () => {
2958
+ // m2/m3 were rerun once already: t2/t3 are orphans, t2p/t3p are live.
2959
+ const [t1, t2, t3] = linearTurns();
2960
+ const t2p = mockTurn({
2961
+ id: "t2p",
2962
+ createdAt: "2026-01-01T00:03:00.000Z",
2963
+ previousTurnId: "t1",
2964
+ input: [{ type: "user.message", content: "second again" }],
2965
+ events: [
2966
+ modelMessage({ id: "m2p", threadId: ROOT_THREAD_ID, content: "reply 2p" }),
2967
+ ],
2968
+ });
2969
+ const t3p = mockTurn({
2970
+ id: "t3p",
2971
+ createdAt: "2026-01-01T00:04:00.000Z",
2972
+ previousTurnId: "t2p",
2973
+ input: [{ type: "user.message", content: "third again" }],
2974
+ events: [
2975
+ modelMessage({ id: "m3p", threadId: ROOT_THREAD_ID, content: "reply 3p" }),
2976
+ ],
2977
+ });
2978
+ const server = mockServerWithTurns([t1!, t2!, t3!, t2p, t3p]);
2979
+
2980
+ // Rerunning the middle live turn rewinds to its parent, not to the
2981
+ // chronologically preceding turn (t3), whose chain still holds t2/t3.
2982
+ const previousTurnId = await resolveGatewayBranchPreviousTurnIdForTurn(
2983
+ server,
2984
+ SESSION_ID,
2985
+ "t2p",
2986
+ );
2987
+ expect(previousTurnId).toBe("t1");
2988
+
2989
+ const snapshot = await buildSnapshotThroughTurn(server, SESSION_ID, previousTurnId);
2990
+ expect(snapshot.turns.map((turn) => turn.id)).toEqual(["t1"]);
2991
+
2992
+ // The orphan chain is still reachable, so the assertion above is not
2993
+ // passing by accident.
2994
+ const orphanSnapshot = await buildSnapshotThroughTurn(server, SESSION_ID, "t3");
2995
+ expect(orphanSnapshot.turns.map((turn) => turn.id)).toEqual(["t1", "t2", "t3"]);
2996
+ });
2997
+ });
2998
+
2999
+ describe("resolveGatewayBranchPreviousTurnIdForTurn", () => {
3000
+ it("uses the turn parent for subsequent turns and none for roots (ASC listTurns)", async () => {
3001
+ const t1 = mockTurn({
3002
+ id: "t1",
3003
+ createdAt: "2026-01-01T00:00:00.000Z",
3004
+ previousTurnId: null,
3005
+ });
3006
+ const t2 = mockTurn({
3007
+ id: "t2",
3008
+ createdAt: "2026-01-01T00:01:00.000Z",
3009
+ previousTurnId: "t1",
3010
+ });
3011
+ const t3 = mockTurn({
3012
+ id: "t3",
3013
+ createdAt: "2026-01-01T00:02:00.000Z",
3014
+ previousTurnId: "t2",
3015
+ });
3016
+ const server = mockServerWithTurns([t1, t2, t3]);
3017
+
3018
+ await expect(
3019
+ resolveGatewayBranchPreviousTurnIdForTurn(server, SESSION_ID, "t1"),
3020
+ ).resolves.toBe("none");
3021
+ await expect(
3022
+ resolveGatewayBranchPreviousTurnIdForTurn(server, SESSION_ID, "t3"),
3023
+ ).resolves.toBe("t2");
3024
+ });
3025
+
3026
+ it("uses the turn parent when listTurns is newest-first", async () => {
3027
+ const t1 = mockTurn({
3028
+ id: "t1",
3029
+ createdAt: "2026-01-01T00:00:00.000Z",
3030
+ previousTurnId: null,
3031
+ });
3032
+ const t2 = mockTurn({
3033
+ id: "t2",
3034
+ createdAt: "2026-01-01T00:01:00.000Z",
3035
+ previousTurnId: "t1",
3036
+ });
3037
+ const server = mockServerWithTurns([t2, t1]);
3038
+
3039
+ await expect(
3040
+ resolveGatewayBranchPreviousTurnIdForTurn(server, SESSION_ID, "t2"),
3041
+ ).resolves.toBe("t1");
3042
+ });
2581
3043
  });
2582
3044