فهرست منبع

test: add message-v2 test

Aiden Cline 1 ماه پیش
والد
کامیت
47ebb2973f
1فایلهای تغییر یافته به همراه570 افزوده شده و 0 حذف شده
  1. 570 0
      packages/opencode/test/session/message-v2.test.ts

+ 570 - 0
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([])
+  })
+})