summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2026-01-02 12:28:29 -0600
committerAiden Cline <[email protected]>2026-01-02 12:28:40 -0600
commit47ebb2973f8bad2c98f4834de7f443b03b097ccd (patch)
treec633afab71e17e1440cae3c998de20b02ee5df6f
parent49d7ccd1dbfd0cf4aeb7e5cadc6b4fa957c91bd4 (diff)
downloadopencode-47ebb2973f8bad2c98f4834de7f443b03b097ccd.tar.gz
opencode-47ebb2973f8bad2c98f4834de7f443b03b097ccd.zip
test: add message-v2 test
-rw-r--r--packages/opencode/test/session/message-v2.test.ts570
1 files changed, 570 insertions, 0 deletions
diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts
new file mode 100644
index 000000000..3e27ff292
--- /dev/null
+++ b/packages/opencode/test/session/message-v2.test.ts
@@ -0,0 +1,570 @@
+import { describe, expect, test } from "bun:test"
+import { MessageV2 } from "../../src/session/message-v2"
+
+const sessionID = "session"
+
+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"]): MessageV2.Assistant {
+ return {
+ id,
+ sessionID,
+ role: "assistant",
+ time: { created: 0 },
+ error,
+ parentID,
+ modelID: "model",
+ providerID: "provider",
+ 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.toModelMessage(input)).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.toModelMessage(input)).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.toModelMessage(input)).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.toModelMessage(input)).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 and emits attachment message", () => {
+ 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: "https://example.com/attachment.png",
+ },
+ ],
+ },
+ metadata: { openai: { tool: "meta" } },
+ },
+ ] as MessageV2.Part[],
+ },
+ ]
+
+ expect(MessageV2.toModelMessage(input)).toStrictEqual([
+ {
+ role: "user",
+ content: [{ type: "text", text: "run tool" }],
+ },
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "Tool bash returned an attachment:" },
+ {
+ type: "file",
+ mediaType: "image/png",
+ filename: "attachment.png",
+ data: "https://example.com/attachment.png",
+ },
+ ],
+ },
+ {
+ 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: "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.toModelMessage(input)).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.toModelMessage(input)).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" },
+ },
+ ],
+ },
+ ])
+ })
+
+ 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.toModelMessage(input)).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.toModelMessage(input)).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.toModelMessage(input)).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.toModelMessage(input)).toStrictEqual([])
+ })
+})