import { describe, expect, test } from "bun:test" import { MessageV2 } from "../../src/session/message-v2" import type { Provider } from "../../src/provider/provider" const sessionID = "session" const model: Provider.Model = { id: "test-model", providerID: "test", api: { id: "test-model", url: "https://example.com", npm: "@ai-sdk/openai", }, name: "Test Model", capabilities: { temperature: true, reasoning: false, attachment: false, toolcall: true, input: { text: true, audio: false, image: false, video: false, pdf: false, }, output: { text: true, audio: false, image: false, video: false, pdf: false, }, interleaved: false, }, cost: { input: 0, output: 0, cache: { read: 0, write: 0, }, }, limit: { context: 0, input: 0, output: 0, }, status: "active", options: {}, headers: {}, release_date: "2026-01-01", } function userInfo(id: string): MessageV2.User { return { id, sessionID, role: "user", time: { created: 0 }, agent: "user", model: { providerID: "test", modelID: "test" }, tools: {}, mode: "", } as unknown as MessageV2.User } function assistantInfo( id: string, parentID: string, error?: MessageV2.Assistant["error"], meta?: { providerID: string; modelID: string }, ): MessageV2.Assistant { const infoModel = meta ?? { providerID: model.providerID, modelID: model.api.id } return { id, sessionID, role: "assistant", time: { created: 0 }, error, parentID, modelID: infoModel.modelID, providerID: infoModel.providerID, mode: "", agent: "agent", path: { cwd: "/", root: "/" }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 }, }, } as unknown as MessageV2.Assistant } function basePart(messageID: string, id: string) { return { id, sessionID, messageID, } } describe("session.message-v2.toModelMessage", () => { test("filters out messages with no parts", () => { const input: MessageV2.WithParts[] = [ { info: userInfo("m-empty"), parts: [], }, { info: userInfo("m-user"), parts: [ { ...basePart("m-user", "p1"), type: "text", text: "hello", }, ] as MessageV2.Part[], }, ] expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ { role: "user", content: [{ type: "text", text: "hello" }], }, ]) }) test("filters out messages with only ignored parts", () => { const messageID = "m-user" const input: MessageV2.WithParts[] = [ { info: userInfo(messageID), parts: [ { ...basePart(messageID, "p1"), type: "text", text: "ignored", ignored: true, }, ] as MessageV2.Part[], }, ] expect(MessageV2.toModelMessages(input, model)).toStrictEqual([]) }) test("includes synthetic text parts", () => { const messageID = "m-user" const input: MessageV2.WithParts[] = [ { info: userInfo(messageID), parts: [ { ...basePart(messageID, "p1"), type: "text", text: "hello", synthetic: true, }, ] as MessageV2.Part[], }, { info: assistantInfo("m-assistant", messageID), parts: [ { ...basePart("m-assistant", "a1"), type: "text", text: "assistant", synthetic: true, }, ] as MessageV2.Part[], }, ] expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ { role: "user", content: [{ type: "text", text: "hello" }], }, { role: "assistant", content: [{ type: "text", text: "assistant" }], }, ]) }) test("converts user text/file parts and injects compaction/subtask prompts", () => { const messageID = "m-user" const input: MessageV2.WithParts[] = [ { info: userInfo(messageID), parts: [ { ...basePart(messageID, "p1"), type: "text", text: "hello", }, { ...basePart(messageID, "p2"), type: "text", text: "ignored", ignored: true, }, { ...basePart(messageID, "p3"), type: "file", mime: "image/png", filename: "img.png", url: "https://example.com/img.png", }, { ...basePart(messageID, "p4"), type: "file", mime: "text/plain", filename: "note.txt", url: "https://example.com/note.txt", }, { ...basePart(messageID, "p5"), type: "file", mime: "application/x-directory", filename: "dir", url: "https://example.com/dir", }, { ...basePart(messageID, "p6"), type: "compaction", auto: true, }, { ...basePart(messageID, "p7"), type: "subtask", prompt: "prompt", description: "desc", agent: "agent", }, ] as MessageV2.Part[], }, ] expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ { role: "user", content: [ { type: "text", text: "hello" }, { type: "file", mediaType: "image/png", filename: "img.png", data: "https://example.com/img.png", }, { type: "text", text: "What did we do so far?" }, { type: "text", text: "The following tool was executed by the user" }, ], }, ]) }) test("converts assistant tool completion into tool-call + tool-result messages with attachments", () => { const userID = "m-user" const assistantID = "m-assistant" const input: MessageV2.WithParts[] = [ { info: userInfo(userID), parts: [ { ...basePart(userID, "u1"), type: "text", text: "run tool", }, ] as MessageV2.Part[], }, { info: assistantInfo(assistantID, userID), parts: [ { ...basePart(assistantID, "a1"), type: "text", text: "done", metadata: { openai: { assistant: "meta" } }, }, { ...basePart(assistantID, "a2"), type: "tool", callID: "call-1", tool: "bash", state: { status: "completed", input: { cmd: "ls" }, output: "ok", title: "Bash", metadata: {}, time: { start: 0, end: 1 }, attachments: [ { ...basePart(assistantID, "file-1"), type: "file", mime: "image/png", filename: "attachment.png", url: "data:image/png;base64,Zm9v", }, ], }, metadata: { openai: { tool: "meta" } }, }, ] as MessageV2.Part[], }, ] expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ { role: "user", content: [{ type: "text", text: "run tool" }], }, { role: "assistant", content: [ { type: "text", text: "done", providerOptions: { openai: { assistant: "meta" } } }, { type: "tool-call", toolCallId: "call-1", toolName: "bash", input: { cmd: "ls" }, providerExecuted: undefined, providerOptions: { openai: { tool: "meta" } }, }, ], }, { role: "tool", content: [ { type: "tool-result", toolCallId: "call-1", toolName: "bash", output: { type: "content", value: [ { type: "text", text: "ok" }, { type: "media", mediaType: "image/png", data: "Zm9v" }, ], }, providerOptions: { openai: { tool: "meta" } }, }, ], }, ]) }) test("omits provider metadata when assistant model differs", () => { const userID = "m-user" const assistantID = "m-assistant" const input: MessageV2.WithParts[] = [ { info: userInfo(userID), parts: [ { ...basePart(userID, "u1"), type: "text", text: "run tool", }, ] as MessageV2.Part[], }, { info: assistantInfo(assistantID, userID, undefined, { providerID: "other", modelID: "other" }), parts: [ { ...basePart(assistantID, "a1"), type: "text", text: "done", metadata: { openai: { assistant: "meta" } }, }, { ...basePart(assistantID, "a2"), type: "tool", callID: "call-1", tool: "bash", state: { status: "completed", input: { cmd: "ls" }, output: "ok", title: "Bash", metadata: {}, time: { start: 0, end: 1 }, }, metadata: { openai: { tool: "meta" } }, }, ] as MessageV2.Part[], }, ] expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ { role: "user", content: [{ type: "text", text: "run tool" }], }, { role: "assistant", content: [ { type: "text", text: "done" }, { type: "tool-call", toolCallId: "call-1", toolName: "bash", input: { cmd: "ls" }, providerExecuted: undefined, }, ], }, { role: "tool", content: [ { type: "tool-result", toolCallId: "call-1", toolName: "bash", output: { type: "text", value: "ok" }, }, ], }, ]) }) test("replaces compacted tool output with placeholder", () => { const userID = "m-user" const assistantID = "m-assistant" const input: MessageV2.WithParts[] = [ { info: userInfo(userID), parts: [ { ...basePart(userID, "u1"), type: "text", text: "run tool", }, ] as MessageV2.Part[], }, { info: assistantInfo(assistantID, userID), parts: [ { ...basePart(assistantID, "a1"), type: "tool", callID: "call-1", tool: "bash", state: { status: "completed", input: { cmd: "ls" }, output: "this should be cleared", title: "Bash", metadata: {}, time: { start: 0, end: 1, compacted: 1 }, }, }, ] as MessageV2.Part[], }, ] expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ { role: "user", content: [{ type: "text", text: "run tool" }], }, { role: "assistant", content: [ { type: "tool-call", toolCallId: "call-1", toolName: "bash", input: { cmd: "ls" }, providerExecuted: undefined, }, ], }, { role: "tool", content: [ { type: "tool-result", toolCallId: "call-1", toolName: "bash", output: { type: "text", value: "[Old tool result content cleared]" }, }, ], }, ]) }) test("converts assistant tool error into error-text tool result", () => { const userID = "m-user" const assistantID = "m-assistant" const input: MessageV2.WithParts[] = [ { info: userInfo(userID), parts: [ { ...basePart(userID, "u1"), type: "text", text: "run tool", }, ] as MessageV2.Part[], }, { info: assistantInfo(assistantID, userID), parts: [ { ...basePart(assistantID, "a1"), type: "tool", callID: "call-1", tool: "bash", state: { status: "error", input: { cmd: "ls" }, error: "nope", time: { start: 0, end: 1 }, metadata: {}, }, metadata: { openai: { tool: "meta" } }, }, ] as MessageV2.Part[], }, ] expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ { role: "user", content: [{ type: "text", text: "run tool" }], }, { role: "assistant", content: [ { type: "tool-call", toolCallId: "call-1", toolName: "bash", input: { cmd: "ls" }, providerExecuted: undefined, providerOptions: { openai: { tool: "meta" } }, }, ], }, { role: "tool", content: [ { type: "tool-result", toolCallId: "call-1", toolName: "bash", output: { type: "error-text", value: "nope" }, providerOptions: { openai: { tool: "meta" } }, }, ], }, ]) }) test("filters assistant messages with non-abort errors", () => { const assistantID = "m-assistant" const input: MessageV2.WithParts[] = [ { info: assistantInfo( assistantID, "m-parent", new MessageV2.APIError({ message: "boom", isRetryable: true }).toObject() as MessageV2.APIError, ), parts: [ { ...basePart(assistantID, "a1"), type: "text", text: "should not render", }, ] as MessageV2.Part[], }, ] expect(MessageV2.toModelMessages(input, model)).toStrictEqual([]) }) test("includes aborted assistant messages only when they have non-step-start/reasoning content", () => { const assistantID1 = "m-assistant-1" const assistantID2 = "m-assistant-2" const aborted = new MessageV2.AbortedError({ message: "aborted" }).toObject() as MessageV2.Assistant["error"] const input: MessageV2.WithParts[] = [ { info: assistantInfo(assistantID1, "m-parent", aborted), parts: [ { ...basePart(assistantID1, "a1"), type: "reasoning", text: "thinking", time: { start: 0 }, }, { ...basePart(assistantID1, "a2"), type: "text", text: "partial answer", }, ] as MessageV2.Part[], }, { info: assistantInfo(assistantID2, "m-parent", aborted), parts: [ { ...basePart(assistantID2, "b1"), type: "step-start", }, { ...basePart(assistantID2, "b2"), type: "reasoning", text: "thinking", time: { start: 0 }, }, ] as MessageV2.Part[], }, ] expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ { role: "assistant", content: [ { type: "reasoning", text: "thinking", providerOptions: undefined }, { type: "text", text: "partial answer" }, ], }, ]) }) test("splits assistant messages on step-start boundaries", () => { const assistantID = "m-assistant" const input: MessageV2.WithParts[] = [ { info: assistantInfo(assistantID, "m-parent"), parts: [ { ...basePart(assistantID, "p1"), type: "text", text: "first", }, { ...basePart(assistantID, "p2"), type: "step-start", }, { ...basePart(assistantID, "p3"), type: "text", text: "second", }, ] as MessageV2.Part[], }, ] expect(MessageV2.toModelMessages(input, model)).toStrictEqual([ { role: "assistant", content: [{ type: "text", text: "first" }], }, { role: "assistant", content: [{ type: "text", text: "second" }], }, ]) }) test("drops messages that only contain step-start parts", () => { const assistantID = "m-assistant" const input: MessageV2.WithParts[] = [ { info: assistantInfo(assistantID, "m-parent"), parts: [ { ...basePart(assistantID, "p1"), type: "step-start", }, ] as MessageV2.Part[], }, ] expect(MessageV2.toModelMessages(input, model)).toStrictEqual([]) }) test("converts pending/running tool calls to error results to prevent dangling tool_use", () => { const userID = "m-user" const assistantID = "m-assistant" const input: MessageV2.WithParts[] = [ { info: userInfo(userID), parts: [ { ...basePart(userID, "u1"), type: "text", text: "run tool", }, ] as MessageV2.Part[], }, { info: assistantInfo(assistantID, userID), parts: [ { ...basePart(assistantID, "a1"), type: "tool", callID: "call-pending", tool: "bash", state: { status: "pending", input: { cmd: "ls" }, raw: "", }, }, { ...basePart(assistantID, "a2"), type: "tool", callID: "call-running", tool: "read", state: { status: "running", input: { path: "/tmp" }, time: { start: 0 }, }, }, ] as MessageV2.Part[], }, ] const result = MessageV2.toModelMessages(input, model) expect(result).toStrictEqual([ { role: "user", content: [{ type: "text", text: "run tool" }], }, { role: "assistant", content: [ { type: "tool-call", toolCallId: "call-pending", toolName: "bash", input: { cmd: "ls" }, providerExecuted: undefined, }, { type: "tool-call", toolCallId: "call-running", toolName: "read", input: { path: "/tmp" }, providerExecuted: undefined, }, ], }, { role: "tool", content: [ { type: "tool-result", toolCallId: "call-pending", toolName: "bash", output: { type: "error-text", value: "[Tool execution was interrupted]" }, }, { type: "tool-result", toolCallId: "call-running", toolName: "read", output: { type: "error-text", value: "[Tool execution was interrupted]" }, }, ], }, ]) }) })