@truefoundry/assistant-ui-runtime 0.1.3-rc.2 → 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.2",
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). */
@@ -61,13 +62,18 @@ export async function* streamTurnContent(
61
62
  */
62
63
  onTurnIdAvailable?: (turnId: string) => void,
63
64
  ): AsyncGenerator<TurnStreamUpdate> {
64
- 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 =
65
69
  options.previousTurnId === null
66
- ? undefined
70
+ ? null
67
71
  : (options.previousTurnId ?? "auto");
68
72
  const turn = session.prepareTurn({
69
73
  input: buildTurnInput(options),
70
- ...(previousTurnId != null ? { previousTurnId } : {}),
74
+ ...(previousTurnId !== undefined
75
+ ? { previousTurnId: previousTurnId as TrueFoundryGatewayApi.PreviousTurnIdInput }
76
+ : {}),
71
77
  });
72
78
 
73
79
  const onAbort = bindAbort(session, abortSignal);
@@ -75,8 +81,15 @@ export async function* streamTurnContent(
75
81
  return;
76
82
  }
77
83
 
84
+ let turnIdNotified = false;
85
+ const notifyTurnIdIfAvailable = () => {
86
+ if (!turnIdNotified && turn.id != null) {
87
+ onTurnIdAvailable?.(turn.id);
88
+ turnIdNotified = true;
89
+ }
90
+ };
91
+
78
92
  try {
79
- let turnIdNotified = false;
80
93
  for await (const update of streamTurnEvents(
81
94
  turn.execute(
82
95
  { stream: true },
@@ -91,17 +104,17 @@ export async function* streamTurnContent(
91
104
  // After the first `turn.created` event, `turn.id` is set.
92
105
  // Notify BEFORE yielding so the caller can update its tracking
93
106
  // before the snapshot is written with the stream update.
94
- if (!turnIdNotified && turn.id != null) {
95
- onTurnIdAvailable?.(turn.id);
96
- turnIdNotified = true;
97
- }
107
+ notifyTurnIdIfAvailable();
98
108
  yield update;
99
109
  }
100
110
  // Handle streams that complete without yielding any content.
101
- if (!turnIdNotified && turn.id != null) {
102
- onTurnIdAvailable?.(turn.id);
103
- }
111
+ notifyTurnIdIfAvailable();
104
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();
105
118
  if (error instanceof Error && error.name === "AbortError") {
106
119
  return;
107
120
  }
@@ -596,6 +596,13 @@ export function useTrueFoundryAgentMessages({
596
596
  isContinuation && continuationTurnId != null
597
597
  ? continuationTurnId
598
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;
599
606
 
600
607
  // Mutable ref so runStream always reads the latest ID. For new
601
608
  // user-message turns the local `generateId()` value is replaced
@@ -689,7 +696,9 @@ export function useTrueFoundryAgentMessages({
689
696
  userMessage: options.userMessage,
690
697
  ...(options.previousTurnId !== undefined
691
698
  ? { previousTurnId: options.previousTurnId }
692
- : {}),
699
+ : isFirstTurnInSession
700
+ ? { previousTurnId: null }
701
+ : {}),
693
702
  ...streamHeaders,
694
703
  },
695
704
  signal,