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

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truefoundry/assistant-ui-runtime",
3
- "version": "0.1.3-rc.1",
3
+ "version": "0.1.3-rc.3",
4
4
  "description": "TrueFoundry Gateway agent runtime adapter for assistant-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -227,6 +227,91 @@ describe("streamTurn", () => {
227
227
  },
228
228
  );
229
229
  });
230
+
231
+ it("notifies gateway turn id when turn.done errors with no content yields", async () => {
232
+ const gatewayTurnId = "01ky6mqzmczwt6ssyd5r02gjjc";
233
+ const turn = {
234
+ id: undefined as string | undefined,
235
+ execute: vi.fn(() =>
236
+ (async function* () {
237
+ // SDK sets turn.id when turn.created is observed.
238
+ turn.id = gatewayTurnId;
239
+ yield streamData(1, {
240
+ type: "turn.created",
241
+ createdAt,
242
+ input: [{ type: "user.message", content: "hello" }],
243
+ });
244
+ yield streamData(2, {
245
+ type: "turn.done",
246
+ createdAt,
247
+ state: {
248
+ status: "error",
249
+ message:
250
+ "Publisher Model is not servable in region us-central1.",
251
+ },
252
+ });
253
+ })(),
254
+ ),
255
+ };
256
+ const prepareTurn = vi.fn(() => turn);
257
+ const session = {
258
+ prepareTurn,
259
+ cancel: vi.fn().mockResolvedValue(undefined),
260
+ } as unknown as AgentSession;
261
+ const onTurnIdAvailable = vi.fn();
262
+
263
+ await expect(
264
+ collectUpdates(
265
+ streamTurnContent(
266
+ session,
267
+ new PeerThreadFoldState(),
268
+ { userMessage: "hello" },
269
+ new AbortController().signal,
270
+ undefined,
271
+ onTurnIdAvailable,
272
+ ),
273
+ ),
274
+ ).rejects.toThrow("Publisher Model is not servable in region us-central1.");
275
+
276
+ expect(onTurnIdAvailable).toHaveBeenCalledTimes(1);
277
+ expect(onTurnIdAvailable).toHaveBeenCalledWith(gatewayTurnId);
278
+ });
279
+
280
+ it("does not notify when an error stream never assigns turn.id", async () => {
281
+ const execute = vi.fn(() =>
282
+ (async function* () {
283
+ yield streamData(1, {
284
+ type: "turn.done",
285
+ createdAt,
286
+ state: {
287
+ status: "error",
288
+ message: "boom",
289
+ },
290
+ });
291
+ })(),
292
+ );
293
+ const prepareTurn = vi.fn(() => ({ id: undefined, execute }));
294
+ const session = {
295
+ prepareTurn,
296
+ cancel: vi.fn().mockResolvedValue(undefined),
297
+ } as unknown as AgentSession;
298
+ const onTurnIdAvailable = vi.fn();
299
+
300
+ await expect(
301
+ collectUpdates(
302
+ streamTurnContent(
303
+ session,
304
+ new PeerThreadFoldState(),
305
+ { userMessage: "hello" },
306
+ new AbortController().signal,
307
+ undefined,
308
+ onTurnIdAvailable,
309
+ ),
310
+ ),
311
+ ).rejects.toThrow("boom");
312
+
313
+ expect(onTurnIdAvailable).not.toHaveBeenCalled();
314
+ });
230
315
  });
231
316
 
