diff options
Diffstat (limited to 'packages/vision-handoff/src/service.test.ts')
| -rw-r--r-- | packages/vision-handoff/src/service.test.ts | 375 |
1 files changed, 375 insertions, 0 deletions
diff --git a/packages/vision-handoff/src/service.test.ts b/packages/vision-handoff/src/service.test.ts new file mode 100644 index 0000000..8c4117e --- /dev/null +++ b/packages/vision-handoff/src/service.test.ts @@ -0,0 +1,375 @@ +import type { + AgentEvent, + ChatMessage, + ModelInfo, + ProviderContract, + ProviderEvent, + ToolContract, +} from "@dispatch/kernel"; +import { describe, expect, it, vi } from "vitest"; +import { createVisionHandoffService, type VisionHandoffDeps } from "./service.js"; + +// ── Test doubles (outermost-edge fakes — NOT @dispatch/* mocks) ────────────── + +function makeVisionProvider( + describe: (imageUrl: string) => string, + id = "umans", +): ProviderContract { + return { + id, + stream: vi.fn( + ( + messages: readonly ChatMessage[], + _tools: readonly ToolContract[], + ): AsyncIterable<ProviderEvent> => { + const img = messages.flatMap((m) => m.chunks).find((c) => c.type === "image"); + const url = img && img.type === "image" ? img.url : ""; + const text = describe(url); + async function* gen(): AsyncIterable<ProviderEvent> { + yield { type: "text-delta", delta: text }; + yield { type: "finish", reason: "stop" }; + } + return gen(); + }, + ), + }; +} + +function makeDeps(overrides: Partial<VisionHandoffDeps> = {}): VisionHandoffDeps { + const visionProvider = makeVisionProvider((url) => `DESCRIPTION of ${url}`); + const catalog = ["umans/umans-kimi-k2.7", "umans/umans-glm-5.2"]; + const infoMap: Record<string, ModelInfo> = { + "umans/umans-kimi-k2.7": { id: "umans-kimi-k2.7", vision: true }, + "umans/umans-glm-5.2": { id: "umans-glm-5.2" }, + }; + return { + credentialStore: { + listCatalog: vi.fn(async () => catalog), + getModelInfo: vi.fn(async (name: string) => infoMap[name]), + resolve: vi.fn((name: string) => { + if (name === "umans/umans-kimi-k2.7") + return { providerId: "umans", model: "umans-kimi-k2.7" }; + if (name === "umans/umans-glm-5.2") return { providerId: "umans", model: "umans-glm-5.2" }; + return undefined; + }), + }, + resolveModel: vi.fn((name: string) => + name === "umans/umans-kimi-k2.7" || name === "umans/umans-glm-5.2" + ? { provider: visionProvider, model: name.split("/")[1] } + : undefined, + ), + readFileAsDataUrl: vi.fn(async (path: string) => `data:image/png;base64,FILE(${path})`), + setConversationTitle: vi.fn(async (_conversationId: string, _title: string) => {}), + ...overrides, + }; +} + +describe("VisionHandoffService.isVisionCapable", () => { + it("returns true for kimi (via ModelInfo)", async () => { + const svc = createVisionHandoffService(makeDeps()); + expect(await svc.isVisionCapable("umans/umans-kimi-k2.7")).toBe(true); + }); + + it("returns false for glm-5.2", async () => { + const svc = createVisionHandoffService(makeDeps()); + expect(await svc.isVisionCapable("umans/umans-glm-5.2")).toBe(false); + }); + + it("returns false for undefined model name", async () => { + const svc = createVisionHandoffService(makeDeps()); + expect(await svc.isVisionCapable(undefined)).toBe(false); + }); +}); + +describe("VisionHandoffService.resolveVisionModel", () => { + it("resolves the kimi model from the catalog", async () => { + const svc = createVisionHandoffService(makeDeps()); + const vision = await svc.resolveVisionModel(); + expect(vision?.modelName).toBe("umans/umans-kimi-k2.7"); + expect(vision?.model).toBe("umans-kimi-k2.7"); + }); + + it("excludes the given model", async () => { + const svc = createVisionHandoffService(makeDeps()); + const vision = await svc.resolveVisionModel("umans/umans-kimi-k2.7"); + expect(vision).toBeUndefined(); + }); +}); + +describe("VisionHandoffService.prepareForProvider", () => { + it("passes messages through unchanged when the model is vision-capable", async () => { + const deps = makeDeps(); + const svc = createVisionHandoffService(deps); + const messages: ChatMessage[] = [ + { + role: "user", + chunks: [ + { type: "text", text: "What's this?" }, + { type: "image", url: "data:image/png;base64,abc" }, + ], + }, + ]; + const result = await svc.prepareForProvider(messages, "umans/umans-kimi-k2.7"); + expect(result).toBe(messages); // same reference — no copy, no change + }); + + it("passes messages through unchanged when there are no images", async () => { + const deps = makeDeps(); + const svc = createVisionHandoffService(deps); + const messages: ChatMessage[] = [{ role: "user", chunks: [{ type: "text", text: "hi" }] }]; + const result = await svc.prepareForProvider(messages, "umans/umans-glm-5.2"); + expect(result).toBe(messages); + }); + + it("replaces image chunks with numbered placeholders for a non-vision model", async () => { + const deps = makeDeps(); + const svc = createVisionHandoffService(deps); + const messages: ChatMessage[] = [ + { + role: "user", + chunks: [ + { type: "text", text: "Describe this" }, + { type: "image", url: "data:image/png;base64,img1" }, + ], + }, + ]; + const result = await svc.prepareForProvider(messages, "umans/umans-glm-5.2", { + conversationId: "conv-1", + }); + expect(result).toHaveLength(1); + const chunks = result[0]?.chunks; + expect(chunks).toHaveLength(2); + // Text chunk unchanged. + expect(chunks?.[0]).toEqual({ type: "text", text: "Describe this" }); + // Image chunk → placeholder text. + expect(chunks?.[1]?.type).toBe("text"); + const placeholder = (chunks?.[1] as { text: string }).text; + expect(placeholder).toContain("Image 1"); + expect(placeholder).toContain("consult_vision"); + }); + + it("assigns sequential image IDs across multiple messages", async () => { + const deps = makeDeps(); + const svc = createVisionHandoffService(deps); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "image", url: "data:image/png;base64,a" }] }, + { role: "assistant", chunks: [{ type: "text", text: "ok" }] }, + { role: "user", chunks: [{ type: "image", url: "data:image/png;base64,b" }] }, + ]; + const result = await svc.prepareForProvider(messages, "umans/umans-glm-5.2", { + conversationId: "conv-1", + }); + // First image → Image 1, second → Image 2. + expect((result[0]?.chunks[0] as { text: string }).text).toContain("Image 1"); + // Assistant message unchanged. + expect(result[1]?.chunks[0]?.type).toBe("text"); + expect((result[2]?.chunks[0] as { text: string }).text).toContain("Image 2"); + }); + + it("registers images so getRegisteredImage can look them up", async () => { + const deps = makeDeps(); + const svc = createVisionHandoffService(deps); + const messages: ChatMessage[] = [ + { + role: "user", + chunks: [{ type: "image", url: "data:image/png;base64,registered" }], + }, + ]; + await svc.prepareForProvider(messages, "umans/umans-glm-5.2", { conversationId: "conv-42" }); + const img = svc.getRegisteredImage("conv-42", 1); + expect(img?.url).toBe("data:image/png;base64,registered"); + }); + + it("uses no-vision placeholder when no vision model is available", async () => { + const deps = makeDeps(); + (deps.credentialStore.listCatalog as ReturnType<typeof vi.fn>).mockResolvedValue([]); + const svc = createVisionHandoffService(deps); + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "image", url: "data:image/png;base64,abc" }] }, + ]; + const result = await svc.prepareForProvider(messages, "umans/umans-glm-5.2", { + conversationId: "conv-1", + }); + const text = (result[0]?.chunks[0] as { text: string }).text; + expect(text).toContain("no vision-capable model"); + expect(text).not.toContain("consult_vision"); + }); +}); + +describe("VisionHandoffService.consultVision", () => { + function makeOrchestratorDouble(response: string): { + orchestrator: NonNullable< + VisionHandoffDeps["resolveOrchestrator"] extends () => infer T ? T : never + >; + handleMessage: ReturnType<typeof vi.fn>; + } { + const handleMessage = vi.fn( + async (input: { + conversationId: string; + text: string; + onEvent: (event: AgentEvent) => void; + }): Promise<void> => { + input.onEvent({ + type: "text-delta", + conversationId: input.conversationId, + turnId: "t1", + delta: response, + }); + input.onEvent({ + type: "done", + conversationId: input.conversationId, + turnId: "t1", + reason: "stop", + }); + }, + ); + return { orchestrator: { handleMessage }, handleMessage }; + } + + it("opens a new consultation with a pasted image and returns convId + response", async () => { + const deps = makeDeps(); + const { orchestrator, handleMessage } = makeOrchestratorDouble("The error is on line 12."); + deps.resolveOrchestrator = () => orchestrator; + const svc = createVisionHandoffService(deps); + + // Register an image first (as prepareForProvider would). + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "image", url: "data:image/png;base64,img1" }] }, + ]; + await svc.prepareForProvider(messages, "umans/umans-glm-5.2", { conversationId: "conv-1" }); + + const result = await svc.consultVision("What error is shown?", { + conversationId: "conv-1", + imageIds: [1], + }); + + expect("error" in result).toBe(false); + if (!("error" in result)) { + expect(result.conversationId).toBeTruthy(); + expect(result.response).toContain("line 12"); + expect(result.response).toContain(result.conversationId); + expect(result.response).toContain("dispatch CLI"); + } + // The orchestrator was called with the vision model + the image. + expect(handleMessage).toHaveBeenCalledOnce(); + const call = handleMessage.mock.calls[0]?.[0]; + expect(call.modelName).toBe("umans/umans-kimi-k2.7"); + expect(call.images).toHaveLength(1); + expect(call.images?.[0]?.url).toBe("data:image/png;base64,img1"); + }); + + it("labels the consultation tab with an 'IMAGE - ' prefixed title", async () => { + const deps = makeDeps(); + const { orchestrator } = makeOrchestratorDouble("The error is on line 12."); + deps.resolveOrchestrator = () => orchestrator; + const svc = createVisionHandoffService(deps); + + // Register an image first (as prepareForProvider would). + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "image", url: "data:image/png;base64,img1" }] }, + ]; + await svc.prepareForProvider(messages, "umans/umans-glm-5.2", { conversationId: "conv-1" }); + + const result = await svc.consultVision("What error is shown?", { + conversationId: "conv-1", + imageIds: [1], + }); + + expect("error" in result).toBe(false); + // The title was set with the IMAGE - prefix + the question. + expect(deps.setConversationTitle).toHaveBeenCalledOnce(); + const [titleConvId, title] = (deps.setConversationTitle as ReturnType<typeof vi.fn>).mock + .calls[0]; + expect(titleConvId).toBe((result as { conversationId: string }).conversationId); + expect(title).toBe("IMAGE - What error is shown?"); + }); + + it("does not call setConversationTitle when it is not provided", async () => { + const deps = makeDeps({ setConversationTitle: undefined }); + const { orchestrator } = makeOrchestratorDouble("response"); + deps.resolveOrchestrator = () => orchestrator; + const svc = createVisionHandoffService(deps); + + const messages: ChatMessage[] = [ + { role: "user", chunks: [{ type: "image", url: "data:image/png;base64,img1" }] }, + ]; + await svc.prepareForProvider(messages, "umans/umans-glm-5.2", { conversationId: "conv-1" }); + + // Should NOT throw — setConversationTitle is optional. + const result = await svc.consultVision("What?", { + conversationId: "conv-1", + imageIds: [1], + }); + expect("error" in result).toBe(false); + }); + + it("opens a consultation with a file path image", async () => { + const deps = makeDeps(); + const { orchestrator } = makeOrchestratorDouble("It's a diagram."); + deps.resolveOrchestrator = () => orchestrator; + const svc = createVisionHandoffService(deps); + + const result = await svc.consultVision("What is this diagram?", { + conversationId: "conv-1", + path: "diagram.png", + cwd: "/work", + }); + + expect("error" in result).toBe(false); + expect(deps.readFileAsDataUrl).toHaveBeenCalledWith("diagram.png", "/work"); + }); + + it("returns an error when imageId is not registered", async () => { + const deps = makeDeps(); + const { orchestrator } = makeOrchestratorDouble("response"); + deps.resolveOrchestrator = () => orchestrator; + const svc = createVisionHandoffService(deps); + + const result = await svc.consultVision("What?", { + conversationId: "conv-1", + imageIds: [99], // not registered + }); + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toContain("Image 99"); + } + }); + + it("returns an error when no orchestrator is available", async () => { + const deps = makeDeps(); + // No resolveOrchestrator provided. + const svc = createVisionHandoffService(deps); + const result = await svc.consultVision("What?", { + conversationId: "conv-1", + imageIds: [1], + }); + expect("error" in result).toBe(true); + }); + + it("returns an error when no vision model is available", async () => { + const deps = makeDeps(); + (deps.credentialStore.listCatalog as ReturnType<typeof vi.fn>).mockResolvedValue([]); + const { orchestrator } = makeOrchestratorDouble("response"); + deps.resolveOrchestrator = () => orchestrator; + const svc = createVisionHandoffService(deps); + const result = await svc.consultVision("What?", { + conversationId: "conv-1", + imageIds: [1], + }); + expect("error" in result).toBe(true); + if ("error" in result) { + expect(result.error).toContain("No vision-capable model"); + } + }); + + it("returns an error when no image source is provided", async () => { + const deps = makeDeps(); + const { orchestrator } = makeOrchestratorDouble("response"); + deps.resolveOrchestrator = () => orchestrator; + const svc = createVisionHandoffService(deps); + const result = await svc.consultVision("What?", { + conversationId: "conv-1", + }); + expect("error" in result).toBe(true); + }); +}); |
