@truefoundry/assistant-ui-runtime 0.1.0-rc.1 → 0.1.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.
Files changed (30) hide show
  1. package/README.md +14 -77
  2. package/dist/index.d.ts +4 -1
  3. package/dist/index.js +314 -122
  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 +362 -0
  8. package/src/convertTurnMessages.ts +193 -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 +6 -0
  14. package/src/index.ts +6 -5
  15. package/src/loadSessionSnapshot.test.ts +21 -6
  16. package/src/loadSessionSnapshot.ts +9 -5
  17. package/src/{truefoundryDraftThreadListAdapter.ts → private/truefoundryDraftThreadListAdapter.ts} +1 -1
  18. package/src/sessionSnapshot.ts +27 -1
  19. package/src/sessions.ts +1 -1
  20. package/src/truefoundryExtras.ts +2 -1
  21. package/src/types.ts +1 -1
  22. package/src/useTrueFoundryAgentMessages.test.tsx +225 -1
  23. package/src/useTrueFoundryAgentMessages.ts +178 -60
  24. package/src/useTrueFoundryAgentRuntime.ts +27 -13
  25. /package/src/{agentSpec.ts → private/agentSpec.ts} +0 -0
  26. /package/src/{bindDraftAgentSession.test.ts → private/bindDraftAgentSession.test.ts} +0 -0
  27. /package/src/{bindDraftAgentSession.ts → private/bindDraftAgentSession.ts} +0 -0
  28. /package/src/{draftSessionBridge.ts → private/draftSessionBridge.ts} +0 -0
  29. /package/src/{truefoundryDraftThreadListAdapter.test.ts → private/truefoundryDraftThreadListAdapter.test.ts} +0 -0
  30. /package/src/{useDraftAgentSpec.ts → private/useDraftAgentSpec.ts} +0 -0
