@truefoundry/assistant-ui-runtime 0.1.3-rc.2 → 0.1.4

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.4",
4
4
  "description": "TrueFoundry Gateway agent runtime adapter for assistant-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -50,7 +50,7 @@
50
50
  "peerDependencies": {
51
51
  "@types/react": "*",
52
52
  "react": "^18 || ^19",
53
- "truefoundry-gateway-sdk": "^0.3.2-rc.1"
53
+ "truefoundry-gateway-sdk": "^0.4.0-rc.1"
54
54
  },
55
55
  "peerDependenciesMeta": {
56
56
  "@types/react": {
@@ -63,7 +63,7 @@
63
63
  "@types/react": "^19.2.17",
64
64
  "jsdom": "^29.1.1",
65
65
  "react": "^19.2.4",
66
- "truefoundry-gateway-sdk": "0.3.2-rc.1",
66
+ "truefoundry-gateway-sdk": "0.4.0-rc.1",
67
67
  "tsup": "^8.5.0",
68
68
  "typescript": "^5.9.3",
69
69
  "vitest": "^4.1.9"
@@ -178,7 +178,7 @@ async function sessionEventItemsFromTurns(
178
178
  turnId: turn.id,
179
179
  input: turn.input,
180
180
  state: { status: "running" },
181
- createdBy: { subjectId: "u1", subjectType: "user" },
181
+ createdBy: { subjectId: "u1", subjectType: "user", subjectSlug: "u1" },
182
182
  createdAt: turn.createdAt,
183
183
  },
184
184
  });
