summaryrefslogtreecommitdiffhomepage
path: root/packages/vision-handoff/src/pure.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/vision-handoff/src/pure.test.ts')
-rw-r--r--packages/vision-handoff/src/pure.test.ts180
1 files changed, 180 insertions, 0 deletions
diff --git a/packages/vision-handoff/src/pure.test.ts b/packages/vision-handoff/src/pure.test.ts
new file mode 100644
index 0000000..21b1224
--- /dev/null
+++ b/packages/vision-handoff/src/pure.test.ts
@@ -0,0 +1,180 @@
+import type { ModelInfo, ProviderEvent } from "@dispatch/kernel";
+import { describe, expect, it } from "vitest";
+import {
+ collectTextFromStream,
+ findVisionModelName,
+ formatConsultationTitle,
+ formatConsultResult,
+ formatImagePlaceholder,
+ formatNoVisionPlaceholder,
+ isVisionCapable,
+} from "./pure.js";
+
+describe("isVisionCapable", () => {
+ it("returns true when ModelInfo.vision is true", () => {
+ expect(isVisionCapable("umans/umans-kimi-k2.7", { id: "umans-kimi-k2.7", vision: true })).toBe(
+ true,
+ );
+ });
+
+ it("returns false when ModelInfo.vision is false (overrides name heuristic)", () => {
+ expect(isVisionCapable("umans/umans-kimi-k2.7", { id: "umans-kimi-k2.7", vision: false })).toBe(
+ false,
+ );
+ });
+
+ it("falls back to name heuristic when vision is absent (umans kimi + qwen)", () => {
+ expect(isVisionCapable("umans/umans-kimi-k2.7", undefined)).toBe(true);
+ expect(isVisionCapable("umans/umans-qwen3.6-35b-a3b", undefined)).toBe(true);
+ });
+
+ it("falls back to name heuristic when vision is absent (non-vision)", () => {
+ expect(isVisionCapable("umans/umans-glm-5.2", undefined)).toBe(false);
+ expect(isVisionCapable("umans/umans-coder", { id: "umans-coder" })).toBe(false);
+ });
+
+ it("returns false for undefined model name", () => {
+ expect(isVisionCapable(undefined, undefined)).toBe(false);
+ });
+});
+
+describe("findVisionModelName", () => {
+ const getInfo = async (name: string): Promise<ModelInfo | undefined> => {
+ const map: Record<string, ModelInfo> = {
+ "umans/umans-kimi-k2.7": { id: "umans-kimi-k2.7", vision: true },
+ "umans/umans-qwen3.6-35b-a3b": { id: "umans-qwen3.6-35b-a3b", vision: true },
+ "umans/umans-glm-5.2": { id: "umans-glm-5.2" },
+ "umans/llama-vision": { id: "llama-vision", vision: true },
+ };
+ return map[name];
+ };
+
+ it("finds the first umans kimi model via name heuristic", async () => {
+ const name = await findVisionModelName(
+ ["umans/umans-glm-5.2", "umans/umans-kimi-k2.7", "umans/llama-vision"],
+ getInfo,
+ );
+ expect(name).toBe("umans/umans-kimi-k2.7");
+ });
+
+ it("finds a vision model via ModelInfo.vision when name heuristic misses", async () => {
+ const name = await findVisionModelName(["umans/umans-glm-5.2", "umans/llama-vision"], getInfo);
+ expect(name).toBe("umans/llama-vision");
+ });
+
+ it("skips the excluded model and finds the next vision model", async () => {
+ const name = await findVisionModelName(
+ ["umans/umans-kimi-k2.7", "umans/umans-qwen3.6-35b-a3b"],
+ getInfo,
+ "umans/umans-kimi-k2.7",
+ );
+ expect(name).toBe("umans/umans-qwen3.6-35b-a3b");
+ });
+
+ it("returns undefined when no vision model is available", async () => {
+ const name = await findVisionModelName(["umans/umans-glm-5.2"], getInfo);
+ expect(name).toBeUndefined();
+ });
+
+ it("returns undefined for empty catalog", async () => {
+ const name = await findVisionModelName([], getInfo);
+ expect(name).toBeUndefined();
+ });
+});
+
+describe("collectTextFromStream", () => {
+ async function* stream(events: ProviderEvent[]): AsyncIterable<ProviderEvent> {
+ for (const e of events) yield e;
+ }
+
+ it("collects text-delta events into a single string", async () => {
+ const events: ProviderEvent[] = [
+ { type: "text-delta", delta: "Hello " },
+ { type: "text-delta", delta: "world!" },
+ ];
+ const text = await collectTextFromStream(stream(events));
+ expect(text).toBe("Hello world!");
+ });
+
+ it("ignores non-text events", async () => {
+ const events: ProviderEvent[] = [
+ { type: "reasoning-delta", delta: "thinking..." },
+ { type: "text-delta", delta: "answer" },
+ { type: "usage", usage: { inputTokens: 5, outputTokens: 1 } },
+ { type: "finish", reason: "stop" },
+ ];
+ const text = await collectTextFromStream(stream(events));
+ expect(text).toBe("answer");
+ });
+
+ it("throws on an error event", async () => {
+ const events: ProviderEvent[] = [
+ { type: "text-delta", delta: "partial" },
+ { type: "error", message: "boom" },
+ ];
+ await expect(collectTextFromStream(stream(events))).rejects.toThrow("boom");
+ });
+
+ it("returns empty string for an empty stream", async () => {
+ const text = await collectTextFromStream(stream([]));
+ expect(text).toBe("");
+ });
+});
+
+describe("formatImagePlaceholder", () => {
+ it("includes the image ID and mentions consult_vision", () => {
+ const text = formatImagePlaceholder(1);
+ expect(text).toContain("Image 1");
+ expect(text).toContain("consult_vision");
+ expect(text).toContain("imageIds=[1]");
+ });
+
+ it("increments the ID for each image", () => {
+ expect(formatImagePlaceholder(2)).toContain("Image 2");
+ expect(formatImagePlaceholder(2)).toContain("imageIds=[2]");
+ });
+});
+
+describe("formatNoVisionPlaceholder", () => {
+ it("explains the limitation", () => {
+ const text = formatNoVisionPlaceholder();
+ expect(text).toContain("no vision-capable model");
+ });
+});
+
+describe("formatConsultResult", () => {
+ it("includes the conversation ID, the response, and the dispatch CLI hint", () => {
+ const result = formatConsultResult("abc-123", "The error is on line 12.");
+ expect(result).toContain("abc-123");
+ expect(result).toContain("The error is on line 12.");
+ expect(result).toContain("dispatch CLI");
+ });
+
+ it("trims the response", () => {
+ const result = formatConsultResult("c1", " spaced ");
+ expect(result).toContain("spaced");
+ expect(result).not.toContain("spaced ");
+ });
+});
+
+describe("formatConsultationTitle", () => {
+ it("prefixes the question with 'IMAGE - '", () => {
+ expect(formatConsultationTitle("What error is shown?")).toBe("IMAGE - What error is shown?");
+ });
+
+ it("truncates long questions to 80 chars with an ellipsis (matching the store's TITLE_MAX)", () => {
+ const long = "x".repeat(100);
+ const title = formatConsultationTitle(long);
+ expect(title).toBe(`IMAGE - ${"x".repeat(80)}…`);
+ expect(title.length).toBe("IMAGE - ".length + 80 + 1); // prefix + 80 + ellipsis
+ });
+
+ it("does not truncate questions at or under 80 chars", () => {
+ expect(formatConsultationTitle("x".repeat(80))).toBe(`IMAGE - ${"x".repeat(80)}`);
+ expect(formatConsultationTitle("x".repeat(79))).toBe(`IMAGE - ${"x".repeat(79)}`);
+ });
+
+ it("handles an empty question", () => {
+ expect(formatConsultationTitle("")).toBe("IMAGE - ");
+ });
+});