@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
@@ -10,12 +10,15 @@ import type {
10
10
  AgentSession,
11
11
  McpAuthRequiredEvent,
12
12
  Turn,
13
+ TurnCreatedEvent,
14
+ TurnDoneEvent,
13
15
  TurnEvent,
14
16
  TurnInputItem,
15
17
  TurnStreamData,
16
18
  } from "truefoundry-gateway-sdk/agents";
17
19
 
18
20
  import { ROOT_THREAD_ID } from "./constants.js";
21
+ import { extractTurnUserText } from "./extractTurnUserText.js";
19
22
  import {
20
23
  buildRootAssistantContent,
21
24
  buildRootAssistantContentForIds,
@@ -24,6 +27,7 @@ import {
24
27
  ingestStreamEvent,
25
28
  ingestTurnEvent,
26
29
  PeerThreadFoldState,
30
+ type ThreadBucket,
27
31
  } from "./foldPeerThreads.js";
28
32
  import {
29
33
  appendMcpAuthToTurnContent,
@@ -39,9 +43,12 @@ import {
39
43
  type ProjectSessionMessagesOptions,
40
44
  type SessionTurnRecord,
41
45
  type RequiredActionsOverlay,
46
+ type GatewaySessionEventItem,
47
+ type SessionHistoryPagination,
42
48
  createEmptySessionSnapshot,
43
49
  emptyRequiredActionsOverlay,
44
50
  replaceSessionSnapshot,
51
+ sessionEventsToSessionRecord,
45
52
  turnToSessionRecord,
46
53
  } from "./sessionSnapshot.js";
47
54
  import {
@@ -186,12 +193,442 @@ import {
186
193
  * `user.message`.
187
194
  * - Tool-call identity is stable via `toolCallId` across events, fold state, and
188
195
  * assistant-ui parts.
189
- * - Reload (`buildSnapshotFromSession`) and live paths must produce the same
196
+ * - Reload (`buildSnapshotFromSessionEvents`) and live paths must produce the same
190
197
  * per-group scoping; history uses cumulative `groupRootIds` per turn index,
191
198
  * live uses `groupRootBaseline`.
192
199
  */
193
200
 
194
201
  const TURN_EVENTS_PAGE_SIZE = 25;
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
+ };
221
+
222
+ /**
223
+ * Drops a leading incomplete turn (events before the first `turn.created`) that
224
+ * appears when a page boundary splits a turn.
225
+ */
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
+ }
195
632
 
196
633
  export type ConvertTurnsResult = {
197
634
  messages: ThreadMessage[];
@@ -775,25 +1212,25 @@ export async function buildSnapshotFromSession(
775
1212
  session: AgentSession,
776
1213
  concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
777
1214
  ): Promise<SessionSnapshot> {
778
- const turns: Turn[] = [];
779
- for await (const turn of await session.listTurns()) {
780
- turns.push(turn);
1215
+ // Completed history comes from session-level listEvents. The session API
1216
+ // excludes the running turn hydrate that turn via turn.listEvents so
1217
+ // convertTurnsToThreadMessages still surfaces in-flight content.
1218
+ const snapshot = await buildSnapshotFromSessionEvents(session);
1219
+ if (snapshot.runningTurn == null) {
1220
+ return snapshot;
781
1221
  }
782
- turns.reverse();
783
1222
 
784
- const eventArrays = await fetchAllTurnEventsWithConcurrency(turns, concurrency);
785
-
786
- const snapshot = createEmptySessionSnapshot();
787
- const runningTurn = ingestTurnsIntoSnapshot(snapshot, turns, eventArrays);
1223
+ const turn = snapshot.runningTurn;
1224
+ const eventArrays = await fetchAllTurnEventsWithConcurrency([turn], concurrency);
1225
+ ingestTurnsIntoSnapshot(snapshot, [turn], eventArrays);
788
1226
 
1227
+ // Hydrated in-flight content lives in `turns` / `fold` now — drop the
1228
+ // reconnect seed so projection does not duplicate the user bubble.
789
1229
  return replaceSessionSnapshot(snapshot, {
790
- ...(runningTurn != null
791
- ? {
792
- runningTurn,
793
- unstable_resume: true as const,
794
- groupRootBaseline: computeGroupRootBaseline(snapshot.turns),
795
- }
796
- : {}),
1230
+ runningTurn: turn,
1231
+ unstable_resume: true as const,
1232
+ groupRootBaseline: computeGroupRootBaseline(snapshot.turns),
1233
+ pendingUser: undefined,
797
1234
  });
798
1235
  }
799
1236
 
@@ -803,11 +1240,7 @@ export async function buildSnapshotBeforeTurn(
803
1240
  beforeTurnId: string,
804
1241
  concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
805
1242
  ): Promise<SessionSnapshot> {
806
- const turns: Turn[] = [];
807
- for await (const turn of await session.listTurns()) {
808
- turns.push(turn);
809
- }
810
- turns.reverse();
1243
+ const turns = await listSessionTurnsOrdered(session);
811
1244
 
812
1245
  const beforeIndex = turns.findIndex((turn) => turn.id === beforeTurnId);
813
1246
  if (beforeIndex === -1) {
@@ -821,7 +1254,7 @@ export async function buildSnapshotBeforeTurn(
821
1254
  export async function buildSnapshotBeforeTurnIndex(
822
1255
  session: AgentSession,
823
1256
  turnIndex: number,
824
- concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
1257
+ _concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
825
1258
  orderedTurns?: Turn[],
826
1259
  ): Promise<SessionSnapshot> {
827
1260
  if (turnIndex <= 0) {
@@ -830,16 +1263,16 @@ export async function buildSnapshotBeforeTurnIndex(
830
1263
 
831
1264
  const turns = orderedTurns ?? (await listSessionTurnsOrdered(session));
832
1265
  const turnsToInclude = turns.slice(0, turnIndex);
833
- if (turnsToInclude.length === 0) {
1266
+ const lastTurnId = turnsToInclude.at(-1)?.id;
1267
+ if (lastTurnId == null) {
834
1268
  return createEmptySessionSnapshot();
835
1269
  }
836
1270
 
837
- const eventArrays = await fetchAllTurnEventsWithConcurrency(
838
- turnsToInclude,
839
- concurrency,
840
- );
1271
+ // Anchor the session events window at the newest included turn so the
1272
+ // ancestor chain matches `[turns[0], …, turns[turnIndex - 1]]`.
1273
+ const items = await fetchAllSessionEvents(session, { lastTurnId });
841
1274
  const snapshot = createEmptySessionSnapshot();
842
- ingestTurnsIntoSnapshot(snapshot, turnsToInclude, eventArrays);
1275
+ ingestSessionEventsIntoSnapshot(snapshot, items);
843
1276
  return snapshot;
844
1277
  }
845
1278
 
@@ -856,6 +1289,16 @@ export async function resolveGatewayBranchPreviousTurnId(
856
1289
  return turns[turnIndex - 1]?.id ?? null;
857
1290
  }
858
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
+
859
1302
  async function listSessionTurnsOrdered(session: AgentSession): Promise<Turn[]> {
860
1303
  const turns: Turn[] = [];
861
1304
  for await (const turn of await session.listTurns()) {
@@ -1,8 +1,16 @@
1
1
  import type { ToolCall } from "truefoundry-gateway-sdk/agents";
2
2
 
3
- export function isCreateSubAgentToolCall(toolCall: Pick<ToolCall, "toolInfo">): boolean {
4
- return (
5
- toolCall.toolInfo.type === "truefoundry-system" &&
6
- toolCall.toolInfo.name === "create_sub_agent"
7
- );
3
+ export function isCreateSubAgentToolCall(
4
+ toolCall: Pick<ToolCall, "toolInfo" | "function">,
5
+ ): boolean {
6
+ // Gateway may attach truefoundry-system toolInfo on persisted turns, but streamed
7
+ // model.message tool calls often only carry function.name. Without the fallback,
8
+ // foldPeerThreads never nests child threads under the spawning tool call.
9
+ if (
10
+ toolCall.toolInfo?.type === "truefoundry-system" &&
11
+ toolCall.toolInfo?.name === "create_sub_agent"
12
+ ) {
13
+ return true;
14
+ }
15
+ return toolCall.function?.name === "create_sub_agent";
8
16
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
- import { mergeAgentSpec } from "./agentSpec.js";
3
+ import { mergeAgentSpec } from "./private/agentSpec.js";
4
4
  import { resolveTrueFoundryAgentConfig } from "./types.js";
5
5
 
6
6
  describe("resolveTrueFoundryAgentConfig", () => {
@@ -129,6 +129,62 @@ describe("foldPeerThreads", () => {
129
129
  });
130
130
  });
131
131
 
132
+ it("attaches sub-agent artifact from thread.created before child model messages arrive", () => {
133
+ const state = new PeerThreadFoldState();
134
+
135
+ ingestTurnEvent(
136
+ state,
137
+ modelMessage({
138
+ id: "root-msg",
139
+ threadId: ROOT_THREAD_ID,
140
+ toolCalls: [
141
+ {
142
+ id: "spawn-1",
143
+ type: "function",
144
+ function: { name: "create_sub_agent", arguments: "{}" },
145
+ },
146
+ ],
147
+ }),
148
+ );
149
+
150
+ ingestTurnEvent(
151
+ state,
152
+ threadCreated({
153
+ id: "t-created",
154
+ threadId: "child-1",
155
+ title: "gateway-usage-7d",
156
+ agentInfo: {
157
+ type: "dynamic",
158
+ name: "gateway-usage-7d",
159
+ input: "run queries",
160
+ },
161
+ parent: { threadId: ROOT_THREAD_ID, toolCallId: "spawn-1" },
162
+ }),
163
+ );
164
+
165
+ const parts = buildRootAssistantContent(state);
166
+ const spawn = parts.find((part) => part.type === "tool-call");
167
+ expect(spawn?.type).toBe("tool-call");
168
+ if (spawn?.type !== "tool-call") {
169
+ return;
170
+ }
171
+
172
+ expect(spawn.messages).toBeUndefined();
173
+ expect(spawn.artifact).toEqual({
174
+ subAgents: [
175
+ {
176
+ threadId: "child-1",
177
+ title: "gateway-usage-7d",
178
+ agentInfo: {
179
+ type: "dynamic",
180
+ name: "gateway-usage-7d",
181
+ input: "run queries",
182
+ },
183
+ },
184
+ ],
185
+ });
186
+ });
187
+
132
188
  it("classifies stream events by thread id", () => {
133
189
  const state = new PeerThreadFoldState();
134
190
  expect(
@@ -389,11 +389,17 @@ function attachSubAgentMessages(
389
389
  });
390
390
  }
391
391
  }
392
- if (messages.length === 0) {
392
+ if (messages.length === 0 && subAgents.length === 0) {
393
393
  return part;
394
394
  }
395
395
 
396
396
  const artifact: SubAgentArtifact = { subAgents };
397
+ // thread.created (title, agentInfo) arrives before the child's first model.message.
398
+ // Attach artifact-only so UI can render the sub-agent header immediately; see README
399
+ // SubAgentArtifact / MessagePartPrimitive.Messages in agent-ui ToolCallContainer.
400
+ if (messages.length === 0) {
401
+ return { ...part, artifact };
402
+ }
397
403
  return { ...part, messages, artifact };
398
404
  });
399
405
  }