@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
package/src/index.ts ADDED
@@ -0,0 +1,68 @@
1
+ /// <reference types="@assistant-ui/core/react" />
2
+
3
+ export { useTrueFoundryAgentRuntime } from "./useTrueFoundryAgentRuntime.js";
4
+ export {
5
+ convertTurnsToThreadMessages,
6
+ buildUserMessageContent,
7
+ buildEditedUserMessageContent,
8
+ getTurnMessageContent,
9
+ parseTurnIdFromMessageId,
10
+ buildTurnAssistantContent,
11
+ repositoryItemsFromMessages,
12
+ } from "./convertTurnMessages.js";
13
+ export type { ConvertTurnsResult, UserMessageContent } from "./convertTurnMessages.js";
14
+ export { ROOT_THREAD_ID } from "./constants.js";
15
+ export type { UseTrueFoundryAgentRuntimeOptions, NamedAgentConfig, DraftAgentConfig, TrueFoundryAgentConfig } from "./types.js";
16
+ export type { AgentSpec, AgentSpecUpdate, DraftSession } from "./agentSpec.js";
17
+ export { mergeAgentSpec, draftSessionTitle } from "./agentSpec.js";
18
+ export { createTrueFoundryDraftThreadListAdapter } from "./truefoundryDraftThreadListAdapter.js";
19
+ export { createDraftSessionBridge } from "./draftSessionBridge.js";
20
+ export type { DraftSessionBridge } from "./draftSessionBridge.js";
21
+ export {
22
+ useTrueFoundryAgentSpec,
23
+ useTrueFoundryUpdateAgentSpec,
24
+ } from "./hooks.js";
25
+ export type { TrueFoundryDraftRuntimeExtras } from "./truefoundryExtras.js";
26
+ export type { SubAgentArtifact, SubAgentCustomMetadata } from "./foldPeerThreads.js";
27
+ export type {
28
+ TrueFoundryMessageCustomMetadata,
29
+ SubAgentMessageCustomMetadata,
30
+ McpAuthMessageCustomMetadata,
31
+ SandboxMessageCustomMetadata,
32
+ ToolApprovalMessageCustomMetadata,
33
+ ToolResponseMessageCustomMetadata,
34
+ } from "./messageCustomMetadata.js";
35
+ export type { SandboxCreatedEvent } from "truefoundry-gateway-sdk/agents";
36
+ export type { PendingApproval, PendingToolResponse } from "./collectPending.js";
37
+ export type { TrueFoundryRuntimeExtras } from "./truefoundryExtras.js";
38
+ export { trueFoundryExtras } from "./truefoundryExtras.js";
39
+ export {
40
+ useTrueFoundryApprovals,
41
+ useTrueFoundryToolResponses,
42
+ useTrueFoundryMcpAuth,
43
+ useTrueFoundryRespondToToolApproval,
44
+ useTrueFoundryRespondToToolResponse,
45
+ useTrueFoundryResumeMcpAuth,
46
+ useTrueFoundrySandboxId,
47
+ useTrueFoundryDownloadSandboxFile,
48
+ useTrueFoundryCancel,
49
+ useTrueFoundryResetFromTurn,
50
+ } from "./hooks.js";
51
+ export {
52
+ collectApprovalInputs,
53
+ messageHasPendingApprovals,
54
+ toTrueFoundryApprovalInputs,
55
+ } from "./toolApproval.js";
56
+ export {
57
+ collectResponseInputs,
58
+ messageHasPendingResponses,
59
+ TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY,
60
+ } from "./toolResponse.js";
61
+ export {
62
+ collectRequiredActionInputs,
63
+ messageHasPendingRequiredActions,
64
+ findPausedAssistantMessage,
65
+ } from "./requiredActionInputs.js";
66
+ export { createTrueFoundryThreadListAdapter } from "./truefoundryThreadListAdapter.js";
67
+ export { getSession } from "./sessions.js";
68
+ export { trueFoundryAttachmentAdapter } from "./attachmentAdapter.js";
@@ -0,0 +1,19 @@
1
+ import type { ThreadMessage } from "@assistant-ui/core";
2
+
3
+ export function lastUserMessageText(messages: readonly ThreadMessage[]): string {
4
+ for (let i = messages.length - 1; i >= 0; i--) {
5
+ const message = messages[i];
6
+ if (message?.role !== "user") {
7
+ continue;
8
+ }
9
+ const text = message.content
10
+ .filter((part) => part.type === "text")
11
+ .map((part) => part.text)
12
+ .join("\n")
13
+ .trim();
14
+ if (text) {
15
+ return text;
16
+ }
17
+ }
18
+ throw new Error("No user message with text content found.");
19
+ }
@@ -0,0 +1,57 @@
1
+ import type { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { createEmptySessionSnapshot } from "./sessionSnapshot.js";
5
+
6
+ vi.mock("./sessions.js", () => ({
7
+ getSession: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("./convertTurnMessages.js", async (importOriginal) => {
11
+ const actual = await importOriginal<typeof import("./convertTurnMessages.js")>();
12
+ return {
13
+ ...actual,
14
+ buildSnapshotFromSession: vi.fn(),
15
+ };
16
+ });
17
+
18
+ import { buildSnapshotFromSession } from "./convertTurnMessages.js";
19
+ import { loadSessionSnapshot } from "./loadSessionSnapshot.js";
20
+ import { getSession } from "./sessions.js";
21
+
22
+ const mockClient = {} as AgentSessionClient;
23
+
24
+ describe("loadSessionSnapshot", () => {
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ it("deduplicates concurrent loads for the same session", async () => {
30
+ vi.mocked(getSession).mockResolvedValue({} as never);
31
+ vi.mocked(buildSnapshotFromSession).mockResolvedValue(
32
+ createEmptySessionSnapshot(),
33
+ );
34
+
35
+ const [first, second] = await Promise.all([
36
+ loadSessionSnapshot(mockClient, "session-1"),
37
+ loadSessionSnapshot(mockClient, "session-1"),
38
+ ]);
39
+
40
+ expect(first).toBe(second);
41
+ expect(getSession).toHaveBeenCalledTimes(1);
42
+ expect(buildSnapshotFromSession).toHaveBeenCalledTimes(1);
43
+ });
44
+
45
+ it("allows a new load after the previous one settles", async () => {
46
+ vi.mocked(getSession).mockResolvedValue({} as never);
47
+ vi.mocked(buildSnapshotFromSession).mockResolvedValue(
48
+ createEmptySessionSnapshot(),
49
+ );
50
+
51
+ await loadSessionSnapshot(mockClient, "session-1");
52
+ await loadSessionSnapshot(mockClient, "session-1");
53
+
54
+ expect(getSession).toHaveBeenCalledTimes(2);
55
+ expect(buildSnapshotFromSession).toHaveBeenCalledTimes(2);
56
+ });
57
+ });
@@ -0,0 +1,35 @@
1
+ import type { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
2
+ import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
3
+
4
+ import { buildSnapshotFromSession } from "./convertTurnMessages.js";
5
+ import { getSession, type GetSessionOptions } from "./sessions.js";
6
+ import type { SessionSnapshot } from "./sessionSnapshot.js";
7
+
8
+ const inflightBySessionId = new Map<string, Promise<SessionSnapshot>>();
9
+
10
+ /**
11
+ * Loads a session snapshot once per concurrent burst for a given session id.
12
+ * React Strict Mode and overlapping fetch/load paths share the same in-flight
13
+ * request instead of duplicating getSession + listTurns calls.
14
+ */
15
+ export function loadSessionSnapshot(
16
+ client: AgentSessionClient,
17
+ sessionId: string,
18
+ concurrency?: number,
19
+ sessionOptions?: GetSessionOptions,
20
+ ): Promise<SessionSnapshot> {
21
+ const cacheKey =
22
+ sessionOptions?.draftGateway != null ? `draft:${sessionId}` : sessionId;
23
+ let inflight = inflightBySessionId.get(cacheKey);
24
+ if (inflight == null) {
25
+ inflight = getSession(client, sessionId, sessionOptions)
26
+ .then((session) => buildSnapshotFromSession(session, concurrency))
27
+ .finally(() => {
28
+ if (inflightBySessionId.get(cacheKey) === inflight) {
29
+ inflightBySessionId.delete(cacheKey);
30
+ }
31
+ });
32
+ inflightBySessionId.set(cacheKey, inflight);
33
+ }
34
+ return inflight;
35
+ }
package/src/mcpAuth.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { MessageStatus } from "@assistant-ui/core";
2
+ import type { McpAuthRequiredEvent, TurnStateDone } from "truefoundry-gateway-sdk/agents";
3
+
4
+ import type { AssistantContentPart } from "./modelMessageContent.js";
5
+ import type { McpAuthMessageCustomMetadata } from "./messageCustomMetadata.js";
6
+
7
+ /** `runConfig.custom` flag: resume after MCP OAuth with an empty SDK turn input. */
8
+ export const MCP_AUTH_RESUME_RUN_CUSTOM_KEY = "resumeMcpAuth";
9
+
10
+ type McpServerAuthInfo = McpAuthRequiredEvent["mcpServers"][number];
11
+
12
+ export function buildMcpAuthTextParts(
13
+ servers: readonly McpServerAuthInfo[],
14
+ ): AssistantContentPart[] {
15
+ const linkLines = servers.map(
16
+ (server) => `- [Authorize ${server.name}](${server.authUrl})`,
17
+ );
18
+ const text = [
19
+ "This agent needs access to external services before it can continue.",
20
+ "",
21
+ ...linkLines,
22
+ "",
23
+ "Open each link to sign in, then press **Continue** below.",
24
+ ].join("\n");
25
+ return [{ type: "text", text }];
26
+ }
27
+
28
+ export function findMcpAuthRequired(
29
+ requiredActions: TurnStateDone["requiredActions"] | undefined,
30
+ ): McpAuthRequiredEvent | undefined {
31
+ return requiredActions?.find(
32
+ (action): action is McpAuthRequiredEvent => action.type === "mcp.auth_required",
33
+ );
34
+ }
35
+
36
+ export function mcpAuthAssistantStatus(): MessageStatus {
37
+ return { type: "requires-action", reason: "interrupt" };
38
+ }
39
+
40
+ export function mcpAuthMessageCustom(
41
+ servers: readonly McpServerAuthInfo[],
42
+ ): McpAuthMessageCustomMetadata {
43
+ return { pendingMcpAuth: true, mcpServers: [...servers] };
44
+ }
@@ -0,0 +1,37 @@
1
+ import type { McpAuthRequiredEvent } from "truefoundry-gateway-sdk/agents";
2
+
3
+ import type { SubAgentCustomMetadata } from "./foldPeerThreads.js";
4
+ import { TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY } from "./toolApproval.js";
5
+ import { TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY } from "./toolResponse.js";
6
+
7
+ /** Keys written to `ThreadMessage.metadata.custom` by this runtime adapter. */
8
+ export type TrueFoundryMessageCustomMetadata = {
9
+ subAgent?: SubAgentCustomMetadata;
10
+ pendingMcpAuth?: true;
11
+ mcpServers?: McpAuthRequiredEvent["mcpServers"];
12
+ sandboxId?: string;
13
+ [TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]?: string;
14
+ [TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]?: string;
15
+ };
16
+
17
+ export type SubAgentMessageCustomMetadata = Pick<
18
+ TrueFoundryMessageCustomMetadata,
19
+ "subAgent"
20
+ >;
21
+
22
+ export type McpAuthMessageCustomMetadata = Pick<
23
+ TrueFoundryMessageCustomMetadata,
24
+ "pendingMcpAuth" | "mcpServers"
25
+ >;
26
+
27
+ export type SandboxMessageCustomMetadata = Pick<TrueFoundryMessageCustomMetadata, "sandboxId">;
28
+
29
+ export type ToolApprovalMessageCustomMetadata = Pick<
30
+ TrueFoundryMessageCustomMetadata,
31
+ typeof TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY
32
+ >;
33
+
34
+ export type ToolResponseMessageCustomMetadata = Pick<
35
+ TrueFoundryMessageCustomMetadata,
36
+ typeof TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY
37
+ >;
@@ -0,0 +1,135 @@
1
+ import type { ThreadAssistantMessagePart } from "@assistant-ui/core";
2
+ import type { ModelMessageEvent } from "truefoundry-gateway-sdk/agents";
3
+ import type { PendingResponseRef } from "./foldPeerThreads.js";
4
+ import { extractImagePartsFromModelMessage } from "./modelMessageImageContent.js";
5
+
6
+ export type AssistantContentPart = ThreadAssistantMessagePart;
7
+
8
+ export type ToolCallContext = {
9
+ toolResults?: ReadonlyMap<string, string>;
10
+ pendingApprovals?: ReadonlyMap<string, { id: string }>;
11
+ approvalDecisions?: ReadonlyMap<
12
+ string,
13
+ { id: string; approved: boolean; reason?: string }
14
+ >;
15
+ pendingResponses?: ReadonlyMap<string, PendingResponseRef>;
16
+ };
17
+
18
+ export type SdkToolCall = NonNullable<ModelMessageEvent["toolCalls"]>[number];
19
+
20
+ function parseToolArgs(argsText: string): Record<string, string | number | boolean | null> {
21
+ if (!argsText) {
22
+ return {};
23
+ }
24
+ try {
25
+ const parsed: unknown = JSON.parse(argsText);
26
+ if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) {
27
+ return parsed as Record<string, string | number | boolean | null>;
28
+ }
29
+ return {};
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ type ToolCallPart = Extract<AssistantContentPart, { type: "tool-call" }>;
36
+
37
+ function toolCallToPart(
38
+ toolCall: SdkToolCall,
39
+ context?: ToolCallContext,
40
+ ): ToolCallPart {
41
+ const argsText = toolCall.function.arguments ?? "";
42
+ const toolResult = context?.toolResults?.get(toolCall.id);
43
+ const pendingResponse = context?.pendingResponses?.get(toolCall.id);
44
+ const pendingApproval = context?.pendingApprovals?.get(toolCall.id);
45
+ const approvalDecision = context?.approvalDecisions?.get(toolCall.id);
46
+
47
+ let interrupt: ToolCallPart["interrupt"];
48
+ if (pendingResponse != null && toolResult === undefined) {
49
+ interrupt = {
50
+ type: "human",
51
+ payload: {
52
+ ...(pendingResponse.question != null
53
+ ? { question: pendingResponse.question }
54
+ : {}),
55
+ ...(pendingResponse.options != null
56
+ ? { options: pendingResponse.options }
57
+ : {}),
58
+ },
59
+ };
60
+ }
61
+
62
+ let approval: ToolCallPart["approval"];
63
+ if (approvalDecision != null) {
64
+ approval = {
65
+ id: approvalDecision.id,
66
+ approved: approvalDecision.approved,
67
+ ...(approvalDecision.reason != null
68
+ ? { reason: approvalDecision.reason }
69
+ : {}),
70
+ };
71
+ } else if (pendingApproval != null) {
72
+ approval = { id: pendingApproval.id };
73
+ }
74
+
75
+ let result: ToolCallPart["result"];
76
+ let isError = false;
77
+ if (toolResult !== undefined) {
78
+ result = toolResult;
79
+ } else if (approvalDecision?.approved === false) {
80
+ result = { error: approvalDecision.reason || "Tool approval denied" };
81
+ isError = true;
82
+ }
83
+
84
+ return {
85
+ type: "tool-call",
86
+ toolCallId: toolCall.id,
87
+ toolName: toolCall.function.name,
88
+ argsText,
89
+ args: parseToolArgs(argsText),
90
+ ...(result !== undefined ? { result } : {}),
91
+ ...(isError ? { isError: true } : {}),
92
+ ...(approval != null ? { approval } : {}),
93
+ ...(interrupt != null ? { interrupt } : {}),
94
+ };
95
+ }
96
+
97
+ function extractText(message: ModelMessageEvent): string {
98
+ const { content, refusal } = message;
99
+ if (content == null) {
100
+ return refusal ?? "";
101
+ }
102
+ if (typeof content === "string") {
103
+ return content;
104
+ }
105
+ return content
106
+ .map((part) => {
107
+ if (part.type === "text") {
108
+ return part.text;
109
+ }
110
+ if (part.type === "refusal") {
111
+ return part.refusal;
112
+ }
113
+ return "";
114
+ })
115
+ .join("");
116
+ }
117
+
118
+ export function buildAssistantContent(
119
+ message: ModelMessageEvent,
120
+ context?: ToolCallContext,
121
+ ): AssistantContentPart[] {
122
+ const parts: AssistantContentPart[] = [];
123
+ if (message.reasoningContent) {
124
+ parts.push({ type: "reasoning", text: message.reasoningContent });
125
+ }
126
+ const text = extractText(message);
127
+ if (text) {
128
+ parts.push({ type: "text", text });
129
+ }
130
+ parts.push(...extractImagePartsFromModelMessage(message));
131
+ for (const toolCall of message.toolCalls ?? []) {
132
+ parts.push(toolCallToPart(toolCall, context));
133
+ }
134
+ return parts;
135
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ModelMessageEvent } from "truefoundry-gateway-sdk/agents";
3
+
4
+ import { buildAssistantContent } from "./modelMessageContent.js";
5
+ import { mergeStreamEventDelta } from "./modelMessageImageContent.js";
6
+ import { PeerThreadFoldState, ingestStreamEvent } from "./foldPeerThreads.js";
7
+
8
+ const imageDataUri = "data:image/jpeg;base64,/9j/4AAQ";
9
+
10
+ function modelMessage(
11
+ event: Omit<ModelMessageEvent, "type" | "createdAt">,
12
+ ): ModelMessageEvent {
13
+ return {
14
+ type: "model.message",
15
+ createdAt: new Date().toISOString(),
16
+ ...event,
17
+ };
18
+ }
19
+
20
+ describe("modelMessageImageContent", () => {
21
+ it("merges content_blocks image_url deltas into the base model message", () => {
22
+ const base = modelMessage({
23
+ id: "m1",
24
+ threadId: "main",
25
+ });
26
+
27
+ mergeStreamEventDelta(base, {
28
+ type: "model.message.delta",
29
+ id: "m1",
30
+ threadId: "main",
31
+ content_blocks: [
32
+ {
33
+ index: 0,
34
+ delta: {
35
+ type: "image_url",
36
+ image_url: { url: imageDataUri },
37
+ },
38
+ },
39
+ ],
40
+ } as never);
41
+
42
+ expect(base.content).toEqual([
43
+ {
44
+ type: "image_url",
45
+ image_url: { url: imageDataUri },
46
+ },
47
+ ]);
48
+ });
49
+
50
+ it("buildAssistantContent emits image parts for image_url content", () => {
51
+ const parts = buildAssistantContent(
52
+ modelMessage({
53
+ id: "m1",
54
+ threadId: "main",
55
+ content: [
56
+ {
57
+ type: "image_url",
58
+ image_url: { url: imageDataUri },
59
+ },
60
+ ] as never,
61
+ }),
62
+ );
63
+
64
+ expect(parts).toEqual([
65
+ {
66
+ type: "image",
67
+ image: imageDataUri,
68
+ filename: "image-1.jpeg",
69
+ },
70
+ ]);
71
+ });
72
+
73
+ it("ingests content_blocks deltas through the fold state", () => {
74
+ const fold = new PeerThreadFoldState();
75
+ ingestStreamEvent(
76
+ fold,
77
+ modelMessage({
78
+ id: "m1",
79
+ threadId: "main",
80
+ }),
81
+ );
82
+ ingestStreamEvent(fold, {
83
+ type: "model.message.delta",
84
+ id: "m1",
85
+ threadId: "main",
86
+ content_blocks: [
87
+ {
88
+ index: 0,
89
+ delta: {
90
+ type: "image_url",
91
+ image_url: { url: imageDataUri },
92
+ },
93
+ },
94
+ ],
95
+ } as never);
96
+
97
+ const event = fold.threads.get("main")?.events.get("m1");
98
+ expect(event?.type).toBe("model.message");
99
+ if (event?.type !== "model.message") {
100
+ return;
101
+ }
102
+ expect(event.content).toEqual([
103
+ {
104
+ type: "image_url",
105
+ image_url: { url: imageDataUri },
106
+ },
107
+ ]);
108
+ });
109
+ });