@truefoundry/assistant-ui-runtime 0.1.0-rc.1 → 0.1.2

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.
Files changed (33) hide show
  1. package/README.md +45 -77
  2. package/dist/index.d.ts +14 -2
  3. package/dist/index.js +697 -147
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/src/agentSessionModule.d.ts +14 -2
  7. package/src/convertTurnMessages.test.ts +502 -1
  8. package/src/convertTurnMessages.ts +471 -28
  9. package/src/createSubAgent.ts +13 -5
  10. package/src/draftAgentConfig.test.ts +1 -1
  11. package/src/foldPeerThreads.test.ts +56 -0
  12. package/src/foldPeerThreads.ts +7 -1
  13. package/src/hooks.ts +24 -0
  14. package/src/index.ts +7 -5
  15. package/src/loadSessionSnapshot.test.ts +21 -6
  16. package/src/loadSessionSnapshot.ts +9 -5
  17. package/src/{bindDraftAgentSession.test.ts → private/bindDraftAgentSession.test.ts} +3 -2
  18. package/src/{draftSessionBridge.ts → private/draftSessionBridge.ts} +8 -2
  19. package/src/{truefoundryDraftThreadListAdapter.ts → private/truefoundryDraftThreadListAdapter.ts} +1 -1
  20. package/src/private/useDraftAgentSpec.test.tsx +153 -0
  21. package/src/{useDraftAgentSpec.ts → private/useDraftAgentSpec.ts} +80 -3
  22. package/src/sessionSnapshot.ts +48 -2
  23. package/src/sessions.ts +1 -1
  24. package/src/streamTurn.test.ts +34 -0
  25. package/src/streamTurn.ts +9 -1
  26. package/src/truefoundryExtras.ts +5 -1
  27. package/src/types.ts +1 -1
  28. package/src/useTrueFoundryAgentMessages.test.tsx +275 -1
  29. package/src/useTrueFoundryAgentMessages.ts +263 -70
  30. package/src/useTrueFoundryAgentRuntime.ts +51 -13
  31. /package/src/{agentSpec.ts → private/agentSpec.ts} +0 -0
  32. /package/src/{bindDraftAgentSession.ts → private/bindDraftAgentSession.ts} +0 -0
  33. /package/src/{truefoundryDraftThreadListAdapter.test.ts → private/truefoundryDraftThreadListAdapter.test.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truefoundry/assistant-ui-runtime",
3
- "version": "0.1.0-rc.1",
3
+ "version": "0.1.2",
4
4
  "description": "TrueFoundry Gateway agent runtime adapter for assistant-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -50,7 +50,7 @@
50
50
  "peerDependencies": {
51
51
  "@types/react": "*",
52
52
  "react": "^18 || ^19",
53
- "truefoundry-gateway-sdk": "^0.2.0"
53
+ "truefoundry-gateway-sdk": "^0.3.1"
54
54
  },
55
55
  "peerDependenciesMeta": {
56
56
  "@types/react": {
@@ -18,8 +18,20 @@ declare module "truefoundry-gateway-sdk/dist/esm/agents/AgentSession.mjs" {
18
18
  }): PreparedTurn;
19
19
  listTurns(
20
20
  opts?: TrueFoundryGatewayApi.agents.SessionsListTurnsRequest,
21
+ requestOptions?: unknown,
21
22
  ): Promise<core.Page<unknown, TrueFoundryGatewayApi.ListTurnsResponse>>;
22
- getTurn(opts: { turnId: string }): Promise<unknown>;
23
- cancel(): Promise<void>;
23
+ /**
24
+ * Paginated session events across turns (newest first); subscribe to a
25
+ * running turn for live events.
26
+ *
27
+ * @param opts.lastTurnId - Newest turn in the listing window (initial load only).
28
+ * Omit to use the session last turn. Running-turn events are excluded.
29
+ */
30
+ listEvents(
31
+ opts?: TrueFoundryGatewayApi.agents.SessionsListEventsRequest,
32
+ requestOptions?: unknown,
33
+ ): Promise<core.Page<TrueFoundryGatewayApi.SessionEventItem, TrueFoundryGatewayApi.ListSessionEventsResponse>>;
34
+ getTurn(opts: { turnId: string }, requestOptions?: unknown): Promise<unknown>;
35
+ cancel(requestOptions?: unknown): Promise<void>;
24
36
  }
25
37
  }
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, vi } from "vitest";
2
2
  import type { AppendMessage } from "@assistant-ui/core";
