@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,136 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { PeerThreadFoldState } from "./foldPeerThreads.js";
4
+ import {
5
+ applyToolResponseToMessage,
6
+ applyUserToolResponsesToFold,
7
+ collectResponseInputs,
8
+ messageHasPendingResponses,
9
+ } from "./toolResponse.js";
10
+ import { ROOT_THREAD_ID } from "./constants.js";
11
+
12
+ describe("toolResponse", () => {
13
+ it("detects pending nested tool responses", () => {
14
+ const message = {
15
+ id: "a1",
16
+ role: "assistant" as const,
17
+ content: [
18
+ {
19
+ type: "tool-call" as const,
20
+ toolCallId: "tc-root",
21
+ toolName: "create_sub_agent",
22
+ args: {},
23
+ argsText: "{}",
24
+ messages: [
25
+ {
26
+ id: "sub",
27
+ role: "assistant" as const,
28
+ content: [
29
+ {
30
+ type: "tool-call" as const,
31
+ toolCallId: "question-1",
32
+ toolName: "ask_user_question",
33
+ args: { question: "Pick one" },
34
+ argsText: '{"question":"Pick one"}',
35
+ interrupt: {
36
+ type: "human" as const,
37
+ payload: { question: "Pick one", options: ["A", "B"] },
38
+ },
39
+ },
40
+ ],
41
+ status: {
42
+ type: "requires-action" as const,
43
+ reason: "tool-calls" as const,
44
+ },
45
+ createdAt: new Date(),
46
+ metadata: {
47
+ unstable_state: null,
48
+ unstable_annotations: [],
49
+ unstable_data: [],
50
+ steps: [],
51
+ custom: { toolResponseThreadId: "child-1" },
52
+ },
53
+ },
54
+ ],
55
+ },
56
+ ],
57
+ status: { type: "requires-action" as const, reason: "tool-calls" as const },
58
+ createdAt: new Date(),
59
+ metadata: {
60
+ unstable_state: null,
61
+ unstable_annotations: [],
62
+ unstable_data: [],
63
+ steps: [],
64
+ custom: {},
65
+ },
66
+ };
67
+
68
+ expect(messageHasPendingResponses(message)).toBe(true);
69
+ });
70
+
71
+ it("collects staged tool responses for sdk resume", () => {
72
+ const pending = {
73
+ id: "a1",
74
+ role: "assistant" as const,
75
+ content: [
76
+ {
77
+ type: "tool-call" as const,
78
+ toolCallId: "question-1",
79
+ toolName: "ask_user_question",
80
+ args: {},
81
+ argsText: "{}",
82
+ interrupt: {
83
+ type: "human" as const,
84
+ payload: { question: "Pick one", options: ["A", "B"] },
85
+ },
86
+ },
87
+ ],
88
+ status: { type: "requires-action" as const, reason: "tool-calls" as const },
89
+ createdAt: new Date(),
90
+ metadata: {
91
+ unstable_state: null,
92
+ unstable_annotations: [],
93
+ unstable_data: [],
94
+ steps: [],
95
+ custom: { toolResponseThreadId: "main" },
96
+ },
97
+ };
98
+
99
+ const answered = applyToolResponseToMessage(pending, {
100
+ toolCallId: "question-1",
101
+ content: "A",
102
+ });
103
+
104
+ expect(messageHasPendingResponses(answered)).toBe(false);
105
+ expect(collectResponseInputs(answered, "main")).toEqual([
106
+ {
107
+ type: "user.tool_response",
108
+ threadId: "main",
109
+ toolCallId: "question-1",
110
+ content: "A",
111
+ },
112
+ ]);
113
+ });
114
+
115
+ it("applyUserToolResponsesToFold records answers from turn input", () => {
116
+ const fold = new PeerThreadFoldState();
117
+ const bucket = fold.getOrCreateBucket(ROOT_THREAD_ID);
118
+ bucket.pendingResponses.set("question-1", {
119
+ id: "question-1",
120
+ sourceEventId: "model-1",
121
+ question: "Pick one",
122
+ });
123
+
124
+ applyUserToolResponsesToFold(fold, [
125
+ {
126
+ type: "user.tool_response",
127
+ threadId: ROOT_THREAD_ID,
128
+ toolCallId: "question-1",
129
+ content: "A",
130
+ },
131
+ ]);
132
+
133
+ expect(bucket.pendingResponses.has("question-1")).toBe(false);
134
+ expect(bucket.toolResults.get("question-1")).toBe("A");
135
+ });
136
+ });
@@ -0,0 +1,427 @@
1
+ import type {
2
+ MessageStatus,
3
+ ThreadAssistantMessage,
4
+ ThreadAssistantMessagePart,
5
+ ThreadMessage,
6
+ } from "@assistant-ui/core";
7
+ import type {
8
+ ToolResponseRequiredEvent,
9
+ Turn,
10
+ TurnInputItem,
11
+ UserToolResponseEvent,
12
+ } from "truefoundry-gateway-sdk/agents";
13
+
14
+ import { ROOT_THREAD_ID } from "./constants.js";
15
+ import {
16
+ recordToolApprovalInFold,
17
+ recordToolResponseInFold,
18
+ type PeerThreadFoldState,
19
+ } from "./foldPeerThreads.js";
20
+ import type { ToolResponseMessageCustomMetadata } from "./messageCustomMetadata.js";
21
+ import type { TurnStreamUpdate } from "./turnStreamUpdate.js";
22
+
23
+ export { ROOT_THREAD_ID } from "./constants.js";
24
+
25
+ export const TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY = "toolResponseThreadId";
26
+
27
+ export type AskUserQuestionInterruptPayload = {
28
+ question?: string;
29
+ options?: string[];
30
+ };
31
+
32
+ export type StoredToolResponse = Pick<UserToolResponseEvent, "content">;
33
+
34
+ export type RespondToToolResponseOptions = Pick<
35
+ UserToolResponseEvent,
36
+ "toolCallId" | "content"
37
+ >;
38
+
39
+ type ToolCallPart = Extract<
40
+ ThreadMessage["content"][number],
41
+ { type: "tool-call" }
42
+ >;
43
+
44
+ type AssistantToolCallPart = Extract<
45
+ ThreadAssistantMessagePart,
46
+ { type: "tool-call" }
47
+ >;
48
+
49
+ export function hasPendingToolResponse(
50
+ part: Pick<ToolCallPart, "interrupt" | "result">,
51
+ ): boolean {
52
+ return part.interrupt != null && part.result === undefined;
53
+ }
54
+
55
+ function isStagedResponseAwaitingSdk(part: ToolCallPart): boolean {
56
+ return part.interrupt != null && part.result !== undefined;
57
+ }
58
+
59
+ export function toolResponseStatus(): MessageStatus {
60
+ return { type: "requires-action", reason: "tool-calls" };
61
+ }
62
+
63
+ export function toolResponseMessageCustom(
64
+ threadId: string,
65
+ ): ToolResponseMessageCustomMetadata {
66
+ return {
67
+ [TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]:
68
+ threadId === ROOT_THREAD_ID ? ROOT_THREAD_ID : threadId,
69
+ };
70
+ }
71
+
72
+ export function getToolResponseThreadId(
73
+ message: ThreadMessage | undefined,
74
+ ): string | undefined {
75
+ if (message?.role !== "assistant") {
76
+ return undefined;
77
+ }
78
+ const threadId = message.metadata.custom[TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY];
79
+ return typeof threadId === "string" ? threadId : undefined;
80
+ }
81
+
82
+ export function findResponseRequiredInTurn(
83
+ turn: Pick<Turn, "state">,
84
+ ): ToolResponseRequiredEvent | undefined {
85
+ if (turn.state.status !== "done") {
86
+ return undefined;
87
+ }
88
+ return turn.state.requiredActions?.find(
89
+ (action): action is ToolResponseRequiredEvent =>
90
+ action.type === "tool.response_required",
91
+ );
92
+ }
93
+
94
+ function applyToolResponseToToolCall(
95
+ part: AssistantToolCallPart,
96
+ content: string,
97
+ ): AssistantToolCallPart {
98
+ return { ...part, result: content };
99
+ }
100
+
101
+ function updateToolResponseInContent(
102
+ content: readonly ThreadAssistantMessagePart[],
103
+ options: RespondToToolResponseOptions,
104
+ ): { content: readonly ThreadAssistantMessagePart[]; found: boolean } {
105
+ let found = false;
106
+ const newContent = content.map((part) => {
107
+ if (part.type !== "tool-call") {
108
+ return part;
109
+ }
110
+
111
+ if (part.toolCallId === options.toolCallId && hasPendingToolResponse(part)) {
112
+ found = true;
113
+ return applyToolResponseToToolCall(part, options.content);
114
+ }
115
+
116
+ if (part.messages == null) {
117
+ return part;
118
+ }
119
+
120
+ const messages = part.messages.map((message) => {
121
+ if (message.role !== "assistant") {
122
+ return message;
123
+ }
124
+ const nested = updateToolResponseInContent(message.content, options);
125
+ if (!nested.found) {
126
+ return message;
127
+ }
128
+ found = true;
129
+ return { ...message, content: nested.content };
130
+ });
131
+ return { ...part, messages };
132
+ });
133
+
134
+ return { content: newContent, found };
135
+ }
136
+
137
+ export function applyToolResponseToMessage(
138
+ message: ThreadAssistantMessage,
139
+ options: RespondToToolResponseOptions,
140
+ ): ThreadAssistantMessage {
141
+ const { content } = updateToolResponseInContent(message.content, options);
142
+ return { ...message, content: [...content] };
143
+ }
144
+
145
+ function nestedMessagesHavePendingResponses(
146
+ messages: readonly ThreadMessage[],
147
+ ): boolean {
148
+ for (const message of messages) {
149
+ if (messageHasPendingResponses(message)) {
150
+ return true;
151
+ }
152
+ }
153
+ return false;
154
+ }
155
+
156
+ export function messageHasPendingResponses(message: ThreadMessage | undefined): boolean {
157
+ if (message?.role !== "assistant") {
158
+ return false;
159
+ }
160
+ for (const part of message.content) {
161
+ if (part.type !== "tool-call") {
162
+ continue;
163
+ }
164
+ if (hasPendingToolResponse(part)) {
165
+ return true;
166
+ }
167
+ if (part.messages != null && nestedMessagesHavePendingResponses(part.messages)) {
168
+ return true;
169
+ }
170
+ }
171
+ return false;
172
+ }
173
+
174
+ function collectResponseInputsFromMessages(
175
+ messages: readonly ThreadMessage[],
176
+ defaultThreadId: string,
177
+ ): UserToolResponseEvent[] {
178
+ const events: UserToolResponseEvent[] = [];
179
+ for (const message of messages) {
180
+ events.push(...collectResponseInputs(message, defaultThreadId));
181
+ }
182
+ return events;
183
+ }
184
+
185
+ export function collectResponseInputs(
186
+ message: ThreadMessage,
187
+ threadId: string,
188
+ ): UserToolResponseEvent[] {
189
+ if (message.role !== "assistant" || !threadId) {
190
+ return [];
191
+ }
192
+ if (messageHasPendingResponses(message)) {
193
+ return [];
194
+ }
195
+
196
+ const scopedThreadId = getToolResponseThreadId(message) ?? threadId;
197
+ const events: UserToolResponseEvent[] = [];
198
+
199
+ for (const part of message.content) {
200
+ if (part.type !== "tool-call") {
201
+ continue;
202
+ }
203
+ if (isStagedResponseAwaitingSdk(part)) {
204
+ events.push({
205
+ type: "user.tool_response",
206
+ threadId: scopedThreadId,
207
+ toolCallId: part.toolCallId,
208
+ content: String(part.result),
209
+ });
210
+ }
211
+ if (part.messages != null) {
212
+ events.push(
213
+ ...collectResponseInputsFromMessages(part.messages, scopedThreadId),
214
+ );
215
+ }
216
+ }
217
+ return events;
218
+ }
219
+
220
+ function contentHasPendingResponses(
221
+ content: readonly ThreadAssistantMessagePart[],
222
+ ): boolean {
223
+ return messageHasPendingResponses({
224
+ id: "pending-check",
225
+ role: "assistant",
226
+ content,
227
+ status: { type: "complete", reason: "stop" },
228
+ createdAt: new Date(),
229
+ metadata: {
230
+ unstable_state: null,
231
+ unstable_annotations: [],
232
+ unstable_data: [],
233
+ steps: [],
234
+ custom: {},
235
+ },
236
+ });
237
+ }
238
+
239
+ export function resolveToolResponseUpdate(update: TurnStreamUpdate): TurnStreamUpdate {
240
+ if (
241
+ contentHasPendingResponses(update.content) ||
242
+ update.status?.type !== "requires-action" ||
243
+ update.status.reason !== "tool-calls"
244
+ ) {
245
+ return update;
246
+ }
247
+
248
+ const { status: _status, metadata: _metadata, ...rest } = update;
249
+ return rest;
250
+ }
251
+
252
+ function walkAssistantToolCallParts(
253
+ content: readonly ThreadAssistantMessagePart[],
254
+ visit: (part: AssistantToolCallPart) => void,
255
+ ): void {
256
+ for (const part of content) {
257
+ if (part.type !== "tool-call") {
258
+ continue;
259
+ }
260
+ visit(part);
261
+ if (part.messages == null) {
262
+ continue;
263
+ }
264
+ for (const message of part.messages) {
265
+ if (message.role === "assistant") {
266
+ walkAssistantToolCallParts(message.content, visit);
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ function collectStagedResponsesFromContent(
273
+ content: readonly ThreadAssistantMessagePart[],
274
+ ): Map<string, string> {
275
+ const staged = new Map<string, string>();
276
+ walkAssistantToolCallParts(content, (part) => {
277
+ if (isStagedResponseAwaitingSdk(part)) {
278
+ staged.set(part.toolCallId, String(part.result));
279
+ }
280
+ });
281
+ return staged;
282
+ }
283
+
284
+ function applyStagedResponsesToContentMap(
285
+ content: readonly ThreadAssistantMessagePart[],
286
+ staged: ReadonlyMap<string, string>,
287
+ ): ThreadAssistantMessagePart[] {
288
+ return content.map((part) => {
289
+ if (part.type !== "tool-call") {
290
+ return part;
291
+ }
292
+
293
+ const contentValue = staged.get(part.toolCallId);
294
+ let nextPart: AssistantToolCallPart = part;
295
+ if (contentValue != null && hasPendingToolResponse(part)) {
296
+ nextPart = applyToolResponseToToolCall(part, contentValue);
297
+ }
298
+
299
+ if (nextPart.messages == null) {
300
+ return nextPart;
301
+ }
302
+
303
+ return {
304
+ ...nextPart,
305
+ messages: nextPart.messages.map((message) => {
306
+ if (message.role !== "assistant") {
307
+ return message;
308
+ }
309
+ return {
310
+ ...message,
311
+ content: applyStagedResponsesToContentMap(message.content, staged),
312
+ };
313
+ }),
314
+ };
315
+ });
316
+ }
317
+
318
+ export function mergeStagedResponsesIntoContent(
319
+ incoming: readonly ThreadAssistantMessagePart[],
320
+ existing: readonly ThreadAssistantMessagePart[],
321
+ ): ThreadAssistantMessagePart[] {
322
+ const staged = collectStagedResponsesFromContent(existing);
323
+ if (staged.size === 0) {
324
+ return [...incoming];
325
+ }
326
+ return applyStagedResponsesToContentMap(incoming, staged);
327
+ }
328
+
329
+ export function extractToolResponsesFromTurnInput(
330
+ input: Turn["input"] | undefined,
331
+ ): UserToolResponseEvent[] {
332
+ const events: UserToolResponseEvent[] = [];
333
+ for (const item of input ?? []) {
334
+ if (item.type === "user.tool_response") {
335
+ events.push(item);
336
+ }
337
+ }
338
+ return events;
339
+ }
340
+
341
+ export function applyUserToolResponsesToFold(
342
+ fold: PeerThreadFoldState,
343
+ inputs: readonly TurnInputItem[],
344
+ ): void {
345
+ for (const item of inputs) {
346
+ if (item.type === "user.tool_response") {
347
+ recordToolResponseInFold(fold, {
348
+ toolCallId: item.toolCallId,
349
+ content: item.content,
350
+ });
351
+ } else if (item.type === "user.tool_approval") {
352
+ recordToolApprovalInFold(fold, {
353
+ toolCallId: item.toolCallId,
354
+ approved: item.approval.status === "allow",
355
+ ...(item.approval.status === "deny" && item.approval.reason != null
356
+ ? { reason: item.approval.reason }
357
+ : {}),
358
+ });
359
+ }
360
+ }
361
+ }
362
+
363
+ export function collectSubsequentToolResponses(
364
+ turns: readonly Pick<Turn, "input">[],
365
+ fromIndex: number,
366
+ ): Map<string, StoredToolResponse> {
367
+ const responses = new Map<string, StoredToolResponse>();
368
+
369
+ for (let index = fromIndex + 1; index < turns.length; index++) {
370
+ const input = turns[index]?.input ?? [];
371
+ if (input.some((item) => item.type === "user.message")) {
372
+ break;
373
+ }
374
+
375
+ for (const event of extractToolResponsesFromTurnInput(input)) {
376
+ responses.set(event.toolCallId, { content: event.content });
377
+ }
378
+ }
379
+
380
+ return responses;
381
+ }
382
+
383
+ export function collectToolResponsesFromTurnInput(
384
+ input: Turn["input"] | undefined,
385
+ ): Map<string, StoredToolResponse> {
386
+ const responses = new Map<string, StoredToolResponse>();
387
+ for (const event of extractToolResponsesFromTurnInput(input)) {
388
+ responses.set(event.toolCallId, { content: event.content });
389
+ }
390
+ return responses;
391
+ }
392
+
393
+ function applyStagedResponsesToMessages(
394
+ messages: readonly ThreadMessage[],
395
+ responses: ReadonlyMap<string, StoredToolResponse>,
396
+ ): ThreadMessage[] {
397
+ return messages.map((message) => {
398
+ if (message.role !== "assistant") {
399
+ return message;
400
+ }
401
+ return {
402
+ ...message,
403
+ content: applyStagedResponsesToContentMap(
404
+ message.content,
405
+ new Map(
406
+ [...responses.entries()].map(([toolCallId, value]) => [
407
+ toolCallId,
408
+ value.content,
409
+ ]),
410
+ ),
411
+ ),
412
+ };
413
+ });
414
+ }
415
+
416
+ export function applyStagedResponsesToContent(
417
+ content: readonly ThreadAssistantMessagePart[],
418
+ responses: ReadonlyMap<string, StoredToolResponse>,
419
+ ): ThreadAssistantMessagePart[] {
420
+ const staged = new Map(
421
+ [...responses.entries()].map(([toolCallId, value]) => [
422
+ toolCallId,
423
+ value.content,
424
+ ]),
425
+ );
426
+ return applyStagedResponsesToContentMap(content, staged);
427
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
4
+
5
+ import { createTrueFoundryDraftThreadListAdapter } from "./truefoundryDraftThreadListAdapter.js";
6
+ import type { AgentSpec } from "./agentSpec.js";
7
+
8
+ const defaultAgentSpec: AgentSpec = {
9
+ model: { name: "anthropic/claude-sonnet-4-6" },
10
+ instructions: "You are helpful.",
11
+ };
12
+
13
+ function mockDraft(id: string, title: string | undefined, updatedAt: string) {
14
+ return {
15
+ id,
16
+ agentSpec: defaultAgentSpec,
17
+ title,
18
+ createdBySubject: { type: "user" as const, id: "u1" },
19
+ createdAt: updatedAt,
20
+ updatedAt,
21
+ };
22
+ }
23
+
24
+ function mockDraftListPage(
25
+ drafts: ReturnType<typeof mockDraft>[],
26
+ nextPageToken?: string,
27
+ ) {
28
+ return {
29
+ data: drafts,
30
+ response: {
31
+ pagination: {
32
+ nextPageToken,
33
+ limit: 20,
34
+ },
35
+ },
36
+ };
37
+ }
38
+
39
+ describe("createTrueFoundryDraftThreadListAdapter", () => {
40
+ it("lists draft sessions with pagination cursor", async () => {
41
+ const list = vi.fn().mockResolvedValue(
42
+ mockDraftListPage(
43
+ [mockDraft("d1", "My draft", "2026-06-30T10:00:00.000Z")],
44
+ "page-2",
45
+ ),
46
+ );
47
+ const gateway = {
48
+ agents: { private: { draftSessions: { list, create: vi.fn(), get: vi.fn() } } },
49
+ } as unknown as TrueFoundryGateway;
50
+
51
+ const adapter = createTrueFoundryDraftThreadListAdapter({
52
+ gateway,
53
+ defaultAgentSpec,
54
+ });
55
+
56
+ const result = await adapter.list();
57
+
58
+ expect(list).toHaveBeenCalledWith(
59
+ expect.objectContaining({
60
+ limit: 20,
61
+ pageToken: undefined,
62
+ startTimestamp: expect.any(String),
63
+ }),
64
+ );
65
+ expect(list).toHaveBeenCalledWith(
66
+ expect.not.objectContaining({ agentName: expect.anything() }),
67
+ );
68
+ expect(result.threads).toEqual([
69
+ {
70
+ status: "regular",
71
+ remoteId: "d1",
72
+ title: "My draft",
73
+ lastMessageAt: new Date("2026-06-30T10:00:00.000Z"),
74
+ },
75
+ ]);
76
+ expect(result.nextCursor).toBe("page-2");
77
+ });
78
+
79
+ it("creates a draft session on initialize", async () => {
80
+ const create = vi.fn().mockResolvedValue({
81
+ data: mockDraft("d-new", undefined, "2026-06-30T12:00:00.000Z"),
82
+ });
83
+ const gateway = {
84
+ agents: { private: { draftSessions: { list: vi.fn(), create, get: vi.fn() } } },
85
+ } as unknown as TrueFoundryGateway;
86
+
87
+ const adapter = createTrueFoundryDraftThreadListAdapter({
88
+ gateway,
89
+ defaultAgentSpec,
90
+ });
91
+
92
+ const result = await adapter.initialize("local-thread-id");
93
+
94
+ expect(create).toHaveBeenCalledWith({ agentSpec: defaultAgentSpec });
95
+ expect(result).toEqual({ remoteId: "d-new", externalId: undefined });
96
+ });
97
+
98
+ it("creates a draft session with the live agent spec when getAgentSpec is provided", async () => {
99
+ const liveAgentSpec: AgentSpec = {
100
+ model: { name: "anthropic/claude-opus-4-8" },
101
+ instructions: "You are helpful.",
102
+ mcpServers: [{ name: "github", enableTools: ["@all"] }],
103
+ skills: [{ fqn: "acme/skill-a:1", preload: false }],
104
+ };
105
+ const create = vi.fn().mockResolvedValue({
106
+ data: mockDraft("d-new", undefined, "2026-06-30T12:00:00.000Z"),
107
+ });
108
+ const gateway = {
109
+ agents: { private: { draftSessions: { list: vi.fn(), create, get: vi.fn() } } },
110
+ } as unknown as TrueFoundryGateway;
111
+
112
+ const adapter = createTrueFoundryDraftThreadListAdapter({
113
+ gateway,
114
+ defaultAgentSpec,
115
+ getAgentSpec: () => liveAgentSpec,
116
+ });
117
+
118
+ await adapter.initialize("local-thread-id");
119
+
120
+ expect(create).toHaveBeenCalledWith({ agentSpec: liveAgentSpec });
121
+ });
122
+
123
+ it("falls back to model name for title when draft has no title", async () => {
124
+ const get = vi.fn().mockResolvedValue({
125
+ data: mockDraft("d1", undefined, "2026-06-30T10:00:00.000Z"),
126
+ });
127
+ const gateway = {
128
+ agents: { private: { draftSessions: { list: vi.fn(), create: vi.fn(), get } } },
129
+ } as unknown as TrueFoundryGateway;
130
+
131
+ const adapter = createTrueFoundryDraftThreadListAdapter({
132
+ gateway,
133
+ defaultAgentSpec,
134
+ });
135
+
136
+ const result = await adapter.fetch("d1");
137
+
138
+ expect(result.title).toBe("anthropic/claude-sonnet-4-6");
139
+ });
140
+ });