@truefoundry/assistant-ui-runtime 0.1.0-rc.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 (58) hide show
  1. package/README.md +567 -0
  2. package/dist/index.d.ts +300 -0
  3. package/dist/index.js +4440 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +70 -0
  6. package/src/agentSessionModule.d.ts +25 -0
  7. package/src/agentSpec.ts +44 -0
  8. package/src/askUserQuestion.ts +37 -0
  9. package/src/attachmentAdapter.test.ts +56 -0
  10. package/src/attachmentAdapter.ts +58 -0
  11. package/src/bindDraftAgentSession.test.ts +55 -0
  12. package/src/bindDraftAgentSession.ts +66 -0
  13. package/src/buildEditedUserMessageContent.test.ts +61 -0
  14. package/src/collectPending.test.ts +69 -0
  15. package/src/collectPending.ts +165 -0
  16. package/src/constants.ts +2 -0
  17. package/src/convertTurnMessages.test.ts +1991 -0
  18. package/src/convertTurnMessages.ts +1251 -0
  19. package/src/createSubAgent.ts +8 -0
  20. package/src/draftAgentConfig.test.ts +88 -0
  21. package/src/draftSessionBridge.ts +28 -0
  22. package/src/extractTurnUserText.ts +21 -0
  23. package/src/foldPeerThreads.test.ts +386 -0
  24. package/src/foldPeerThreads.ts +587 -0
  25. package/src/hooks.ts +123 -0
  26. package/src/index.ts +68 -0
  27. package/src/lastUserMessageText.ts +19 -0
  28. package/src/loadSessionSnapshot.test.ts +57 -0
  29. package/src/loadSessionSnapshot.ts +35 -0
  30. package/src/mcpAuth.ts +44 -0
  31. package/src/messageCustomMetadata.ts +37 -0
  32. package/src/modelMessageContent.ts +135 -0
  33. package/src/modelMessageImageContent.test.ts +109 -0
  34. package/src/modelMessageImageContent.ts +193 -0
  35. package/src/requiredActionInputs.test.ts +394 -0
  36. package/src/requiredActionInputs.ts +65 -0
  37. package/src/requiredActionsFromActiveUpdate.test.ts +95 -0
  38. package/src/sessionListStartTimestamp.ts +6 -0
  39. package/src/sessionSnapshot.ts +107 -0
  40. package/src/sessions.ts +36 -0
  41. package/src/streamTurn.test.ts +243 -0
  42. package/src/streamTurn.ts +119 -0
  43. package/src/toolApproval.test.ts +137 -0
  44. package/src/toolApproval.ts +459 -0
  45. package/src/toolResponse.test.ts +136 -0
  46. package/src/toolResponse.ts +427 -0
  47. package/src/truefoundryDraftThreadListAdapter.test.ts +140 -0
  48. package/src/truefoundryDraftThreadListAdapter.ts +63 -0
  49. package/src/truefoundryExtras.ts +45 -0
  50. package/src/truefoundryThreadListAdapter.test.ts +103 -0
  51. package/src/truefoundryThreadListAdapter.ts +59 -0
  52. package/src/turnEventHelpers.ts +98 -0
  53. package/src/turnStreamUpdate.ts +11 -0
  54. package/src/types.ts +92 -0
  55. package/src/useDraftAgentSpec.ts +173 -0
  56. package/src/useTrueFoundryAgentMessages.test.tsx +723 -0
  57. package/src/useTrueFoundryAgentMessages.ts +757 -0
  58. package/src/useTrueFoundryAgentRuntime.ts +268 -0
