@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.
@@ -516,10 +516,6 @@ function attachRunningTurn(
516
516
  if (runningTurn == null) {
517
517
  return snapshot;
518
518
  }
519
- // Session-level history excludes the running turn. Apply continuation inputs
520
- // from the turn listing so answered approvals / ask-user prompts are not
521
- // restored as pending while reconnecting to that turn after a refresh.
522
- applyUserToolResponsesToFold(snapshot.fold, runningTurn.input ?? []);
523
519
  const pendingUserText = extractTurnUserText(runningTurn.input);
524
520
  return replaceSessionSnapshot(snapshot, {
525
521
  runningTurn,
@@ -537,13 +533,117 @@ function attachRunningTurn(
537
533
  });
538
534
  }
539
535
 
536
+ /**
537
+ * Last `turn.created` in ASC event order with no following `turn.done` — the
538
+ * open tip of the active branch in this window.
539
+ */
540
+ function findOpenTurnCreated(
541
+ itemsAsc: readonly GatewaySessionEventItem[],
542
+ ): { turnId: string; event: TurnCreatedEvent } | undefined {
543
+ let open: { turnId: string; event: TurnCreatedEvent } | undefined;
544
+ for (const item of itemsAsc) {
545
+ if (item.event.type === "turn.created") {
546
+ open = { turnId: item.turnId, event: item.event };
547
+ } else if (item.event.type === "turn.done") {
548
+ open = undefined;
549
+ }
550
+ }
551
+ return open;
552
+ }
553
+
554
+ function turnFromCreatedEvent(options: {
555
+ sessionId: string;
556
+ turnId: string;
557
+ event: TurnCreatedEvent;
558
+ }): Turn {
559
+ const { sessionId, turnId, event } = options;
560
+ return {
561
+ id: turnId,
562
+ sessionId,
563
+ state: { status: "running" },
564
+ createdAt: event.createdAt,
565
+ ...(event.input != null ? { input: event.input } : {}),
566
+ ...(event.previousTurnId === undefined
567
+ ? {}
568
+ : { previousTurnId: event.previousTurnId }),
569
+ };
570
+ }
571
+
572
+ type SessionTip = {
573
+ /** Turn to resume and subscribe to; absent once the tip has finished. */
574
+ runningTurn?: Turn;
575
+ /**
576
+ * Tip input that event ingestion could not apply, because it only folds a
577
+ * turn's input once that turn's `turn.done` arrives. Answered ask-user
578
+ * prompts and approvals live here, so this must be folded even when the tip
579
+ * is no longer running.
580
+ */
581
+ continuationInput: readonly TurnInputItem[];
582
+ };
583
+
584
+ /**
585
+ * Resolves the tip turn of the active branch for resume/subscribe.
586
+ *
587
+ * Prefer an open tip from the events window (works when listTurns is
588
+ * oldest-first and when listEvents includes the running turn). Fall back to
589
+ * `listTurns({ limit: 1 })` for hosts that omit the running turn from
590
+ * listEvents and put the tip first.
591
+ */
592
+ async function resolveSessionTip(options: {
593
+ server: AgentChatServer;
594
+ sessionId: string;
595
+ itemsAsc: readonly GatewaySessionEventItem[];
596
+ }): Promise<SessionTip> {
597
+ const { server, sessionId, itemsAsc } = options;
598
+ const open = findOpenTurnCreated(itemsAsc);
599
+ if (open != null) {
600
+ const continuationInput = open.event.input ?? [];
601
+ if (typeof server.getTurn === "function") {
602
+ try {
603
+ const turn = await server.getTurn({
604
+ sessionId,
605
+ turnId: open.turnId,
606
+ });
607
+ if (turn.state.status === "running") {
608
+ return {
609
+ runningTurn: turn,
610
+ continuationInput: turn.input ?? continuationInput,
611
+ };
612
+ }
613
+ // Tip finished between listEvents and getTurn: nothing to
614
+ // resume, but its answers still belong in the fold.
615
+ return { continuationInput: turn.input ?? continuationInput };
616
+ } catch {
617
+ // getTurn failed; synthesize from the open turn.created below.
618
+ }
619
+ }
620
+ return {
621
+ runningTurn: turnFromCreatedEvent({
622
+ sessionId,
623
+ turnId: open.turnId,
624
+ event: open.event,
625
+ }),
626
+ continuationInput,
627
+ };
628
+ }
629
+
630
+ // Hosts that exclude the running turn from listEvents still surface it as
631
+ // the first row of listTurns when that API is tip-first.
632
+ const turnsPage = await server.listTurns({ sessionId, limit: 1 });
633
+ const tip = turnsPage.data[0] as Turn | undefined;
634
+ return tip?.state?.status === "running"
635
+ ? { runningTurn: tip, continuationInput: tip.input ?? [] }
636
+ : { continuationInput: [] };
637
+ }
638
+
540
639
  /**
541
640
  * Builds a session snapshot using the session-level `listEvents` API.
542
641
  *
543
642
  * Loads the newest event page (extending only when a page boundary splits a
544
643
  * turn group), then leaves older pages for `prependOlderSessionHistory`.
545
- * Only `listTurns({ limit: 1 })` is called first to detect a currently-running
546
- * turn (the session-level API does not return events for the running turn).
644
+ * Detects a currently-running tip from an open `turn.created` in that window
645
+ * when present; otherwise falls back to `listTurns({ limit: 1 })` for hosts
646
+ * that omit the running turn from listEvents.
547
647
  *
548
648
  * `onProgress` is called after each complete turn is ingested so callers can
549
649
  * update the UI progressively while the processing loop runs.
@@ -553,12 +653,6 @@ export async function buildSnapshotFromSessionEvents(
553
653
  sessionId: string,
554
654
  onProgress?: (snap: SessionSnapshot) => void,
555
655
  ): Promise<SessionSnapshot> {
556
- // Detect a running turn with a single listTurns page — do not drain pagination.
557
- const turnsPage = await server.listTurns({ sessionId, limit: 1 });
558
- const newestTurn = turnsPage.data[0] as Turn | undefined;
559
- const runningTurn =
560
- newestTurn?.state?.status === "running" ? newestTurn : undefined;
561
-
562
656
  const window = await fetchSessionEventsWindow(server, sessionId);
563
657
  const historyPagination: SessionHistoryPagination = {
564
658
  hasOlder: window.hasOlder,
@@ -575,7 +669,16 @@ export async function buildSnapshotFromSessionEvents(
575
669
  historyPagination,
576
670
  });
577
671
 
578
- return attachRunningTurn(withHistory, runningTurn);
672
+ const tip = await resolveSessionTip({
673
+ server,
674
+ sessionId,
675
+ itemsAsc: window.itemsAsc,
676
+ });
677
+ // The tip has no turn.done in this window, so ingestion never folded its
678
+ // input. Apply it here so answered approvals / ask-user prompts are not
679
+ // restored as pending after a refresh.
680
+ applyUserToolResponsesToFold(withHistory.fold, tip.continuationInput);
681
+ return attachRunningTurn(withHistory, tip.runningTurn);
579
682
  }
580
683
 
581
684
  /**
@@ -1276,93 +1379,38 @@ export async function buildSnapshotFromSession(
1276
1379
  });
1277
1380
  }
1278
1381
 
1279
- /** Rebuilds session state from turns strictly before `beforeTurnId` (excludes that turn). */
1280
- export async function buildSnapshotBeforeTurn(
1281
- server: AgentChatServer,
1282
- sessionId: string,
1283
- beforeTurnId: string,
1284
- concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
1285
- ): Promise<SessionSnapshot> {
1286
- const turns = await listSessionTurnsOrdered(server, sessionId);
1287
-
1288
- const beforeIndex = turns.findIndex((turn) => turn.id === beforeTurnId);
1289
- if (beforeIndex === -1) {
1290
- throw new Error(`Turn ${beforeTurnId} not found in session`);
1291
- }
1292
-
1293
- return buildSnapshotBeforeTurnIndex(
1294
- server,
1295
- sessionId,
1296
- beforeIndex,
1297
- concurrency,
1298
- turns,
1299
- );
1300
- }
1301
-
1302
- /** Rebuilds session state from the first `turnIndex` turns (excludes that turn). */
1303
- export async function buildSnapshotBeforeTurnIndex(
1382
+ /**
1383
+ * Rebuilds the conversation through `anchorTurnId`, including that turn.
1384
+ * The server follows parent links from the anchor, so turns from abandoned
1385
+ * branches are excluded. A null anchor represents an empty conversation.
1386
+ */
1387
+ export async function buildSnapshotThroughTurn(
1304
1388
  server: AgentChatServer,
1305
1389
  sessionId: string,
1306
- turnIndex: number,
1307
- _concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
1308
- orderedTurns?: Turn[],
1390
+ anchorTurnId: string | null,
1309
1391
  ): Promise<SessionSnapshot> {
1310
- if (turnIndex <= 0) {
1392
+ if (anchorTurnId == null) {
1311
1393
  return createEmptySessionSnapshot();
1312
1394
  }
1313
-
1314
- const turns = orderedTurns ?? (await listSessionTurnsOrdered(server, sessionId));
1315
- const turnsToInclude = turns.slice(0, turnIndex);
1316
- const lastTurnId = turnsToInclude.at(-1)?.id;
1317
- if (lastTurnId == null) {
1318
- return createEmptySessionSnapshot();
1319
- }
1320
-
1321
- // Anchor the session events window at the newest included turn so the
1322
- // ancestor chain matches `[turns[0], …, turns[turnIndex - 1]]`.
1323
- const items = await fetchAllSessionEvents(server, sessionId, { lastTurnId });
1395
+ const items = await fetchAllSessionEvents(server, sessionId, {
1396
+ lastTurnId: anchorTurnId,
1397
+ });
1324
1398
  const snapshot = createEmptySessionSnapshot();
1325
1399
  ingestSessionEventsIntoSnapshot(snapshot, items);
1326
1400
  return snapshot;
1327
1401
  }
1328
1402
 
1329
- /** Turn id to branch from when resubmitting at `turnIndex` (`"none"` for first turn). */
1330
- export async function resolveGatewayBranchPreviousTurnId(
1331
- server: AgentChatServer,
1332
- sessionId: string,
1333
- turnIndex: number,
1334
- orderedTurns?: Turn[],
1335
- ): Promise<string> {
1336
- if (turnIndex <= 0) {
1337
- return "none";
1338
- }
1339
- const turns = orderedTurns ?? (await listSessionTurnsOrdered(server, sessionId));
1340
- return turns[turnIndex - 1]?.id ?? "none";
1341
- }
1342
-
1343
- /** Resolves `previousTurnId` by turn id so partial history windows stay correct. */
1403
+ /**
1404
+ * Resolves `previousTurnId` for edit/retry of `turnId` from the turn's own
1405
+ * parent pointer (`"none"` for roots). Independent of listTurns order.
1406
+ */
1344
1407
  export async function resolveGatewayBranchPreviousTurnIdForTurn(
1345
1408
  server: AgentChatServer,
1346
1409
  sessionId: string,
1347
1410
  turnId: string,
1348
1411
  ): Promise<string> {
1349
- const turns = await listSessionTurnsOrdered(server, sessionId);
1350
- const turnIndex = turns.findIndex((turn) => turn.id === turnId);
1351
- return resolveGatewayBranchPreviousTurnId(server, sessionId, turnIndex, turns);
1352
- }
1353
-
1354
- async function listSessionTurnsOrdered(
1355
- server: AgentChatServer,
1356
- sessionId: string,
1357
- ): Promise<Turn[]> {
1358
- const turns = await drainListPages((pageToken) =>
1359
- server.listTurns({
1360
- sessionId,
1361
- ...(pageToken != null ? { pageToken } : {}),
1362
- }),
1363
- );
1364
- turns.reverse();
1365
- return turns;
1412
+ const turn = await server.getTurn({ sessionId, turnId });
1413
+ return turn.previousTurnId ?? "none";
1366
1414
  }
1367
1415
 
1368
1416
  export async function buildTurnAssistantContent(
package/src/types.ts CHANGED
@@ -28,7 +28,6 @@ type TrueFoundryAgentRuntimeBaseOptions = ExternalStoreSharedOptions & {
28
28
  threadId?: string | undefined;
29
29
  onThreadIdChange?: ((threadId: string | undefined) => void) | undefined;
30
30
  onError?: ((error: unknown) => void) | undefined;
31
- listEventsConcurrency?: number | undefined;
32
31
  /**
33
32
  * Optional filter forwarded to `listSessions({ agentId })`.
34
33
  * Omit for all chats; hosts that key agents by name pass that name as the id.
@@ -47,6 +47,7 @@ vi.mock("./convertTurnMessages.js", async (importOriginal) => {
47
47
  const mockServer = {
48
48
  cancelSession: vi.fn().mockResolvedValue(undefined),
49
49
  listTurns: vi.fn(),
50
+ getTurn: vi.fn(),
50
51
  // Present so resume-capable paths are exercised; resumeTurnStream is mocked.
51
52
  subscribeToTurn: vi.fn(),
52
53
  } as unknown as AgentChatServer;
@@ -429,6 +430,7 @@ describe("useTrueFoundryAgentMessages", () => {
429
430
  expect.any(PeerThreadFoldState),
430
431
  {
431
432
  userMessage: "first",
433
+ previousTurnId: "none",
432
434
  headers: {
433
435
  "x-tfy-session-last-updated-at": "2026-06-30T12:00:00.000Z",
434
436
  },
@@ -689,21 +691,22 @@ describe("useTrueFoundryAgentMessages", () => {
689
691
 
690
692
  it("editFromTurn drops prior turns before showing the edited user message", async () => {
691
693
  const createdAt = new Date().toISOString();
694
+ const rootTurn = {
695
+ id: "turn-1",
696
+ sessionId: "session-1",
697
+ createdAt,
698
+ previousTurnId: null,
699
+ state: {
700
+ status: "done" as const,
701
+ requiredActions: [],
702
+ completedAt: createdAt,
703
+ },
704
+ input: [{ type: "user.message" as const, content: "Hello" }],
705
+ } as Turn;
692
706
  vi.mocked(mockServer.listTurns).mockResolvedValue({
693
- data: [
694
- {
695
- id: "turn-1",
696
- sessionId: "session-1",
697
- createdAt,
698
- state: {
699
- status: "done",
700
- requiredActions: [],
701
- completedAt: createdAt,
702
- },
703
- input: [{ type: "user.message", content: "Hello" }],
704
- } as Turn,
705
- ],
707
+ data: [rootTurn],
706
708
  });
709
+ vi.mocked(mockServer.getTurn).mockResolvedValue(rootTurn);
707
710
  const fold = new PeerThreadFoldState();
708
711
  ingestTurnEvent(fold, {
709
712
  type: "model.message",
@@ -805,21 +808,22 @@ describe("useTrueFoundryAgentMessages", () => {
805
808
  const onError = vi.fn();
806
809
  const original = snapshotWithUserTurn("Hello");
807
810
  vi.mocked(loadSessionSnapshot).mockResolvedValue(original);
811
+ const rootTurn = {
812
+ id: "turn-1",
813
+ sessionId: "session-1",
814
+ createdAt,
815
+ previousTurnId: null,
816
+ state: {
817
+ status: "done" as const,
818
+ requiredActions: [],
819
+ completedAt: createdAt,
820
+ },
821
+ input: [{ type: "user.message" as const, content: "Hello" }],
822
+ } as Turn;
808
823
  vi.mocked(mockServer.listTurns).mockResolvedValue({
809
- data: [
810
- {
811
- id: "turn-1",
812
- sessionId: "session-1",
813
- createdAt,
814
- state: {
815
- status: "done",
816
- requiredActions: [],
817
- completedAt: createdAt,
818
- },
819
- input: [{ type: "user.message", content: "Hello" }],
820
- } as Turn,
821
- ],
824
+ data: [rootTurn],
822
825
  });
826
+ vi.mocked(mockServer.getTurn).mockResolvedValue(rootTurn);
823
827
  vi.mocked(streamTurnContent).mockImplementation(async function* () {
824
828
  throw new Error("Turn preparation failed");
825
829
  });
@@ -15,7 +15,7 @@ import type { AgentChatServer } from "./server/types.js";
15
15
  import { ROOT_THREAD_ID } from "./constants.js";
16
16
  import {
17
17
  buildEditedUserMessageContent,
18
- buildSnapshotBeforeTurn,
18
+ buildSnapshotThroughTurn,
19
19
  computeGroupRootBaseline,
20
20
  extractTurnUserMessageContent,
21
21
  prependOlderSessionHistory,
@@ -60,7 +60,6 @@ export type UseTrueFoundryAgentMessagesOptions = {
60
60
  isMain?: boolean | undefined;
61
61
  /** URL-selected session may load before the thread list marks it as main. */
62
62
  isInitialSession?: boolean | undefined;
63
- listEventsConcurrency?: number | undefined;
64
63
  onError?: ((error: unknown) => void) | undefined;
65
64
  initializeSession?: () => Promise<{
66
65
  remoteId: string;
@@ -293,7 +292,6 @@ export function useTrueFoundryAgentMessages({
293
292
  sessionId,
294
293
  isMain,
295
294
  isInitialSession,
296
- listEventsConcurrency,
297
295
  onError,
298
296
  initializeSession,
299
297
  resolveConversationSessionId,
@@ -975,11 +973,12 @@ export function useTrueFoundryAgentMessages({
975
973
  conversationSessionId,
976
974
  turnId,
977
975
  );
978
- rewound = await buildSnapshotBeforeTurn(
976
+ // Rewind to the exact parent used for the new branch. Using the
977
+ // previous item from listTurns could select an abandoned branch.
978
+ rewound = await buildSnapshotThroughTurn(
979
979
  server,
980
980
  conversationSessionId,
981
- turnId,
982
- listEventsConcurrency,
981
+ previousTurnId === "none" ? null : previousTurnId,
983
982
  );
984
983
  createdAtByMessageIdRef.current = new Map();
985
984
  // Keep the ref aligned before awaiting sendTurn so any intermediate
@@ -1000,13 +999,7 @@ export function useTrueFoundryAgentMessages({
1000
999
  branchRollbackSnapshot: committed,
1001
1000
  });
1002
1001
  },
1003
- [
1004
- cancel,
1005
- server,
1006
- listEventsConcurrency,
1007
- sendTurn,
1008
- sessionId,
1009
- ],
1002
+ [cancel, server, sendTurn, sessionId],
1010
1003
  );
1011
1004
 
1012
1005
  const resetFromTurn = useCallback(
@@ -49,7 +49,6 @@ function useTrueFoundryAgentRuntimeImpl(
49
49
  agent,
50
50
  adapters,
51
51
  onError,
52
- listEventsConcurrency,
53
52
  ...sharedOptions
54
53
  } = options;
55
54
 
@@ -126,7 +125,6 @@ function useTrueFoundryAgentRuntimeImpl(
126
125
  sessionId,
127
126
  isMain,
128
127
  isInitialSession,
129
- listEventsConcurrency,
130
128
  onError,
131
129
  initializeSession,
132
130
  getTurnHeaders: agent.mode === "draft" ? getTurnHeaders : undefined,