@truefoundry/assistant-ui-runtime 0.1.1 → 0.1.3-rc.1

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.
@@ -27,6 +27,7 @@ import {
27
27
  ingestStreamEvent,
28
28
  ingestTurnEvent,
29
29
  PeerThreadFoldState,
30
+ type ThreadBucket,
30
31
  } from "./foldPeerThreads.js";
31
32
  import {
32
33
  appendMcpAuthToTurnContent,
@@ -42,6 +43,8 @@ import {
42
43
  type ProjectSessionMessagesOptions,
43
44
  type SessionTurnRecord,
44
45
  type RequiredActionsOverlay,
46
+ type GatewaySessionEventItem,
47
+ type SessionHistoryPagination,
45
48
  createEmptySessionSnapshot,
46
49
  emptyRequiredActionsOverlay,
47
50
  replaceSessionSnapshot,
@@ -197,16 +200,435 @@ import {
197
200
 
198
201
  const TURN_EVENTS_PAGE_SIZE = 25;
199
202
  const SESSION_EVENTS_PAGE_SIZE = 100;
203
+ /** Cap how many event pages initial load / load-older may chain for a group boundary. */
204
+ const MAX_HISTORY_BOUNDARY_PAGES = 10;
205
+
206
+ type FetchSessionEventsOptions = {
207
+ /**
208
+ * Newest turn in the listing window (initial load only). Lists that turn and
209
+ * its ancestors. Omit to use the session last turn. Running-turn events are
210
+ * excluded by the API — subscribe to that turn for live events.
211
+ */
212
+ lastTurnId?: string;
213
+ };
214
+
215
+ type SessionEventsPageResult = {
216
+ /** Newest-first items from this request. */
217
+ itemsNewestFirst: GatewaySessionEventItem[];
218
+ olderPageToken?: string;
219
+ hasOlder: boolean;
220
+ };
200
221
 
201
222
  /**
202
- * Session-level event item structurally equivalent to
203
- * `TrueFoundryGateway.SessionEventItem` from
204
- * `AgentSession.listEvents` (not re-exported from agents subpath).
223
+ * Drops a leading incomplete turn (events before the first `turn.created`) that
224
+ * appears when a page boundary splits a turn.
205
225
  */
206
- type GatewaySessionEventItem = {
207
- turnId: string;
208
- event: TurnCreatedEvent | TurnDoneEvent | TurnEvent;
209
- };
226
+ function trimIncompleteLeadingEvents(
227
+ itemsAsc: GatewaySessionEventItem[],
228
+ ): GatewaySessionEventItem[] {
229
+ const start = itemsAsc.findIndex((item) => item.event.type === "turn.created");
230
+ if (start === -1) {
231
+ return [];
232
+ }
233
+ return itemsAsc.slice(start);
234
+ }
235
+
236
+ /**
237
+ * Whether the oldest complete turn in a chronological window opens a user group.
238
+ * `incomplete` means we still lack a finished oldest turn (need an older page).
239
+ */
240
+ function oldestCompleteTurnGroupState(
241
+ itemsAsc: GatewaySessionEventItem[],
242
+ ): "user-group" | "continuation" | "incomplete" {
243
+ const trimmed = trimIncompleteLeadingEvents(itemsAsc);
244
+ if (trimmed.length === 0) {
245
+ return "incomplete";
246
+ }
247
+ const created = trimmed[0];
248
+ if (created?.event.type !== "turn.created") {
249
+ return "incomplete";
250
+ }
251
+ const hasDone = trimmed.some(
252
+ (item) => item.turnId === created.turnId && item.event.type === "turn.done",
253
+ );
254
+ if (!hasDone) {
255
+ return "incomplete";
256
+ }
257
+ return extractTurnUserText(created.event.input) != null
258
+ ? "user-group"
259
+ : "continuation";
260
+ }
261
+
262
+ async function fetchSessionEventsPage(
263
+ session: AgentSession,
264
+ options?: FetchSessionEventsOptions & { pageToken?: string },
265
+ ): Promise<SessionEventsPageResult> {
266
+ const page = await session.listEvents({
267
+ limit: SESSION_EVENTS_PAGE_SIZE,
268
+ ...(options?.lastTurnId != null ? { lastTurnId: options.lastTurnId } : {}),
269
+ ...(options?.pageToken != null ? { pageToken: options.pageToken } : {}),
270
+ });
271
+ const response = page.response as {
272
+ pagination?: { nextPageToken?: string };
273
+ };
274
+ const olderPageToken = response.pagination?.nextPageToken;
275
+ const hasOlder =
276
+ typeof page.hasNextPage === "function"
277
+ ? page.hasNextPage()
278
+ : olderPageToken != null && olderPageToken !== "";
279
+ return {
280
+ itemsNewestFirst: page.data as GatewaySessionEventItem[],
281
+ ...(olderPageToken != null && olderPageToken !== ""
282
+ ? { olderPageToken }
283
+ : {}),
284
+ hasOlder,
285
+ };
286
+ }
287
+
288
+ /**
289
+ * Fetches pages until the chronological window starts on a complete user-message
290
+ * turn group (or history is exhausted).
291
+ */
292
+ async function fetchSessionEventsWindow(
293
+ session: AgentSession,
294
+ options?: FetchSessionEventsOptions & { pageToken?: string },
295
+ ): Promise<{
296
+ itemsAsc: GatewaySessionEventItem[];
297
+ olderPageToken?: string;
298
+ hasOlder: boolean;
299
+ }> {
300
+ let itemsNewestFirst: GatewaySessionEventItem[] = [];
301
+ let pageToken = options?.pageToken;
302
+ let olderPageToken: string | undefined;
303
+ let hasOlder = false;
304
+
305
+ for (let pageCount = 0; pageCount < MAX_HISTORY_BOUNDARY_PAGES; pageCount++) {
306
+ const page = await fetchSessionEventsPage(session, {
307
+ ...options,
308
+ ...(pageToken != null ? { pageToken } : {}),
309
+ });
310
+ itemsNewestFirst = [...itemsNewestFirst, ...page.itemsNewestFirst];
311
+ olderPageToken = page.olderPageToken;
312
+ hasOlder = page.hasOlder;
313
+
314
+ const itemsAsc = trimIncompleteLeadingEvents([...itemsNewestFirst].reverse());
315
+ const groupState = oldestCompleteTurnGroupState(itemsAsc);
316
+ if (groupState === "user-group" || !hasOlder) {
317
+ return {
318
+ itemsAsc,
319
+ ...(olderPageToken != null ? { olderPageToken } : {}),
320
+ hasOlder,
321
+ };
322
+ }
323
+
324
+ if (olderPageToken == null) {
325
+ return { itemsAsc, hasOlder: false };
326
+ }
327
+ pageToken = olderPageToken;
328
+ }
329
+
330
+ const itemsAsc = trimIncompleteLeadingEvents([...itemsNewestFirst].reverse());
331
+ return {
332
+ itemsAsc,
333
+ ...(olderPageToken != null ? { olderPageToken } : {}),
334
+ hasOlder,
335
+ };
336
+ }
337
+
338
+ /**
339
+ * Fetches session-level events via `session.listEvents()`. The API returns pages
340
+ * in desc order (newest first); the collected array is reversed before returning
341
+ * so callers receive events in chronological (asc) order.
342
+ *
343
+ * Used by rewind/edit paths that need the full ancestor window.
344
+ */
345
+ async function fetchAllSessionEvents(
346
+ session: AgentSession,
347
+ options?: FetchSessionEventsOptions,
348
+ ): Promise<GatewaySessionEventItem[]> {
349
+ const items: GatewaySessionEventItem[] = [];
350
+ for await (const item of await session.listEvents({
351
+ limit: SESSION_EVENTS_PAGE_SIZE,
352
+ ...(options?.lastTurnId != null ? { lastTurnId: options.lastTurnId } : {}),
353
+ })) {
354
+ items.push(item as GatewaySessionEventItem);
355
+ }
356
+ items.reverse();
357
+ return items;
358
+ }
359
+
360
+ function cloneThreadBucket(bucket: ThreadBucket): ThreadBucket {
361
+ return {
362
+ events: new Map(bucket.events),
363
+ modelMessageIds: [...bucket.modelMessageIds],
364
+ toolResults: new Map(bucket.toolResults),
365
+ pendingApprovals: new Map(bucket.pendingApprovals),
366
+ approvalDecisions: new Map(bucket.approvalDecisions),
367
+ pendingResponses: new Map(bucket.pendingResponses),
368
+ done: bucket.done,
369
+ ...(bucket.title != null ? { title: bucket.title } : {}),
370
+ ...(bucket.agentInfo != null ? { agentInfo: bucket.agentInfo } : {}),
371
+ };
372
+ }
373
+
374
+ /** Prepends older fold state ahead of the currently loaded fold (scroll-up). */
375
+ export function prependFoldState(
376
+ older: PeerThreadFoldState,
377
+ newer: PeerThreadFoldState,
378
+ ): PeerThreadFoldState {
379
+ const result = new PeerThreadFoldState();
380
+ const threadIds = new Set([...older.threads.keys(), ...newer.threads.keys()]);
381
+ for (const threadId of threadIds) {
382
+ const olderBucket = older.threads.get(threadId);
383
+ const newerBucket = newer.threads.get(threadId);
384
+ if (olderBucket == null && newerBucket != null) {
385
+ result.threads.set(threadId, cloneThreadBucket(newerBucket));
386
+ continue;
387
+ }
388
+ if (newerBucket == null && olderBucket != null) {
389
+ result.threads.set(threadId, cloneThreadBucket(olderBucket));
390
+ continue;
391
+ }
392
+ if (olderBucket == null || newerBucket == null) {
393
+ continue;
394
+ }
395
+ result.threads.set(threadId, {
396
+ events: new Map([...olderBucket.events, ...newerBucket.events]),
397
+ modelMessageIds: [
398
+ ...olderBucket.modelMessageIds,
399
+ ...newerBucket.modelMessageIds,
400
+ ],
401
+ toolResults: new Map([
402
+ ...olderBucket.toolResults,
403
+ ...newerBucket.toolResults,
404
+ ]),
405
+ pendingApprovals: new Map([
406
+ ...olderBucket.pendingApprovals,
407
+ ...newerBucket.pendingApprovals,
408
+ ]),
409
+ approvalDecisions: new Map([
410
+ ...olderBucket.approvalDecisions,
411
+ ...newerBucket.approvalDecisions,
412
+ ]),
413
+ pendingResponses: new Map([
414
+ ...olderBucket.pendingResponses,
415
+ ...newerBucket.pendingResponses,
416
+ ]),
417
+ done: newerBucket.done || olderBucket.done,
418
+ ...(newerBucket.title != null || olderBucket.title != null
419
+ ? { title: newerBucket.title ?? olderBucket.title }
420
+ : {}),
421
+ ...(newerBucket.agentInfo != null || olderBucket.agentInfo != null
422
+ ? { agentInfo: newerBucket.agentInfo ?? olderBucket.agentInfo }
423
+ : {}),
424
+ });
425
+ }
426
+ for (const [threadId, link] of older.threadParents) {
427
+ result.threadParents.set(threadId, link);
428
+ }
429
+ for (const [threadId, link] of newer.threadParents) {
430
+ result.threadParents.set(threadId, link);
431
+ }
432
+ return result;
433
+ }
434
+
435
+ /**
436
+ * Ingests a chronologically ordered list of session-level event items into the
437
+ * snapshot. `turn.created` marks the start of a turn (provides user input),
438
+ * content events are folded as usual, and `turn.done` finalises the turn record
439
+ * and pushes it to `snapshot.turns`.
440
+ *
441
+ * `onTurnComplete` is called after each `turn.done` with the partially-built
442
+ * snapshot so callers can update UI progressively. The fold state is correct at
443
+ * that point for every turn up to and including the just-completed one.
444
+ */
445
+ function ingestSessionEventsIntoSnapshot(
446
+ snapshot: SessionSnapshot,
447
+ items: GatewaySessionEventItem[],
448
+ onTurnComplete?: (snap: SessionSnapshot) => void,
449
+ ): void {
450
+ let currentTurnId: string | null = null;
451
+ let currentCreatedEvent: TurnCreatedEvent | null = null;
452
+ let currentContentEvents: TurnEvent[] = [];
453
+ let beforeCount = 0;
454
+
455
+ for (const item of items) {
456
+ const { turnId, event } = item;
457
+
458
+ if (event.type === "turn.created") {
459
+ currentTurnId = turnId;
460
+ currentCreatedEvent = event;
461
+ currentContentEvents = [];
462
+ beforeCount =
463
+ snapshot.fold.threads.get(ROOT_THREAD_ID)?.modelMessageIds.length ?? 0;
464
+ } else if (event.type === "turn.done") {
465
+ if (currentTurnId == null || currentCreatedEvent == null) {
466
+ continue;
467
+ }
468
+
469
+ const afterBucket = snapshot.fold.threads.get(ROOT_THREAD_ID);
470
+ const rootModelMessageIds = (afterBucket?.modelMessageIds ?? []).slice(
471
+ beforeCount,
472
+ );
473
+
474
+ const sandboxEvent = currentContentEvents.find(
475
+ (ev): ev is Extract<TurnEvent, { type: "sandbox.created" }> =>
476
+ ev.type === "sandbox.created",
477
+ );
478
+
479
+ applyUserToolResponsesToFold(
480
+ snapshot.fold,
481
+ currentCreatedEvent.input ?? [],
482
+ );
483
+ snapshot.turns.push(
484
+ sessionEventsToSessionRecord(
485
+ currentTurnId,
486
+ currentCreatedEvent,
487
+ event,
488
+ rootModelMessageIds,
489
+ sandboxEvent?.sandboxId,
490
+ ),
491
+ );
492
+
493
+ // Pass a new object reference so React's Object.is check in
494
+ // useState sees a changed value and schedules a re-render.
495
+ // The snapshot is mutated in place throughout this loop, so
496
+ // passing `snapshot` directly would make every tick look identical
497
+ // to React after the first setSnapshot call.
498
+ onTurnComplete?.(replaceSessionSnapshot(snapshot, {}));
499
+
500
+ currentTurnId = null;
501
+ currentCreatedEvent = null;
502
+ currentContentEvents = [];
503
+ } else {
504
+ if (currentTurnId != null) {
505
+ ingestTurnEvent(snapshot.fold, event as TurnEvent);
506
+ currentContentEvents.push(event as TurnEvent);
507
+ }
508
+ }
509
+ }
510
+ }
511
+
512
+ function attachRunningTurn(
513
+ snapshot: SessionSnapshot,
514
+ runningTurn: Turn | undefined,
515
+ ): SessionSnapshot {
516
+ if (runningTurn == null) {
517
+ return snapshot;
518
+ }
519
+ const pendingUserText = extractTurnUserText(runningTurn.input);
520
+ return replaceSessionSnapshot(snapshot, {
521
+ runningTurn,
522
+ unstable_resume: true as const,
523
+ groupRootBaseline: computeGroupRootBaseline(snapshot.turns),
524
+ ...(pendingUserText
525
+ ? {
526
+ pendingUser: {
527
+ turnId: runningTurn.id,
528
+ content: extractTurnUserMessageContent(runningTurn.input),
529
+ createdAt: new Date(runningTurn.createdAt),
530
+ },
531
+ }
532
+ : {}),
533
+ });
534
+ }
535
+
536
+ /**
537
+ * Builds a session snapshot using the session-level `listEvents` API.
538
+ *
539
+ * Loads the newest event page (extending only when a page boundary splits a
540
+ * turn group), then leaves older pages for `prependOlderSessionHistory`.
541
+ * Only `listTurns({ limit: 1 })` is called first to detect a currently-running
542
+ * turn (the session-level API does not return events for the running turn).
543
+ *
544
+ * `onProgress` is called after each complete turn is ingested so callers can
545
+ * update the UI progressively while the processing loop runs.
546
+ */
547
+ export async function buildSnapshotFromSessionEvents(
548
+ session: AgentSession,
549
+ onProgress?: (snap: SessionSnapshot) => void,
550
+ ): Promise<SessionSnapshot> {
551
+ // Detect a running turn with a single listTurns page — do not drain pagination.
552
+ const turnsPage = await session.listTurns({ limit: 1 });
553
+ const newestTurn = turnsPage.data[0] as Turn | undefined;
554
+ const runningTurn =
555
+ newestTurn?.state?.status === "running" ? newestTurn : undefined;
556
+
557
+ const window = await fetchSessionEventsWindow(session);
558
+ const historyPagination: SessionHistoryPagination = {
559
+ hasOlder: window.hasOlder,
560
+ ...(window.olderPageToken != null
561
+ ? { olderPageToken: window.olderPageToken }
562
+ : {}),
563
+ };
564
+
565
+ const snapshot = createEmptySessionSnapshot();
566
+ ingestSessionEventsIntoSnapshot(snapshot, window.itemsAsc, onProgress);
567
+
568
+ const withHistory = replaceSessionSnapshot(snapshot, {
569
+ historyEvents: window.itemsAsc,
570
+ historyPagination,
571
+ });
572
+
573
+ return attachRunningTurn(withHistory, runningTurn);
574
+ }
575
+
576
+ /**
577
+ * Fetches the next older `listEvents` window and prepends it onto `snapshot`
578
+ * without tearing down live stream / pending UI state.
579
+ */
580
+ export async function prependOlderSessionHistory(
581
+ session: AgentSession,
582
+ snapshot: SessionSnapshot,
583
+ ): Promise<SessionSnapshot> {
584
+ const pagination = snapshot.historyPagination;
585
+ if (pagination?.hasOlder !== true || pagination.olderPageToken == null) {
586
+ return snapshot;
587
+ }
588
+
589
+ const window = await fetchSessionEventsWindow(session, {
590
+ pageToken: pagination.olderPageToken,
591
+ });
592
+ if (window.itemsAsc.length === 0) {
593
+ return replaceSessionSnapshot(snapshot, {
594
+ historyPagination: { hasOlder: false },
595
+ });
596
+ }
597
+
598
+ const olderSnap = createEmptySessionSnapshot();
599
+ ingestSessionEventsIntoSnapshot(olderSnap, window.itemsAsc);
600
+
601
+ const existingIds = new Set(snapshot.turns.map((turn) => turn.id));
602
+ const olderTurns = olderSnap.turns.filter((turn) => !existingIds.has(turn.id));
603
+ const mergedFold = prependFoldState(olderSnap.fold, snapshot.fold);
604
+ const historyEvents = [
605
+ ...window.itemsAsc,
606
+ ...(snapshot.historyEvents ?? []),
607
+ ];
608
+ const olderRootIds = olderTurns.flatMap(
609
+ (turn) => turn.rootModelMessageIds ?? [],
610
+ );
611
+
612
+ return replaceSessionSnapshot(snapshot, {
613
+ fold: mergedFold,
614
+ turns: [...olderTurns, ...snapshot.turns],
615
+ historyEvents,
616
+ historyPagination: {
617
+ hasOlder: window.hasOlder,
618
+ ...(window.olderPageToken != null
619
+ ? { olderPageToken: window.olderPageToken }
620
+ : {}),
621
+ },
622
+ ...(snapshot.groupRootBaseline != null
623
+ ? {
624
+ groupRootBaseline: [
625
+ ...olderRootIds,
626
+ ...snapshot.groupRootBaseline,
627
+ ],
628
+ }
629
+ : {}),
630
+ });
631
+ }
210
632
 
211
633
  export type ConvertTurnsResult = {
212
634
  messages: ThreadMessage[];
@@ -746,163 +1168,6 @@ export function projectSessionMessages(
746
1168
 
747
1169
  const DEFAULT_LIST_EVENTS_CONCURRENCY = 5;
748
1170
 
749
- type FetchSessionEventsOptions = {
750
- /**
751
- * Newest turn in the listing window (initial load only). Lists that turn and
752
- * its ancestors. Omit to use the session last turn. Running-turn events are
753
- * excluded by the API — subscribe to that turn for live events.
754
- */
755
- lastTurnId?: string;
756
- };
757
-
758
- /**
759
- * Fetches session-level events via `session.listEvents()`. The API returns pages
760
- * in desc order (newest first); the collected array is reversed before returning
761
- * so callers receive events in chronological (asc) order.
762
- */
763
- async function fetchAllSessionEvents(
764
- session: AgentSession,
765
- options?: FetchSessionEventsOptions,
766
- ): Promise<GatewaySessionEventItem[]> {
767
- const items: GatewaySessionEventItem[] = [];
768
- for await (const item of await session.listEvents({
769
- limit: SESSION_EVENTS_PAGE_SIZE,
770
- ...(options?.lastTurnId != null ? { lastTurnId: options.lastTurnId } : {}),
771
- })) {
772
- items.push(item as GatewaySessionEventItem);
773
- }
774
- items.reverse();
775
- return items;
776
- }
777
-
778
- /**
779
- * Ingests a chronologically ordered list of session-level event items into the
780
- * snapshot. `turn.created` marks the start of a turn (provides user input),
781
- * content events are folded as usual, and `turn.done` finalises the turn record
782
- * and pushes it to `snapshot.turns`.
783
- *
784
- * `onTurnComplete` is called after each `turn.done` with the partially-built
785
- * snapshot so callers can update UI progressively. The fold state is correct at
786
- * that point for every turn up to and including the just-completed one.
787
- */
788
- function ingestSessionEventsIntoSnapshot(
789
- snapshot: SessionSnapshot,
790
- items: GatewaySessionEventItem[],
791
- onTurnComplete?: (snap: SessionSnapshot) => void,
792
- ): void {
793
- let currentTurnId: string | null = null;
794
- let currentCreatedEvent: TurnCreatedEvent | null = null;
795
- let currentContentEvents: TurnEvent[] = [];
796
- let beforeCount = 0;
797
-
798
- for (const item of items) {
799
- const { turnId, event } = item;
800
-
801
- if (event.type === "turn.created") {
802
- currentTurnId = turnId;
803
- currentCreatedEvent = event;
804
- currentContentEvents = [];
805
- beforeCount =
806
- snapshot.fold.threads.get(ROOT_THREAD_ID)?.modelMessageIds.length ?? 0;
807
- } else if (event.type === "turn.done") {
808
- if (currentTurnId == null || currentCreatedEvent == null) {
809
- continue;
810
- }
811
-
812
- const afterBucket = snapshot.fold.threads.get(ROOT_THREAD_ID);
813
- const rootModelMessageIds = (afterBucket?.modelMessageIds ?? []).slice(
814
- beforeCount,
815
- );
816
-
817
- const sandboxEvent = currentContentEvents.find(
818
- (ev): ev is Extract<TurnEvent, { type: "sandbox.created" }> =>
819
- ev.type === "sandbox.created",
820
- );
821
-
822
- applyUserToolResponsesToFold(
823
- snapshot.fold,
824
- currentCreatedEvent.input ?? [],
825
- );
826
- snapshot.turns.push(
827
- sessionEventsToSessionRecord(
828
- currentTurnId,
829
- currentCreatedEvent,
830
- event,
831
- rootModelMessageIds,
832
- sandboxEvent?.sandboxId,
833
- ),
834
- );
835
-
836
- // Pass a new object reference so React's Object.is check in
837
- // useState sees a changed value and schedules a re-render.
838
- // The snapshot is mutated in place throughout this loop, so
839
- // passing `snapshot` directly would make every tick look identical
840
- // to React after the first setSnapshot call.
841
- onTurnComplete?.(replaceSessionSnapshot(snapshot, {}));
842
-
843
- currentTurnId = null;
844
- currentCreatedEvent = null;
845
- currentContentEvents = [];
846
- } else {
847
- if (currentTurnId != null) {
848
- ingestTurnEvent(snapshot.fold, event as TurnEvent);
849
- currentContentEvents.push(event as TurnEvent);
850
- }
851
- }
852
- }
853
- }
854
-
855
- /**
856
- * Builds a session snapshot using the session-level `listEvents` API.
857
- *
858
- * Compared to `buildSnapshotFromSession`, this makes a single paginated request
859
- * for all history events instead of one per turn. Only `listTurns({ limit: 1 })`
860
- * is called first to detect a currently-running turn (the session-level API does
861
- * not return events for the running turn).
862
- *
863
- * `onProgress` is called after each complete turn is ingested so callers can
864
- * update the UI progressively while the processing loop runs.
865
- */
866
- export async function buildSnapshotFromSessionEvents(
867
- session: AgentSession,
868
- onProgress?: (snap: SessionSnapshot) => void,
869
- ): Promise<SessionSnapshot> {
870
- // Detect a running turn with a single minimal listTurns call.
871
- let runningTurn: Turn | undefined;
872
- for await (const turn of await session.listTurns({ limit: 1 })) {
873
- if ((turn as Turn).state?.status === "running") {
874
- runningTurn = turn as Turn;
875
- }
876
- }
877
-
878
- const items = await fetchAllSessionEvents(session);
879
-
880
- const snapshot = createEmptySessionSnapshot();
881
- ingestSessionEventsIntoSnapshot(snapshot, items, onProgress);
882
-
883
- if (runningTurn == null) {
884
- return snapshot;
885
- }
886
-
887
- // Session listEvents excludes the running turn. Seed its user message so
888
- // reconnect UI can show the in-flight bubble before subscribe yields.
889
- const pendingUserText = extractTurnUserText(runningTurn.input);
890
- return replaceSessionSnapshot(snapshot, {
891
- runningTurn,
892
- unstable_resume: true as const,
893
- groupRootBaseline: computeGroupRootBaseline(snapshot.turns),
894
- ...(pendingUserText
895
- ? {
896
- pendingUser: {
897
- turnId: runningTurn.id,
898
- content: extractTurnUserMessageContent(runningTurn.input),
899
- createdAt: new Date(runningTurn.createdAt),
900
- },
901
- }
902
- : {}),
903
- });
904
- }
905
-
906
1171
  function ingestTurnsIntoSnapshot(
907
1172
  snapshot: SessionSnapshot,
908
1173
  turns: Turn[],
@@ -959,10 +1224,13 @@ export async function buildSnapshotFromSession(
959
1224
  const eventArrays = await fetchAllTurnEventsWithConcurrency([turn], concurrency);
960
1225
  ingestTurnsIntoSnapshot(snapshot, [turn], eventArrays);
961
1226
 
1227
+ // Hydrated in-flight content lives in `turns` / `fold` now — drop the
1228
+ // reconnect seed so projection does not duplicate the user bubble.
962
1229
  return replaceSessionSnapshot(snapshot, {
963
1230
  runningTurn: turn,
964
1231
  unstable_resume: true as const,
965
1232
  groupRootBaseline: computeGroupRootBaseline(snapshot.turns),
1233
+ pendingUser: undefined,
966
1234
  });
967
1235
  }
968
1236
 
@@ -1021,6 +1289,16 @@ export async function resolveGatewayBranchPreviousTurnId(
1021
1289
  return turns[turnIndex - 1]?.id ?? null;
1022
1290
  }
1023
1291
 
1292
+ /** Resolves `previousTurnId` by turn id so partial history windows stay correct. */
1293
+ export async function resolveGatewayBranchPreviousTurnIdForTurn(
1294
+ session: AgentSession,
1295
+ turnId: string,
1296
+ ): Promise<string | null> {
1297
+ const turns = await listSessionTurnsOrdered(session);
1298
+ const turnIndex = turns.findIndex((turn) => turn.id === turnId);
1299
+ return resolveGatewayBranchPreviousTurnId(session, turnIndex, turns);
1300
+ }
1301
+
1024
1302
  async function listSessionTurnsOrdered(session: AgentSession): Promise<Turn[]> {
1025
1303
  const turns: Turn[] = [];
1026
1304
  for await (const turn of await session.listTurns()) {
@@ -1,7 +1,12 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { mergeAgentSpec } from "./private/agentSpec.js";
4
- import { resolveTrueFoundryAgentConfig } from "./types.js";
4
+ import {
5
+ resolveTrueFoundryAgentConfig,
6
+ resolveTrueFoundryAgentRuntimeOptions,
7
+ } from "./types.js";
8
+ import type { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
9
+ import type { PrivateAgentSessionClient } from "truefoundry-gateway-sdk/agents/private";
5
10
 
6
11
  describe("resolveTrueFoundryAgentConfig", () => {
7
12
  it("supports legacy agentName", () => {
@@ -36,6 +41,30 @@ describe("resolveTrueFoundryAgentConfig", () => {
36
41
  });
37
42
  });
38
43
 
44
+ describe("resolveTrueFoundryAgentRuntimeOptions", () => {
45
+ const client = {} as AgentSessionClient;
46
+
47
+ it("requires privateClient for draft mode", () => {
48
+ expect(() =>
49
+ resolveTrueFoundryAgentRuntimeOptions({
50
+ client,
51
+ agent: { mode: "draft", defaultAgentSpec: { model: { name: "x" } } },
52
+ }),
53
+ ).toThrow(/privateClient/);
54
+ });
55
+
56
+ it("accepts privateClient for draft mode", () => {
57
+ const privateClient = {} as PrivateAgentSessionClient;
58
+ const resolved = resolveTrueFoundryAgentRuntimeOptions({
59
+ client,
60
+ privateClient,
61
+ agent: { mode: "draft", defaultAgentSpec: { model: { name: "x" } } },
62
+ });
63
+ expect(resolved.privateClient).toBe(privateClient);
64
+ expect(resolved.agent.mode).toBe("draft");
65
+ });
66
+ });
67
+
39
68
  describe("mergeAgentSpec", () => {
40
69
  it("deep-merges model params", () => {
41
70
  const base = {