@@ -0,0 +1,8 @@
1
+ import type { ToolCall } from "truefoundry-gateway-sdk/agents";
2
+
3
+ export function isCreateSubAgentToolCall(toolCall: Pick<ToolCall, "toolInfo">): boolean {
4
+ return (
5
+ toolCall.toolInfo.type === "truefoundry-system" &&
6
+ toolCall.toolInfo.name === "create_sub_agent"
7
+ );
8
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { mergeAgentSpec } from "./agentSpec.js";
4
+ import { resolveTrueFoundryAgentConfig } from "./types.js";
5
+
6
+ describe("resolveTrueFoundryAgentConfig", () => {
7
+ it("supports legacy agentName", () => {
8
+ expect(resolveTrueFoundryAgentConfig({ agentName: "my-agent" })).toEqual({
9
+ mode: "named",
10
+ agentName: "my-agent",
11
+ });
12
+ });
13
+
14
+ it("prefers explicit agentName over named config", () => {
15
+ expect(
16
+ resolveTrueFoundryAgentConfig({
17
+ agent: { mode: "named", agentName: "ignored" },
18
+ agentName: "preferred",
19
+ }),
20
+ ).toEqual({
21
+ mode: "named",
22
+ agentName: "preferred",
23
+ });
24
+ });
25
+
26
+ it("returns draft config unchanged", () => {
27
+ const spec = { model: { name: "openai/gpt-4o" } };
28
+ expect(
29
+ resolveTrueFoundryAgentConfig({
30
+ agent: { mode: "draft", defaultAgentSpec: spec },
31
+ }),
32
+ ).toEqual({
33
+ mode: "draft",
34
+ defaultAgentSpec: spec,
35
+ });
36
+ });
37
+ });
38
+
39
+ describe("mergeAgentSpec", () => {
40
+ it("deep-merges model params", () => {
41
+ const base = {
42
+ model: {
43
+ name: "anthropic/claude-sonnet-4-6",
44
+ params: { maxTokens: 1024, temperature: 0.5 },
45
+ },
46
+ };
47
+ const next = mergeAgentSpec(base, {
48
+ model: { params: { temperature: 1.0 } },
49
+ });
50
+ expect(next.model.params).toEqual({ maxTokens: 1024, temperature: 1.0 });
51
+ });
52
+
53
+ it("replaces mcpServers array wholesale", () => {
54
+ const base = {
55
+ model: { name: "openai/gpt-4o" },
56
+ mcpServers: [{ name: "github", enableTools: ["@all"] }],
57
+ };
58
+ const next = mergeAgentSpec(base, {
59
+ mcpServers: [{ name: "slack", enableTools: ["@all"] }],
60
+ });
61
+ expect(next.mcpServers).toEqual([{ name: "slack", enableTools: ["@all"] }]);
62
+ });
63
+
64
+ it("replaces skills array wholesale", () => {
65
+ const base = {
66
+ model: { name: "openai/gpt-4o" },
67
+ skills: [{ fqn: "acme/skill-a:1", preload: false }],
68
+ };
69
+ const next = mergeAgentSpec(base, {
70
+ skills: [{ fqn: "acme/skill-b:2", preload: true }],
71
+ });
72
+ expect(next.skills).toEqual([{ fqn: "acme/skill-b:2", preload: true }]);
73
+ });
74
+
75
+ it("model partial update does not clear mcpServers or skills", () => {
76
+ const base = {
77
+ model: { name: "openai/gpt-4o", params: { maxTokens: 1024 } },
78
+ mcpServers: [{ name: "github", enableTools: ["@all"] }],
79
+ skills: [{ fqn: "acme/skill-a:1", preload: false }],
80
+ };
81
+ const next = mergeAgentSpec(base, {
82
+ model: { name: "anthropic/claude-sonnet-4-6" },
83
+ });
84
+ expect(next.model.name).toBe("anthropic/claude-sonnet-4-6");
85
+ expect(next.mcpServers).toEqual(base.mcpServers);
86
+ expect(next.skills).toEqual(base.skills);
87
+ });
88
+ });
@@ -0,0 +1,28 @@
1
+ import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
2
+
3
+ import type { AgentSpec } from "./agentSpec.js";
4
+
5
+ export type DraftSessionBridge = {
6
+ syncAgentSpec: (draftSessionId: string, agentSpec: AgentSpec) => Promise<void>;
7
+ getDraftAgentSpec: (draftSessionId: string) => Promise<AgentSpec>;
8
+ };
9
+
10
+ export function createDraftSessionBridge(
11
+ gateway: TrueFoundryGateway,
12
+ ): DraftSessionBridge {
13
+ async function getDraft(draftSessionId: string) {
14
+ const response = await gateway.agents.private.draftSessions.get(draftSessionId);
15
+ return response.data;
16
+ }
17
+
18
+ return {
19
+ async getDraftAgentSpec(draftSessionId) {
20
+ const draft = await getDraft(draftSessionId);
21
+ return draft.agentSpec;
22
+ },
23
+
24
+ async syncAgentSpec(draftSessionId, agentSpec) {
25
+ await gateway.agents.private.draftSessions.update(draftSessionId, { agentSpec });
26
+ },
27
+ };
28
+ }
@@ -0,0 +1,21 @@
1
+ import type { Turn } from "truefoundry-gateway-sdk/agents";
2
+
3
+ export function extractTurnUserText(input: Turn["input"]): string {
4
+ const parts: string[] = [];
5
+ for (const item of input ?? []) {
6
+ if (item.type !== "user.message") {
7
+ continue;
8
+ }
9
+ const { content } = item;
10
+ if (typeof content === "string") {
11
+ parts.push(content);
12
+ continue;
13
+ }
14
+ for (const part of content) {
15
+ if (part.type === "text") {
16
+ parts.push(part.text);
17
+ }
18
+ }
19
+ }
20
+ return parts.join("\n").trim();
21
+ }
@@ -0,0 +1,386 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type {
3
+ ModelMessageEvent,
4
+ ThreadCreatedEvent,
5
+ TurnEvent,
6
+ } from "truefoundry-gateway-sdk/agents";
7
+
8
+ import { ROOT_THREAD_ID } from "./constants.js";
9
+ import {
10
+ buildRootAssistantContent,
11
+ ingestStreamEvent,
12
+ ingestTurnEvent,
13
+ PeerThreadFoldState,
14
+ recordToolApprovalInFold,
15
+ recordToolResponseInFold,
16
+ } from "./foldPeerThreads.js";
17
+
18
+ const createdAt = new Date().toISOString();
19
+
20
+ function modelMessage(
21
+ event: Omit<ModelMessageEvent, "type" | "createdAt">,
22
+ ): ModelMessageEvent {
23
+ return { type: "model.message", createdAt, ...event };
24
+ }
25
+
26
+ function threadCreated(
27
+ event: Omit<ThreadCreatedEvent, "type" | "createdAt">,
28
+ ): ThreadCreatedEvent {
29
+ return { type: "thread.created", createdAt, ...event };
30
+ }
31
+
32
+ describe("foldPeerThreads", () => {
33
+ it("ignores sandbox.created (no threadId, not foldable) without crashing", () => {
34
+ const state = new PeerThreadFoldState();
35
+ const sandboxEvent = {
36
+ type: "sandbox.created" as const,
37
+ id: "sandbox-evt",
38
+ createdAt,
39
+ sandboxId: "sbx-123",
40
+ } as unknown as TurnEvent;
41
+
42
+ expect(() => ingestTurnEvent(state, sandboxEvent)).not.toThrow();
43
+ expect(ingestStreamEvent(state, sandboxEvent)).toBe(false);
44
+ expect(buildRootAssistantContent(state)).toEqual([]);
45
+ });
46
+
47
+ it("anchors root content on main without inference", () => {
48
+ const state = new PeerThreadFoldState();
49
+
50
+ const rootMessage: TurnEvent = modelMessage({
51
+ id: "m1",
52
+ threadId: ROOT_THREAD_ID,
53
+ content: "hello",
54
+ });
55
+
56
+ ingestTurnEvent(state, rootMessage);
57
+
58
+ expect(buildRootAssistantContent(state)).toEqual([
59
+ { type: "text", text: "hello" },
60
+ ]);
61
+ });
62
+
63
+ it("captures sub-agent title from thread.created and tags nested messages", () => {
64
+ const state = new PeerThreadFoldState();
65
+
66
+ ingestTurnEvent(
67
+ state,
68
+ modelMessage({
69
+ id: "root-msg",
70
+ threadId: ROOT_THREAD_ID,
71
+ toolCalls: [
72
+ {
73
+ id: "spawn-1",
74
+ type: "function",
75
+ function: { name: "create_sub_agent", arguments: "{}" },
76
+ toolInfo: { type: "truefoundry-system", name: "create_sub_agent" },
77
+ },
78
+ ],
79
+ }),
80
+ );
81
+
82
+ ingestTurnEvent(
83
+ state,
84
+ threadCreated({
85
+ id: "t-created",
86
+ threadId: "child-1",
87
+ title: "Research agent",
88
+ agentInfo: { type: "dynamic", name: "researcher", input: "do research" },
89
+ parent: { threadId: ROOT_THREAD_ID, toolCallId: "spawn-1" },
90
+ }),
91
+ );
92
+
93
+ ingestTurnEvent(
94
+ state,
95
+ modelMessage({
96
+ id: "child-msg",
97
+ threadId: "child-1",
98
+ content: "working",
99
+ }),
100
+ );
101
+
102
+ const parts = buildRootAssistantContent(state);
103
+ const spawn = parts.find((part) => part.type === "tool-call");
104
+ expect(spawn?.type).toBe("tool-call");
105
+ if (spawn?.type !== "tool-call") {
106
+ return;
107
+ }
108
+
109
+ expect(spawn.artifact).toEqual({
110
+ subAgents: [
111
+ {
112
+ threadId: "child-1",
113
+ title: "Research agent",
114
+ agentInfo: {
115
+ type: "dynamic",
116
+ name: "researcher",
117
+ input: "do research",
118
+ },
119
+ },
120
+ ],
121
+ });
122
+
123
+ const nested = spawn.messages?.[0];
124
+ expect(nested?.metadata.custom.subAgent).toEqual({
125
+ threadId: "child-1",
126
+ title: "Research agent",
127
+ name: "researcher",
128
+ input: "do research",
129
+ });
130
+ });
131
+
132
+ it("classifies stream events by thread id", () => {
133
+ const state = new PeerThreadFoldState();
134
+ expect(
135
+ ingestStreamEvent(
136
+ state,
137
+ threadCreated({
138
+ id: "e1",
139
+ threadId: "child-1",
140
+ title: "Child",
141
+ agentInfo: { type: "dynamic", name: "child", input: "x" },
142
+ parent: { threadId: ROOT_THREAD_ID, toolCallId: "tc" },
143
+ }),
144
+ ),
145
+ ).toBe(true);
146
+ expect(state.threads.get("child-1")?.title).toBe("Child");
147
+ });
148
+
149
+ it("ingests tool.response_required and marks tool calls as pending responses", () => {
150
+ const state = new PeerThreadFoldState();
151
+
152
+ ingestTurnEvent(
153
+ state,
154
+ modelMessage({
155
+ id: "model-1",
156
+ threadId: ROOT_THREAD_ID,
157
+ toolCalls: [
158
+ {
159
+ id: "question-1",
160
+ type: "function",
161
+ function: {
162
+ name: "ask_user_question",
163
+ arguments: JSON.stringify({
164
+ question: "Pick one",
165
+ options: ["A", "B"],
166
+ }),
167
+ },
168
+ toolInfo: { type: "truefoundry-system", name: "ask_user_question" },
169
+ },
170
+ ],
171
+ }),
172
+ );
173
+
174
+ ingestTurnEvent(state, {
175
+ type: "tool.response_required",
176
+ id: "resp-req-1",
177
+ createdAt,
178
+ threadId: ROOT_THREAD_ID,
179
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
180
+ });
181
+
182
+ const bucket = state.threads.get(ROOT_THREAD_ID)!;
183
+ expect(bucket.pendingResponses.get("question-1")).toMatchObject({
184
+ id: "question-1",
185
+ sourceEventId: "model-1",
186
+ question: "Pick one",
187
+ options: ["A", "B"],
188
+ });
189
+
190
+ const parts = buildRootAssistantContent(state);
191
+ const toolCall = parts.find((part) => part.type === "tool-call");
192
+ expect(toolCall?.type).toBe("tool-call");
193
+ if (toolCall?.type !== "tool-call") {
194
+ return;
195
+ }
196
+ expect(toolCall.interrupt).toEqual({
197
+ type: "human",
198
+ payload: { question: "Pick one", options: ["A", "B"] },
199
+ });
200
+ });
201
+
202
+ it("recordToolResponseInFold clears pending and attaches result", () => {
203
+ const state = new PeerThreadFoldState();
204
+
205
+ ingestTurnEvent(
206
+ state,
207
+ modelMessage({
208
+ id: "model-1",
209
+ threadId: ROOT_THREAD_ID,
210
+ toolCalls: [
211
+ {
212
+ id: "question-1",
213
+ type: "function",
214
+ function: {
215
+ name: "ask_user_question",
216
+ arguments: JSON.stringify({
217
+ question: "Pick one",
218
+ options: ["A", "B"],
219
+ }),
220
+ },
221
+ toolInfo: { type: "truefoundry-system", name: "ask_user_question" },
222
+ },
223
+ ],
224
+ }),
225
+ );
226
+
227
+ ingestTurnEvent(state, {
228
+ type: "tool.response_required",
229
+ id: "resp-req-1",
230
+ createdAt,
231
+ threadId: ROOT_THREAD_ID,
232
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
233
+ });
234
+
235
+ recordToolResponseInFold(state, {
236
+ toolCallId: "question-1",
237
+ content: "A",
238
+ });
239
+
240
+ const bucket = state.threads.get(ROOT_THREAD_ID)!;
241
+ expect(bucket.pendingResponses.has("question-1")).toBe(false);
242
+ expect(bucket.toolResults.get("question-1")).toBe("A");
243
+
244
+ const toolCall = buildRootAssistantContent(state).find(
245
+ (part) => part.type === "tool-call",
246
+ );
247
+ expect(toolCall?.type).toBe("tool-call");
248
+ if (toolCall?.type !== "tool-call") {
249
+ return;
250
+ }
251
+ expect(toolCall.result).toBe("A");
252
+ expect(toolCall.interrupt).toBeUndefined();
253
+ });
254
+
255
+ it("ingests tool.response and clears pending ask-user state", () => {
256
+ const state = new PeerThreadFoldState();
257
+
258
+ ingestTurnEvent(
259
+ state,
260
+ modelMessage({
261
+ id: "model-1",
262
+ threadId: ROOT_THREAD_ID,
263
+ toolCalls: [
264
+ {
265
+ id: "question-1",
266
+ type: "function",
267
+ function: {
268
+ name: "ask_user_question",
269
+ arguments: JSON.stringify({
270
+ question: "Pick one",
271
+ options: ["A", "B"],
272
+ }),
273
+ },
274
+ toolInfo: { type: "truefoundry-system", name: "ask_user_question" },
275
+ },
276
+ ],
277
+ }),
278
+ );
279
+
280
+ ingestTurnEvent(state, {
281
+ type: "tool.response_required",
282
+ id: "resp-req-1",
283
+ createdAt,
284
+ threadId: ROOT_THREAD_ID,
285
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
286
+ });
287
+
288
+ ingestTurnEvent(state, {
289
+ type: "tool.response",
290
+ id: "resp-1",
291
+ createdAt,
292
+ threadId: ROOT_THREAD_ID,
293
+ toolCallId: "question-1",
294
+ content: "A",
295
+ });
296
+
297
+ const bucket = state.threads.get(ROOT_THREAD_ID)!;
298
+ expect(bucket.pendingResponses.has("question-1")).toBe(false);
299
+ expect(bucket.toolResults.get("question-1")).toBe("A");
300
+
301
+ const toolCall = buildRootAssistantContent(state).find(
302
+ (part) => part.type === "tool-call",
303
+ );
304
+ expect(toolCall?.type).toBe("tool-call");
305
+ if (toolCall?.type !== "tool-call") {
306
+ return;
307
+ }
308
+ expect(toolCall.result).toBe("A");
309
+ expect(toolCall.interrupt).toBeUndefined();
310
+ });
311
+
312
+ function seedPendingApproval(): PeerThreadFoldState {
313
+ const state = new PeerThreadFoldState();
314
+ ingestTurnEvent(
315
+ state,
316
+ modelMessage({
317
+ id: "model-1",
318
+ threadId: ROOT_THREAD_ID,
319
+ toolCalls: [
320
+ {
321
+ id: "tool-1",
322
+ type: "function",
323
+ function: { name: "run_shell", arguments: "{}" },
324
+ toolInfo: {
325
+ type: "mcp",
326
+ name: "run_shell",
327
+ serverId: "srv-1",
328
+ serverName: "shell",
329
+ },
330
+ },
331
+ ],
332
+ }),
333
+ );
334
+ ingestTurnEvent(state, {
335
+ type: "tool.approval_required",
336
+ id: "appr-req-1",
337
+ createdAt,
338
+ threadId: ROOT_THREAD_ID,
339
+ toolCalls: [{ id: "tool-1", sourceEventId: "model-1" }],
340
+ });
341
+ return state;
342
+ }
343
+
344
+ it("recordToolApprovalInFold resolves a pending approval on allow", () => {
345
+ const state = seedPendingApproval();
346
+ const bucket = state.threads.get(ROOT_THREAD_ID)!;
347
+ expect(bucket.pendingApprovals.has("tool-1")).toBe(true);
348
+
349
+ recordToolApprovalInFold(state, { toolCallId: "tool-1", approved: true });
350
+
351
+ // Once decided, the approval must leave the pending set so the turn is
352
+ // not re-paused (and the user re-prompted) for it after resume.
353
+ expect(bucket.pendingApprovals.has("tool-1")).toBe(false);
354
+
355
+ const toolCall = buildRootAssistantContent(state).find(
356
+ (part) => part.type === "tool-call",
357
+ );
358
+ if (toolCall?.type !== "tool-call") {
359
+ throw new Error("expected a tool-call part");
360
+ }
361
+ expect(toolCall.approval?.approved).toBe(true);
362
+ });
363
+
364
+ it("recordToolApprovalInFold synthesizes an error result on deny", () => {
365
+ const state = seedPendingApproval();
366
+
367
+ recordToolApprovalInFold(state, {
368
+ toolCallId: "tool-1",
369
+ approved: false,
370
+ reason: "not allowed",
371
+ });
372
+
373
+ const bucket = state.threads.get(ROOT_THREAD_ID)!;
374
+ expect(bucket.pendingApprovals.has("tool-1")).toBe(false);
375
+
376
+ const toolCall = buildRootAssistantContent(state).find(
377
+ (part) => part.type === "tool-call",
378
+ );
379
+ if (toolCall?.type !== "tool-call") {
380
+ throw new Error("expected a tool-call part");
381
+ }
382
+ expect(toolCall.approval?.approved).toBe(false);
383
+ expect(toolCall.isError).toBe(true);
384
+ expect(toolCall.result).toEqual({ error: "not allowed" });
385
+ });
386
+ });