// @vitest-environment node import { convertToModelMessages } from "ai" import { jsonrepair } from "jsonrepair" import { describe, expect, it } from "vitest" import { dropInvalidToolCalls, fixToolInputJson, isMinimalDiagram, replaceHistoricalToolInputs, validateFileParts, } from "@/lib/chat-helpers" describe("validateFileParts", () => { it("returns valid for no files", () => { const messages = [ { role: "user", parts: [{ type: "text", text: "hello" }] }, ] expect(validateFileParts(messages)).toEqual({ valid: true }) }) it("returns valid for files under limit", () => { const smallBase64 = btoa("x".repeat(100)) const messages = [ { role: "user", parts: [ { type: "file", url: `data:image/png;base64,${smallBase64}`, }, ], }, ] expect(validateFileParts(messages)).toEqual({ valid: true }) }) it("returns error for too many files", () => { const messages = [ { role: "user", parts: Array(6) .fill(null) .map(() => ({ type: "file", url: "data:image/png;base64,abc", })), }, ] const result = validateFileParts(messages) expect(result.valid).toBe(false) expect(result.error).toContain("Too many files") }) it("returns error for file exceeding size limit", () => { // Create base64 that decodes to > 2MB const largeBase64 = btoa("x".repeat(3 * 1024 * 1024)) const messages = [ { role: "user", parts: [ { type: "file", url: `data:image/png;base64,${largeBase64}`, }, ], }, ] const result = validateFileParts(messages) expect(result.valid).toBe(false) expect(result.error).toContain("exceeds") }) it("rejects file URLs the server would have to download", () => { for (const url of [ "http://10.0.0.5/secret.png", "https://example.com/a.png", undefined, ]) { const messages = [{ role: "user", parts: [{ type: "file", url }] }] expect(validateFileParts(messages).valid).toBe(false) } }) it("checks files in earlier messages too", () => { const messages = [ { role: "user", parts: [{ type: "file", url: "http://169.254.169.254/x" }], }, { role: "assistant", parts: [{ type: "text", text: "ok" }] }, { role: "user", parts: [{ type: "text", text: "hello" }] }, ] expect(validateFileParts(messages).valid).toBe(false) }) }) describe("isMinimalDiagram", () => { it("returns true for empty diagram", () => { const xml = '' expect(isMinimalDiagram(xml)).toBe(true) }) it("returns false for diagram with content", () => { const xml = '' expect(isMinimalDiagram(xml)).toBe(false) }) it("handles whitespace correctly", () => { const xml = ' ' expect(isMinimalDiagram(xml)).toBe(true) }) it("returns false for a shape drawn in draw.io with a random id", () => { const xml = '' expect(isMinimalDiagram(xml)).toBe(false) }) it("does not mistake ids that start with 0 or 1 for root cells", () => { const xml = '' expect(isMinimalDiagram(xml)).toBe(false) }) }) describe("replaceHistoricalToolInputs", () => { it("replaces display_diagram tool inputs with placeholder", () => { const messages = [ { role: "assistant", content: [ { type: "tool-call", toolName: "display_diagram", input: { xml: "" }, }, ], }, ] const result = replaceHistoricalToolInputs(messages) expect(result[0].content[0].input.placeholder).toContain( "XML content replaced", ) }) it("replaces edit_diagram tool inputs with placeholder", () => { const messages = [ { role: "assistant", content: [ { type: "tool-call", toolName: "edit_diagram", input: { operations: [] }, }, ], }, ] const result = replaceHistoricalToolInputs(messages) expect(result[0].content[0].input.placeholder).toContain( "XML content replaced", ) }) it("leaves tool calls with invalid inputs for dropInvalidToolCalls", () => { const messages = [ { role: "assistant", content: [ { type: "tool-call", toolName: "display_diagram", input: {}, }, { type: "tool-call", toolName: "display_diagram", input: null, }, ], }, ] const result = replaceHistoricalToolInputs(messages) expect(result[0].content).toEqual(messages[0].content) }) it("preserves non-assistant messages", () => { const messages = [{ role: "user", content: "hello" }] const result = replaceHistoricalToolInputs(messages) expect(result).toEqual(messages) }) it("preserves other tool calls", () => { const messages = [ { role: "assistant", content: [ { type: "tool-call", toolName: "other_tool", input: { foo: "bar" }, }, ], }, ] const result = replaceHistoricalToolInputs(messages) expect(result[0].content[0].input).toEqual({ foo: "bar" }) }) }) describe("dropInvalidToolCalls", () => { it("drops an invalid tool-call together with its tool-result", () => { const messages = [ { role: "user", content: [{ type: "text", text: "draw" }] }, { role: "assistant", content: [ { type: "tool-call", toolCallId: "call-1", toolName: "display_diagram", input: undefined, }, ], }, { role: "tool", content: [ { type: "tool-result", toolCallId: "call-1", toolName: "display_diagram", output: { type: "error-text", value: "Stopped" }, }, ], }, { role: "user", content: [{ type: "text", text: "again" }] }, ] const result = dropInvalidToolCalls(messages) expect(result.map((m) => m.role)).toEqual(["user", "user"]) }) it("keeps valid calls and results in the same messages", () => { const messages = [ { role: "assistant", content: [ { type: "text", text: "Here you go" }, { type: "tool-call", toolCallId: "bad", toolName: "edit_diagram", input: "{broken", }, { type: "tool-call", toolCallId: "good", toolName: "display_diagram", input: { xml: "" }, }, ], }, { role: "tool", content: [ { type: "tool-result", toolCallId: "bad", output: {} }, { type: "tool-result", toolCallId: "good", output: {} }, ], }, ] const result = dropInvalidToolCalls(messages) expect(result[0].content.map((p: any) => p.toolCallId)).toEqual([ undefined, "good", ]) expect(result[1].content.map((p: any) => p.toolCallId)).toEqual([ "good", ]) }) it("cleans up a tool call the user stopped before its input arrived", async () => { // handleStop turns a still-streaming call into output-error with no input const modelMessages = await convertToModelMessages([ { role: "user", parts: [{ type: "text", text: "draw" }] }, { role: "assistant", parts: [ { type: "tool-display_diagram", toolCallId: "call-1", state: "output-error", input: undefined, errorText: "Stopped by user", } as any, ], }, { role: "user", parts: [{ type: "text", text: "again" }] }, ]) const result = dropInvalidToolCalls(modelMessages) expect(result.map((m) => m.role)).toEqual(["user", "user"]) }) it("leaves messages with string content alone", () => { const messages = [{ role: "system", content: "You are..." }] expect(dropInvalidToolCalls(messages)).toEqual(messages) }) }) describe("fixToolInputJson", () => { it("fixes an attribute whose closing quote alone is escaped", () => { const input = '{"xml": ""}' const parsed = JSON.parse(jsonrepair(fixToolInputJson(input))) expect(parsed.xml).toContain('y="-20"') expect(parsed.xml).toContain('id="2"') }) it("fixes = used instead of : after a JSON key", () => { const input = '{"xml"= ""}' const parsed = JSON.parse(jsonrepair(fixToolInputJson(input))) expect(parsed.xml).toBe('') }) it("leaves well-formed input unchanged", () => { const input = '{"operations": [{"operation": "add", "cell_id": "a", "new_xml": ""}]}' expect(fixToolInputJson(input)).toBe(input) }) })