232
317
  describe("resumeTurnStream", () => {
package/src/streamTurn.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  type AgentSession,
3
3
  type Turn,
4
4
  type TurnInputItem,
5
+ TrueFoundryGatewayApi,
5
6
  } from "truefoundry-gateway-sdk/agents";
6
7
 
7
8
  import {
@@ -18,7 +19,7 @@ export type StreamTurnOptions = {
18
19
  inputs?: RequiredActionInput[];
19
20
  /**
20
21
  * Branch anchor for `prepareTurn`. Omit for `"auto"`. Pass `null` for a fresh
21
- * root turn (no `previousTurnId` field).
22
+ * root turn sent as `previous_turn_id: null` on the wire.
22
23
  */
23
24
  previousTurnId?: string | null;
24
25
  /** Extra headers for the createTurn request (`execute` request options). */
@@ -53,14 +54,26 @@ export async function* streamTurnContent(
53
54
  options: StreamTurnOptions,
54
55
  abortSignal: AbortSignal,
55
56
  groupRootBaseline?: readonly string[],
57
+ /**
58
+ * Called once with the gateway-assigned turn ID as soon as it becomes
59
+ * available (after the first `turn.created` SSE event). Use this to
60
+ * reconcile the locally-generated optimistic ID with the real gateway ID
61
+ * so that edit/retry can find the turn in `buildSnapshotBeforeTurn`.
62
+ */
63
+ onTurnIdAvailable?: (turnId: string) => void,
56
64
  ): AsyncGenerator<TurnStreamUpdate> {
57
- const previousTurnId =
65
+ // When previousTurnId is explicitly null, pass it through to prepareTurn so the
66
+ // SDK serializer emits `previous_turn_id: null` on the wire (first turn in session).
67
+ // The SDK type doesn't admit null, but the Fern serializer handles it correctly.
68
+ const previousTurnId: TrueFoundryGatewayApi.PreviousTurnIdInput | null | undefined =
58
69
  options.previousTurnId === null
59
- ? undefined
70
+ ? null
60
71
  : (options.previousTurnId ?? "auto");
61
72
  const turn = session.prepareTurn({
62
73
  input: buildTurnInput(options),
63
- ...(previousTurnId != null ? { previousTurnId } : {}),
74
+ ...(previousTurnId !== undefined
75
+ ? { previousTurnId: previousTurnId as TrueFoundryGatewayApi.PreviousTurnIdInput }
76
+ : {}),
64
77
  });
65
78
 
66
79
  const onAbort = bindAbort(session, abortSignal);
@@ -68,8 +81,16 @@ export async function* streamTurnContent(
68
81
  return;
69
82
  }
70
83
 
84
+ let turnIdNotified = false;
85
+ const notifyTurnIdIfAvailable = () => {
86
+ if (!turnIdNotified && turn.id != null) {
87
+ onTurnIdAvailable?.(turn.id);
88
+ turnIdNotified = true;
89
+ }
90
+ };
91
+
71
92
  try {
72
- yield* streamTurnEvents(
93
+ for await (const update of streamTurnEvents(
73
94
  turn.execute(
74
95
  { stream: true },
75
96
  {
@@ -79,8 +100,21 @@ export async function* streamTurnContent(
79
100
  ),
80
101
  foldState,
81
102
  groupRootBaseline,
82
- );
103
+ )) {
104
+ // After the first `turn.created` event, `turn.id` is set.
105
+ // Notify BEFORE yielding so the caller can update its tracking
106
+ // before the snapshot is written with the stream update.
107
+ notifyTurnIdIfAvailable();
108
+ yield update;
109
+ }
110
+ // Handle streams that complete without yielding any content.
111
+ notifyTurnIdIfAvailable();
83
112
  } catch (error) {
113
+ // Error streams often throw on `turn.done` (status=error) without ever
114
+ // yielding content (e.g. model not servable). `turn.id` is still set
115
+ // after `turn.created` — notify so edit/retry can resolve the turn.
116
+ // Same for AbortError after create: keep local ids aligned with gateway.
117
+ notifyTurnIdIfAvailable();
84
118
  if (error instanceof Error && error.name === "AbortError") {
85
119
  return;
86
120
  }
@@ -353,7 +353,14 @@ export function useTrueFoundryAgentMessages({
353
353
  const runStream = useCallback(
354
354
  (
355
355
  createStream: (signal: AbortSignal) => AsyncGenerator<TurnStreamUpdate>,
356
- turnId: string,
356
+ /**
357
+ * A mutable ref whose `.current` is the turn ID to use for
358
+ * `activeStream.turnId`. Callers that capture the gateway turn ID
359
+ * via `onTurnIdAvailable` update this ref in-place so that both the
360
+ * pending-update flush and `commitActiveStream` always see the real
361
+ * gateway ID rather than the locally-generated optimistic one.
362
+ */
363
+ turnIdRef: { current: string },
357
364
  isContinuation: boolean,
358
365
  ): Promise<void> => {
359
366
  const streamGeneration = ++streamGenerationRef.current;
@@ -368,7 +375,6 @@ export function useTrueFoundryAgentMessages({
368
375
  // message tree (UI hang). The buffer belongs to this stream only.
369
376
  let pendingStreamUpdate: {
370
377
  update: TurnStreamUpdate;
371
- turnId: string;
372
378
  isContinuation: boolean;
373
379
  } | null = null;
374
380
  let streamUpdateRaf: number | null = null;
@@ -383,12 +389,14 @@ export function useTrueFoundryAgentMessages({
383
389
  ) {
384
390
  return;
385
391
  }
386
- const { update, turnId: pendingTurnId, isContinuation: pendingIsContinuation } =
387
- pending;
392
+ const { update, isContinuation: pendingIsContinuation } = pending;
388
393
  setSnapshot((prev) =>
389
394
  replaceSessionSnapshot(prev, {
390
395
  activeStream: {
391
- turnId: pendingTurnId,
396
+ // Read from the ref so we always use the latest ID,
397
+ // including any gateway ID that arrived after the RAf
398
+ // was scheduled.
399
+ turnId: turnIdRef.current,
392
400
  update,
393
401
  isContinuation: pendingIsContinuation,
394
402
  },
@@ -397,7 +405,7 @@ export function useTrueFoundryAgentMessages({
397
405
  };
398
406
 
399
407
  const applyStreamUpdate = (update: TurnStreamUpdate) => {
400
- pendingStreamUpdate = { update, turnId, isContinuation };
408
+ pendingStreamUpdate = { update, isContinuation };
401
409
  if (streamUpdateRaf == null) {
402
410
  streamUpdateRaf = requestAnimationFrame(flushPendingStreamUpdate);
403
411
  }
@@ -540,7 +548,7 @@ export function useTrueFoundryAgentMessages({
540
548
  undefined,
541
549
  loadedSnapshot.groupRootBaseline,
542
550
  ),
543
- turn.id,
551
+ { current: turn.id },
544
552
  isContinuation,
545
553
  ).catch(() => undefined);
546
554
  }
@@ -588,6 +596,18 @@ export function useTrueFoundryAgentMessages({
588
596
  isContinuation && continuationTurnId != null
589
597
  ? continuationTurnId
590
598
  : generateId();
599
+ // First turns must send previousTurnId: null.
600
+ const isFirstTurnInSession =
601
+ "userMessage" in options &&
602
+ options.previousTurnId === undefined &&
603
+ snapshotRef.current.turns.length === 0 &&
604
+ snapshotRef.current.pendingUser == null &&
605
+ snapshotRef.current.activeStream == null;
606
+
607
+ // Mutable ref so runStream always reads the latest ID. For new
608
+ // user-message turns the local `generateId()` value is replaced
609
+ // with the gateway-assigned ID once the first SSE event arrives.
610
+ const turnIdRef = { current: turnId };
591
611
 
592
612
  if ("inputs" in options) {
593
613
  applyUserToolResponsesToFold(
@@ -676,14 +696,33 @@ export function useTrueFoundryAgentMessages({
676
696
  userMessage: options.userMessage,
677
697
  ...(options.previousTurnId !== undefined
678
698
  ? { previousTurnId: options.previousTurnId }
679
- : {}),
699
+ : isFirstTurnInSession
700
+ ? { previousTurnId: null }
701
+ : {}),
680
702
  ...streamHeaders,
681
703
  },
682
704
  signal,
683
705
  groupRootBaseline,
706
+ // Rename the optimistic local ID to the gateway turn ID
707
+ // so that edit/retry can resolve the turn via the gateway.
708
+ (gatewayTurnId) => {
709
+ const oldId = turnIdRef.current;
710
+ if (gatewayTurnId === oldId) return;
711
+ turnIdRef.current = gatewayTurnId;
712
+ // Rename in the ref immediately so any synchronous read
713
+ // (e.g. commitActiveStream) sees the correct ID.
714
+ const renamePendingUser = (prev: SessionSnapshot): SessionSnapshot => {
715
+ if (prev.pendingUser?.turnId !== oldId) return prev;
716
+ return replaceSessionSnapshot(prev, {
717
+ pendingUser: { ...prev.pendingUser, turnId: gatewayTurnId },
718
+ });
719
+ };
720
+ snapshotRef.current = renamePendingUser(snapshotRef.current);
721
+ setSnapshot(renamePendingUser);
722
+ },
684
723
  );
685
724
  },
686
- turnId,
725
+ turnIdRef,
687
726
  isContinuation,
688
727
  );
689
728
  },
@@ -784,7 +823,7 @@ export function useTrueFoundryAgentMessages({
784
823
  undefined,
785
824
  snapshotRef.current.groupRootBaseline,
786
825
  ),
787
- turn.id,
826
+ { current: turn.id },
788
827
  true,
789
828
  );
790
829
  }, [runStream]);