3
3
  import type {
4
4
  AgentSession,
@@ -8,6 +8,8 @@ import type {
8
8
  ToolApprovalRequiredEvent,
9
9
  ToolResponseRequiredEvent,
10
10
  Turn,
11
+ TurnCreatedEvent,
12
+ TurnDoneEvent,
11
13
  TurnEvent,
12
14
  TurnStreamData,
13
15
  } from "truefoundry-gateway-sdk/agents";
@@ -15,11 +17,14 @@ import type {
15
17
  import { ROOT_THREAD_ID } from "./constants.js";
16
18
  import { collectPendingToolResponses } from "./collectPending.js";
17
19
  import {
20
+ buildSnapshotBeforeTurnIndex,
21
+ buildSnapshotFromSessionEvents,
18
22
  buildTurnAssistantContent,
19
23
  buildUserMessageContent,
20
24
  buildUserMessageFromTurnInput,
21
25
  convertTurnsToThreadMessages,
22
26
  getTurnMessageContent,
27
+ prependOlderSessionHistory,
23
28
  projectSessionMessages,
24
29
  repositoryItemsFromMessages,
25
30
  streamTurnEvents,
@@ -91,6 +96,12 @@ function eventsPage(events: TurnEvent[]) {
91
96
 
92
97
  function turnsPage(turns: Turn[]) {
93
98
  return async () => ({
99
+ data: turns,
100
+ response: {
101
+ data: turns,
102
+ pagination: { limit: turns.length || 1 },
103
+ },
104
+ hasNextPage: () => false,
94
105
  async *[Symbol.asyncIterator]() {
95
106
  for (const turn of turns) {
96
107
  yield turn;
@@ -132,9 +143,81 @@ function mockTurn(
132
143
  };
133
144
  }
134
145
 
146
+ async function collectTurnListEvents(
147
+ turn: Pick<Turn, "listEvents">,
148
+ ): Promise<TurnEvent[]> {
149
+ const events: TurnEvent[] = [];
150
+ for await (const event of await turn.listEvents()) {
151
+ events.push(event as TurnEvent);
152
+ }
153
+ return events;
154
+ }
155
+
156
+ /**
157
+ * Builds session-level event items from per-turn mocks — newest-first turns,
158
+ * running turns excluded (matches session.listEvents API contract).
159
+ */
160
+ async function sessionEventItemsFromTurns(
161
+ turnsNewestFirst: ReturnType<typeof mockTurn>[],
162
+ lastTurnId?: string,
163
+ ): Promise<{ turnId: string; event: TurnCreatedEvent | TurnDoneEvent | TurnEvent }[]> {
164
+ let chain = turnsNewestFirst.filter((turn) => turn.state.status !== "running");
165
+ if (lastTurnId != null) {
166
+ const anchorIndex = chain.findIndex((turn) => turn.id === lastTurnId);
167
+ chain = anchorIndex === -1 ? [] : chain.slice(anchorIndex);
168
+ }
169
+
170
+ const items: { turnId: string; event: TurnCreatedEvent | TurnDoneEvent | TurnEvent }[] =
171
+ [];
172
+ for (const turn of [...chain].reverse()) {
173
+ items.push({
174
+ turnId: turn.id,
175
+ event: {
176
+ type: "turn.created",
177
+ id: `created-${turn.id}`,
178
+ turnId: turn.id,
179
+ input: turn.input,
180
+ state: { status: "running" },
181
+ createdBy: { subjectId: "u1", subjectType: "user" },
182
+ createdAt: turn.createdAt,
183
+ },
184
+ });
185
+ for (const event of await collectTurnListEvents(turn as Turn)) {
186
+ items.push({ turnId: turn.id, event });
187
+ }
188
+ items.push({
189
+ turnId: turn.id,
190
+ event: {
191
+ type: "turn.done",
192
+ id: `done-${turn.id}`,
193
+ state: turn.state as TurnDoneEvent["state"],
194
+ createdAt: turn.createdAt,
195
+ },
196
+ });
197
+ }
198
+ return items;
199
+ }
200
+
135
201
  function mockSession(turns: ReturnType<typeof mockTurn>[]): AgentSession {
136
202
  return {
137
203
  listTurns: turnsPage(turns as Turn[]) as unknown as AgentSession["listTurns"],
204
+ listEvents: async (opts?: { lastTurnId?: string; pageToken?: string; limit?: number }) => {
205
+ const items = await sessionEventItemsFromTurns(turns, opts?.lastTurnId);
206
+ const newestFirst = [...items].reverse();
207
+ return {
208
+ data: newestFirst,
209
+ response: {
210
+ data: newestFirst,
211
+ pagination: { limit: newestFirst.length || 1 },
212
+ },
213
+ hasNextPage: () => false,
214
+ async *[Symbol.asyncIterator]() {
215
+ for (const item of newestFirst) {
216
+ yield item;
217
+ }
218
+ },
219
+ };
220
+ },
138
221
  } as unknown as AgentSession;
139
222
  }
140
223
 
@@ -1113,6 +1196,44 @@ describe("convertTurnMessages", () => {
1113
1196
  expect(updates).toEqual([{ content: [{ type: "text", text: "streaming" }] }]);
1114
1197
  });
1115
1198
 
1199
+ it("yields folded content after each ingested stream event", async () => {
1200
+ const foldState = new PeerThreadFoldState();
1201
+ const updates = await collectStream(
1202
+ streamTurnEvents(
1203
+ streamFrom([
1204
+ modelMessage({
1205
+ id: "m1",
1206
+ threadId: ROOT_THREAD_ID,
1207
+ content: "first",
1208
+ }),
1209
+ modelMessage({
1210
+ id: "m2",
1211
+ threadId: ROOT_THREAD_ID,
1212
+ content: "second",
1213
+ }),
1214
+ modelMessage({
1215
+ id: "m3",
1216
+ threadId: ROOT_THREAD_ID,
1217
+ content: "third",
1218
+ }),
1219
+ ]),
1220
+ foldState,
1221
+ ),
1222
+ );
1223
+
1224
+ expect(updates).toHaveLength(3);
1225
+ expect(updates[0]?.content).toEqual([{ type: "text", text: "first" }]);
1226
+ expect(updates[1]?.content).toEqual([
1227
+ { type: "text", text: "first" },
1228
+ { type: "text", text: "second" },
1229
+ ]);
1230
+ expect(updates[2]?.content).toEqual([
1231
+ { type: "text", text: "first" },
1232
+ { type: "text", text: "second" },
1233
+ { type: "text", text: "third" },
1234
+ ]);
1235
+ });
1236
+
1116
1237
  it("scopes streamed content to ids after the group baseline", async () => {
1117
1238
  const foldState = new PeerThreadFoldState();
1118
1239
  foldState.getOrCreateBucket(ROOT_THREAD_ID);
@@ -1989,3 +2110,383 @@ describe("convertTurnMessages", () => {
1989
2110
  });
1990
2111
  });
1991
2112
  });
2113
+
2114
+ // ---------------------------------------------------------------------------
2115
+ // buildSnapshotFromSessionEvents
2116
+ // ---------------------------------------------------------------------------
2117
+
2118
+ type SessionEventItem = { turnId: string; event: TurnCreatedEvent | TurnDoneEvent | TurnEvent };
2119
+
2120
+ /** Simulates session.listEvents — returns items in desc order (newest first). */
2121
+ function sessionEventsPage(
2122
+ items: SessionEventItem[],
2123
+ options?: { pageSize?: number },
2124
+ ) {
2125
+ return async (opts?: {
2126
+ lastTurnId?: string;
2127
+ pageToken?: string;
2128
+ limit?: number;
2129
+ }) => {
2130
+ let filtered = items;
2131
+ if (opts?.lastTurnId != null) {
2132
+ // Chronological items: keep through lastTurnId, drop later turns.
2133
+ const lastIndex = filtered.findLastIndex(
2134
+ (item) => item.turnId === opts.lastTurnId,
2135
+ );
2136
+ filtered = lastIndex === -1 ? [] : filtered.slice(0, lastIndex + 1);
2137
+ }
2138
+ const newestFirst = [...filtered].reverse();
2139
+ // Prefer fixture pageSize so pagination tests are not forced to the API default limit.
2140
+ const pageSize =
2141
+ options?.pageSize ?? opts?.limit ?? (newestFirst.length || 1);
2142
+ const pageIndex = opts?.pageToken != null ? Number(opts.pageToken) : 0;
2143
+ const start = pageIndex * pageSize;
2144
+ const slice = newestFirst.slice(start, start + pageSize);
2145
+ const hasMore = start + pageSize < newestFirst.length;
2146
+ const nextPageToken = hasMore ? String(pageIndex + 1) : undefined;
2147
+ return {
2148
+ data: slice,
2149
+ response: {
2150
+ data: slice,
2151
+ pagination: {
2152
+ limit: pageSize,
2153
+ ...(nextPageToken != null ? { nextPageToken } : {}),
2154
+ },
2155
+ },
2156
+ hasNextPage: () => hasMore,
2157
+ async *[Symbol.asyncIterator]() {
2158
+ // Full drain for rewind paths that still iterate all pages.
2159
+ for (const item of newestFirst) {
2160
+ yield item;
2161
+ }
2162
+ },
2163
+ };
2164
+ };
2165
+ }
2166
+
2167
+ /** Builds a mock AgentSession with listTurns and listEvents. */
2168
+ function mockSessionWithEvents(
2169
+ turns: Turn[],
2170
+ eventItems: SessionEventItem[],
2171
+ ): AgentSession {
2172
+ return {
2173
+ listTurns: turnsPage(turns),
2174
+ listEvents: sessionEventsPage(eventItems),
2175
+ } as unknown as AgentSession;
2176
+ }
2177
+
2178
+ describe("buildSnapshotFromSessionEvents", () => {
2179
+ it("builds a snapshot from a single complete turn", async () => {
2180
+ const items: SessionEventItem[] = [
2181
+ {
2182
+ turnId: "t1",
2183
+ event: {
2184
+ type: "turn.created",
2185
+ id: "evt-c1",
2186
+ turnId: "t1",
2187
+ input: [{ type: "user.message", content: "hello" }],
2188
+ state: { status: "running" },
2189
+ createdBy: { subjectId: "u1", subjectType: "user" },
2190
+ createdAt,
2191
+ },
2192
+ },
2193
+ {
2194
+ turnId: "t1",
2195
+ event: modelMessage({ id: "m1", threadId: ROOT_THREAD_ID, content: "hi there" }),
2196
+ },
2197
+ {
2198
+ turnId: "t1",
2199
+ event: {
2200
+ type: "turn.done",
2201
+ id: "evt-d1",
2202
+ state: { status: "done", requiredActions: [], completedAt: createdAt },
2203
+ createdAt,
2204
+ } as TurnDoneEvent,
2205
+ },
2206
+ ];
2207
+
2208
+ const snapshot = await buildSnapshotFromSessionEvents(mockSessionWithEvents([], items));
2209
+
2210
+ expect(snapshot.turns).toHaveLength(1);
2211
+ expect(snapshot.turns[0]?.id).toBe("t1");
2212
+ expect(snapshot.turns[0]?.userText).toBe("hello");
2213
+ expect(snapshot.turns[0]?.state).toEqual({
2214
+ status: "done",
2215
+ requiredActions: [],
2216
+ completedAt: createdAt,
2217
+ });
2218
+ expect(snapshot.turns[0]?.rootModelMessageIds).toEqual(["m1"]);
2219
+ expect(snapshot.runningTurn).toBeUndefined();
2220
+
2221
+ const messages = projectSessionMessages(snapshot);
2222
+ expect(messages).toHaveLength(2);
2223
+ expect(messages[0]?.role).toBe("user");
2224
+ expect(messages[1]?.role).toBe("assistant");
2225
+ });
2226
+
2227
+ it("detects and attaches the running turn without events", async () => {
2228
+ const runningTurn = {
2229
+ id: "t2",
2230
+ state: { status: "running" },
2231
+ input: [{ type: "user.message", content: "in progress" }],
2232
+ createdAt,
2233
+ } as unknown as Turn;
2234
+
2235
+ const items: SessionEventItem[] = [
2236
+ {
2237
+ turnId: "t1",
2238
+ event: {
2239
+ type: "turn.created",
2240
+ id: "evt-c1",
2241
+ turnId: "t1",
2242
+ input: [{ type: "user.message", content: "first" }],
2243
+ state: { status: "running" },
2244
+ createdBy: { subjectId: "u1", subjectType: "user" },
2245
+ createdAt,
2246
+ },
2247
+ },
2248
+ {
2249
+ turnId: "t1",
2250
+ event: modelMessage({ id: "m1", threadId: ROOT_THREAD_ID, content: "reply 1" }),
2251
+ },
2252
+ {
2253
+ turnId: "t1",
2254
+ event: {
2255
+ type: "turn.done",
2256
+ id: "evt-d1",
2257
+ state: { status: "done", requiredActions: [], completedAt: createdAt },
2258
+ createdAt,
2259
+ } as TurnDoneEvent,
2260
+ },
2261
+ // t2 (running) has no events — it is excluded from session-level listEvents
2262
+ ];
2263
+
2264
+ const snapshot = await buildSnapshotFromSessionEvents(
2265
+ mockSessionWithEvents([runningTurn], items),
2266
+ );
2267
+
2268
+ expect(snapshot.turns).toHaveLength(1);
2269
+ expect(snapshot.turns[0]?.id).toBe("t1");
2270
+ expect(snapshot.runningTurn).toBe(runningTurn);
2271
+ expect(snapshot.unstable_resume).toBe(true);
2272
+ expect(snapshot.groupRootBaseline).toBeDefined();
2273
+ expect(snapshot.pendingUser).toMatchObject({
2274
+ turnId: "t2",
2275
+ content: "in progress",
2276
+ });
2277
+ });
2278
+
2279
+ it("calls onProgress after each completed turn", async () => {
2280
+ const items: SessionEventItem[] = [
2281
+ {
2282
+ turnId: "t1",
2283
+ event: {
2284
+ type: "turn.created",
2285
+ id: "evt-c1",
2286
+ turnId: "t1",
2287
+ input: [{ type: "user.message", content: "first" }],
2288
+ state: { status: "running" },
2289
+ createdBy: { subjectId: "u1", subjectType: "user" },
2290
+ createdAt,
2291
+ },
2292
+ },
2293
+ {
2294
+ turnId: "t1",
2295
+ event: modelMessage({ id: "m1", threadId: ROOT_THREAD_ID, content: "reply 1" }),
2296
+ },
2297
+ {
2298
+ turnId: "t1",
2299
+ event: {
2300
+ type: "turn.done",
2301
+ id: "evt-d1",
2302
+ state: { status: "done", requiredActions: [], completedAt: createdAt },
2303
+ createdAt,
2304
+ } as TurnDoneEvent,
2305
+ },
2306
+ {
2307
+ turnId: "t2",
2308
+ event: {
2309
+ type: "turn.created",
2310
+ id: "evt-c2",
2311
+ turnId: "t2",
2312
+ input: [{ type: "user.message", content: "second" }],
2313
+ state: { status: "running" },
2314
+ createdBy: { subjectId: "u1", subjectType: "user" },
2315
+ createdAt,
2316
+ },
2317
+ },
2318
+ {
2319
+ turnId: "t2",
2320
+ event: modelMessage({ id: "m2", threadId: ROOT_THREAD_ID, content: "reply 2" }),
2321
+ },
2322
+ {
2323
+ turnId: "t2",
2324
+ event: {
2325
+ type: "turn.done",
2326
+ id: "evt-d2",
2327
+ state: { status: "done", requiredActions: [], completedAt: createdAt },
2328
+ createdAt,
2329
+ } as TurnDoneEvent,
2330
+ },
2331
+ ];
2332
+
2333
+ const progressSnapshots: number[] = [];
2334
+ await buildSnapshotFromSessionEvents(
2335
+ mockSessionWithEvents([], items),
2336
+ (snap) => progressSnapshots.push(snap.turns.length),
2337
+ );
2338
+
2339
+ expect(progressSnapshots).toEqual([1, 2]);
2340
+ });
2341
+
2342
+ it("only inspects the newest listTurns page for a running turn", async () => {
2343
+ const listTurns = vi.fn(async () => ({
2344
+ data: [
2345
+ {
2346
+ id: "t-running",
2347
+ state: { status: "running" },
2348
+ input: [{ type: "user.message", content: "now" }],
2349
+ createdAt,
2350
+ },
2351
+ ],
2352
+ response: { data: [], pagination: { limit: 1, nextPageToken: "more" } },
2353
+ hasNextPage: () => true,
2354
+ getNextPage: async () => {
2355
+ throw new Error("listTurns must not paginate on initial load");
2356
+ },
2357
+ async *[Symbol.asyncIterator]() {
2358
+ throw new Error("listTurns must not be fully iterated on initial load");
2359
+ },
2360
+ }));
2361
+
2362
+ const session = {
2363
+ listTurns,
2364
+ listEvents: sessionEventsPage([]),
2365
+ } as unknown as AgentSession;
2366
+
2367
+ const snapshot = await buildSnapshotFromSessionEvents(session);
2368
+ expect(listTurns).toHaveBeenCalledWith({ limit: 1 });
2369
+ expect(snapshot.runningTurn?.id).toBe("t-running");
2370
+ expect(snapshot.pendingUser?.content).toBe("now");
2371
+ });
2372
+
2373
+ it("loads only the newest event page and exposes an older-history cursor", async () => {
2374
+ const makeTurnItems = (id: string, text: string): SessionEventItem[] => [
2375
+ {
2376
+ turnId: id,
2377
+ event: {
2378
+ type: "turn.created",
2379
+ id: `evt-c-${id}`,
2380
+ turnId: id,
2381
+ input: [{ type: "user.message", content: text }],
2382
+ state: { status: "running" },
2383
+ createdBy: { subjectId: "u1", subjectType: "user" },
2384
+ createdAt,
2385
+ },
2386
+ },
2387
+ {
2388
+ turnId: id,
2389
+ event: modelMessage({
2390
+ id: `m-${id}`,
2391
+ threadId: ROOT_THREAD_ID,
2392
+ content: `reply ${text}`,
2393
+ }),
2394
+ },
2395
+ {
2396
+ turnId: id,
2397
+ event: {
2398
+ type: "turn.done",
2399
+ id: `evt-d-${id}`,
2400
+ state: { status: "done", requiredActions: [], completedAt: createdAt },
2401
+ createdAt,
2402
+ } as TurnDoneEvent,
2403
+ },
2404
+ ];
2405
+
2406
+ // 3 events per turn; pageSize 3 → one turn per page (newest first).
2407
+ const items = [
2408
+ ...makeTurnItems("t1", "first"),
2409
+ ...makeTurnItems("t2", "second"),
2410
+ ...makeTurnItems("t3", "third"),
2411
+ ];
2412
+ const listEvents = sessionEventsPage(items, { pageSize: 3 });
2413
+ const listEventsSpy = vi.fn(listEvents);
2414
+ const session = {
2415
+ listTurns: turnsPage([]),
2416
+ listEvents: listEventsSpy,
2417
+ } as unknown as AgentSession;
2418
+
2419
+ const snapshot = await buildSnapshotFromSessionEvents(session);
2420
+ expect(listEventsSpy).toHaveBeenCalledTimes(1);
2421
+ expect(snapshot.turns.map((t) => t.id)).toEqual(["t3"]);
2422
+ expect(snapshot.historyPagination).toEqual({
2423
+ hasOlder: true,
2424
+ olderPageToken: "1",
2425
+ });
2426
+
2427
+ const withOlder = await prependOlderSessionHistory(session, snapshot);
2428
+ expect(withOlder.turns.map((t) => t.id)).toEqual(["t2", "t3"]);
2429
+ expect(projectSessionMessages(withOlder).map((m) => m.role)).toEqual([
2430
+ "user",
2431
+ "assistant",
2432
+ "user",
2433
+ "assistant",
2434
+ ]);
2435
+ expect(withOlder.historyPagination?.hasOlder).toBe(true);
2436
+
2437
+ const withAll = await prependOlderSessionHistory(session, withOlder);
2438
+ expect(withAll.turns.map((t) => t.id)).toEqual(["t1", "t2", "t3"]);
2439
+ expect(withAll.historyPagination?.hasOlder).toBe(false);
2440
+ });
2441
+ });
2442
+
2443
+ describe("buildSnapshotBeforeTurnIndex", () => {
2444
+ it("rewinds via session.listEvents({ lastTurnId }) excluding the branch turn", async () => {
2445
+ const t1 = mockTurn({
2446
+ id: "t1",
2447
+ createdAt,
2448
+ input: [{ type: "user.message", content: "first" }],
2449
+ listEvents: eventsPage([
2450
+ modelMessage({ id: "m1", threadId: ROOT_THREAD_ID, content: "reply 1" }),
2451
+ ]) as unknown as Turn["listEvents"],
2452
+ });
2453
+ const t2 = mockTurn({
2454
+ id: "t2",
2455
+ createdAt,
2456
+ input: [{ type: "user.message", content: "second" }],
2457
+ listEvents: eventsPage([
2458
+ modelMessage({ id: "m2", threadId: ROOT_THREAD_ID, content: "reply 2" }),
2459
+ ]) as unknown as Turn["listEvents"],
2460
+ });
2461
+ const t3 = mockTurn({
2462
+ id: "t3",
2463
+ createdAt,
2464
+ input: [{ type: "user.message", content: "third" }],
2465
+ listEvents: eventsPage([
2466
+ modelMessage({ id: "m3", threadId: ROOT_THREAD_ID, content: "reply 3" }),
2467
+ ]) as unknown as Turn["listEvents"],
2468
+ });
2469
+
2470
+ // listTurns is newest-first.
2471
+ const session = mockSession([t3, t2, t1]);
2472
+ const snapshot = await buildSnapshotBeforeTurnIndex(session, 2);
2473
+
2474
+ expect(snapshot.turns.map((turn) => turn.id)).toEqual(["t1", "t2"]);
2475
+ const messages = projectSessionMessages(snapshot);
2476
+ expect(messages.map((message) => message.id)).toEqual([
2477
+ "t1-user",
2478
+ "t1-assistant",
2479
+ "t2-user",
2480
+ "t2-assistant",
2481
+ ]);
2482
+ });
2483
+
2484
+ it("returns an empty snapshot when branching from the first turn", async () => {
2485
+ const session = mockSession([
2486
+ mockTurn({ id: "t1", createdAt }),
2487
+ ]);
2488
+ const snapshot = await buildSnapshotBeforeTurnIndex(session, 0);
2489
+ expect(snapshot.turns).toHaveLength(0);
2490
+ });
2491
+ });
2492
+