diff --git a/canvas/src/components/ConversationTraceModal.tsx b/canvas/src/components/ConversationTraceModal.tsx index 41dd9f80..4dfd380f 100644 --- a/canvas/src/components/ConversationTraceModal.tsx +++ b/canvas/src/components/ConversationTraceModal.tsx @@ -13,7 +13,8 @@ interface Props { onClose: () => void; } -function extractMessageText(body: Record | null): string { +/** Exported for unit testing — see ConversationTraceModal.test.ts */ +export function extractMessageText(body: Record | null): string { if (!body) return ""; try { // Simple task format from MCP server: {task: "..."} @@ -84,6 +85,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos }); }, [open, nodes]); + /** Exported for unit testing — see ConversationTraceModal.test.ts */ const isA2A = (e: ActivityEntry) => e.activity_type === "a2a_receive" || e.activity_type === "a2a_send"; diff --git a/canvas/src/components/__tests__/ConversationTraceModal.test.tsx b/canvas/src/components/__tests__/ConversationTraceModal.test.tsx new file mode 100644 index 00000000..39d16a86 --- /dev/null +++ b/canvas/src/components/__tests__/ConversationTraceModal.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment jsdom +/** + * Tests for ConversationTraceModal's extractMessageText helper. + * + * Covers: MCP simple task format, request params.message.parts extraction, + * response result.parts extraction, result.root.text extraction, plain string + * result, null input, malformed input, empty strings. + */ +import { describe, expect, it } from "vitest"; +import { extractMessageText } from "../ConversationTraceModal"; + +describe("extractMessageText — MCP simple task format", () => { + it("extracts text from body.task field", () => { + const body = { task: "Deploy the agent to production" }; + expect(extractMessageText(body)).toBe("Deploy the agent to production"); + }); + + it("returns empty string when body is null", () => { + expect(extractMessageText(null)).toBe(""); + }); + + it("returns empty string when body is undefined", () => { + expect(extractMessageText(undefined as unknown as null)).toBe(""); + }); +}); + +describe("extractMessageText — request params.message format", () => { + it("extracts text from params.message.parts[].text", () => { + const body = { + params: { + message: { + parts: [{ text: "Hello world" }], + }, + }, + }; + expect(extractMessageText(body)).toBe("Hello world"); + }); + + it("joins multiple parts with newlines", () => { + const body = { + params: { + message: { + parts: [ + { text: "First part" }, + { text: "Second part" }, + { text: "Third part" }, + ], + }, + }, + }; + expect(extractMessageText(body)).toBe("First part\nSecond part\nThird part"); + }); + + it("ignores parts without text field", () => { + const body = { + params: { + message: { + parts: [{ text: "Hello" }, { other: "field" }, { text: "World" }], + }, + }, + }; + expect(extractMessageText(body)).toBe("Hello\nWorld"); + }); + + it("returns empty string when params.message is absent", () => { + const body = { params: {} }; + expect(extractMessageText(body)).toBe(""); + }); +}); + +describe("extractMessageText — response result format", () => { + it("extracts text from result.parts[].text", () => { + const body = { + result: { + parts: [{ text: "Agent response" }], + }, + }; + expect(extractMessageText(body)).toBe("Agent response"); + }); + + it("extracts text from result.parts[].root.text", () => { + const body = { + result: { + parts: [{ root: { text: "Root response text" } }], + }, + }; + expect(extractMessageText(body)).toBe("Root response text"); + }); + + it("prefers parts[].text over parts[].root.text", () => { + const body = { + result: { + parts: [ + { text: "Direct text" }, + { root: { text: "Root text" } }, + ], + }, + }; + // Both are non-empty strings, so the first one wins (filter picks the first) + // The implementation: rText from rParts[0].text = "Direct text" + expect(extractMessageText(body)).toBe("Direct text"); + }); +}); + +describe("extractMessageText — plain string result", () => { + it("returns body.result when it is a plain string", () => { + const body = { result: "Simple string response" }; + expect(extractMessageText(body)).toBe("Simple string response"); + }); +}); + +describe("extractMessageText — priority order", () => { + it("prefers task format over params format", () => { + const body = { + task: "Task text", + params: { message: { parts: [{ text: "Params text" }] } }, + }; + // Implementation: checks task first, returns if non-empty + expect(extractMessageText(body)).toBe("Task text"); + }); + + it("prefers params format over result format", () => { + const body = { + params: { message: { parts: [{ text: "Params text" }] } }, + result: { parts: [{ text: "Result text" }] }, + }; + // Implementation: checks params.message.parts first (after task) + expect(extractMessageText(body)).toBe("Params text"); + }); +}); + +describe("extractMessageText — error resilience", () => { + it("returns empty string on malformed input", () => { + expect(extractMessageText({})).toBe(""); + expect(extractMessageText({ params: null })).toBe(""); + expect(extractMessageText({ result: null })).toBe(""); + }); + + it("returns empty string when all fields are absent", () => { + expect(extractMessageText({ random: "field" })).toBe(""); + }); + + it("handles missing parts array gracefully", () => { + const body = { params: { message: {} } }; + expect(extractMessageText(body)).toBe(""); + }); + + it("handles parts with undefined text gracefully", () => { + const body = { + result: { + parts: [{ text: undefined }, { text: "valid" }], + }, + }; + expect(extractMessageText(body)).toBe("valid"); + }); +}); diff --git a/canvas/src/components/__tests__/MissingKeysModal.test.tsx b/canvas/src/components/__tests__/MissingKeysModal.test.tsx new file mode 100644 index 00000000..23db886f --- /dev/null +++ b/canvas/src/components/__tests__/MissingKeysModal.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +/** + * Tests for MissingKeysModal's providerIdForModel helper. + * + * Covers: model match, no match, empty modelId, whitespace-only modelId, + * model with no required_env, models undefined, single vs multiple env vars, + * stable sort order for env var ordering. + */ +import { describe, expect, it } from "vitest"; +import { providerIdForModel } from "../MissingKeysModal"; + +describe("providerIdForModel — match behavior", () => { + it("returns sorted-joined env vars when model is found", () => { + const models = [ + { id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", required_env: ["ANTHROPIC_API_KEY"] }, + ]; + expect(providerIdForModel("claude-3-5-sonnet", models)).toBe("ANTHROPIC_API_KEY"); + }); + + it("returns null when model is not found", () => { + const models = [ + { id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", required_env: ["ANTHROPIC_API_KEY"] }, + ]; + expect(providerIdForModel("unknown-model", models)).toBeNull(); + }); + + it("returns null when models is undefined", () => { + expect(providerIdForModel("claude-3-5-sonnet", undefined)).toBeNull(); + }); + + it("returns null when modelId is empty string", () => { + const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }]; + expect(providerIdForModel("", models)).toBeNull(); + }); + + it("returns null when modelId is whitespace-only", () => { + const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }]; + expect(providerIdForModel(" ", models)).toBeNull(); + }); + + it("trims whitespace from modelId before matching", () => { + const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }]; + expect(providerIdForModel(" claude ", models)).toBe("KEY"); + }); +}); + +describe("providerIdForModel — required_env variations", () => { + it("returns null when model has no required_env", () => { + const models = [{ id: "local-model", name: "Local Model", required_env: [] }]; + expect(providerIdForModel("local-model", models)).toBeNull(); + }); + + it("returns null when model.required_env is undefined", () => { + const models = [{ id: "local-model", name: "Local Model" }] as Array<{ + id: string; + name: string; + required_env?: string[]; + }>; + expect(providerIdForModel("local-model", models)).toBeNull(); + }); + + it("sorts and joins multiple required_env alphabetically", () => { + const models = [ + { id: "openrouter", name: "OpenRouter", required_env: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] }, + ]; + // Expected: alphabetically sorted = ANTHROPIC_API_KEY|OPENAI_API_KEY + expect(providerIdForModel("openrouter", models)).toBe("ANTHROPIC_API_KEY|OPENAI_API_KEY"); + }); +});