@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,1991 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { AppendMessage } from "@assistant-ui/core";
3
+ import type {
4
+ AgentSession,
5
+ ModelMessageEvent,
6
+ SandboxCreatedEvent,
7
+ ThreadCreatedEvent,
8
+ ToolApprovalRequiredEvent,
9
+ ToolResponseRequiredEvent,
10
+ Turn,
11
+ TurnEvent,
12
+ TurnStreamData,
13
+ } from "truefoundry-gateway-sdk/agents";
14
+
15
+ import { ROOT_THREAD_ID } from "./constants.js";
16
+ import { collectPendingToolResponses } from "./collectPending.js";
17
+ import {
18
+ buildTurnAssistantContent,
19
+ buildUserMessageContent,
20
+ buildUserMessageFromTurnInput,
21
+ convertTurnsToThreadMessages,
22
+ getTurnMessageContent,
23
+ projectSessionMessages,
24
+ repositoryItemsFromMessages,
25
+ streamTurnEvents,
26
+ turnStreamUpdateToAssistantMessage,
27
+ } from "./convertTurnMessages.js";
28
+ import { PeerThreadFoldState, buildRootAssistantContent, ingestTurnEvent } from "./foldPeerThreads.js";
29
+ import { findPausedAssistantMessage } from "./requiredActionInputs.js";
30
+ import {
31
+ createEmptySessionSnapshot,
32
+ replaceSessionSnapshot,
33
+ } from "./sessionSnapshot.js";
34
+ import { TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY } from "./toolApproval.js";
35
+ import {
36
+ applyUserToolResponsesToFold,
37
+ TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY,
38
+ toolResponseStatus,
39
+ } from "./toolResponse.js";
40
+ import type { TurnStreamUpdate } from "./turnStreamUpdate.js";
41
+
42
+ const createdAt = new Date().toISOString();
43
+
44
+ function modelMessage(
45
+ event: Omit<ModelMessageEvent, "type" | "createdAt">,
46
+ ): ModelMessageEvent {
47
+ return { type: "model.message", createdAt, ...event };
48
+ }
49
+
50
+ function approvalRequired(
51
+ event: Omit<ToolApprovalRequiredEvent, "type" | "createdAt">,
52
+ ): ToolApprovalRequiredEvent {
53
+ return { type: "tool.approval_required", createdAt, ...event };
54
+ }
55
+
56
+ function threadCreated(
57
+ event: Omit<ThreadCreatedEvent, "type" | "createdAt">,
58
+ ): ThreadCreatedEvent {
59
+ return { type: "thread.created", createdAt, ...event };
60
+ }
61
+
62
+ function sandboxCreated(
63
+ event: Omit<SandboxCreatedEvent, "type" | "createdAt">,
64
+ ): SandboxCreatedEvent {
65
+ return { type: "sandbox.created", createdAt, ...event };
66
+ }
67
+
68
+ function responseRequired(
69
+ event: Omit<ToolResponseRequiredEvent, "type" | "createdAt">,
70
+ ): ToolResponseRequiredEvent {
71
+ return { type: "tool.response_required", createdAt, ...event };
72
+ }
73
+
74
+ async function* streamFrom(
75
+ events: TurnStreamData["event"][],
76
+ ): AsyncGenerator<TurnStreamData> {
77
+ for (const [index, event] of events.entries()) {
78
+ yield { sequenceNumber: index + 1, event };
79
+ }
80
+ }
81
+
82
+ function eventsPage(events: TurnEvent[]) {
83
+ return async () => ({
84
+ async *[Symbol.asyncIterator]() {
85
+ for (const event of events) {
86
+ yield event;
87
+ }
88
+ },
89
+ });
90
+ }
91
+
92
+ function turnsPage(turns: Turn[]) {
93
+ return async () => ({
94
+ async *[Symbol.asyncIterator]() {
95
+ for (const turn of turns) {
96
+ yield turn;
97
+ }
98
+ },
99
+ });
100
+ }
101
+
102
+ function appendUserMessage(
103
+ content: AppendMessage["content"],
104
+ attachments: AppendMessage["attachments"] = [],
105
+ ): AppendMessage {
106
+ return {
107
+ role: "user",
108
+ content,
109
+ attachments,
110
+ createdAt: new Date(),
111
+ metadata: { custom: {} },
112
+ parentId: null,
113
+ sourceId: null,
114
+ runConfig: undefined,
115
+ };
116
+ }
117
+
118
+ function mockTurn(
119
+ overrides: Partial<Turn> & Pick<Turn, "id" | "createdAt">,
120
+ ): Pick<Turn, "id" | "createdAt" | "input" | "state" | "listEvents"> {
121
+ return {
122
+ input: [{ type: "user.message", content: "hello" }],
123
+ state: { status: "done", requiredActions: [], completedAt: createdAt },
124
+ listEvents: eventsPage([
125
+ modelMessage({
126
+ id: "m1",
127
+ threadId: ROOT_THREAD_ID,
128
+ content: "assistant reply",
129
+ }),
130
+ ]) as unknown as Turn["listEvents"],
131
+ ...overrides,
132
+ };
133
+ }
134
+
135
+ function mockSession(turns: ReturnType<typeof mockTurn>[]): AgentSession {
136
+ return {
137
+ listTurns: turnsPage(turns as Turn[]) as unknown as AgentSession["listTurns"],
138
+ } as unknown as AgentSession;
139
+ }
140
+
141
+ async function collectStream<T>(stream: AsyncGenerator<T>): Promise<T[]> {
142
+ const items: T[] = [];
143
+ for await (const item of stream) {
144
+ items.push(item);
145
+ }
146
+ return items;
147
+ }
148
+
149
+ describe("convertTurnMessages", () => {
150
+ describe("getTurnMessageContent", () => {
151
+ it("joins text parts from append message content", () => {
152
+ expect(
153
+ getTurnMessageContent(
154
+ appendUserMessage([
155
+ { type: "text", text: "line one" },
156
+ { type: "text", text: "line two" },
157
+ ]),
158
+ ),
159
+ ).toBe("line one\nline two");
160
+ });
161
+
162
+ it("throws when no text content is present", () => {
163
+ expect(() => getTurnMessageContent(appendUserMessage([]))).toThrow(
164
+ "User message must contain text content.",
165
+ );
166
+ });
167
+ });
168
+
169
+ describe("buildUserMessageContent", () => {
170
+ it("returns a bare string for text-only messages", () => {
171
+ expect(
172
+ buildUserMessageContent(
173
+ appendUserMessage([{ type: "text", text: "hello there" }]),
174
+ ),
175
+ ).toBe("hello there");
176
+ });
177
+
178
+ it("forwards file attachments as gateway file parts", () => {
179
+ expect(
180
+ buildUserMessageContent(
181
+ appendUserMessage(
182
+ [{ type: "text", text: "see attached" }],
183
+ [
184
+ {
185
+ id: "att-1",
186
+ type: "file",
187
+ name: "doc.pdf",
188
+ contentType: "application/pdf",
189
+ status: { type: "complete" },
190
+ content: [
191
+ {
192
+ type: "file",
193
+ mimeType: "application/pdf",
194
+ data: "JVBERi0xLjQK",
195
+ },
196
+ ],
197
+ },
198
+ ],
199
+ ),
200
+ ),
201
+ ).toEqual([
202
+ { type: "text", text: "see attached" },
203
+ {
204
+ type: "file",
205
+ name: "doc.pdf",
206
+ data: "data:application/pdf;base64,JVBERi0xLjQK",
207
+ },
208
+ ]);
209
+ });
210
+
211
+ it("passes through data URIs unchanged", () => {
212
+ const dataUri = "data:application/pdf;base64,JVBERi0xLjQK";
213
+ expect(
214
+ buildUserMessageContent(
215
+ appendUserMessage(
216
+ [{ type: "text", text: "file" }],
217
+ [
218
+ {
219
+ id: "att-1",
220
+ type: "file",
221
+ name: "doc.pdf",
222
+ contentType: "application/pdf",
223
+ status: { type: "complete" },
224
+ content: [
225
+ {
226
+ type: "file",
227
+ mimeType: "application/pdf",
228
+ data: dataUri,
229
+ },
230
+ ],
231
+ },
232
+ ],
233
+ ),
234
+ ),
235
+ ).toEqual([
236
+ { type: "text", text: "file" },
237
+ {
238
+ type: "file",
239
+ name: "doc.pdf",
240
+ data: dataUri,
241
+ },
242
+ ]);
243
+ });
244
+
245
+ it("maps image parts to gateway file parts", () => {
246
+ expect(
247
+ buildUserMessageContent(
248
+ appendUserMessage(
249
+ [{ type: "text", text: "image" }],
250
+ [
251
+ {
252
+ id: "att-1",
253
+ type: "image",
254
+ name: "photo.png",
255
+ contentType: "image/png",
256
+ status: { type: "complete" },
257
+ content: [
258
+ {
259
+ type: "image",
260
+ image: "data:image/png;base64,AAAA",
261
+ },
262
+ ],
263
+ },
264
+ ],
265
+ ),
266
+ ),
267
+ ).toEqual([
268
+ { type: "text", text: "image" },
269
+ {
270
+ type: "file",
271
+ name: "photo.png",
272
+ data: "data:image/png;base64,AAAA",
273
+ },
274
+ ]);
275
+ });
276
+
277
+ it("includes file parts from message content and attachments", () => {
278
+ expect(
279
+ buildUserMessageContent(
280
+ appendUserMessage(
281
+ [
282
+ { type: "text", text: "inline" },
283
+ {
284
+ type: "file",
285
+ mimeType: "application/pdf",
286
+ data: "AAAA",
287
+ filename: "inline.pdf",
288
+ },
289
+ ],
290
+ [
291
+ {
292
+ id: "att-1",
293
+ type: "file",
294
+ name: "attached.pdf",
295
+ contentType: "application/pdf",
296
+ status: { type: "complete" },
297
+ content: [
298
+ {
299
+ type: "file",
300
+ mimeType: "application/pdf",
301
+ data: "BBBB",
302
+ },
303
+ ],
304
+ },
305
+ ],
306
+ ),
307
+ ),
308
+ ).toEqual([
309
+ { type: "text", text: "inline" },
310
+ {
311
+ type: "file",
312
+ name: "inline.pdf",
313
+ data: "data:application/pdf;base64,AAAA",
314
+ },
315
+ {
316
+ type: "file",
317
+ name: "attached.pdf",
318
+ data: "data:application/pdf;base64,BBBB",
319
+ },
320
+ ]);
321
+ });
322
+ });
323
+
324
+ describe("buildUserMessageFromTurnInput", () => {
325
+ it("maps gateway file parts to assistant-ui attachments", () => {
326
+ const message = buildUserMessageFromTurnInput(
327
+ "turn-1",
328
+ [
329
+ {
330
+ type: "user.message",
331
+ content: [
332
+ { type: "text", text: "see attached" },
333
+ {
334
+ type: "file",
335
+ name: "doc.pdf",
336
+ data: "data:application/pdf;base64,JVBERi0xLjQK",
337
+ },
338
+ ],
339
+ },
340
+ ],
341
+ createdAt,
342
+ );
343
+
344
+ expect(message.role).toBe("user");
345
+ expect(message.content).toEqual([{ type: "text", text: "see attached" }]);
346
+ if (message.role !== "user") {
347
+ return;
348
+ }
349
+ expect(message.attachments).toHaveLength(1);
350
+ expect(message.attachments[0]?.name).toBe("doc.pdf");
351
+ });
352
+
353
+ it("maps gateway image_url parts to assistant-ui image attachments", () => {
354
+ const message = buildUserMessageFromTurnInput(
355
+ "turn-1",
356
+ [
357
+ {
358
+ type: "user.message",
359
+ content: [
360
+ { type: "text", text: "see attached" },
361
+ {
362
+ type: "image_url",
363
+ image_url: {
364
+ url: "data:image/jpeg;base64,/9j/4AAQ",
365
+ },
366
+ },
367
+ ] as never,
368
+ },
369
+ ],
370
+ createdAt,
371
+ );
372
+
373
+ if (message.role !== "user") {
374
+ throw new Error("expected user message");
375
+ }
376
+ expect(message.attachments).toHaveLength(1);
377
+ expect(message.attachments[0]?.type).toBe("image");
378
+ expect(message.attachments[0]?.content[0]).toMatchObject({
379
+ type: "image",
380
+ image: "data:image/jpeg;base64,/9j/4AAQ",
381
+ });
382
+ });
383
+ });
384
+
385
+ describe("turnStreamUpdateToAssistantMessage", () => {
386
+ it("creates a new assistant message when none exists", () => {
387
+ const message = turnStreamUpdateToAssistantMessage("turn-1", {
388
+ content: [{ type: "text", text: "hi" }],
389
+ });
390
+ expect(message.id).toBe("turn-1-assistant");
391
+ expect(message.role).toBe("assistant");
392
+ expect(message.status).toEqual({ type: "running" });
393
+ });
394
+
395
+ it("preserves the existing assistant message id on update", () => {
396
+ const existing = turnStreamUpdateToAssistantMessage("turn-1", {
397
+ content: [{ type: "text", text: "first" }],
398
+ });
399
+ const updated = turnStreamUpdateToAssistantMessage(
400
+ "turn-1",
401
+ { content: [{ type: "text", text: "second" }] },
402
+ existing,
403
+ );
404
+ expect(updated.id).toBe(existing.id);
405
+ expect(updated.content).toEqual([{ type: "text", text: "second" }]);
406
+ });
407
+ });
408
+
409
+ describe("repositoryItemsFromMessages", () => {
410
+ it("chains parent ids in order", () => {
411
+ const userMessage = {
412
+ id: "u1",
413
+ role: "user" as const,
414
+ content: [{ type: "text" as const, text: "hi" }],
415
+ attachments: [],
416
+ createdAt: new Date(),
417
+ metadata: { custom: {} },
418
+ };
419
+ const assistant = turnStreamUpdateToAssistantMessage("t1", {
420
+ content: [{ type: "text", text: "a" }],
421
+ });
422
+
423
+ const items = repositoryItemsFromMessages([userMessage, assistant]);
424
+ expect(items).toEqual([
425
+ { parentId: null, message: userMessage },
426
+ { parentId: "u1", message: assistant },
427
+ ]);
428
+ });
429
+ });
430
+
431
+ describe("buildTurnAssistantContent", () => {
432
+ it("aggregates turn listEvents into root assistant content", async () => {
433
+ const content = await buildTurnAssistantContent(
434
+ mockTurn({
435
+ id: "turn-1",
436
+ createdAt,
437
+ }),
438
+ );
439
+ expect(content).toEqual([{ type: "text", text: "assistant reply" }]);
440
+ });
441
+ });
442
+
443
+ describe("convertTurnsToThreadMessages", () => {
444
+ it("builds user and assistant messages from completed turns", async () => {
445
+ const result = await convertTurnsToThreadMessages(
446
+ mockSession([
447
+ mockTurn({
448
+ id: "turn-1",
449
+ createdAt,
450
+ input: [{ type: "user.message", content: "hello" }],
451
+ }),
452
+ ]),
453
+ );
454
+
455
+ expect(result.messages).toHaveLength(2);
456
+ expect(result.messages[0]).toMatchObject({
457
+ id: "turn-1-user",
458
+ role: "user",
459
+ content: [{ type: "text", text: "hello" }],
460
+ });
461
+ expect(result.messages[1]).toMatchObject({
462
+ id: "turn-1-assistant",
463
+ role: "assistant",
464
+ content: [{ type: "text", text: "assistant reply" }],
465
+ status: { type: "complete", reason: "stop" },
466
+ });
467
+ expect(result.runningTurn).toBeUndefined();
468
+ });
469
+
470
+ it("carries sandboxId from a historical sandbox.created event onto the assistant message", async () => {
471
+ const result = await convertTurnsToThreadMessages(
472
+ mockSession([
473
+ mockTurn({
474
+ id: "turn-1",
475
+ createdAt,
476
+ input: [{ type: "user.message", content: "hello" }],
477
+ listEvents: eventsPage([
478
+ sandboxCreated({ id: "sandbox-evt", sandboxId: "sbx-123" }),
479
+ modelMessage({
480
+ id: "m1",
481
+ threadId: ROOT_THREAD_ID,
482
+ content: "assistant reply",
483
+ }),
484
+ ]) as unknown as Turn["listEvents"],
485
+ }),
486
+ ]),
487
+ );
488
+
489
+ expect(result.messages[1]).toMatchObject({
490
+ role: "assistant",
491
+ metadata: { custom: { sandboxId: "sbx-123" } },
492
+ });
493
+ });
494
+
495
+ it("returns runningTurn and unstable_resume for an in-flight turn", async () => {
496
+ const runningTurn = mockTurn({
497
+ id: "turn-running",
498
+ createdAt,
499
+ state: { status: "running" },
500
+ });
501
+ const result = await convertTurnsToThreadMessages(mockSession([runningTurn]));
502
+
503
+ expect(result.runningTurn).toBe(runningTurn);
504
+ expect(result.unstable_resume).toBe(true);
505
+ expect(result.messages.at(-1)?.role).toBe("assistant");
506
+ });
507
+
508
+ it("merges continuation turns without user input into the last assistant message", async () => {
509
+ const firstTurn = mockTurn({
510
+ id: "turn-1",
511
+ createdAt,
512
+ listEvents: eventsPage([
513
+ modelMessage({
514
+ id: "m1",
515
+ threadId: ROOT_THREAD_ID,
516
+ content: "first chunk",
517
+ }),
518
+ ]) as unknown as Turn["listEvents"],
519
+ });
520
+ const continuationTurn = mockTurn({
521
+ id: "turn-2",
522
+ createdAt,
523
+ input: [{ type: "user.tool_approval", threadId: ROOT_THREAD_ID, toolCallId: "tc-1", approval: { status: "allow" } }],
524
+ listEvents: eventsPage([
525
+ modelMessage({
526
+ id: "m2",
527
+ threadId: ROOT_THREAD_ID,
528
+ content: " after approval",
529
+ }),
530
+ ]) as unknown as Turn["listEvents"],
531
+ });
532
+
533
+ const result = await convertTurnsToThreadMessages(
534
+ mockSession([continuationTurn, firstTurn]),
535
+ );
536
+
537
+ expect(result.messages).toHaveLength(2);
538
+ const assistant = result.messages[1];
539
+ expect(assistant?.role).toBe("assistant");
540
+ if (assistant?.role !== "assistant") {
541
+ return;
542
+ }
543
+ expect(assistant.content).toEqual([
544
+ { type: "text", text: "first chunk" },
545
+ { type: "text", text: " after approval" },
546
+ ]);
547
+ });
548
+
549
+ it("preserves requires-action on reload when a turn ends awaiting tool approval", async () => {
550
+ const pausedTurn = mockTurn({
551
+ id: "turn-approval",
552
+ createdAt,
553
+ input: [{ type: "user.message", content: "run tool" }],
554
+ state: {
555
+ status: "done",
556
+ output: null,
557
+ requiredActions: [
558
+ approvalRequired({
559
+ id: "approval-event",
560
+ threadId: ROOT_THREAD_ID,
561
+ toolCalls: [{ id: "approval-1", sourceEventId: "m1" }],
562
+ }),
563
+ ],
564
+ completedAt: createdAt,
565
+ },
566
+ listEvents: eventsPage([
567
+ modelMessage({
568
+ id: "m1",
569
+ threadId: ROOT_THREAD_ID,
570
+ content: "run tool",
571
+ toolCalls: [
572
+ {
573
+ id: "approval-1",
574
+ type: "function",
575
+ function: { name: "bash", arguments: "{}" },
576
+ toolInfo: {
577
+ type: "mcp",
578
+ name: "bash",
579
+ serverId: "bash-server",
580
+ serverName: "bash",
581
+ },
582
+ },
583
+ ],
584
+ }),
585
+ approvalRequired({
586
+ id: "approval-event",
587
+ threadId: ROOT_THREAD_ID,
588
+ toolCalls: [{ id: "approval-1", sourceEventId: "m1" }],
589
+ }),
590
+ ]) as unknown as Turn["listEvents"],
591
+ });
592
+
593
+ const result = await convertTurnsToThreadMessages(mockSession([pausedTurn]));
594
+
595
+ expect(result.messages).toHaveLength(2);
596
+ const assistant = result.messages[1];
597
+ expect(assistant?.role).toBe("assistant");
598
+ if (assistant?.role !== "assistant") {
599
+ return;
600
+ }
601
+ expect(assistant.status).toEqual({
602
+ type: "requires-action",
603
+ reason: "tool-calls",
604
+ });
605
+ expect(assistant.metadata.custom[TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]).toBe(
606
+ ROOT_THREAD_ID,
607
+ );
608
+ expect(assistant.content.find((part) => part.type === "tool-call")).toMatchObject({
609
+ type: "tool-call",
610
+ toolCallId: "approval-1",
611
+ approval: { id: "approval-1" },
612
+ });
613
+ expect(findPausedAssistantMessage(result.messages)).toBe(assistant);
614
+ });
615
+
616
+ it("downgrades to complete after a later turn submits user.tool_approval", async () => {
617
+ const pausedTurn = mockTurn({
618
+ id: "turn-approval",
619
+ createdAt,
620
+ input: [{ type: "user.message", content: "run tool" }],
621
+ state: {
622
+ status: "done",
623
+ output: null,
624
+ requiredActions: [
625
+ approvalRequired({
626
+ id: "approval-event",
627
+ threadId: ROOT_THREAD_ID,
628
+ toolCalls: [{ id: "approval-1", sourceEventId: "m1" }],
629
+ }),
630
+ ],
631
+ completedAt: createdAt,
632
+ },
633
+ listEvents: eventsPage([
634
+ modelMessage({
635
+ id: "m1",
636
+ threadId: ROOT_THREAD_ID,
637
+ content: "run tool",
638
+ toolCalls: [
639
+ {
640
+ id: "approval-1",
641
+ type: "function",
642
+ function: { name: "bash", arguments: "{}" },
643
+ toolInfo: {
644
+ type: "mcp",
645
+ name: "bash",
646
+ serverId: "bash-server",
647
+ serverName: "bash",
648
+ },
649
+ },
650
+ ],
651
+ }),
652
+ approvalRequired({
653
+ id: "approval-event",
654
+ threadId: ROOT_THREAD_ID,
655
+ toolCalls: [{ id: "approval-1", sourceEventId: "m1" }],
656
+ }),
657
+ ]) as unknown as Turn["listEvents"],
658
+ });
659
+ const continuationTurn = mockTurn({
660
+ id: "turn-approval-resume",
661
+ createdAt,
662
+ input: [
663
+ {
664
+ type: "user.tool_approval",
665
+ threadId: ROOT_THREAD_ID,
666
+ toolCallId: "approval-1",
667
+ approval: { status: "allow" },
668
+ },
669
+ ],
670
+ listEvents: eventsPage([
671
+ modelMessage({
672
+ id: "m2",
673
+ threadId: ROOT_THREAD_ID,
674
+ content: "done",
675
+ }),
676
+ ]) as unknown as Turn["listEvents"],
677
+ });
678
+
679
+ const result = await convertTurnsToThreadMessages(
680
+ mockSession([continuationTurn, pausedTurn]),
681
+ );
682
+
683
+ const assistant = result.messages[1];
684
+ expect(assistant?.role).toBe("assistant");
685
+ if (assistant?.role !== "assistant") {
686
+ return;
687
+ }
688
+ expect(assistant.status).toEqual({ type: "complete", reason: "stop" });
689
+ expect(
690
+ assistant.metadata.custom[TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY],
691
+ ).toBeUndefined();
692
+ const toolCall = assistant.content.find((part) => part.type === "tool-call");
693
+ expect(toolCall?.type).toBe("tool-call");
694
+ if (toolCall?.type !== "tool-call") {
695
+ return;
696
+ }
697
+ expect(toolCall.approval?.approved).toBe(true);
698
+ });
699
+
700
+ it("preserves requires-action on reload when a turn ends awaiting tool response", async () => {
701
+ const pausedTurn = mockTurn({
702
+ id: "turn-response",
703
+ createdAt,
704
+ input: [{ type: "user.message", content: "ask me" }],
705
+ state: {
706
+ status: "done",
707
+ output: null,
708
+ requiredActions: [
709
+ responseRequired({
710
+ id: "resp-req-1",
711
+ threadId: ROOT_THREAD_ID,
712
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
713
+ }),
714
+ ],
715
+ completedAt: createdAt,
716
+ },
717
+ listEvents: eventsPage([
718
+ modelMessage({
719
+ id: "model-1",
720
+ threadId: ROOT_THREAD_ID,
721
+ toolCalls: [
722
+ {
723
+ id: "question-1",
724
+ type: "function",
725
+ function: {
726
+ name: "ask_user_question",
727
+ arguments: JSON.stringify({
728
+ question: "Pick one",
729
+ options: ["A", "B"],
730
+ }),
731
+ },
732
+ toolInfo: {
733
+ type: "truefoundry-system",
734
+ name: "ask_user_question",
735
+ },
736
+ },
737
+ ],
738
+ }),
739
+ responseRequired({
740
+ id: "resp-req-1",
741
+ threadId: ROOT_THREAD_ID,
742
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
743
+ }),
744
+ ]) as unknown as Turn["listEvents"],
745
+ });
746
+
747
+ const result = await convertTurnsToThreadMessages(mockSession([pausedTurn]));
748
+
749
+ const assistant = result.messages[1];
750
+ expect(assistant?.role).toBe("assistant");
751
+ if (assistant?.role !== "assistant") {
752
+ return;
753
+ }
754
+ expect(assistant.status).toEqual({
755
+ type: "requires-action",
756
+ reason: "tool-calls",
757
+ });
758
+ expect(assistant.metadata.custom[TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]).toBe(
759
+ ROOT_THREAD_ID,
760
+ );
761
+ expect(assistant.content[0]).toMatchObject({
762
+ type: "tool-call",
763
+ toolCallId: "question-1",
764
+ interrupt: {
765
+ type: "human",
766
+ payload: { question: "Pick one", options: ["A", "B"] },
767
+ },
768
+ });
769
+ expect(findPausedAssistantMessage(result.messages)).toBe(assistant);
770
+ });
771
+
772
+ it("downgrades to complete after a later turn submits user.tool_response", async () => {
773
+ const pausedTurn = mockTurn({
774
+ id: "turn-response",
775
+ createdAt,
776
+ input: [{ type: "user.message", content: "ask me" }],
777
+ state: {
778
+ status: "done",
779
+ output: null,
780
+ requiredActions: [
781
+ responseRequired({
782
+ id: "resp-req-1",
783
+ threadId: ROOT_THREAD_ID,
784
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
785
+ }),
786
+ ],
787
+ completedAt: createdAt,
788
+ },
789
+ listEvents: eventsPage([
790
+ modelMessage({
791
+ id: "model-1",
792
+ threadId: ROOT_THREAD_ID,
793
+ toolCalls: [
794
+ {
795
+ id: "question-1",
796
+ type: "function",
797
+ function: {
798
+ name: "ask_user_question",
799
+ arguments: JSON.stringify({
800
+ question: "Pick one",
801
+ options: ["A", "B"],
802
+ }),
803
+ },
804
+ toolInfo: {
805
+ type: "truefoundry-system",
806
+ name: "ask_user_question",
807
+ },
808
+ },
809
+ ],
810
+ }),
811
+ responseRequired({
812
+ id: "resp-req-1",
813
+ threadId: ROOT_THREAD_ID,
814
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
815
+ }),
816
+ ]) as unknown as Turn["listEvents"],
817
+ });
818
+ const continuationTurn = mockTurn({
819
+ id: "turn-response-resume",
820
+ createdAt,
821
+ input: [
822
+ {
823
+ type: "user.tool_response",
824
+ threadId: ROOT_THREAD_ID,
825
+ toolCallId: "question-1",
826
+ content: "A",
827
+ },
828
+ ],
829
+ listEvents: eventsPage([
830
+ modelMessage({
831
+ id: "model-2",
832
+ threadId: ROOT_THREAD_ID,
833
+ content: "Thanks",
834
+ }),
835
+ ]) as unknown as Turn["listEvents"],
836
+ });
837
+
838
+ const result = await convertTurnsToThreadMessages(
839
+ mockSession([continuationTurn, pausedTurn]),
840
+ );
841
+
842
+ const assistant = result.messages[1];
843
+ expect(assistant?.role).toBe("assistant");
844
+ if (assistant?.role !== "assistant") {
845
+ return;
846
+ }
847
+ expect(assistant.status).toEqual({ type: "complete", reason: "stop" });
848
+ expect(
849
+ assistant.metadata.custom[TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY],
850
+ ).toBeUndefined();
851
+ const toolCall = assistant.content[0];
852
+ expect(toolCall?.type).toBe("tool-call");
853
+ if (toolCall?.type !== "tool-call") {
854
+ return;
855
+ }
856
+ expect(toolCall.result).toBe("A");
857
+ });
858
+
859
+ it("scopes assistant content to each user turn group", async () => {
860
+ const helloTurn = mockTurn({
861
+ id: "turn-hello",
862
+ createdAt,
863
+ input: [{ type: "user.message", content: "hello" }],
864
+ listEvents: eventsPage([
865
+ modelMessage({
866
+ id: "m-hello",
867
+ threadId: ROOT_THREAD_ID,
868
+ content: "Hello! How can I help?",
869
+ }),
870
+ ]) as unknown as Turn["listEvents"],
871
+ });
872
+ const analyzeTurn = mockTurn({
873
+ id: "turn-analyze",
874
+ createdAt,
875
+ input: [{ type: "user.message", content: "analyze" }],
876
+ listEvents: eventsPage([
877
+ modelMessage({
878
+ id: "m-analyze",
879
+ threadId: ROOT_THREAD_ID,
880
+ content: "Let me look at the file.",
881
+ }),
882
+ ]) as unknown as Turn["listEvents"],
883
+ });
884
+ const ticketsTurn = mockTurn({
885
+ id: "turn-tickets",
886
+ createdAt,
887
+ input: [
888
+ {
889
+ type: "user.message",
890
+ content: "create linear tickets",
891
+ },
892
+ ],
893
+ listEvents: eventsPage([
894
+ modelMessage({
895
+ id: "m-tickets",
896
+ threadId: ROOT_THREAD_ID,
897
+ content: "Creating tickets now.",
898
+ }),
899
+ ]) as unknown as Turn["listEvents"],
900
+ });
901
+
902
+ const result = await convertTurnsToThreadMessages(
903
+ mockSession([ticketsTurn, analyzeTurn, helloTurn]),
904
+ );
905
+
906
+ expect(result.messages).toHaveLength(6);
907
+ expect(result.messages[1]).toMatchObject({
908
+ id: "turn-hello-assistant",
909
+ role: "assistant",
910
+ content: [{ type: "text", text: "Hello! How can I help?" }],
911
+ });
912
+ expect(result.messages[3]).toMatchObject({
913
+ id: "turn-analyze-assistant",
914
+ role: "assistant",
915
+ content: [{ type: "text", text: "Let me look at the file." }],
916
+ });
917
+ expect(result.messages[5]).toMatchObject({
918
+ id: "turn-tickets-assistant",
919
+ role: "assistant",
920
+ content: [{ type: "text", text: "Creating tickets now." }],
921
+ });
922
+ });
923
+
924
+ it("projects file attachments on user messages from turn input", async () => {
925
+ const fileData = "data:text/x-python;base64,cHJpbnQoJ2hlbGxvJyk=";
926
+ const analyzeTurn = mockTurn({
927
+ id: "turn-analyze",
928
+ createdAt,
929
+ input: [
930
+ {
931
+ type: "user.message",
932
+ content: [
933
+ { type: "text", text: "analyze" },
934
+ { type: "file", name: "Untitled.py", data: fileData },
935
+ ],
936
+ },
937
+ ],
938
+ listEvents: eventsPage([
939
+ modelMessage({
940
+ id: "m-analyze",
941
+ threadId: ROOT_THREAD_ID,
942
+ content: "File analyzed.",
943
+ }),
944
+ ]) as unknown as Turn["listEvents"],
945
+ });
946
+
947
+ const result = await convertTurnsToThreadMessages(mockSession([analyzeTurn]));
948
+
949
+ expect(result.messages).toHaveLength(2);
950
+ expect(result.messages[0]).toMatchObject({
951
+ id: "turn-analyze-user",
952
+ role: "user",
953
+ content: [{ type: "text", text: "analyze" }],
954
+ });
955
+ if (result.messages[0]?.role !== "user") {
956
+ return;
957
+ }
958
+ expect(result.messages[0].attachments).toHaveLength(1);
959
+ expect(result.messages[0].attachments[0]).toMatchObject({
960
+ type: "file",
961
+ name: "Untitled.py",
962
+ contentType: "text/x-python",
963
+ status: { type: "complete" },
964
+ content: [
965
+ {
966
+ type: "file",
967
+ mimeType: "text/x-python",
968
+ filename: "Untitled.py",
969
+ data: fileData,
970
+ },
971
+ ],
972
+ });
973
+ });
974
+
975
+ it("nests sub-agents within a turn group and isolates the next user turn", async () => {
976
+ const spawnTurn = mockTurn({
977
+ id: "turn-spawn",
978
+ createdAt,
979
+ input: [{ type: "user.message", content: "spawn agent" }],
980
+ listEvents: eventsPage([
981
+ modelMessage({
982
+ id: "root-spawn",
983
+ threadId: ROOT_THREAD_ID,
984
+ toolCalls: [
985
+ {
986
+ id: "spawn-1",
987
+ type: "function",
988
+ function: { name: "create_sub_agent", arguments: "{}" },
989
+ toolInfo: {
990
+ type: "truefoundry-system",
991
+ name: "create_sub_agent",
992
+ },
993
+ },
994
+ ],
995
+ }),
996
+ threadCreated({
997
+ id: "t-created",
998
+ threadId: "child-1",
999
+ title: "Research agent",
1000
+ agentInfo: {
1001
+ type: "dynamic",
1002
+ name: "researcher",
1003
+ input: "do research",
1004
+ },
1005
+ parent: { threadId: ROOT_THREAD_ID, toolCallId: "spawn-1" },
1006
+ }),
1007
+ modelMessage({
1008
+ id: "child-msg-1",
1009
+ threadId: "child-1",
1010
+ content: "initial child work",
1011
+ }),
1012
+ ]) as unknown as Turn["listEvents"],
1013
+ });
1014
+ const continuationTurn = mockTurn({
1015
+ id: "turn-continuation",
1016
+ createdAt,
1017
+ input: [
1018
+ {
1019
+ type: "user.tool_approval",
1020
+ threadId: ROOT_THREAD_ID,
1021
+ toolCallId: "spawn-1",
1022
+ approval: { status: "allow" },
1023
+ },
1024
+ ],
1025
+ listEvents: eventsPage([
1026
+ modelMessage({
1027
+ id: "child-msg-2",
1028
+ threadId: "child-1",
1029
+ content: "more child work",
1030
+ }),
1031
+ ]) as unknown as Turn["listEvents"],
1032
+ });
1033
+ const nextUserTurn = mockTurn({
1034
+ id: "turn-next",
1035
+ createdAt,
1036
+ input: [{ type: "user.message", content: "thanks" }],
1037
+ listEvents: eventsPage([
1038
+ modelMessage({
1039
+ id: "m-thanks",
1040
+ threadId: ROOT_THREAD_ID,
1041
+ content: "You're welcome!",
1042
+ }),
1043
+ ]) as unknown as Turn["listEvents"],
1044
+ });
1045
+
1046
+ const result = await convertTurnsToThreadMessages(
1047
+ mockSession([nextUserTurn, continuationTurn, spawnTurn]),
1048
+ );
1049
+
1050
+ expect(result.messages).toHaveLength(4);
1051
+ const groupAssistant = result.messages[1];
1052
+ expect(groupAssistant?.role).toBe("assistant");
1053
+ if (groupAssistant?.role !== "assistant") {
1054
+ return;
1055
+ }
1056
+ const spawnPart = groupAssistant.content.find(
1057
+ (part) => part.type === "tool-call",
1058
+ );
1059
+ expect(spawnPart?.type).toBe("tool-call");
1060
+ if (spawnPart?.type !== "tool-call") {
1061
+ return;
1062
+ }
1063
+ expect(spawnPart.artifact).toEqual({
1064
+ subAgents: [
1065
+ {
1066
+ threadId: "child-1",
1067
+ title: "Research agent",
1068
+ agentInfo: {
1069
+ type: "dynamic",
1070
+ name: "researcher",
1071
+ input: "do research",
1072
+ },
1073
+ },
1074
+ ],
1075
+ });
1076
+ expect(spawnPart.messages?.[0]?.metadata.custom.subAgent).toEqual({
1077
+ threadId: "child-1",
1078
+ title: "Research agent",
1079
+ name: "researcher",
1080
+ input: "do research",
1081
+ });
1082
+
1083
+ const nextAssistant = result.messages[3];
1084
+ expect(nextAssistant?.role).toBe("assistant");
1085
+ if (nextAssistant?.role !== "assistant") {
1086
+ return;
1087
+ }
1088
+ expect(nextAssistant.content).toEqual([
1089
+ { type: "text", text: "You're welcome!" },
1090
+ ]);
1091
+ expect(
1092
+ nextAssistant.content.some((part) => part.type === "tool-call"),
1093
+ ).toBe(false);
1094
+ });
1095
+ });
1096
+
1097
+ describe("streamTurnEvents", () => {
1098
+ it("yields folded root content as model messages arrive", async () => {
1099
+ const foldState = new PeerThreadFoldState();
1100
+ const updates = await collectStream(
1101
+ streamTurnEvents(
1102
+ streamFrom([
1103
+ modelMessage({
1104
+ id: "m1",
1105
+ threadId: ROOT_THREAD_ID,
1106
+ content: "streaming",
1107
+ }),
1108
+ ]),
1109
+ foldState,
1110
+ ),
1111
+ );
1112
+
1113
+ expect(updates).toEqual([{ content: [{ type: "text", text: "streaming" }] }]);
1114
+ });
1115
+
1116
+ it("scopes streamed content to ids after the group baseline", async () => {
1117
+ const foldState = new PeerThreadFoldState();
1118
+ foldState.getOrCreateBucket(ROOT_THREAD_ID);
1119
+ foldState.threads.get(ROOT_THREAD_ID)!.modelMessageIds.push("prior");
1120
+ foldState.threads.get(ROOT_THREAD_ID)!.events.set(
1121
+ "prior",
1122
+ modelMessage({
1123
+ id: "prior",
1124
+ threadId: ROOT_THREAD_ID,
1125
+ content: "old turn",
1126
+ }),
1127
+ );
1128
+
1129
+ const updates = await collectStream(
1130
+ streamTurnEvents(
1131
+ streamFrom([
1132
+ modelMessage({
1133
+ id: "m-new",
1134
+ threadId: ROOT_THREAD_ID,
1135
+ content: "new turn only",
1136
+ }),
1137
+ ]),
1138
+ foldState,
1139
+ ["prior"],
1140
+ ),
1141
+ );
1142
+
1143
+ expect(updates).toEqual([
1144
+ { content: [{ type: "text", text: "new turn only" }] },
1145
+ ]);
1146
+ });
1147
+
1148
+ it("throws when the turn ends in error", async () => {
1149
+ const foldState = new PeerThreadFoldState();
1150
+ await expect(
1151
+ collectStream(
1152
+ streamTurnEvents(
1153
+ streamFrom([
1154
+ {
1155
+ type: "turn.done",
1156
+ id: "turn-done",
1157
+ createdAt,
1158
+ state: {
1159
+ status: "error",
1160
+ message: "boom",
1161
+ completedAt: createdAt,
1162
+ },
1163
+ },
1164
+ ]),
1165
+ foldState,
1166
+ ),
1167
+ ),
1168
+ ).rejects.toThrow("boom");
1169
+ });
1170
+
1171
+ it("emits tool approval metadata after the stream when approvals remain pending", async () => {
1172
+ const foldState = new PeerThreadFoldState();
1173
+ const updates = await collectStream(
1174
+ streamTurnEvents(
1175
+ streamFrom([
1176
+ modelMessage({
1177
+ id: "m1",
1178
+ threadId: ROOT_THREAD_ID,
1179
+ content: "run tool",
1180
+ toolCalls: [
1181
+ {
1182
+ id: "approval-1",
1183
+ type: "function",
1184
+ function: { name: "bash", arguments: "{}" },
1185
+ toolInfo: {
1186
+ type: "mcp",
1187
+ name: "bash",
1188
+ serverId: "bash-server",
1189
+ serverName: "bash",
1190
+ },
1191
+ },
1192
+ ],
1193
+ }),
1194
+ approvalRequired({
1195
+ id: "approval-event",
1196
+ threadId: ROOT_THREAD_ID,
1197
+ toolCalls: [{ id: "approval-1", sourceEventId: "m1" }],
1198
+ }),
1199
+ ]),
1200
+ foldState,
1201
+ ),
1202
+ );
1203
+
1204
+ const final = updates.at(-1);
1205
+ expect(final?.status).toEqual({
1206
+ type: "requires-action",
1207
+ reason: "tool-calls",
1208
+ });
1209
+ expect(final?.metadata?.custom?.[TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]).toBe(
1210
+ ROOT_THREAD_ID,
1211
+ );
1212
+ });
1213
+
1214
+ it("defers mcp auth until stream end and appends auth links", async () => {
1215
+ const foldState = new PeerThreadFoldState();
1216
+ const updates = await collectStream(
1217
+ streamTurnEvents(
1218
+ streamFrom([
1219
+ modelMessage({
1220
+ id: "m1",
1221
+ threadId: ROOT_THREAD_ID,
1222
+ content: "before auth",
1223
+ }),
1224
+ {
1225
+ type: "mcp.auth_required",
1226
+ id: "mcp-auth",
1227
+ createdAt,
1228
+ mcpServers: [
1229
+ {
1230
+ id: "github-auth",
1231
+ name: "github",
1232
+ authUrl: "https://example.com/auth",
1233
+ },
1234
+ ],
1235
+ },
1236
+ ]),
1237
+ foldState,
1238
+ ),
1239
+ );
1240
+
1241
+ expect(updates).toHaveLength(2);
1242
+ expect(updates[0]).toEqual({
1243
+ content: [{ type: "text", text: "before auth" }],
1244
+ });
1245
+ expect(updates[1]?.status).toEqual({
1246
+ type: "requires-action",
1247
+ reason: "interrupt",
1248
+ });
1249
+ expect(updates[1]?.content.at(-1)).toMatchObject({
1250
+ type: "text",
1251
+ text: expect.stringContaining("Authorize github"),
1252
+ });
1253
+ });
1254
+
1255
+ it("stamps sandboxId onto every content yield after sandbox.created is seen", async () => {
1256
+ const foldState = new PeerThreadFoldState();
1257
+ const updates = await collectStream(
1258
+ streamTurnEvents(
1259
+ streamFrom([
1260
+ modelMessage({
1261
+ id: "m1",
1262
+ threadId: ROOT_THREAD_ID,
1263
+ content: "before sandbox",
1264
+ }),
1265
+ sandboxCreated({ id: "sandbox-evt", sandboxId: "sbx-123" }),
1266
+ modelMessage({
1267
+ id: "m2",
1268
+ threadId: ROOT_THREAD_ID,
1269
+ content: "after sandbox",
1270
+ }),
1271
+ ]),
1272
+ foldState,
1273
+ ),
1274
+ );
1275
+
1276
+ expect(updates).toHaveLength(2);
1277
+ expect(updates[0]?.metadata?.custom?.sandboxId).toBeUndefined();
1278
+ expect(updates[1]?.metadata?.custom?.sandboxId).toBe("sbx-123");
1279
+ });
1280
+
1281
+ it("yields a trailing update carrying sandboxId when sandbox.created is the last event", async () => {
1282
+ const foldState = new PeerThreadFoldState();
1283
+ const updates = await collectStream(
1284
+ streamTurnEvents(
1285
+ streamFrom([
1286
+ modelMessage({
1287
+ id: "m1",
1288
+ threadId: ROOT_THREAD_ID,
1289
+ content: "some content",
1290
+ }),
1291
+ sandboxCreated({ id: "sandbox-evt", sandboxId: "sbx-123" }),
1292
+ ]),
1293
+ foldState,
1294
+ ),
1295
+ );
1296
+
1297
+ const final = updates.at(-1);
1298
+ expect(final?.metadata?.custom?.sandboxId).toBe("sbx-123");
1299
+ });
1300
+
1301
+ it("keeps sandboxId on the final mcp auth yield when both occur in the same stream", async () => {
1302
+ const foldState = new PeerThreadFoldState();
1303
+ const updates = await collectStream(
1304
+ streamTurnEvents(
1305
+ streamFrom([
1306
+ sandboxCreated({ id: "sandbox-evt", sandboxId: "sbx-123" }),
1307
+ modelMessage({
1308
+ id: "m1",
1309
+ threadId: ROOT_THREAD_ID,
1310
+ content: "before auth",
1311
+ }),
1312
+ {
1313
+ type: "mcp.auth_required",
1314
+ id: "mcp-auth",
1315
+ createdAt,
1316
+ mcpServers: [
1317
+ {
1318
+ id: "github-auth",
1319
+ name: "github",
1320
+ authUrl: "https://example.com/auth",
1321
+ },
1322
+ ],
1323
+ },
1324
+ ]),
1325
+ foldState,
1326
+ ),
1327
+ );
1328
+
1329
+ const final = updates.at(-1);
1330
+ expect(final?.metadata?.custom?.sandboxId).toBe("sbx-123");
1331
+ expect(final?.status).toEqual({ type: "requires-action", reason: "interrupt" });
1332
+ });
1333
+ });
1334
+
1335
+ describe("projectSessionMessages streamComplete", () => {
1336
+ const turnId = "turn-live";
1337
+
1338
+ function snapshotWithCompletedStream(
1339
+ update: TurnStreamUpdate,
1340
+ foldState: PeerThreadFoldState,
1341
+ ) {
1342
+ return {
1343
+ ...replaceSessionSnapshot(createEmptySessionSnapshot(), {
1344
+ pendingUser: {
1345
+ turnId,
1346
+ content: "hello",
1347
+ createdAt: new Date(createdAt),
1348
+ },
1349
+ activeStream: {
1350
+ turnId,
1351
+ update,
1352
+ isContinuation: false,
1353
+ streamComplete: true,
1354
+ },
1355
+ }),
1356
+ fold: foldState,
1357
+ };
1358
+ }
1359
+
1360
+ it("keeps streamed images on the assistant message for text-only user prompts", () => {
1361
+ const imageDataUri = "data:image/jpeg;base64,/9j/4AAQ";
1362
+ const messages = projectSessionMessages(
1363
+ replaceSessionSnapshot(createEmptySessionSnapshot(), {
1364
+ pendingUser: {
1365
+ turnId,
1366
+ content: "Create image of dog",
1367
+ createdAt: new Date(createdAt),
1368
+ },
1369
+ activeStream: {
1370
+ turnId,
1371
+ update: {
1372
+ content: [
1373
+ {
1374
+ type: "image",
1375
+ image: imageDataUri,
1376
+ filename: "image-1.jpeg",
1377
+ },
1378
+ ],
1379
+ },
1380
+ isContinuation: false,
1381
+ streamComplete: true,
1382
+ },
1383
+ }),
1384
+ );
1385
+
1386
+ expect(messages).toHaveLength(2);
1387
+ const user = messages[0];
1388
+ const assistant = messages[1];
1389
+ expect(user?.role).toBe("user");
1390
+ if (user?.role !== "user") {
1391
+ return;
1392
+ }
1393
+ expect(user.attachments).toEqual([]);
1394
+ expect(assistant?.role).toBe("assistant");
1395
+ if (assistant?.role !== "assistant") {
1396
+ return;
1397
+ }
1398
+ expect(assistant.content).toEqual([
1399
+ {
1400
+ type: "image",
1401
+ image: imageDataUri,
1402
+ filename: "image-1.jpeg",
1403
+ },
1404
+ ]);
1405
+ });
1406
+
1407
+ it("preserves requires-action when streamComplete and update has approval status", async () => {
1408
+ const foldState = new PeerThreadFoldState();
1409
+ const updates = await collectStream(
1410
+ streamTurnEvents(
1411
+ streamFrom([
1412
+ modelMessage({
1413
+ id: "m1",
1414
+ threadId: ROOT_THREAD_ID,
1415
+ content: "run tool",
1416
+ toolCalls: [
1417
+ {
1418
+ id: "approval-1",
1419
+ type: "function",
1420
+ function: { name: "bash", arguments: "{}" },
1421
+ toolInfo: {
1422
+ type: "mcp",
1423
+ name: "bash",
1424
+ serverId: "bash-server",
1425
+ serverName: "bash",
1426
+ },
1427
+ },
1428
+ ],
1429
+ }),
1430
+ approvalRequired({
1431
+ id: "approval-event",
1432
+ threadId: ROOT_THREAD_ID,
1433
+ toolCalls: [{ id: "approval-1", sourceEventId: "m1" }],
1434
+ }),
1435
+ ]),
1436
+ foldState,
1437
+ ),
1438
+ );
1439
+
1440
+ const finalUpdate = updates.at(-1)!;
1441
+ const messages = projectSessionMessages(
1442
+ snapshotWithCompletedStream(finalUpdate, foldState),
1443
+ );
1444
+
1445
+ const assistant = messages.at(-1);
1446
+ expect(assistant?.role).toBe("assistant");
1447
+ if (assistant?.role !== "assistant") {
1448
+ return;
1449
+ }
1450
+ expect(assistant.status).toEqual({
1451
+ type: "requires-action",
1452
+ reason: "tool-calls",
1453
+ });
1454
+ expect(assistant.content.find((part) => part.type === "tool-call")).toMatchObject({
1455
+ type: "tool-call",
1456
+ toolCallId: "approval-1",
1457
+ approval: { id: "approval-1" },
1458
+ });
1459
+ expect(findPausedAssistantMessage(messages)).toBe(assistant);
1460
+ });
1461
+
1462
+ it("preserves requires-action when streamComplete and update has ask-user status", async () => {
1463
+ const foldState = new PeerThreadFoldState();
1464
+ const updates = await collectStream(
1465
+ streamTurnEvents(
1466
+ streamFrom([
1467
+ modelMessage({
1468
+ id: "model-1",
1469
+ threadId: ROOT_THREAD_ID,
1470
+ toolCalls: [
1471
+ {
1472
+ id: "question-1",
1473
+ type: "function",
1474
+ function: {
1475
+ name: "ask_user_question",
1476
+ arguments: JSON.stringify({
1477
+ question: "Pick one",
1478
+ options: ["A", "B"],
1479
+ }),
1480
+ },
1481
+ toolInfo: {
1482
+ type: "truefoundry-system",
1483
+ name: "ask_user_question",
1484
+ },
1485
+ },
1486
+ ],
1487
+ }),
1488
+ responseRequired({
1489
+ id: "resp-req-1",
1490
+ threadId: ROOT_THREAD_ID,
1491
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
1492
+ }),
1493
+ ]),
1494
+ foldState,
1495
+ ),
1496
+ );
1497
+
1498
+ const finalUpdate = updates.at(-1)!;
1499
+ const messages = projectSessionMessages(
1500
+ snapshotWithCompletedStream(finalUpdate, foldState),
1501
+ );
1502
+
1503
+ const assistant = messages.at(-1);
1504
+ expect(assistant?.role).toBe("assistant");
1505
+ if (assistant?.role !== "assistant") {
1506
+ return;
1507
+ }
1508
+ expect(assistant.status).toEqual({
1509
+ type: "requires-action",
1510
+ reason: "tool-calls",
1511
+ });
1512
+ expect(assistant.content[0]).toMatchObject({
1513
+ type: "tool-call",
1514
+ toolCallId: "question-1",
1515
+ interrupt: {
1516
+ type: "human",
1517
+ payload: { question: "Pick one", options: ["A", "B"] },
1518
+ },
1519
+ });
1520
+ expect(findPausedAssistantMessage(messages)).toBe(assistant);
1521
+ });
1522
+
1523
+ it("forces complete when streamComplete and update has no explicit status", () => {
1524
+ const foldState = new PeerThreadFoldState();
1525
+ const messages = projectSessionMessages(
1526
+ snapshotWithCompletedStream(
1527
+ { content: [{ type: "text", text: "done" }] },
1528
+ foldState,
1529
+ ),
1530
+ );
1531
+
1532
+ const assistant = messages.at(-1);
1533
+ expect(assistant?.role).toBe("assistant");
1534
+ if (assistant?.role !== "assistant") {
1535
+ return;
1536
+ }
1537
+ expect(assistant.status).toEqual({
1538
+ type: "complete",
1539
+ reason: "stop",
1540
+ });
1541
+ });
1542
+ });
1543
+
1544
+ describe("ask-user reload projection", () => {
1545
+ it("does not resurface answered ask-user prompts after a continuation turn", () => {
1546
+ const fold = new PeerThreadFoldState();
1547
+ const turn1Id = "turn-1";
1548
+ const turn2Id = "turn-2";
1549
+
1550
+ ingestTurnEvent(
1551
+ fold,
1552
+ modelMessage({
1553
+ id: "model-1",
1554
+ threadId: ROOT_THREAD_ID,
1555
+ toolCalls: [
1556
+ {
1557
+ id: "question-1",
1558
+ type: "function",
1559
+ function: {
1560
+ name: "ask_user_question",
1561
+ arguments: JSON.stringify({
1562
+ question: "What would you like help with today?",
1563
+ options: ["Help with a coding or technical task"],
1564
+ }),
1565
+ },
1566
+ toolInfo: {
1567
+ type: "truefoundry-system",
1568
+ name: "ask_user_question",
1569
+ },
1570
+ },
1571
+ ],
1572
+ }),
1573
+ );
1574
+ ingestTurnEvent(
1575
+ fold,
1576
+ responseRequired({
1577
+ id: "resp-req-1",
1578
+ threadId: ROOT_THREAD_ID,
1579
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
1580
+ }),
1581
+ );
1582
+ applyUserToolResponsesToFold(fold, []);
1583
+
1584
+ ingestTurnEvent(
1585
+ fold,
1586
+ modelMessage({
1587
+ id: "model-2",
1588
+ threadId: ROOT_THREAD_ID,
1589
+ content: "Great, you'd like help with a coding task!",
1590
+ }),
1591
+ );
1592
+ applyUserToolResponsesToFold(fold, [
1593
+ {
1594
+ type: "user.tool_response",
1595
+ threadId: ROOT_THREAD_ID,
1596
+ toolCallId: "question-1",
1597
+ content: "Help with a coding or technical task",
1598
+ },
1599
+ ]);
1600
+
1601
+ const snapshot = replaceSessionSnapshot(createEmptySessionSnapshot(), {
1602
+ fold,
1603
+ turns: [
1604
+ {
1605
+ id: turn1Id,
1606
+ createdAt,
1607
+ userText: "hello",
1608
+ state: {
1609
+ status: "done",
1610
+ requiredActions: [],
1611
+ completedAt: createdAt,
1612
+ },
1613
+ input: [{ type: "user.message", content: "hello" }],
1614
+ rootModelMessageIds: ["model-1"],
1615
+ },
1616
+ {
1617
+ id: turn2Id,
1618
+ createdAt,
1619
+ state: {
1620
+ status: "done",
1621
+ requiredActions: [],
1622
+ completedAt: createdAt,
1623
+ },
1624
+ input: [
1625
+ {
1626
+ type: "user.tool_response",
1627
+ threadId: ROOT_THREAD_ID,
1628
+ toolCallId: "question-1",
1629
+ content: "Help with a coding or technical task",
1630
+ },
1631
+ ],
1632
+ rootModelMessageIds: ["model-2"],
1633
+ },
1634
+ ],
1635
+ });
1636
+
1637
+ const messages = projectSessionMessages(snapshot);
1638
+ expect(collectPendingToolResponses(messages)).toHaveLength(0);
1639
+
1640
+ const assistant = messages.find((message) => message.role === "assistant");
1641
+ expect(assistant).toBeDefined();
1642
+ const toolCall = assistant?.content.find((part) => part.type === "tool-call");
1643
+ expect(toolCall).toMatchObject({
1644
+ type: "tool-call",
1645
+ toolCallId: "question-1",
1646
+ result: "Help with a coding or technical task",
1647
+ });
1648
+ if (toolCall?.type === "tool-call") {
1649
+ expect(toolCall.interrupt).toBeUndefined();
1650
+ }
1651
+ });
1652
+
1653
+ it("keeps a committed ask-user pause as requires-action so it can be resumed", () => {
1654
+ // Reproduces the live-stream pause path: once the SSE stream ends,
1655
+ // `commitActiveStream` moves the paused turn into `turns` with a
1656
+ // fabricated `TurnStateDone`. The pause must survive as a
1657
+ // `tool.response_required` required action, otherwise the projected
1658
+ // assistant message is not `requires-action` and
1659
+ // `findPausedAssistantMessage` (the gate that fires the resume turn)
1660
+ // never sees it.
1661
+ const fold = new PeerThreadFoldState();
1662
+ const turnId = "turn-ask";
1663
+
1664
+ ingestTurnEvent(
1665
+ fold,
1666
+ modelMessage({
1667
+ id: "model-1",
1668
+ threadId: ROOT_THREAD_ID,
1669
+ toolCalls: [
1670
+ {
1671
+ id: "question-1",
1672
+ type: "function",
1673
+ function: {
1674
+ name: "ask_user_question",
1675
+ arguments: JSON.stringify({
1676
+ question: "Pick one",
1677
+ options: ["A", "B"],
1678
+ }),
1679
+ },
1680
+ toolInfo: {
1681
+ type: "truefoundry-system",
1682
+ name: "ask_user_question",
1683
+ },
1684
+ },
1685
+ ],
1686
+ }),
1687
+ );
1688
+ ingestTurnEvent(
1689
+ fold,
1690
+ responseRequired({
1691
+ id: "resp-req-1",
1692
+ threadId: ROOT_THREAD_ID,
1693
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
1694
+ }),
1695
+ );
1696
+
1697
+ const snapshot = replaceSessionSnapshot(createEmptySessionSnapshot(), {
1698
+ fold,
1699
+ turns: [
1700
+ {
1701
+ id: turnId,
1702
+ createdAt,
1703
+ userText: "ask me a question",
1704
+ state: {
1705
+ status: "done",
1706
+ requiredActions: [
1707
+ responseRequired({
1708
+ id: "resp-req-1",
1709
+ threadId: ROOT_THREAD_ID,
1710
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
1711
+ }),
1712
+ ],
1713
+ completedAt: createdAt,
1714
+ },
1715
+ input: [{ type: "user.message", content: "ask me a question" }],
1716
+ rootModelMessageIds: ["model-1"],
1717
+ },
1718
+ ],
1719
+ });
1720
+
1721
+ const messages = projectSessionMessages(snapshot);
1722
+ const paused = findPausedAssistantMessage(messages);
1723
+ expect(paused).toBeDefined();
1724
+ expect(paused?.status).toMatchObject({ type: "requires-action" });
1725
+ });
1726
+
1727
+ it("rebuilds paused activeStream content from fold after an ask-user answer is recorded", () => {
1728
+ const fold = new PeerThreadFoldState();
1729
+ const turnId = "turn-ask";
1730
+
1731
+ ingestTurnEvent(
1732
+ fold,
1733
+ modelMessage({
1734
+ id: "model-1",
1735
+ threadId: ROOT_THREAD_ID,
1736
+ toolCalls: [
1737
+ {
1738
+ id: "question-1",
1739
+ type: "function",
1740
+ function: {
1741
+ name: "ask_user_question",
1742
+ arguments: JSON.stringify({
1743
+ question: "Pick one",
1744
+ options: ["A", "B"],
1745
+ }),
1746
+ },
1747
+ toolInfo: {
1748
+ type: "truefoundry-system",
1749
+ name: "ask_user_question",
1750
+ },
1751
+ },
1752
+ ],
1753
+ }),
1754
+ );
1755
+ ingestTurnEvent(
1756
+ fold,
1757
+ responseRequired({
1758
+ id: "resp-req-1",
1759
+ threadId: ROOT_THREAD_ID,
1760
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
1761
+ }),
1762
+ );
1763
+
1764
+ const pausedContent = buildRootAssistantContent(fold);
1765
+ applyUserToolResponsesToFold(fold, [
1766
+ {
1767
+ type: "user.tool_response",
1768
+ threadId: ROOT_THREAD_ID,
1769
+ toolCallId: "question-1",
1770
+ content: "A",
1771
+ },
1772
+ ]);
1773
+
1774
+ const snapshot = replaceSessionSnapshot(createEmptySessionSnapshot(), {
1775
+ fold,
1776
+ pendingUser: {
1777
+ turnId,
1778
+ content: "ask me a question",
1779
+ createdAt: new Date(createdAt),
1780
+ },
1781
+ activeStream: {
1782
+ turnId,
1783
+ isContinuation: false,
1784
+ streamComplete: true,
1785
+ update: {
1786
+ content: pausedContent,
1787
+ status: toolResponseStatus(),
1788
+ },
1789
+ },
1790
+ groupRootBaseline: [],
1791
+ });
1792
+
1793
+ const messages = projectSessionMessages(snapshot);
1794
+ expect(collectPendingToolResponses(messages)).toHaveLength(0);
1795
+ });
1796
+ });
1797
+
1798
+ describe("ask-user live continuation projection", () => {
1799
+ it("scopes activeStream to the current group when a continuation turn completes", () => {
1800
+ const fold = new PeerThreadFoldState();
1801
+
1802
+ ingestTurnEvent(
1803
+ fold,
1804
+ modelMessage({
1805
+ id: "model-1",
1806
+ threadId: ROOT_THREAD_ID,
1807
+ toolCalls: [
1808
+ {
1809
+ id: "question-1",
1810
+ type: "function",
1811
+ function: {
1812
+ name: "ask_user_question",
1813
+ arguments: JSON.stringify({
1814
+ question: "What would you like help with?",
1815
+ options: ["Help with a coding task"],
1816
+ }),
1817
+ },
1818
+ toolInfo: {
1819
+ type: "truefoundry-system",
1820
+ name: "ask_user_question",
1821
+ },
1822
+ },
1823
+ ],
1824
+ }),
1825
+ );
1826
+ ingestTurnEvent(
1827
+ fold,
1828
+ responseRequired({
1829
+ id: "resp-req-1",
1830
+ threadId: ROOT_THREAD_ID,
1831
+ toolCalls: [{ id: "question-1", sourceEventId: "model-1" }],
1832
+ }),
1833
+ );
1834
+ applyUserToolResponsesToFold(fold, [
1835
+ {
1836
+ type: "user.tool_response",
1837
+ threadId: ROOT_THREAD_ID,
1838
+ toolCallId: "question-1",
1839
+ content: "Help with a coding task",
1840
+ },
1841
+ ]);
1842
+ ingestTurnEvent(
1843
+ fold,
1844
+ modelMessage({
1845
+ id: "model-2",
1846
+ threadId: ROOT_THREAD_ID,
1847
+ content: "Group 1 follow-up",
1848
+ }),
1849
+ );
1850
+
1851
+ ingestTurnEvent(
1852
+ fold,
1853
+ modelMessage({
1854
+ id: "model-3",
1855
+ threadId: ROOT_THREAD_ID,
1856
+ toolCalls: [
1857
+ {
1858
+ id: "question-2",
1859
+ type: "function",
1860
+ function: {
1861
+ name: "ask_user_question",
1862
+ arguments: JSON.stringify({
1863
+ question: "What kind of code?",
1864
+ options: ["Write new code"],
1865
+ }),
1866
+ },
1867
+ toolInfo: {
1868
+ type: "truefoundry-system",
1869
+ name: "ask_user_question",
1870
+ },
1871
+ },
1872
+ ],
1873
+ }),
1874
+ );
1875
+ ingestTurnEvent(
1876
+ fold,
1877
+ responseRequired({
1878
+ id: "resp-req-2",
1879
+ threadId: ROOT_THREAD_ID,
1880
+ toolCalls: [{ id: "question-2", sourceEventId: "model-3" }],
1881
+ }),
1882
+ );
1883
+ applyUserToolResponsesToFold(fold, [
1884
+ {
1885
+ type: "user.tool_response",
1886
+ threadId: ROOT_THREAD_ID,
1887
+ toolCallId: "question-2",
1888
+ content: "Write new code",
1889
+ },
1890
+ ]);
1891
+ ingestTurnEvent(
1892
+ fold,
1893
+ modelMessage({
1894
+ id: "model-4",
1895
+ threadId: ROOT_THREAD_ID,
1896
+ content: "Group 2 follow-up",
1897
+ }),
1898
+ );
1899
+
1900
+ const snapshot = replaceSessionSnapshot(createEmptySessionSnapshot(), {
1901
+ fold,
1902
+ groupRootBaseline: ["model-1", "model-2"],
1903
+ turns: [
1904
+ {
1905
+ id: "turn-1",
1906
+ createdAt,
1907
+ userText: "ask me a question",
1908
+ state: {
1909
+ status: "done",
1910
+ requiredActions: [],
1911
+ completedAt: createdAt,
1912
+ },
1913
+ input: [{ type: "user.message", content: "ask me a question" }],
1914
+ rootModelMessageIds: ["model-1"],
1915
+ },
1916
+ {
1917
+ id: "turn-2",
1918
+ createdAt,
1919
+ state: {
1920
+ status: "done",
1921
+ requiredActions: [],
1922
+ completedAt: createdAt,
1923
+ },
1924
+ input: [
1925
+ {
1926
+ type: "user.tool_response",
1927
+ threadId: ROOT_THREAD_ID,
1928
+ toolCallId: "question-1",
1929
+ content: "Help with a coding task",
1930
+ },
1931
+ ],
1932
+ rootModelMessageIds: ["model-2"],
1933
+ },
1934
+ {
1935
+ id: "turn-3",
1936
+ createdAt,
1937
+ userText: "ask again",
1938
+ state: {
1939
+ status: "done",
1940
+ requiredActions: [],
1941
+ completedAt: createdAt,
1942
+ },
1943
+ input: [{ type: "user.message", content: "ask again" }],
1944
+ rootModelMessageIds: ["model-3"],
1945
+ },
1946
+ ],
1947
+ activeStream: {
1948
+ turnId: "turn-4",
1949
+ isContinuation: true,
1950
+ streamComplete: true,
1951
+ update: {
1952
+ content: [{ type: "text", text: "Group 2 follow-up" }],
1953
+ status: { type: "complete", reason: "stop" },
1954
+ },
1955
+ },
1956
+ });
1957
+
1958
+ const messages = projectSessionMessages(snapshot);
1959
+ expect(messages).toHaveLength(4);
1960
+
1961
+ const group1Assistant = messages[1];
1962
+ const group2Assistant = messages[3];
1963
+ expect(group1Assistant?.role).toBe("assistant");
1964
+ expect(group2Assistant?.role).toBe("assistant");
1965
+ if (group1Assistant?.role !== "assistant" || group2Assistant?.role !== "assistant") {
1966
+ return;
1967
+ }
1968
+
1969
+ const group1Texts = group1Assistant.content
1970
+ .filter((part) => part.type === "text")
1971
+ .map((part) => part.text);
1972
+ const group2Texts = group2Assistant.content
1973
+ .filter((part) => part.type === "text")
1974
+ .map((part) => part.text);
1975
+
1976
+ expect(group1Texts).toContain("Group 1 follow-up");
1977
+ expect(group1Texts).not.toContain("Group 2 follow-up");
1978
+ expect(group2Texts).toContain("Group 2 follow-up");
1979
+ expect(group2Texts).not.toContain("Group 1 follow-up");
1980
+
1981
+ const group2ToolCalls = group2Assistant.content.filter(
1982
+ (part) => part.type === "tool-call",
1983
+ );
1984
+ expect(group2ToolCalls).toHaveLength(1);
1985
+ expect(group2ToolCalls[0]).toMatchObject({
1986
+ toolCallId: "question-2",
1987
+ result: "Write new code",
1988
+ });
1989
+ });
1990
+ });
1991
+ });