diff options
Diffstat (limited to 'packages/openai-stream/src/convert-messages.test.ts')
| -rw-r--r-- | packages/openai-stream/src/convert-messages.test.ts | 810 |
1 files changed, 452 insertions, 358 deletions
diff --git a/packages/openai-stream/src/convert-messages.test.ts b/packages/openai-stream/src/convert-messages.test.ts index 004a6b7..57c7d81 100644 --- a/packages/openai-stream/src/convert-messages.test.ts +++ b/packages/openai-stream/src/convert-messages.test.ts @@ -3,362 +3,456 @@ import { describe, expect, it } from "vitest"; import { convertMessages } from "./convert-messages.js"; describe("convertMessages", () => { - it("converts a system message with text chunks", () => { - const messages: ChatMessage[] = [ - { - role: "system", - chunks: [ - { type: "system", text: "You are a helpful assistant." }, - { type: "text", text: " Additional context." }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([ - { role: "system", content: "You are a helpful assistant. Additional context." }, - ]); - }); - - it("converts a user message with text chunks", () => { - const messages: ChatMessage[] = [ - { - role: "user", - chunks: [ - { type: "text", text: "Hello, " }, - { type: "text", text: "world!" }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([{ role: "user", content: "Hello, world!" }]); - }); - - it("converts an assistant message with text only", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { type: "text", text: "I can help " }, - { type: "text", text: "with that." }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([{ role: "assistant", content: "I can help with that." }]); - }); - - it("converts an assistant message with tool calls", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { type: "text", text: "Let me check that." }, - { - type: "tool-call", - toolCallId: "call_1", - toolName: "read_file", - input: { path: "/src/main.ts" }, - }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([ - { - role: "assistant", - content: "Let me check that.", - tool_calls: [ - { - id: "call_1", - type: "function", - function: { - name: "read_file", - arguments: JSON.stringify({ path: "/src/main.ts" }), - }, - }, - ], - }, - ]); - }); - - it("converts an assistant message with tool calls but no text", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_2", - toolName: "run_shell", - input: { command: "ls" }, - }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([ - { - role: "assistant", - content: null, - tool_calls: [ - { - id: "call_2", - type: "function", - function: { - name: "run_shell", - arguments: JSON.stringify({ command: "ls" }), - }, - }, - ], - }, - ]); - }); - - it("converts tool result messages", () => { - const messages: ChatMessage[] = [ - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "read_file", - content: "file contents here", - isError: false, - }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([ - { - role: "tool", - content: "file contents here", - tool_call_id: "call_1", - }, - ]); - }); - - it("converts a full multi-turn history with tool round-trip", () => { - const messages: ChatMessage[] = [ - { - role: "system", - chunks: [{ type: "system", text: "You are helpful." }], - }, - { - role: "user", - chunks: [{ type: "text", text: "Read main.ts" }], - }, - { - role: "assistant", - chunks: [ - { type: "text", text: "Sure." }, - { - type: "tool-call", - toolCallId: "call_1", - toolName: "read_file", - input: { path: "main.ts" }, - }, - ], - }, - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "read_file", - content: "console.log('hello')", - isError: false, - }, - ], - }, - { - role: "assistant", - chunks: [{ type: "text", text: "The file logs hello." }], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([ - { role: "system", content: "You are helpful." }, - { role: "user", content: "Read main.ts" }, - { - role: "assistant", - content: "Sure.", - tool_calls: [ - { - id: "call_1", - type: "function", - function: { - name: "read_file", - arguments: JSON.stringify({ path: "main.ts" }), - }, - }, - ], - }, - { - role: "tool", - content: "console.log('hello')", - tool_call_id: "call_1", - }, - { role: "assistant", content: "The file logs hello." }, - ]); - }); - - it("handles multiple tool results in one tool message", () => { - const messages: ChatMessage[] = [ - { - role: "tool", - chunks: [ - { - type: "tool-result", - toolCallId: "call_1", - toolName: "read_file", - content: "file1", - isError: false, - }, - { - type: "tool-result", - toolCallId: "call_2", - toolName: "read_file", - content: "file2", - isError: false, - }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([ - { role: "tool", content: "file1", tool_call_id: "call_1" }, - { role: "tool", content: "file2", tool_call_id: "call_2" }, - ]); - }); - - it("includes thinking chunks in assistant content", () => { - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { type: "thinking", text: "Let me think..." }, - { type: "text", text: "Here is my answer." }, - ], - }, - ]; - - const result = convertMessages(messages); - expect(result).toEqual([{ role: "assistant", content: "Let me think...Here is my answer." }]); - }); - - it("arguments is valid JSON when input is a malformed string", () => { - // Production seq-134 shape: the model emitted broken JSON as the tool - // arguments and it was stored verbatim. Unquoted key fails JSON.parse at - // some column (position 1 here). - const malformed = '{path: "/src/main.ts"}'; - expect(() => JSON.parse(malformed)).toThrow(); - - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_bad", - toolName: "read_file", - input: malformed, - }, - ], - }, - ]; - - const result = convertMessages(messages); - const args = result[0]?.tool_calls?.[0]?.function.arguments; - expect(args).toBeDefined(); - // The output MUST parse without throwing — the provider receives valid JSON. - expect(() => JSON.parse(args as string)).not.toThrow(); - // And it is the fallback object preserving a truncated hint. - expect(JSON.parse(args as string)).toEqual({ - _malformed_arguments: malformed.slice(0, 200), - }); - }); - - it("arguments passes through valid string input", () => { - const validJson = '{"path":"/src/main.ts"}'; - expect(() => JSON.parse(validJson)).not.toThrow(); - - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_str", - toolName: "read_file", - input: validJson, - }, - ], - }, - ]; - - const result = convertMessages(messages); - const args = result[0]?.tool_calls?.[0]?.function.arguments; - // A valid-JSON string round-trips to a canonical JSON string. - expect(args).toBe(JSON.stringify(JSON.parse(validJson))); - expect(args).toBe('{"path":"/src/main.ts"}'); - }); - - it("stringifies object input", () => { - const input = { path: "/src/main.ts", line: 42 }; - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_obj", - toolName: "read_file", - input, - }, - ], - }, - ]; - - const result = convertMessages(messages); - const args = result[0]?.tool_calls?.[0]?.function.arguments; - expect(args).toBe(JSON.stringify(input)); - }); - - it("truncates the malformed input hint to 200 characters", () => { - // A long bare run of letters is not valid JSON (no quotes/braces). - const malformed = "x".repeat(500); - expect(() => JSON.parse(malformed)).toThrow(); - - const messages: ChatMessage[] = [ - { - role: "assistant", - chunks: [ - { - type: "tool-call", - toolCallId: "call_long", - toolName: "read_file", - input: malformed, - }, - ], - }, - ]; - - const result = convertMessages(messages); - const args = result[0]?.tool_calls?.[0]?.function.arguments; - expect(() => JSON.parse(args as string)).not.toThrow(); - const parsed = JSON.parse(args as string); - expect(parsed).toEqual({ _malformed_arguments: malformed.slice(0, 200) }); - expect(parsed._malformed_arguments.length).toBe(200); - }); + it("converts a system message with text chunks", () => { + const messages: ChatMessage[] = [ + { + role: "system", + chunks: [ + { type: "system", text: "You are a helpful assistant." }, + { type: "text", text: " Additional context." }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([ + { role: "system", content: "You are a helpful assistant. Additional context." }, + ]); + }); + + it("converts a user message with text chunks", () => { + const messages: ChatMessage[] = [ + { + role: "user", + chunks: [ + { type: "text", text: "Hello, " }, + { type: "text", text: "world!" }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([{ role: "user", content: "Hello, world!" }]); + }); + + it("converts a user message with a text + image chunk to a multimodal content array", () => { + const messages: ChatMessage[] = [ + { + role: "user", + chunks: [ + { type: "text", text: "What is in this image?" }, + { type: "image", url: "data:image/png;base64,iVBORw0KGgo=" }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image_url", image_url: { url: "data:image/png;base64,iVBORw0KGgo=" } }, + ], + }, + ]); + }); + + it("converts an image-only user message (no text) to a content array with just the image", () => { + const messages: ChatMessage[] = [ + { + role: "user", + chunks: [{ type: "image", url: "https://example.com/cat.png" }], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "https://example.com/cat.png" } }], + }, + ]); + }); + + it("converts a user message with multiple images interspersed with text", () => { + const messages: ChatMessage[] = [ + { + role: "user", + chunks: [ + { type: "text", text: "Compare these:" }, + { type: "image", url: "data:image/png;base64,aaa" }, + { type: "text", text: "and" }, + { type: "image", url: "data:image/jpeg;base64,bbb" }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toHaveLength(1); + const content = result[0]?.content; + expect(Array.isArray(content)).toBe(true); + if (Array.isArray(content)) { + expect(content).toHaveLength(4); + expect(content[0]).toEqual({ type: "text", text: "Compare these:" }); + expect(content[1]).toEqual({ + type: "image_url", + image_url: { url: "data:image/png;base64,aaa" }, + }); + expect(content[2]).toEqual({ type: "text", text: "and" }); + expect(content[3]).toEqual({ + type: "image_url", + image_url: { url: "data:image/jpeg;base64,bbb" }, + }); + } + }); + + it("skips empty text parts in a multimodal message but keeps images", () => { + const messages: ChatMessage[] = [ + { + role: "user", + chunks: [ + { type: "text", text: "" }, + { type: "image", url: "data:image/png;base64,x" }, + ], + }, + ]; + + const result = convertMessages(messages); + const content = result[0]?.content; + expect(Array.isArray(content)).toBe(true); + if (Array.isArray(content)) { + // Empty text part is dropped; only the image remains. + expect(content).toEqual([ + { type: "image_url", image_url: { url: "data:image/png;base64,x" } }, + ]); + } + }); + + it("converts an assistant message with text only", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { type: "text", text: "I can help " }, + { type: "text", text: "with that." }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([{ role: "assistant", content: "I can help with that." }]); + }); + + it("converts an assistant message with tool calls", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { type: "text", text: "Let me check that." }, + { + type: "tool-call", + toolCallId: "call_1", + toolName: "read_file", + input: { path: "/src/main.ts" }, + }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([ + { + role: "assistant", + content: "Let me check that.", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "read_file", + arguments: JSON.stringify({ path: "/src/main.ts" }), + }, + }, + ], + }, + ]); + }); + + it("converts an assistant message with tool calls but no text", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_2", + toolName: "run_shell", + input: { command: "ls" }, + }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([ + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_2", + type: "function", + function: { + name: "run_shell", + arguments: JSON.stringify({ command: "ls" }), + }, + }, + ], + }, + ]); + }); + + it("converts tool result messages", () => { + const messages: ChatMessage[] = [ + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "read_file", + content: "file contents here", + isError: false, + }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([ + { + role: "tool", + content: "file contents here", + tool_call_id: "call_1", + }, + ]); + }); + + it("converts a full multi-turn history with tool round-trip", () => { + const messages: ChatMessage[] = [ + { + role: "system", + chunks: [{ type: "system", text: "You are helpful." }], + }, + { + role: "user", + chunks: [{ type: "text", text: "Read main.ts" }], + }, + { + role: "assistant", + chunks: [ + { type: "text", text: "Sure." }, + { + type: "tool-call", + toolCallId: "call_1", + toolName: "read_file", + input: { path: "main.ts" }, + }, + ], + }, + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "read_file", + content: "console.log('hello')", + isError: false, + }, + ], + }, + { + role: "assistant", + chunks: [{ type: "text", text: "The file logs hello." }], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([ + { role: "system", content: "You are helpful." }, + { role: "user", content: "Read main.ts" }, + { + role: "assistant", + content: "Sure.", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "read_file", + arguments: JSON.stringify({ path: "main.ts" }), + }, + }, + ], + }, + { + role: "tool", + content: "console.log('hello')", + tool_call_id: "call_1", + }, + { role: "assistant", content: "The file logs hello." }, + ]); + }); + + it("handles multiple tool results in one tool message", () => { + const messages: ChatMessage[] = [ + { + role: "tool", + chunks: [ + { + type: "tool-result", + toolCallId: "call_1", + toolName: "read_file", + content: "file1", + isError: false, + }, + { + type: "tool-result", + toolCallId: "call_2", + toolName: "read_file", + content: "file2", + isError: false, + }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([ + { role: "tool", content: "file1", tool_call_id: "call_1" }, + { role: "tool", content: "file2", tool_call_id: "call_2" }, + ]); + }); + + it("includes thinking chunks in assistant content", () => { + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { type: "thinking", text: "Let me think..." }, + { type: "text", text: "Here is my answer." }, + ], + }, + ]; + + const result = convertMessages(messages); + expect(result).toEqual([{ role: "assistant", content: "Let me think...Here is my answer." }]); + }); + + it("arguments is valid JSON when input is a malformed string", () => { + // Production seq-134 shape: the model emitted broken JSON as the tool + // arguments and it was stored verbatim. Unquoted key fails JSON.parse at + // some column (position 1 here). + const malformed = '{path: "/src/main.ts"}'; + expect(() => JSON.parse(malformed)).toThrow(); + + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_bad", + toolName: "read_file", + input: malformed, + }, + ], + }, + ]; + + const result = convertMessages(messages); + const args = result[0]?.tool_calls?.[0]?.function.arguments; + expect(args).toBeDefined(); + // The output MUST parse without throwing — the provider receives valid JSON. + expect(() => JSON.parse(args as string)).not.toThrow(); + // And it is the fallback object preserving a truncated hint. + expect(JSON.parse(args as string)).toEqual({ + _malformed_arguments: malformed.slice(0, 200), + }); + }); + + it("arguments passes through valid string input", () => { + const validJson = '{"path":"/src/main.ts"}'; + expect(() => JSON.parse(validJson)).not.toThrow(); + + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_str", + toolName: "read_file", + input: validJson, + }, + ], + }, + ]; + + const result = convertMessages(messages); + const args = result[0]?.tool_calls?.[0]?.function.arguments; + // A valid-JSON string round-trips to a canonical JSON string. + expect(args).toBe(JSON.stringify(JSON.parse(validJson))); + expect(args).toBe('{"path":"/src/main.ts"}'); + }); + + it("stringifies object input", () => { + const input = { path: "/src/main.ts", line: 42 }; + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_obj", + toolName: "read_file", + input, + }, + ], + }, + ]; + + const result = convertMessages(messages); + const args = result[0]?.tool_calls?.[0]?.function.arguments; + expect(args).toBe(JSON.stringify(input)); + }); + + it("truncates the malformed input hint to 200 characters", () => { + // A long bare run of letters is not valid JSON (no quotes/braces). + const malformed = "x".repeat(500); + expect(() => JSON.parse(malformed)).toThrow(); + + const messages: ChatMessage[] = [ + { + role: "assistant", + chunks: [ + { + type: "tool-call", + toolCallId: "call_long", + toolName: "read_file", + input: malformed, + }, + ], + }, + ]; + + const result = convertMessages(messages); + const args = result[0]?.tool_calls?.[0]?.function.arguments; + expect(() => JSON.parse(args as string)).not.toThrow(); + const parsed = JSON.parse(args as string); + expect(parsed).toEqual({ _malformed_arguments: malformed.slice(0, 200) }); + expect(parsed._malformed_arguments.length).toBe(200); + }); }); |