@@ -57,6 +57,8 @@ import type { TurnStreamUpdate } from "./turnStreamUpdate.js";
57
57
  export type UseTrueFoundryAgentMessagesOptions = {
58
58
  client: AgentSessionClient;
59
59
  sessionId: string | undefined;
60
+ /** When true the thread is the currently selected (main) thread. */
61
+ isMain?: boolean | undefined;
60
62
  listEventsConcurrency?: number | undefined;
61
63
  onError?: ((error: unknown) => void) | undefined;
62
64
  initializeSession?: () => Promise<{
@@ -70,7 +72,16 @@ export type UseTrueFoundryAgentMessagesOptions = {
70
72
  };
71
73
 
72
74
  export type SendTurnOptions =
73
- | { userMessage: UserMessageContent; previousTurnId?: string | null }
75
+ | {
76
+ userMessage: UserMessageContent;
77
+ previousTurnId?: string | null;
78
+ /**
79
+ * When branching (edit/reset), the already-rewound history to send from.
80
+ * Applied atomically with `pendingUser` so a stale React snapshot cannot
81
+ * keep pre-branch turns while the new user message is appended.
82
+ */
83
+ branchFromSnapshot?: SessionSnapshot;
84
+ }
74
85
  | { inputs: RequiredActionInput[] }
75
86
  | { resumeMcpAuth: true };
76
87
 
@@ -272,6 +283,7 @@ function resolveTurnInput(
272
283
  export function useTrueFoundryAgentMessages({
273
284
  client,
274
285
  sessionId,
286
+ isMain,
275
287
  listEventsConcurrency,
276
288
  onError,
277
289
  initializeSession,
@@ -287,15 +299,24 @@ export function useTrueFoundryAgentMessages({
287
299
  const [snapshot, setSnapshot] = useState<SessionSnapshot>(createEmptySessionSnapshot);
288
300
  const [isRunning, setIsRunning] = useState(false);
289
301
  const [isLoading, setIsLoading] = useState(false);
302
+ const [loadRetryTrigger, setLoadRetryTrigger] = useState(0);
290
303
 
291
304
  const snapshotRef = useRef(snapshot);
292
305
  snapshotRef.current = snapshot;
293
306
 
307
+ const onErrorRef = useRef(onError);
308
+ onErrorRef.current = onError;
309
+ const resolveConversationSessionIdRef = useRef(resolveConversationSessionId);
310
+ resolveConversationSessionIdRef.current = resolveConversationSessionId;
311
+ const initializeSessionRef = useRef(initializeSession);
312
+ initializeSessionRef.current = initializeSession;
313
+
294
314
  const createdAtByMessageIdRef = useRef(new Map<string, Date>());
295
315
  const abortControllerRef = useRef<AbortController | null>(null);
296
316
  const activeRunRef = useRef<Promise<void> | null>(null);
297
317
  const runningTurnRef = useRef<Turn | undefined>(undefined);
298
318
  const loadGenerationRef = useRef(0);
319
+ const streamGenerationRef = useRef(0);
299
320
  const lazilyCreatedSessionIdRef = useRef<string | undefined>(undefined);
300
321
 
301
322
  const projectOptions = useMemo(
@@ -318,47 +339,81 @@ export function useTrueFoundryAgentMessages({
318
339
  [snapshot, projectOptions],
319
340
  );
320
341
 
321
- const applyStreamUpdate = useCallback(
322
- (update: TurnStreamUpdate, turnId: string, isContinuation: boolean) => {
323
- setSnapshot((prev) =>
324
- replaceSessionSnapshot(prev, {
325
- activeStream: {
326
- turnId,
327
- update,
328
- isContinuation,
329
- },
330
- }),
331
- );
332
- },
333
- [],
334
- );
335
-
336
342
  const runStream = useCallback(
337
343
  (
338
344
  createStream: (signal: AbortSignal) => AsyncGenerator<TurnStreamUpdate>,
339
345
  turnId: string,
340
346
  isContinuation: boolean,
341
347
  ): Promise<void> => {
348
+ const streamGeneration = ++streamGenerationRef.current;
342
349
  abortControllerRef.current?.abort();
343
350
  const abortController = new AbortController();
344
351
  abortControllerRef.current = abortController;
345
352
  setIsRunning(true);
346
353
 
347
354
  const run = (async () => {
355
+ // Sub-agent turns can emit 100+ stream events per frame. Coalesce to one
356
+ // setSnapshot per animation frame so assistant-ui does not remount the whole
357
+ // message tree (UI hang). The buffer belongs to this stream only.
358
+ let pendingStreamUpdate: {
359
+ update: TurnStreamUpdate;
360
+ turnId: string;
361
+ isContinuation: boolean;
362
+ } | null = null;
363
+ let streamUpdateRaf: number | null = null;
364
+
365
+ const flushPendingStreamUpdate = () => {
366
+ streamUpdateRaf = null;
367
+ const pending = pendingStreamUpdate;
368
+ pendingStreamUpdate = null;
369
+ if (
370
+ pending == null ||
371
+ streamGeneration !== streamGenerationRef.current
372
+ ) {
373
+ return;
374
+ }
375
+ const { update, turnId: pendingTurnId, isContinuation: pendingIsContinuation } =
376
+ pending;
377
+ setSnapshot((prev) =>
378
+ replaceSessionSnapshot(prev, {
379
+ activeStream: {
380
+ turnId: pendingTurnId,
381
+ update,
382
+ isContinuation: pendingIsContinuation,
383
+ },
384
+ }),
385
+ );
386
+ };
387
+
388
+ const applyStreamUpdate = (update: TurnStreamUpdate) => {
389
+ pendingStreamUpdate = { update, turnId, isContinuation };
390
+ if (streamUpdateRaf == null) {
391
+ streamUpdateRaf = requestAnimationFrame(flushPendingStreamUpdate);
392
+ }
393
+ };
394
+
348
395
  try {
349
396
  for await (const update of createStream(abortController.signal)) {
350
397
  if (abortController.signal.aborted) {
351
398
  return;
352
399
  }
353
- applyStreamUpdate(update, turnId, isContinuation);
400
+ applyStreamUpdate(update);
354
401
  }
355
402
  } catch (error) {
356
403
  if (error instanceof Error && error.name === "AbortError") {
357
404
  return;
358
405
  }
359
- onError?.(error);
406
+ onErrorRef.current?.(error);
360
407
  throw error;
361
408
  } finally {
409
+ if (streamUpdateRaf != null) {
410
+ cancelAnimationFrame(streamUpdateRaf);
411
+ streamUpdateRaf = null;
412
+ }
413
+ if (streamGeneration !== streamGenerationRef.current) {
414
+ return;
415
+ }
416
+ flushPendingStreamUpdate();
362
417
  if (abortControllerRef.current === abortController) {
363
418
  abortControllerRef.current = null;
364
419
  }
@@ -392,7 +447,7 @@ export function useTrueFoundryAgentMessages({
392
447
  });
393
448
  return run;
394
449
  },
395
- [applyStreamUpdate, onError],
450
+ [onError],
396
451
  );
397
452
 
398
453
  const load = useCallback(async () => {
@@ -401,24 +456,45 @@ export function useTrueFoundryAgentMessages({
401
456
  setSnapshot(createEmptySessionSnapshot());
402
457
  return;
403
458
  }
459
+
460
+ // Thread components are never unmounted on navigation — isMain going
461
+ // false→true is the only reliable signal that the user has clicked on
462
+ // this thread. Skip the load when this thread is not the active one
463
+ // so that isMain being a dep triggers a fresh load on every selection.
464
+ if (isMain === false) return;
465
+
466
+ // When we are loading a *different* session the user has navigated away
467
+ // from the lazily-created one — clear the guard so navigating back to it
468
+ // later triggers a proper reload instead of silently skipping.
469
+ if (lazilyCreatedSessionIdRef.current != null && sessionId !== lazilyCreatedSessionIdRef.current) {
470
+ lazilyCreatedSessionIdRef.current = undefined;
471
+ }
472
+
404
473
  if (sessionId === lazilyCreatedSessionIdRef.current) {
405
474
  return;
406
475
  }
407
476
 
408
477
  const generation = ++loadGenerationRef.current;
478
+ ++streamGenerationRef.current;
409
479
  abortControllerRef.current?.abort();
480
+ createdAtByMessageIdRef.current = new Map();
481
+ setSnapshot(createEmptySessionSnapshot());
410
482
  setIsLoading(true);
411
483
 
412
484
  try {
413
485
  const conversationSessionId = await resolveActiveSessionId(
414
486
  sessionId,
415
- resolveConversationSessionId,
487
+ resolveConversationSessionIdRef.current,
416
488
  );
417
489
  const loadedSnapshot = await loadSessionSnapshot(
418
490
  client,
419
491
  conversationSessionId,
420
- listEventsConcurrency,
421
492
  sessionOptions,
493
+ (snap) => {
494
+ if (generation === loadGenerationRef.current) {
495
+ setSnapshot(snap);
496
+ }
497
+ },
422
498
  );
423
499
  if (generation !== loadGenerationRef.current) {
424
500
  return;
@@ -428,32 +504,44 @@ export function useTrueFoundryAgentMessages({
428
504
  setSnapshot(loadedSnapshot);
429
505
  runningTurnRef.current = loadedSnapshot.runningTurn;
430
506
 
507
+ // History (and any seeded pendingUser for the in-flight turn) is
508
+ // ready — clear loading before resuming. Awaiting the subscribe
509
+ // stream here previously kept isLoading true for the entire
510
+ // backend run, so reconnect UIs stayed on shimmers even though
511
+ // subscribe was already live.
512
+ setIsLoading(false);
513
+
431
514
  if (loadedSnapshot.runningTurn != null) {
432
515
  const turn = loadedSnapshot.runningTurn;
433
516
  const isContinuation = !extractTurnUserText(turn.input);
434
517
  // TODO: pass afterSequenceNumber once stream ingestion tracks sequence numbers.
435
- await runStream(
518
+ // Use loadedSnapshot directly — snapshotRef.current still points at
519
+ // the empty snapshot cleared above until the setSnapshot(loadedSnapshot)
520
+ // call re-renders.
521
+ void runStream(
436
522
  (signal) =>
437
523
  resumeTurnStream(
438
524
  turn,
439
- snapshotRef.current.fold,
525
+ loadedSnapshot.fold,
440
526
  signal,
441
527
  undefined,
442
- snapshotRef.current.groupRootBaseline,
528
+ loadedSnapshot.groupRootBaseline,
443
529
  ),
444
530
  turn.id,
445
531
  isContinuation,
446
- );
532
+ ).catch(() => undefined);
447
533
  }
448
534
  } catch (error) {
449
- onError?.(error);
535
+ if (generation === loadGenerationRef.current) {
536
+ onErrorRef.current?.(error);
537
+ }
450
538
  throw error;
451
539
  } finally {
452
540
  if (generation === loadGenerationRef.current) {
453
541
  setIsLoading(false);
454
542
  }
455
543
  }
456
- }, [client, listEventsConcurrency, onError, resolveConversationSessionId, runStream, sessionId, sessionOptions]);
544
+ }, [client, runStream, sessionId, sessionOptions, loadRetryTrigger, isMain]);
457
545
 
458
546
  useEffect(() => {
459
547
  void load().catch(() => undefined);
@@ -463,17 +551,17 @@ export function useTrueFoundryAgentMessages({
463
551
  async (options: SendTurnOptions) => {
464
552
  let activeSessionId = sessionId;
465
553
  if (activeSessionId == null) {
466
- if (initializeSession == null) {
554
+ if (initializeSessionRef.current == null) {
467
555
  throw new Error("Cannot send a turn without an active session.");
468
556
  }
469
- const { remoteId } = await initializeSession();
557
+ const { remoteId } = await initializeSessionRef.current();
470
558
  activeSessionId = remoteId;
471
559
  lazilyCreatedSessionIdRef.current = remoteId;
472
560
  }
473
561
 
474
562
  const conversationSessionId = await resolveActiveSessionId(
475
563
  activeSessionId,
476
- resolveConversationSessionId,
564
+ resolveConversationSessionIdRef.current,
477
565
  );
478
566
  const session = await getSession(client, conversationSessionId, sessionOptions);
479
567
  const isContinuation =
@@ -492,34 +580,57 @@ export function useTrueFoundryAgentMessages({
492
580
  );
493
581
  }
494
582
 
495
- setSnapshot((prev) =>
496
- commitActiveStream(
497
- prev,
498
- "inputs" in options ? options.inputs : undefined,
499
- ),
500
- );
583
+ const branchBase =
584
+ "userMessage" in options ? options.branchFromSnapshot : undefined;
501
585
 
502
586
  let groupRootBaseline: readonly string[] | undefined;
503
587
 
504
- if ("userMessage" in options) {
505
- const rootBucket =
506
- snapshotRef.current.fold.threads.get(ROOT_THREAD_ID);
588
+ if (branchBase != null && "userMessage" in options) {
589
+ // Atomic apply: never merge pendingUser onto a stale React `prev`
590
+ // that still holds pre-branch turns (edit would show old + new).
591
+ const rootBucket = branchBase.fold.threads.get(ROOT_THREAD_ID);
507
592
  groupRootBaseline = [...(rootBucket?.modelMessageIds ?? [])];
593
+ const nextSnapshot = replaceSessionSnapshot(branchBase, {
594
+ pendingUser: {
595
+ turnId,
596
+ content: options.userMessage,
597
+ createdAt: new Date(),
598
+ },
599
+ activeStream: undefined,
600
+ groupRootBaseline,
601
+ });
602
+ snapshotRef.current = nextSnapshot;
603
+ setSnapshot(nextSnapshot);
604
+ } else {
508
605
  setSnapshot((prev) =>
509
- replaceSessionSnapshot(prev, {
510
- pendingUser: {
511
- turnId,
512
- content: options.userMessage,
513
- createdAt: new Date(),
514
- },
515
- activeStream: undefined,
516
- groupRootBaseline,
517
- }),
606
+ commitActiveStream(
607
+ prev,
608
+ "inputs" in options ? options.inputs : undefined,
609
+ ),
518
610
  );
519
- } else {
520
- groupRootBaseline =
521
- snapshotRef.current.groupRootBaseline ??
522
- computeGroupRootBaseline(snapshotRef.current.turns);
611
+
612
+ if ("userMessage" in options) {
613
+ const rootBucket =
614
+ snapshotRef.current.fold.threads.get(ROOT_THREAD_ID);
615
+ groupRootBaseline = [...(rootBucket?.modelMessageIds ?? [])];
616
+ setSnapshot((prev) => {
617
+ const next = replaceSessionSnapshot(prev, {
618
+ pendingUser: {
619
+ turnId,
620
+ content: options.userMessage,
621
+ createdAt: new Date(),
622
+ },
623
+ activeStream: undefined,
624
+ groupRootBaseline,
625
+ });
626
+ snapshotRef.current = next;
627
+ return next;
628
+ });
629
+ } else {
630
+ groupRootBaseline =
631
+ snapshotRef.current.groupRootBaseline ??
632
+ computeGroupRootBaseline(snapshotRef.current.turns);
633
+ }
523
634
  }
524
635
 
525
636
  await runStream(
@@ -559,7 +670,7 @@ export function useTrueFoundryAgentMessages({
559
670
  isContinuation,
560
671
  );
561
672
  },
562
- [client, initializeSession, resolveConversationSessionId, runStream, sessionId, sessionOptions],
673
+ [client, runStream, sessionId, sessionOptions],
563
674
  );
564
675
 
565
676
  const cancel = useCallback(async () => {
@@ -569,7 +680,7 @@ export function useTrueFoundryAgentMessages({
569
680
  }
570
681
  const conversationSessionId = await resolveActiveSessionId(
571
682
  sessionId,
572
- resolveConversationSessionId,
683
+ resolveConversationSessionIdRef.current,
573
684
  );
574
685
  const session = await getSession(client, conversationSessionId, sessionOptions);
575
686
  // Request cancellation but keep consuming the stream. After cancel(),
@@ -581,7 +692,7 @@ export function useTrueFoundryAgentMessages({
581
692
  // reconcile is needed here — the cancelled turn is terminal and local
582
693
  // state reconciles against the event log on the next session load.
583
694
  await activeRunRef.current?.catch(() => undefined);
584
- }, [client, resolveConversationSessionId, sessionId, sessionOptions]);
695
+ }, [client, sessionId, sessionOptions]);
585
696
 
586
697
  const isRunningRef = useRef(isRunning);
587
698
  isRunningRef.current = isRunning;
@@ -598,10 +709,10 @@ export function useTrueFoundryAgentMessages({
598
709
  }
599
710
  const inputs = collectRequiredActionInputs(paused);
600
711
  if (inputs.length > 0) {
601
- void sendTurn({ inputs }).catch((error) => onError?.(error));
712
+ void sendTurn({ inputs }).catch((error) => onErrorRef.current?.(error));
602
713
  }
603
714
  },
604
- [onError, projectOptions, sendTurn],
715
+ [projectOptions, sendTurn],
605
716
  );
606
717
 
607
718
  const respondToToolApproval = useCallback(
@@ -677,7 +788,7 @@ export function useTrueFoundryAgentMessages({
677
788
 
678
789
  const conversationSessionId = await resolveActiveSessionId(
679
790
  activeSessionId,
680
- resolveConversationSessionId,
791
+ resolveConversationSessionIdRef.current,
681
792
  );
682
793
  const session = await getSession(client, conversationSessionId, sessionOptions);
683
794
  const previousTurnId = await resolveGatewayBranchPreviousTurnId(
@@ -690,18 +801,21 @@ export function useTrueFoundryAgentMessages({
690
801
  listEventsConcurrency,
691
802
  );
692
803
  createdAtByMessageIdRef.current = new Map();
804
+ // Keep the ref aligned before awaiting sendTurn so any intermediate
805
+ // reads (and the atomic pendingUser apply) see the rewound history.
806
+ snapshotRef.current = rewound;
693
807
  setSnapshot(rewound);
694
808
 
695
809
  await sendTurn({
696
810
  userMessage,
697
811
  previousTurnId,
812
+ branchFromSnapshot: rewound,
698
813
  });
699
814
  },
700
815
  [
701
816
  cancel,
702
817
  client,
703
818
  listEventsConcurrency,
704
- resolveConversationSessionId,
705
819
  sendTurn,
706
820
  sessionId,
707
821
  sessionOptions,
@@ -737,11 +851,15 @@ export function useTrueFoundryAgentMessages({
737
851
  [branchFromTurn],
738
852
  );
739
853
 
854
+ const retryLoad = useCallback(() => {
855
+ setLoadRetryTrigger((n) => n + 1);
856
+ }, []);
857
+
740
858
  return {
741
859
  messages,
742
860
  isRunning,
743
861
  isLoading,
744
- load,
862
+ retryLoad,
745
863
  sendTurn,
746
864
  cancel,
747
865
  respondToToolApproval,
@@ -14,7 +14,7 @@ import { useAui, useAuiState } from "@assistant-ui/store";
14
14
  import type { MutableRefObject } from "react";
15
15
  import { useCallback, useMemo, useRef, useState } from "react";
16
16
 
17
- import type { AgentSpec } from "./agentSpec.js";
17
+ import type { AgentSpec } from "./private/agentSpec.js";
18
18
  import {
19
19
  collectPendingApprovals,
20
20
  collectPendingToolResponses,
@@ -26,14 +26,14 @@ import {
26
26
  extractEditedText,
27
27
  parseTurnIdFromMessageId,
28
28
  } from "./convertTurnMessages.js";
29
- import { createDraftSessionBridge } from "./draftSessionBridge.js";
29
+ import { createDraftSessionBridge } from "./private/draftSessionBridge.js";
30
30
  import { MCP_AUTH_RESUME_RUN_CUSTOM_KEY } from "./mcpAuth.js";
31
- import { createTrueFoundryDraftThreadListAdapter } from "./truefoundryDraftThreadListAdapter.js";
31
+ import { createTrueFoundryDraftThreadListAdapter } from "./private/truefoundryDraftThreadListAdapter.js";
32
32
  import { trueFoundryExtras } from "./truefoundryExtras.js";
33
33
  import { createTrueFoundryThreadListAdapter } from "./truefoundryThreadListAdapter.js";
34
34
  import type { UseTrueFoundryAgentRuntimeOptions } from "./types.js";
35
35
  import { resolveTrueFoundryAgentRuntimeOptions } from "./types.js";
36
- import { useDraftAgentSpec } from "./useDraftAgentSpec.js";
36
+ import { useDraftAgentSpec } from "./private/useDraftAgentSpec.js";
37
37
  import { useTrueFoundryAgentMessages } from "./useTrueFoundryAgentMessages.js";
38
38
 
39
39
  function useTrueFoundryAgentRuntimeImpl(
@@ -63,6 +63,9 @@ function useTrueFoundryAgentRuntimeImpl(
63
63
  : undefined,
64
64
  );
65
65
  const sessionId = useAuiState((state) => state.threadListItem.remoteId ?? undefined);
66
+ const isMain = useAuiState(
67
+ (state) => state.threads.mainThreadId === state.threadListItem.id,
68
+ );
66
69
 
67
70
  const draftSpec = useDraftAgentSpec({
68
71
  draftSessionId,
@@ -94,9 +97,11 @@ function useTrueFoundryAgentRuntimeImpl(
94
97
  resumeRun,
95
98
  editFromTurn,
96
99
  resetFromTurn,
100
+ retryLoad,
97
101
  } = useTrueFoundryAgentMessages({
98
102
  client,
99
103
  sessionId,
104
+ isMain,
100
105
  listEventsConcurrency,
101
106
  onError,
102
107
  initializeSession,
@@ -175,6 +180,7 @@ function useTrueFoundryAgentRuntimeImpl(
175
180
  resetFromTurn(turnId).catch((error) => {
176
181
  onError?.(error);
177
182
  }),
183
+ reload: retryLoad,
178
184
  draft: draftExtras,
179
185
  }),
180
186
  unstable_enableToolInvocations: true,
@@ -228,34 +234,42 @@ function useTrueFoundryAgentRuntimeImpl(
228
234
  }
229
235
 
230
236
  export function useTrueFoundryAgentRuntime(options: UseTrueFoundryAgentRuntimeOptions) {
231
- const resolved = useMemo(
232
- () => resolveTrueFoundryAgentRuntimeOptions(options),
233
- [options],
234
- );
237
+ // resolveTrueFoundryAgentRuntimeOptions is a cheap pure function; memoizing on the
238
+ // whole `options` object (which callers typically pass as an inline literal) means
239
+ // `resolved` — and everything derived from it — would be recreated on every render,
240
+ // causing `threadListAdapter` to be a new object each render. We compute `resolved`
241
+ // unconditionally and stabilize individual downstream values with primitives/refs.
242
+ const resolved = resolveTrueFoundryAgentRuntimeOptions(options);
235
243
  const { client, agent, gateway } = resolved;
236
244
 
237
245
  const pendingAgentSpecRef = useRef<AgentSpec | undefined>(
238
246
  agent.mode === "draft" ? agent.defaultAgentSpec : undefined,
239
247
  );
240
248
 
249
+ // Use stable primitive deps so that threadListAdapter is not recreated every render
250
+ // when options is an inline object literal (new reference on every parent render).
251
+ const agentMode = agent.mode;
252
+ const namedAgentName = agent.mode === "named" ? agent.agentName : undefined;
241
253
  const threadListAdapter = useMemo(() => {
242
- if (agent.mode === "draft") {
254
+ if (agentMode === "draft") {
243
255
  if (gateway == null) {
244
256
  throw new Error(
245
257
  "Draft agent mode requires a `gateway` TrueFoundryGateway client.",
246
258
  );
247
259
  }
260
+ const draftAgent = agent as Extract<typeof agent, { mode: "draft" }>;
248
261
  return createTrueFoundryDraftThreadListAdapter({
249
262
  gateway,
250
- defaultAgentSpec: agent.defaultAgentSpec,
251
- getAgentSpec: () => pendingAgentSpecRef.current ?? agent.defaultAgentSpec,
263
+ defaultAgentSpec: draftAgent.defaultAgentSpec,
264
+ getAgentSpec: () => pendingAgentSpecRef.current ?? draftAgent.defaultAgentSpec,
252
265
  });
253
266
  }
254
267
  return createTrueFoundryThreadListAdapter({
255
268
  client,
256
- agentName: agent.agentName,
269
+ agentName: namedAgentName!,
257
270
  });
258
- }, [agent, client, gateway]);
271
+ // eslint-disable-next-line react-hooks/exhaustive-deps
272
+ }, [agentMode, namedAgentName, client, gateway]);
259
273
 
260
274
  return useRemoteThreadListRuntime({
261
275
  allowNesting: true,
File without changes