6f60b242bd
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
242 lines
10 KiB
TypeScript
242 lines
10 KiB
TypeScript
/**
|
|
* Unit tests (offline) for the read_image "vision-model describe" variant, driven by a fake LLM:
|
|
* definition overrides (new prompt parameter, description mentioning the vision model id), a
|
|
* single image + prompt sent to the vision model, text output and failure paths (no vision model
|
|
* configured / vision model request fails), results carrying no images; and swapping on the
|
|
* Environment side (injecting visionDescriber switches to the describe variant).
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
DESCRIBE_IMAGE_NAME,
|
|
createDescribeImageTool,
|
|
} from "../src/environment/tools/describe-image.js";
|
|
import { BUILTIN_TOOL_FACTORIES } from "../src/environment/tools/registry.js";
|
|
import { Environment } from "../src/environment/environment.js";
|
|
import { assistantText, partialText, toolCall } from "../src/omnimessage/index.js";
|
|
import type { OmniMessage } from "../src/omnimessage/index.js";
|
|
import type { ToolResult } from "../src/environment/tools/types.js";
|
|
import type {
|
|
GenerativeModelParameters,
|
|
LLMInterface,
|
|
LLMOutcome,
|
|
ToolDefinitionConfig,
|
|
VisionDescriberService,
|
|
} from "../src/interfaces.js";
|
|
|
|
/** 1x1 transparent PNG (includes magic bytes, enough for mime sniffing). */
|
|
const PNG_1X1 = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
|
"base64",
|
|
);
|
|
|
|
/** Config entry for describe_image (forModel: "text-only"; the definition comes entirely from config, the implementation never rewrites it at runtime). */
|
|
const definition: ToolDefinitionConfig = {
|
|
name: DESCRIBE_IMAGE_NAME,
|
|
forModel: "text-only",
|
|
description: "describe image via vision model",
|
|
parameters: {
|
|
type: "object",
|
|
properties: { source: { type: "string" }, prompt: { type: "string" } },
|
|
required: ["source"],
|
|
},
|
|
permission: "r",
|
|
};
|
|
|
|
/**
|
|
* Fake vision LLM: records the received newMessages, emits output following the real streaming
|
|
* protocol (partial start -> word-by-word delta -> stop -> complete text), and finishes with the given outcome.
|
|
*/
|
|
function fakeLLM(reply: string, outcome: LLMOutcome = { status: "completed" }) {
|
|
const calls: GenerativeModelParameters[] = [];
|
|
const llm: LLMInterface = {
|
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
async *streamGenerate(params: GenerativeModelParameters) {
|
|
calls.push(params);
|
|
if (reply) {
|
|
yield partialText("start");
|
|
// Split into two delta chunks to verify piecewise forwarding (rather than buffering the whole thing).
|
|
const mid = Math.ceil(reply.length / 2);
|
|
yield partialText("delta", reply.slice(0, mid));
|
|
yield partialText("delta", reply.slice(mid));
|
|
yield partialText("stop");
|
|
yield assistantText(reply);
|
|
}
|
|
return outcome;
|
|
},
|
|
};
|
|
return { llm, calls };
|
|
}
|
|
|
|
async function run(
|
|
args: Record<string, unknown>,
|
|
workspaceDir: string,
|
|
describer: VisionDescriberService,
|
|
) {
|
|
const tool = createDescribeImageTool(definition, describer);
|
|
const gen = tool.execute(args, { workspaceDir, toolCallId: "c1" });
|
|
const messages: OmniMessage[] = [];
|
|
let result: ToolResult | void;
|
|
for (;;) {
|
|
const res = await gen.next();
|
|
if (res.done) {
|
|
result = res.value;
|
|
break;
|
|
}
|
|
messages.push(res.value);
|
|
}
|
|
const text = messages.map((m) => (m.payload as { output?: string }).output ?? "").join("");
|
|
return { messages, result, text };
|
|
}
|
|
|
|
let tmp: string;
|
|
|
|
beforeEach(async () => {
|
|
tmp = await mkdtemp(path.join(tmpdir(), "penguin-descimg-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("describe_image (the text-only-model variant of read_image)", () => {
|
|
it("the definition is taken verbatim from the config entry (no runtime rewriting)", () => {
|
|
const tool = createDescribeImageTool(definition, { modelId: "vis-1" });
|
|
expect(tool.name).toBe(DESCRIBE_IMAGE_NAME);
|
|
expect(tool.definition).toBe(definition);
|
|
});
|
|
|
|
it("the registry assembles by tool name; without an injected visionDescriber it finishes as failed with a note (instead of returning the image)", async () => {
|
|
const factory = BUILTIN_TOOL_FACTORIES[DESCRIBE_IMAGE_NAME]!;
|
|
await writeFile(path.join(tmp, "a.png"), PNG_1X1);
|
|
const describeTool = factory(definition, undefined);
|
|
const gen = describeTool.execute({ source: "a.png" }, { workspaceDir: tmp, toolCallId: "c1" });
|
|
let result: ToolResult | void;
|
|
let text = "";
|
|
for (;;) {
|
|
const res = await gen.next();
|
|
if (res.done) {
|
|
result = res.value;
|
|
break;
|
|
}
|
|
text += (res.value.payload as { output?: string }).output ?? "";
|
|
}
|
|
expect(result?.stopReason).toBe("failed");
|
|
expect(text).toContain("No vision model");
|
|
});
|
|
|
|
it("image + custom prompt is sent to the vision model in a single shot, its text comes back, the result carries no images", async () => {
|
|
await writeFile(path.join(tmp, "a.png"), PNG_1X1);
|
|
const { llm, calls } = fakeLLM("The image shows a penguin.");
|
|
const describer: VisionDescriberService = { modelId: "vis-1", createLLM: () => llm };
|
|
const { messages, result, text } = await run(
|
|
{ source: "a.png", prompt: "What animal is in the image?" },
|
|
tmp,
|
|
describer,
|
|
);
|
|
|
|
// Single message = prompt text + data URL image (same role user, merged into one request).
|
|
expect(calls).toHaveLength(1);
|
|
const payloads = calls[0]!.newMessages.map(
|
|
(m) => m.payload as { type: string; text?: string; image_url?: string },
|
|
);
|
|
expect(payloads[0]!.type).toBe("text");
|
|
expect(payloads[0]!.text).toBe("What animal is in the image?");
|
|
expect(payloads[1]!.type).toBe("image_url");
|
|
expect(payloads[1]!.image_url).toBe(`data:image/png;base64,${PNG_1X1.toString("base64")}`);
|
|
|
|
expect(text).toContain("described by vis-1");
|
|
expect(text).toContain("The image shows a penguin.");
|
|
// Streaming forward: the header line and description deltas are emitted as separate chunks (not buffered as a whole), and the complete text is not forwarded again.
|
|
const outputs = messages.map((m) => (m.payload as { output?: string }).output ?? "");
|
|
expect(outputs.length).toBeGreaterThanOrEqual(3); // header + >=2 description delta chunks
|
|
expect(outputs[0]).toContain("described by vis-1");
|
|
expect(outputs.slice(1).join("")).toBe("The image shows a penguin.");
|
|
// Text-based description: the result carries no images (images never enter session history).
|
|
expect(result?.images).toBeUndefined();
|
|
expect(result?.stopReason).toBeUndefined(); // defaults to completed
|
|
});
|
|
|
|
it("uses the default question when no prompt is given", async () => {
|
|
await writeFile(path.join(tmp, "a.png"), PNG_1X1);
|
|
const { llm, calls } = fakeLLM("desc");
|
|
await run({ source: "a.png" }, tmp, { modelId: "vis-1", createLLM: () => llm });
|
|
const first = calls[0]!.newMessages[0]!.payload as { text?: string };
|
|
expect(first.text).toContain("Describe this image");
|
|
});
|
|
|
|
it("no vision model configured: failed plus an explanation of how to configure one", async () => {
|
|
await writeFile(path.join(tmp, "a.png"), PNG_1X1);
|
|
const { result, text } = await run({ source: "a.png" }, tmp, { modelId: null });
|
|
expect(result?.stopReason).toBe("failed");
|
|
expect(text).toContain("No vision model");
|
|
expect(text).toContain("vision_model");
|
|
});
|
|
|
|
it("vision model request failure: failed with the status and message", async () => {
|
|
await writeFile(path.join(tmp, "a.png"), PNG_1X1);
|
|
const { llm } = fakeLLM("", { status: "failed", errorMessage: "401 unauthorized" });
|
|
const { result, text } = await run({ source: "a.png" }, tmp, {
|
|
modelId: "vis-1",
|
|
createLLM: () => llm,
|
|
});
|
|
expect(result?.stopReason).toBe("failed");
|
|
expect(text).toContain("failed");
|
|
expect(text).toContain("401 unauthorized");
|
|
});
|
|
|
|
it("image validation reuses read_image: an unsupported type fails immediately without calling the vision model", async () => {
|
|
await writeFile(path.join(tmp, "a.txt"), "not an image");
|
|
const { llm, calls } = fakeLLM("desc");
|
|
const { result } = await run({ source: "a.txt" }, tmp, {
|
|
modelId: "vis-1",
|
|
createLLM: () => llm,
|
|
});
|
|
expect(result?.stopReason).toBe("failed");
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
it("Environment assembles the describe_image entry with the delegated-description implementation; definition matches the config", async () => {
|
|
await writeFile(path.join(tmp, "a.png"), PNG_1X1);
|
|
const { llm } = fakeLLM("delegated description result");
|
|
const env = new Environment({
|
|
workspaceDir: tmp,
|
|
toolConfig: {
|
|
// Already filtered by selectBuiltinToolsForModel per session model before assembly; only the describe entry remains here.
|
|
customTools: [definition],
|
|
mcpServers: [],
|
|
},
|
|
services: { visionDescriber: { modelId: "vis-1", createLLM: () => llm } },
|
|
});
|
|
// Tool listing matches config: describe_image carries the prompt parameter.
|
|
const tools = await env.listTools();
|
|
const describeImage = tools.find((t) => t.name === DESCRIBE_IMAGE_NAME)!;
|
|
const props = (describeImage.parameters as { properties: Record<string, unknown> }).properties;
|
|
expect(Object.keys(props)).toContain("prompt");
|
|
|
|
// Execution goes through description: outputs text, the complete message carries no images.
|
|
const out: OmniMessage[] = [];
|
|
for await (const m of env.executeTool({
|
|
toolCall: toolCall({
|
|
name: DESCRIBE_IMAGE_NAME,
|
|
arguments: '{"source":"a.png"}',
|
|
toolCallId: "t1",
|
|
}),
|
|
})) {
|
|
out.push(m);
|
|
}
|
|
const complete = out[out.length - 1]!.payload as {
|
|
type?: string;
|
|
output?: string;
|
|
images?: string[];
|
|
stop_reason?: string;
|
|
};
|
|
expect(complete.type).toBe("tool_call_output");
|
|
expect(complete.stop_reason).toBe("completed");
|
|
expect(complete.output).toContain("delegated description result");
|
|
expect(complete.images).toBeUndefined();
|
|
});
|
|
});
|