@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,193 @@
1
+ import type { CompleteAttachment } from "@assistant-ui/core";
2
+ import type {
3
+ ModelMessageEvent,
4
+ TurnEvent,
5
+ TurnStreamingEvent,
6
+ } from "truefoundry-gateway-sdk/agents";
7
+ import { isEventDelta, mergeEventDelta } from "truefoundry-gateway-sdk/agents";
8
+
9
+ import type { AssistantContentPart } from "./modelMessageContent.js";
10
+
11
+ export type ImageUrlContentPart = {
12
+ type: "image_url";
13
+ image_url: { url: string };
14
+ };
15
+
16
+ type ModelMessageContentPart =
17
+ | { type: "text"; text: string }
18
+ | { type: "refusal"; refusal: string }
19
+ | ImageUrlContentPart;
20
+
21
+ type ContentBlockDelta = {
22
+ index: number;
23
+ delta:
24
+ | { type: "text"; text?: string }
25
+ | { type: "image_url"; image_url?: { url?: string } };
26
+ };
27
+
28
+ type ModelMessageDeltaWithContentBlocks = {
29
+ type: "model.message.delta";
30
+ id: string;
31
+ contentBlocks?: ContentBlockDelta[];
32
+ content_blocks?: ContentBlockDelta[];
33
+ };
34
+
35
+ function parseDataUriMime(data: string): string {
36
+ if (!data.startsWith("data:")) {
37
+ return "image/png";
38
+ }
39
+ const match = /^data:([^;,]+)/.exec(data);
40
+ return match?.[1] ?? "image/png";
41
+ }
42
+
43
+ function imageFilenameFromUrl(url: string, index: number): string {
44
+ const mimeType = parseDataUriMime(url);
45
+ const ext = mimeType.split("/")[1] ?? "png";
46
+ return `image-${index + 1}.${ext}`;
47
+ }
48
+
49
+ export function isImageUrlContentPart(
50
+ part: unknown,
51
+ ): part is ImageUrlContentPart {
52
+ return (
53
+ part != null &&
54
+ typeof part === "object" &&
55
+ (part as ImageUrlContentPart).type === "image_url" &&
56
+ typeof (part as ImageUrlContentPart).image_url?.url === "string"
57
+ );
58
+ }
59
+
60
+ function normalizeModelMessageContent(
61
+ message: ModelMessageEvent,
62
+ ): ModelMessageContentPart[] {
63
+ const { content } = message;
64
+ if (content == null) {
65
+ return [];
66
+ }
67
+ if (typeof content === "string") {
68
+ return content.length > 0 ? [{ type: "text", text: content }] : [];
69
+ }
70
+ return content as ModelMessageContentPart[];
71
+ }
72
+
73
+ function ensureModelMessageContentArray(message: ModelMessageEvent): void {
74
+ if (Array.isArray(message.content)) {
75
+ return;
76
+ }
77
+ message.content = normalizeModelMessageContent(message);
78
+ }
79
+
80
+ function mergeContentBlockDeltas(
81
+ message: ModelMessageEvent,
82
+ blocks: readonly ContentBlockDelta[],
83
+ ): void {
84
+ ensureModelMessageContentArray(message);
85
+ const content = message.content as ModelMessageContentPart[];
86
+
87
+ for (const block of blocks) {
88
+ const index = block.index;
89
+ while (content.length <= index) {
90
+ content.push({ type: "text", text: "" });
91
+ }
92
+
93
+ const delta = block.delta;
94
+ if (delta.type === "text") {
95
+ const existing = content[index];
96
+ if (existing?.type === "text") {
97
+ existing.text += delta.text ?? "";
98
+ } else {
99
+ content[index] = { type: "text", text: delta.text ?? "" };
100
+ }
101
+ continue;
102
+ }
103
+
104
+ if (delta.type !== "image_url") {
105
+ continue;
106
+ }
107
+
108
+ const chunk = delta.image_url?.url ?? "";
109
+ const existing = content[index];
110
+ if (isImageUrlContentPart(existing)) {
111
+ existing.image_url.url += chunk;
112
+ } else {
113
+ content[index] = { type: "image_url", image_url: { url: chunk } };
114
+ }
115
+ }
116
+ }
117
+
118
+ export function mergeStreamEventDelta(
119
+ base: TurnEvent,
120
+ delta: TurnStreamingEvent,
121
+ ): void {
122
+ if (!isEventDelta(delta)) {
123
+ return;
124
+ }
125
+
126
+ mergeEventDelta(base, delta);
127
+
128
+ if (base.type !== "model.message" || delta.type !== "model.message.delta") {
129
+ return;
130
+ }
131
+
132
+ const extended = delta as ModelMessageDeltaWithContentBlocks;
133
+ const blocks = extended.contentBlocks ?? extended.content_blocks;
134
+ if (blocks == null || blocks.length === 0) {
135
+ return;
136
+ }
137
+
138
+ mergeContentBlockDeltas(base, blocks);
139
+ }
140
+
141
+ export function imageUrlToAttachment(
142
+ url: string,
143
+ attachmentId: string,
144
+ ): CompleteAttachment {
145
+ const mimeType = parseDataUriMime(url);
146
+ return {
147
+ id: attachmentId,
148
+ type: "image",
149
+ name: imageFilenameFromUrl(url, 0),
150
+ contentType: mimeType,
151
+ status: { type: "complete" },
152
+ content: [{ type: "image", image: url, filename: imageFilenameFromUrl(url, 0) }],
153
+ };
154
+ }
155
+
156
+ export function imagePartToAssistantImage(url: string, index: number): AssistantContentPart {
157
+ return {
158
+ type: "image",
159
+ image: url,
160
+ filename: imageFilenameFromUrl(url, index),
161
+ };
162
+ }
163
+
164
+ export function extractImagePartsFromModelMessage(
165
+ message: ModelMessageEvent,
166
+ ): AssistantContentPart[] {
167
+ const parts: AssistantContentPart[] = [];
168
+ let imageIndex = 0;
169
+
170
+ for (const part of normalizeModelMessageContent(message)) {
171
+ if (!isImageUrlContentPart(part)) {
172
+ continue;
173
+ }
174
+ const url = part.image_url.url.trim();
175
+ if (url.length === 0) {
176
+ continue;
177
+ }
178
+ parts.push(imagePartToAssistantImage(url, imageIndex));
179
+ imageIndex += 1;
180
+ }
181
+
182
+ return parts;
183
+ }
184
+
185
+ export function extractImageUrlFromUserContentItem(
186
+ part: unknown,
187
+ ): string | undefined {
188
+ if (isImageUrlContentPart(part)) {
189
+ const url = part.image_url.url.trim();
190
+ return url.length > 0 ? url : undefined;
191
+ }
192
+ return undefined;
193
+ }
@@ -0,0 +1,394 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { ROOT_THREAD_ID } from "./constants.js";
4
+ import {
5
+ applyApprovalDecisionsToMessage,
6
+ collectApprovalInputs,
7
+ } from "./toolApproval.js";
8
+ import {
9
+ applyToolResponseToMessage,
10
+ collectResponseInputs,
11
+ } from "./toolResponse.js";
12
+ import {
13
+ collectRequiredActionInputs,
14
+ messageHasPendingRequiredActions,
15
+ } from "./requiredActionInputs.js";
16
+
17
+ describe("requiredActionInputs", () => {
18
+ describe("batched resume invariant", () => {
19
+ it("bundles approvals and responses once nothing is pending", () => {
20
+ const message = {
21
+ id: "turn-1-assistant",
22
+ role: "assistant" as const,
23
+ content: [
24
+ {
25
+ type: "tool-call" as const,
26
+ toolCallId: "approval-1",
27
+ toolName: "bash",
28
+ args: {},
29
+ argsText: "{}",
30
+ approval: { id: "approval-1", approved: true },
31
+ },
32
+ {
33
+ type: "tool-call" as const,
34
+ toolCallId: "question-1",
35
+ toolName: "ask_user_question",
36
+ args: {},
37
+ argsText: "{}",
38
+ interrupt: { type: "human" as const, payload: { question: "Q?" } },
39
+ result: "answer",
40
+ },
41
+ ],
42
+ status: { type: "requires-action" as const, reason: "tool-calls" as const },
43
+ createdAt: new Date(),
44
+ metadata: {
45
+ unstable_state: null,
46
+ unstable_annotations: [],
47
+ unstable_data: [],
48
+ steps: [],
49
+ custom: {
50
+ toolApprovalThreadId: ROOT_THREAD_ID,
51
+ toolResponseThreadId: ROOT_THREAD_ID,
52
+ },
53
+ },
54
+ };
55
+
56
+ expect(messageHasPendingRequiredActions(message)).toBe(false);
57
+ expect(collectRequiredActionInputs(message)).toEqual([
58
+ {
59
+ type: "user.tool_approval",
60
+ threadId: ROOT_THREAD_ID,
61
+ toolCallId: "approval-1",
62
+ approval: { status: "allow" },
63
+ },
64
+ {
65
+ type: "user.tool_response",
66
+ threadId: ROOT_THREAD_ID,
67
+ toolCallId: "question-1",
68
+ content: "answer",
69
+ },
70
+ ]);
71
+ });
72
+
73
+ it("returns empty inputs while any required action is still pending", () => {
74
+ const pendingApproval = {
75
+ id: "turn-1-assistant",
76
+ role: "assistant" as const,
77
+ content: [
78
+ {
79
+ type: "tool-call" as const,
80
+ toolCallId: "approval-1",
81
+ toolName: "bash",
82
+ args: {},
83
+ argsText: "{}",
84
+ approval: { id: "approval-1" },
85
+ },
86
+ {
87
+ type: "tool-call" as const,
88
+ toolCallId: "question-1",
89
+ toolName: "ask_user_question",
90
+ args: {},
91
+ argsText: "{}",
92
+ interrupt: { type: "human" as const, payload: { question: "Q?" } },
93
+ result: "answer",
94
+ },
95
+ ],
96
+ status: { type: "requires-action" as const, reason: "tool-calls" as const },
97
+ createdAt: new Date(),
98
+ metadata: {
99
+ unstable_state: null,
100
+ unstable_annotations: [],
101
+ unstable_data: [],
102
+ steps: [],
103
+ custom: {},
104
+ },
105
+ };
106
+
107
+ expect(messageHasPendingRequiredActions(pendingApproval)).toBe(true);
108
+ expect(collectRequiredActionInputs(pendingApproval)).toEqual([]);
109
+ });
110
+
111
+ it("collects nested thread ids independently", () => {
112
+ const message = {
113
+ id: "root-assistant",
114
+ role: "assistant" as const,
115
+ content: [
116
+ {
117
+ type: "tool-call" as const,
118
+ toolCallId: "spawn-1",
119
+ toolName: "create_sub_agent",
120
+ args: {},
121
+ argsText: "{}",
122
+ messages: [
123
+ {
124
+ id: "child-assistant",
125
+ role: "assistant" as const,
126
+ content: [
127
+ {
128
+ type: "tool-call" as const,
129
+ toolCallId: "approval-sub",
130
+ toolName: "bash",
131
+ args: {},
132
+ argsText: "{}",
133
+ approval: { id: "approval-sub", approved: true },
134
+ },
135
+ ],
136
+ status: {
137
+ type: "requires-action" as const,
138
+ reason: "tool-calls" as const,
139
+ },
140
+ createdAt: new Date(),
141
+ metadata: {
142
+ unstable_state: null,
143
+ unstable_annotations: [],
144
+ unstable_data: [],
145
+ steps: [],
146
+ custom: { toolApprovalThreadId: "child-1" },
147
+ },
148
+ },
149
+ ],
150
+ },
151
+ {
152
+ type: "tool-call" as const,
153
+ toolCallId: "approval-root",
154
+ toolName: "bash",
155
+ args: {},
156
+ argsText: "{}",
157
+ approval: { id: "approval-root", approved: true },
158
+ },
159
+ ],
160
+ status: { type: "requires-action" as const, reason: "tool-calls" as const },
161
+ createdAt: new Date(),
162
+ metadata: {
163
+ unstable_state: null,
164
+ unstable_annotations: [],
165
+ unstable_data: [],
166
+ steps: [],
167
+ custom: { toolApprovalThreadId: ROOT_THREAD_ID },
168
+ },
169
+ };
170
+
171
+ expect(collectApprovalInputs(message, ROOT_THREAD_ID)).toEqual([
172
+ {
173
+ type: "user.tool_approval",
174
+ threadId: "child-1",
175
+ toolCallId: "approval-sub",
176
+ approval: { status: "allow" },
177
+ },
178
+ {
179
+ type: "user.tool_approval",
180
+ threadId: ROOT_THREAD_ID,
181
+ toolCallId: "approval-root",
182
+ approval: { status: "allow" },
183
+ },
184
+ ]);
185
+ });
186
+
187
+ it("collects both action types after approvals and responses are staged", () => {
188
+ const pending = {
189
+ id: "turn-1-assistant",
190
+ role: "assistant" as const,
191
+ content: [
192
+ {
193
+ type: "tool-call" as const,
194
+ toolCallId: "approval-1",
195
+ toolName: "bash",
196
+ args: {},
197
+ argsText: "{}",
198
+ approval: { id: "approval-1" },
199
+ },
200
+ {
201
+ type: "tool-call" as const,
202
+ toolCallId: "question-1",
203
+ toolName: "ask_user_question",
204
+ args: {},
205
+ argsText: "{}",
206
+ interrupt: {
207
+ type: "human" as const,
208
+ payload: { question: "Pick one", options: ["A", "B"] },
209
+ },
210
+ },
211
+ ],
212
+ status: { type: "requires-action" as const, reason: "tool-calls" as const },
213
+ createdAt: new Date(),
214
+ metadata: {
215
+ unstable_state: null,
216
+ unstable_annotations: [],
217
+ unstable_data: [],
218
+ steps: [],
219
+ custom: {
220
+ toolApprovalThreadId: ROOT_THREAD_ID,
221
+ toolResponseThreadId: ROOT_THREAD_ID,
222
+ },
223
+ },
224
+ };
225
+
226
+ const decided = applyApprovalDecisionsToMessage(pending, {
227
+ approvalId: "approval-1",
228
+ approved: true,
229
+ });
230
+ expect(messageHasPendingRequiredActions(decided)).toBe(true);
231
+ expect(collectRequiredActionInputs(decided)).toEqual([]);
232
+
233
+ const answered = applyToolResponseToMessage(decided, {
234
+ toolCallId: "question-1",
235
+ content: "A",
236
+ });
237
+ expect(messageHasPendingRequiredActions(answered)).toBe(false);
238
+ expect(collectRequiredActionInputs(answered)).toEqual([
239
+ {
240
+ type: "user.tool_approval",
241
+ threadId: ROOT_THREAD_ID,
242
+ toolCallId: "approval-1",
243
+ approval: { status: "allow" },
244
+ },
245
+ {
246
+ type: "user.tool_response",
247
+ threadId: ROOT_THREAD_ID,
248
+ toolCallId: "question-1",
249
+ content: "A",
250
+ },
251
+ ]);
252
+ });
253
+
254
+ it("does not emit approval-only inputs while a response is still pending", () => {
255
+ const approvalDecidedResponsePending = applyApprovalDecisionsToMessage(
256
+ {
257
+ id: "turn-1-assistant",
258
+ role: "assistant" as const,
259
+ content: [
260
+ {
261
+ type: "tool-call" as const,
262
+ toolCallId: "approval-1",
263
+ toolName: "bash",
264
+ args: {},
265
+ argsText: "{}",
266
+ approval: { id: "approval-1" },
267
+ },
268
+ {
269
+ type: "tool-call" as const,
270
+ toolCallId: "question-1",
271
+ toolName: "ask_user_question",
272
+ args: {},
273
+ argsText: "{}",
274
+ interrupt: {
275
+ type: "human" as const,
276
+ payload: { question: "Pick one" },
277
+ },
278
+ },
279
+ ],
280
+ status: { type: "requires-action" as const, reason: "tool-calls" as const },
281
+ createdAt: new Date(),
282
+ metadata: {
283
+ unstable_state: null,
284
+ unstable_annotations: [],
285
+ unstable_data: [],
286
+ steps: [],
287
+ custom: {
288
+ toolApprovalThreadId: ROOT_THREAD_ID,
289
+ toolResponseThreadId: ROOT_THREAD_ID,
290
+ },
291
+ },
292
+ },
293
+ { approvalId: "approval-1", approved: true },
294
+ );
295
+
296
+ expect(collectApprovalInputs(approvalDecidedResponsePending, ROOT_THREAD_ID)).toEqual([
297
+ {
298
+ type: "user.tool_approval",
299
+ threadId: ROOT_THREAD_ID,
300
+ toolCallId: "approval-1",
301
+ approval: { status: "allow" },
302
+ },
303
+ ]);
304
+ expect(collectRequiredActionInputs(approvalDecidedResponsePending)).toEqual([]);
305
+ });
306
+
307
+ it("bundles nested multi-thread approvals and responses in one input array", () => {
308
+ const pending = {
309
+ id: "root-assistant",
310
+ role: "assistant" as const,
311
+ content: [
312
+ {
313
+ type: "tool-call" as const,
314
+ toolCallId: "spawn-1",
315
+ toolName: "create_sub_agent",
316
+ args: {},
317
+ argsText: "{}",
318
+ messages: [
319
+ {
320
+ id: "child-assistant",
321
+ role: "assistant" as const,
322
+ content: [
323
+ {
324
+ type: "tool-call" as const,
325
+ toolCallId: "question-sub",
326
+ toolName: "ask_user_question",
327
+ args: {},
328
+ argsText: "{}",
329
+ interrupt: {
330
+ type: "human" as const,
331
+ payload: { question: "Sub?" },
332
+ },
333
+ },
334
+ ],
335
+ status: {
336
+ type: "requires-action" as const,
337
+ reason: "tool-calls" as const,
338
+ },
339
+ createdAt: new Date(),
340
+ metadata: {
341
+ unstable_state: null,
342
+ unstable_annotations: [],
343
+ unstable_data: [],
344
+ steps: [],
345
+ custom: { toolResponseThreadId: "child-1" },
346
+ },
347
+ },
348
+ ],
349
+ },
350
+ {
351
+ type: "tool-call" as const,
352
+ toolCallId: "approval-root",
353
+ toolName: "bash",
354
+ args: {},
355
+ argsText: "{}",
356
+ approval: { id: "approval-root" },
357
+ },
358
+ ],
359
+ status: { type: "requires-action" as const, reason: "tool-calls" as const },
360
+ createdAt: new Date(),
361
+ metadata: {
362
+ unstable_state: null,
363
+ unstable_annotations: [],
364
+ unstable_data: [],
365
+ steps: [],
366
+ custom: { toolApprovalThreadId: ROOT_THREAD_ID },
367
+ },
368
+ };
369
+
370
+ const answered = applyToolResponseToMessage(
371
+ applyApprovalDecisionsToMessage(pending, {
372
+ approvalId: "approval-root",
373
+ approved: true,
374
+ }),
375
+ { toolCallId: "question-sub", content: "sub-answer" },
376
+ );
377
+
378
+ expect(collectRequiredActionInputs(answered)).toEqual([
379
+ {
380
+ type: "user.tool_approval",
381
+ threadId: ROOT_THREAD_ID,
382
+ toolCallId: "approval-root",
383
+ approval: { status: "allow" },
384
+ },
385
+ {
386
+ type: "user.tool_response",
387
+ threadId: "child-1",
388
+ toolCallId: "question-sub",
389
+ content: "sub-answer",
390
+ },
391
+ ]);
392
+ });
393
+ });
394
+ });
@@ -0,0 +1,65 @@
1
+ import type { ThreadMessage } from "@assistant-ui/core";
2
+ import type {
3
+ TurnInputItem,
4
+ UserToolApprovalEvent,
5
+ UserToolResponseEvent,
6
+ } from "truefoundry-gateway-sdk/agents";
7
+
8
+ import { ROOT_THREAD_ID } from "./constants.js";
9
+ import {
10
+ collectApprovalInputs,
11
+ messageHasPendingApprovals,
12
+ } from "./toolApproval.js";
13
+ import {
14
+ collectResponseInputs,
15
+ messageHasPendingResponses,
16
+ } from "./toolResponse.js";
17
+
18
+ export type RequiredActionInput = Extract<
19
+ TurnInputItem,
20
+ UserToolApprovalEvent | UserToolResponseEvent
21
+ >;
22
+
23
+ export function messageHasPendingRequiredActions(
24
+ message: ThreadMessage | undefined,
25
+ ): boolean {
26
+ return (
27
+ messageHasPendingApprovals(message) || messageHasPendingResponses(message)
28
+ );
29
+ }
30
+
31
+ export function collectRequiredActionInputs(
32
+ message: ThreadMessage,
33
+ defaultThreadId: string = ROOT_THREAD_ID,
34
+ ): RequiredActionInput[] {
35
+ if (messageHasPendingRequiredActions(message)) {
36
+ return [];
37
+ }
38
+ return [
39
+ ...collectApprovalInputs(message, defaultThreadId),
40
+ ...collectResponseInputs(message, defaultThreadId),
41
+ ];
42
+ }
43
+
44
+ export function isRequiredActionInput(
45
+ item: TurnInputItem,
46
+ ): item is RequiredActionInput {
47
+ return (
48
+ item.type === "user.tool_approval" || item.type === "user.tool_response"
49
+ );
50
+ }
51
+
52
+ export function findPausedAssistantMessage(
53
+ messages: readonly ThreadMessage[],
54
+ ): Extract<ThreadMessage, { role: "assistant" }> | undefined {
55
+ for (let i = messages.length - 1; i >= 0; i--) {
56
+ const candidate = messages[i];
57
+ if (
58
+ candidate?.role === "assistant" &&
59
+ candidate.status?.type === "requires-action"
60
+ ) {
61
+ return candidate;
62
+ }
63
+ }
64
+ return undefined;
65
+ }