@@ -636,7 +636,7 @@ describe("convertTurnMessages", () => {
636
636
  input: [{ type: "user.message", content: "run tool" }],
637
637
  state: {
638
638
  status: "done",
639
- output: null,
639
+ output: undefined,
640
640
  requiredActions: [
641
641
  approvalRequired({
642
642
  id: "approval-event",
@@ -703,7 +703,7 @@ describe("convertTurnMessages", () => {
703
703
  input: [{ type: "user.message", content: "run tool" }],
704
704
  state: {
705
705
  status: "done",
706
- output: null,
706
+ output: undefined,
707
707
  requiredActions: [
708
708
  approvalRequired({
709
709
  id: "approval-event",
@@ -787,7 +787,7 @@ describe("convertTurnMessages", () => {
787
787
  input: [{ type: "user.message", content: "ask me" }],
788
788
  state: {
789
789
  status: "done",
790
- output: null,
790
+ output: undefined,
791
791
  requiredActions: [
792
792
  responseRequired({
793
793
  id: "resp-req-1",
@@ -859,7 +859,7 @@ describe("convertTurnMessages", () => {
859
859
  input: [{ type: "user.message", content: "ask me" }],
860
860
  state: {
861
861
  status: "done",
862
- output: null,
862
+ output: undefined,
863
863
  requiredActions: [
864
864
  responseRequired({
865
865
  id: "resp-req-1",
@@ -2186,7 +2186,7 @@ describe("buildSnapshotFromSessionEvents", () => {
2186
2186
  turnId: "t1",
2187
2187
  input: [{ type: "user.message", content: "hello" }],
2188
2188
  state: { status: "running" },
2189
- createdBy: { subjectId: "u1", subjectType: "user" },
2189
+ createdBy: { subjectId: "u1", subjectType: "user", subjectSlug: "u1" },
2190
2190
  createdAt,
2191
2191
  },
2192
2192
  },
@@ -2241,7 +2241,7 @@ describe("buildSnapshotFromSessionEvents", () => {
2241
2241
  turnId: "t1",
2242
2242
  input: [{ type: "user.message", content: "first" }],
2243
2243
  state: { status: "running" },
2244
- createdBy: { subjectId: "u1", subjectType: "user" },
2244
+ createdBy: { subjectId: "u1", subjectType: "user", subjectSlug: "u1" },
2245
2245
  createdAt,
2246
2246
  },
2247
2247
  },
@@ -2286,7 +2286,7 @@ describe("buildSnapshotFromSessionEvents", () => {
2286
2286
  turnId: "t1",
2287
2287
  input: [{ type: "user.message", content: "first" }],
2288
2288
  state: { status: "running" },
2289
- createdBy: { subjectId: "u1", subjectType: "user" },
2289
+ createdBy: { subjectId: "u1", subjectType: "user", subjectSlug: "u1" },
2290
2290
  createdAt,
2291
2291
  },
2292
2292
  },
@@ -2311,7 +2311,7 @@ describe("buildSnapshotFromSessionEvents", () => {
2311
2311
  turnId: "t2",
2312
2312
  input: [{ type: "user.message", content: "second" }],
2313
2313
  state: { status: "running" },
2314
- createdBy: { subjectId: "u1", subjectType: "user" },
2314
+ createdBy: { subjectId: "u1", subjectType: "user", subjectSlug: "u1" },
2315
2315
  createdAt,
2316
2316
  },
2317
2317
  },
@@ -2380,7 +2380,7 @@ describe("buildSnapshotFromSessionEvents", () => {
2380
2380
  turnId: id,
2381
2381
  input: [{ type: "user.message", content: text }],
2382
2382
  state: { status: "running" },
2383
- createdBy: { subjectId: "u1", subjectType: "user" },
2383
+ createdBy: { subjectId: "u1", subjectType: "user", subjectSlug: "u1" },
2384
2384
  createdAt,
2385
2385
  },
2386
2386
  },
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
- import { mergeAgentSpec } from "./private/agentSpec.js";
3
+ import { mergeAgentSpec, type AgentSpec } from "./private/agentSpec.js";
4
4
  import {
5
5
  resolveTrueFoundryAgentConfig,
6
6
  resolveTrueFoundryAgentRuntimeOptions,
@@ -80,14 +80,14 @@ describe("mergeAgentSpec", () => {
80
80
  });
81
81
 
82
82
  it("replaces mcpServers array wholesale", () => {
83
- const base = {
83
+ const base: AgentSpec = {
84
84
  model: { name: "openai/gpt-4o" },
85
- mcpServers: [{ name: "github", enableTools: ["@all"] }],
85
+ mcpServers: [{ type: "truefoundry-mcp-registry", name: "github", enableTools: ["@all"] }],
86
86
  };
87
87
  const next = mergeAgentSpec(base, {
88
- mcpServers: [{ name: "slack", enableTools: ["@all"] }],
88
+ mcpServers: [{ type: "truefoundry-mcp-registry", name: "slack", enableTools: ["@all"] }],
89
89
  });
90
- expect(next.mcpServers).toEqual([{ name: "slack", enableTools: ["@all"] }]);
90
+ expect(next.mcpServers).toEqual([{ type: "truefoundry-mcp-registry", name: "slack", enableTools: ["@all"] }]);
91
91
  });
92
92
 
93
93
  it("replaces skills array wholesale", () => {
@@ -102,9 +102,9 @@ describe("mergeAgentSpec", () => {
102
102
  });
103
103
 
104
104
  it("model partial update does not clear mcpServers or skills", () => {
105
- const base = {
105
+ const base: AgentSpec = {
106
106
  model: { name: "openai/gpt-4o", params: { maxTokens: 1024 } },
107
- mcpServers: [{ name: "github", enableTools: ["@all"] }],
107
+ mcpServers: [{ type: "truefoundry-mcp-registry", name: "github", enableTools: ["@all"] }],
108
108
  skills: [{ fqn: "acme/skill-a:1", preload: false }],
109
109
  };
110
110
  const next = mergeAgentSpec(base, {
@@ -142,7 +142,7 @@ describe("foldPeerThreads", () => {
142
142
  id: "spawn-1",
143
143
  type: "function",
144
144
  function: { name: "create_sub_agent", arguments: "{}" },
145
- },
145
+ toolInfo: { type: "truefoundry-system", name: "create_sub_agent" }, },
146
146
  ],
147
147
  }),
148
148
  );
@@ -74,7 +74,9 @@ function ensureModelMessageContentArray(message: ModelMessageEvent): void {
74
74
  if (Array.isArray(message.content)) {
75
75
  return;
76
76
  }
77
- message.content = normalizeModelMessageContent(message);
77
+ // Gateway may stream `image_url` content blocks that aren't in the SDK content union yet.
78
+ (message as { content?: ModelMessageContentPart[] }).content =
79
+ normalizeModelMessageContent(message);
78
80
  }
79
81
 
80
82
  function mergeContentBlockDeltas(
@@ -104,7 +104,7 @@ describe("createTrueFoundryDraftThreadListAdapter", () => {
104
104
  const liveAgentSpec: AgentSpec = {
105
105
  model: { name: "anthropic/claude-opus-4-8" },
106
106
  instructions: "You are helpful.",
107
- mcpServers: [{ name: "github", enableTools: ["@all"] }],
107
+ mcpServers: [{ type: "truefoundry-mcp-registry", name: "github", enableTools: ["@all"] }],
108
108
  skills: [{ fqn: "acme/skill-a:1", preload: false }],
109
109
  };
110
110
  const createDraftSession = vi.fn().mockResolvedValue(
@@ -13,9 +13,9 @@ const createdAt = new Date().toISOString();
13
13
 
14
14
  function streamData(
15
15
  sequenceNumber: number,
16
- event: TurnStreamData["event"],
16
+ event: TurnStreamData["event"] | Record<string, unknown>,
17
17
  ): TurnStreamData {
18
- return { sequenceNumber, event };
18
+ return { sequenceNumber, event: event as TurnStreamData["event"] };
19
19
  }
20
20
 
21
21
  async function collectUpdates(
@@ -150,7 +150,7 @@ describe("streamTurn", () => {
150
150
  });
151
151
  });
152
152
 
153
- it("omits previousTurnId when branching from root", async () => {
153
+ it("forwards previousTurnId null when branching from root", async () => {
154
154
  const execute = vi.fn(() => (async function* () {})());
155
155
  const prepareTurn = vi.fn(() => ({ execute }));
156
156
  const session = {
@@ -169,6 +169,7 @@ describe("streamTurn", () => {
169
169
 
170
170
  expect(prepareTurn).toHaveBeenCalledWith({
171
171
  input: [{ type: "user.message", content: "first" }],
172
+ previousTurnId: null,
172
173
  });
173
174
  });
174
175
 
@@ -227,6 +228,93 @@ describe("streamTurn", () => {
227
228
  },
228
229
  );
229
230
  });
231
+
232
+ it("notifies gateway turn id when turn.done errors with no content yields", async () => {
233
+ const gatewayTurnId = "01ky6mqzmczwt6ssyd5r02gjjc";
234
+ const turn = {
235
+ id: undefined as string | undefined,
236
+ execute: vi.fn(() =>
237
+ (async function* () {
238
+ // SDK sets turn.id when turn.created is observed.
239
+ turn.id = gatewayTurnId;
240
+ yield streamData(1, {
241
+ type: "turn.created",
242
+ createdAt,
243
+ input: [{ type: "user.message", content: "hello" }],
244
+ });
245
+ yield streamData(2, {
246
+ type: "turn.done",
247
+ createdAt,
248
+ state: {
249
+ status: "error",
250
+ message:
251
+ "Publisher Model is not servable in region us-central1.",
252
+ completedAt: createdAt,
253
+ },
254
+ });
255
+ })(),
256
+ ),
257
+ };
258
+ const prepareTurn = vi.fn(() => turn);
259
+ const session = {
260
+ prepareTurn,
261
+ cancel: vi.fn().mockResolvedValue(undefined),
262
+ } as unknown as AgentSession;
263
+ const onTurnIdAvailable = vi.fn();
264
+
265
+ await expect(
266
+ collectUpdates(
267
+ streamTurnContent(
268
+ session,
269
+ new PeerThreadFoldState(),
270
+ { userMessage: "hello" },
271
+ new AbortController().signal,
272
+ undefined,
273
+ onTurnIdAvailable,
274
+ ),
275
+ ),
276
+ ).rejects.toThrow("Publisher Model is not servable in region us-central1.");
277
+
278
+ expect(onTurnIdAvailable).toHaveBeenCalledTimes(1);
279
+ expect(onTurnIdAvailable).toHaveBeenCalledWith(gatewayTurnId);
280
+ });
281
+
282
+ it("does not notify when an error stream never assigns turn.id", async () => {
283
+ const execute = vi.fn(() =>
284
+ (async function* () {
285
+ yield streamData(1, {
286
+ type: "turn.done",
287
+ createdAt,
288
+ state: {
289
+ status: "error",
290
+ completedAt: createdAt,
291
+ message: "boom",
292
+ },
293
+ });
294
+ })(),
295
+ );
296
+ const prepareTurn = vi.fn(() => ({ id: undefined, execute }));
297
+ const session = {
298
+ prepareTurn,
299
+ cancel: vi.fn().mockResolvedValue(undefined),
300
+ } as unknown as AgentSession;
301
+ const onTurnIdAvailable = vi.fn();
302
+
303
+ await expect(
304
+ collectUpdates(
305
+ streamTurnContent(
306
+ session,
307
+ new PeerThreadFoldState(),
308
+ { userMessage: "hello" },
309
+ new AbortController().signal,
310
+ undefined,
311
+ onTurnIdAvailable,
312
+ ),
313
+ ),
314
+ ).rejects.toThrow("boom");
315
+
316
+ expect(onTurnIdAvailable).not.toHaveBeenCalled();
317
+ });
230
318
  });
231
319
 
232
320
  describe("resumeTurnStream", () => {
package/src/streamTurn.ts CHANGED
@@ -1,8 +1,9 @@
1
- import {
2
- type AgentSession,
3
- type Turn,
4
- type TurnInputItem,
1
+ import type {
2
+ AgentSession,
3
+ Turn,
4
+ TurnInputItem,
5
5
  } from "truefoundry-gateway-sdk/agents";
6
+ import type { TruefoundryGatewayApi } from "truefoundry-gateway-sdk";
6
7
 
7
8
  import {
8
9
  streamTurnEvents,
@@ -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,
@@ -30,7 +30,6 @@ import {
30
30
  createDraftSessionBridge,
31
31
  DRAFT_SESSION_LAST_UPDATED_AT_HEADER,
32
32
  } from "./private/draftSessionBridge.js";
33
- import { getGatewayFromPrivateClient } from "./private/getGatewayFromPrivateClient.js";
34
33
  import { MCP_AUTH_RESUME_RUN_CUSTOM_KEY } from "./mcpAuth.js";
35
34
  import { createTrueFoundryDraftThreadListAdapter } from "./private/truefoundryDraftThreadListAdapter.js";
36
35
  import { trueFoundryExtras } from "./truefoundryExtras.js";
@@ -164,9 +163,7 @@ function useTrueFoundryAgentRuntimeImpl(
164
163
  onError?.(error);
165
164
  throw error;
166
165
  }
167
- // Same gateway.agents.downloadSandboxFile path as before, via the wrapped client.
168
- const gateway = getGatewayFromPrivateClient(privateClient);
169
- const response = await gateway.agents.downloadSandboxFile(sandboxId, { path });
166
+ const response = await privateClient.downloadSandboxFile(sandboxId, { path });
170
167
  return await response.blob();
171
168
  },
172
169
  [privateClient, sandboxId, onError],