test(image-tool): share temp agent dirs and table-drive validation cases

This commit is contained in:
Peter Steinberger
2026-02-21 23:46:32 +00:00
parent 150c048b0a
commit a353dae14f

View File

@@ -18,6 +18,15 @@ async function writeAuthProfiles(agentDir: string, profiles: unknown) {
); );
} }
async function withTempAgentDir<T>(run: (agentDir: string) => Promise<T>): Promise<T> {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-"));
try {
return await run(agentDir);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
}
const ONE_PIXEL_PNG_B64 = const ONE_PIXEL_PNG_B64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII="; "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
const ONE_PIXEL_GIF_B64 = "R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs="; const ONE_PIXEL_GIF_B64 = "R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=";
@@ -141,84 +150,89 @@ describe("image tool implicit imageModel config", () => {
}); });
it("stays disabled without auth when no pairing is possible", async () => { it("stays disabled without auth when no pairing is possible", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); await withTempAgentDir(async (agentDir) => {
const cfg: OpenClawConfig = { const cfg: OpenClawConfig = {
agents: { defaults: { model: { primary: "openai/gpt-5.2" } } }, agents: { defaults: { model: { primary: "openai/gpt-5.2" } } },
}; };
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toBeNull(); expect(resolveImageModelConfigForTool({ cfg, agentDir })).toBeNull();
expect(createImageTool({ config: cfg, agentDir })).toBeNull(); expect(createImageTool({ config: cfg, agentDir })).toBeNull();
});
}); });
it("pairs minimax primary with MiniMax-VL-01 (and fallbacks) when auth exists", async () => { it("pairs minimax primary with MiniMax-VL-01 (and fallbacks) when auth exists", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); await withTempAgentDir(async (agentDir) => {
vi.stubEnv("MINIMAX_API_KEY", "minimax-test"); vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
vi.stubEnv("OPENAI_API_KEY", "openai-test"); vi.stubEnv("OPENAI_API_KEY", "openai-test");
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test"); vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
const cfg: OpenClawConfig = { const cfg: OpenClawConfig = {
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.1" } } }, agents: { defaults: { model: { primary: "minimax/MiniMax-M2.1" } } },
}; };
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({
primary: "minimax/MiniMax-VL-01", primary: "minimax/MiniMax-VL-01",
fallbacks: ["openai/gpt-5-mini", "anthropic/claude-opus-4-5"], fallbacks: ["openai/gpt-5-mini", "anthropic/claude-opus-4-5"],
});
expect(createImageTool({ config: cfg, agentDir })).not.toBeNull();
}); });
expect(createImageTool({ config: cfg, agentDir })).not.toBeNull();
}); });
it("pairs zai primary with glm-4.6v (and fallbacks) when auth exists", async () => { it("pairs zai primary with glm-4.6v (and fallbacks) when auth exists", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); await withTempAgentDir(async (agentDir) => {
vi.stubEnv("ZAI_API_KEY", "zai-test"); vi.stubEnv("ZAI_API_KEY", "zai-test");
vi.stubEnv("OPENAI_API_KEY", "openai-test"); vi.stubEnv("OPENAI_API_KEY", "openai-test");
vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test"); vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test");
const cfg: OpenClawConfig = { const cfg: OpenClawConfig = {
agents: { defaults: { model: { primary: "zai/glm-4.7" } } }, agents: { defaults: { model: { primary: "zai/glm-4.7" } } },
}; };
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({
primary: "zai/glm-4.6v", primary: "zai/glm-4.6v",
fallbacks: ["openai/gpt-5-mini", "anthropic/claude-opus-4-5"], fallbacks: ["openai/gpt-5-mini", "anthropic/claude-opus-4-5"],
});
expect(createImageTool({ config: cfg, agentDir })).not.toBeNull();
}); });
expect(createImageTool({ config: cfg, agentDir })).not.toBeNull();
}); });
it("pairs a custom provider when it declares an image-capable model", async () => { it("pairs a custom provider when it declares an image-capable model", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); await withTempAgentDir(async (agentDir) => {
await writeAuthProfiles(agentDir, { await writeAuthProfiles(agentDir, {
version: 1, version: 1,
profiles: { profiles: {
"acme:default": { type: "api_key", provider: "acme", key: "sk-test" }, "acme:default": { type: "api_key", provider: "acme", key: "sk-test" },
}, },
}); });
const cfg: OpenClawConfig = { const cfg: OpenClawConfig = {
agents: { defaults: { model: { primary: "acme/text-1" } } }, agents: { defaults: { model: { primary: "acme/text-1" } } },
models: { models: {
providers: { providers: {
acme: { acme: {
baseUrl: "https://example.com", baseUrl: "https://example.com",
models: [ models: [
makeModelDefinition("text-1", ["text"]), makeModelDefinition("text-1", ["text"]),
makeModelDefinition("vision-1", ["text", "image"]), makeModelDefinition("vision-1", ["text", "image"]),
], ],
},
}, },
}, },
}, };
}; expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ primary: "acme/vision-1",
primary: "acme/vision-1", });
expect(createImageTool({ config: cfg, agentDir })).not.toBeNull();
}); });
expect(createImageTool({ config: cfg, agentDir })).not.toBeNull();
}); });
it("prefers explicit agents.defaults.imageModel", async () => { it("prefers explicit agents.defaults.imageModel", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); await withTempAgentDir(async (agentDir) => {
const cfg: OpenClawConfig = { const cfg: OpenClawConfig = {
agents: { agents: {
defaults: { defaults: {
model: { primary: "minimax/MiniMax-M2.1" }, model: { primary: "minimax/MiniMax-M2.1" },
imageModel: { primary: "openai/gpt-5-mini" }, imageModel: { primary: "openai/gpt-5-mini" },
},
}, },
}, };
}; expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ primary: "openai/gpt-5-mini",
primary: "openai/gpt-5-mini", });
}); });
}); });
@@ -227,30 +241,33 @@ describe("image tool implicit imageModel config", () => {
// because images are auto-injected into prompts. The tool description is // because images are auto-injected into prompts. The tool description is
// adjusted via modelHasVision to discourage redundant usage. // adjusted via modelHasVision to discourage redundant usage.
vi.stubEnv("OPENAI_API_KEY", "test-key"); vi.stubEnv("OPENAI_API_KEY", "test-key");
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-image-")); await withTempAgentDir(async (agentDir) => {
const cfg: OpenClawConfig = { const cfg: OpenClawConfig = {
agents: { agents: {
defaults: { defaults: {
model: { primary: "acme/vision-1" }, model: { primary: "acme/vision-1" },
imageModel: { primary: "openai/gpt-5-mini" }, imageModel: { primary: "openai/gpt-5-mini" },
},
},
models: {
providers: {
acme: {
baseUrl: "https://example.com",
models: [makeModelDefinition("vision-1", ["text", "image"])],
}, },
}, },
}, models: {
}; providers: {
// Tool should still be available for explicit image analysis requests acme: {
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ baseUrl: "https://example.com",
primary: "openai/gpt-5-mini", models: [makeModelDefinition("vision-1", ["text", "image"])],
},
},
},
};
// Tool should still be available for explicit image analysis requests
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({
primary: "openai/gpt-5-mini",
});
const tool = createImageTool({ config: cfg, agentDir, modelHasVision: true });
expect(tool).not.toBeNull();
expect(tool?.description).toContain(
"Only use this tool when images were NOT already provided",
);
}); });
const tool = createImageTool({ config: cfg, agentDir, modelHasVision: true });
expect(tool).not.toBeNull();
expect(tool?.description).toContain("Only use this tool when images were NOT already provided");
}); });
it("exposes an Anthropic-safe image schema without union keywords", async () => { it("exposes an Anthropic-safe image schema without union keywords", async () => {
@@ -598,41 +615,50 @@ describe("image tool response validation", () => {
}; };
} }
it("caps image-tool max tokens by model capability", () => { it.each([
expect(__testing.resolveImageToolMaxTokens(4000)).toBe(4000); {
name: "caps image-tool max tokens by model capability",
maxOutputTokens: 4000,
expected: 4000,
},
{
name: "keeps requested image-tool max tokens when model capability is higher",
maxOutputTokens: 8192,
expected: 4096,
},
{
name: "falls back to requested image-tool max tokens when model capability is missing",
maxOutputTokens: undefined,
expected: 4096,
},
])("$name", ({ maxOutputTokens, expected }) => {
expect(__testing.resolveImageToolMaxTokens(maxOutputTokens)).toBe(expected);
}); });
it("keeps requested image-tool max tokens when model capability is higher", () => { it.each([
expect(__testing.resolveImageToolMaxTokens(8192)).toBe(4096); {
}); name: "rejects image-model responses with no final text",
message: createAssistantMessage({
it("falls back to requested image-tool max tokens when model capability is missing", () => { content: [{ type: "thinking", thinking: "hmm" }],
expect(__testing.resolveImageToolMaxTokens(undefined)).toBe(4096); }) as never,
}); expectedError: /returned no text/i,
},
it("rejects image-model responses with no final text", () => { {
name: "surfaces provider errors from image-model responses",
message: createAssistantMessage({
stopReason: "error",
errorMessage: "boom",
}) as never,
expectedError: /boom/i,
},
])("$name", ({ message, expectedError }) => {
expect(() => expect(() =>
__testing.coerceImageAssistantText({ __testing.coerceImageAssistantText({
provider: "openai", provider: "openai",
model: "gpt-5-mini", model: "gpt-5-mini",
message: createAssistantMessage({ message,
content: [{ type: "thinking", thinking: "hmm" }],
}) as never,
}), }),
).toThrow(/returned no text/i); ).toThrow(expectedError);
});
it("surfaces provider errors from image-model responses", () => {
expect(() =>
__testing.coerceImageAssistantText({
provider: "openai",
model: "gpt-5-mini",
message: createAssistantMessage({
stopReason: "error",
errorMessage: "boom",
}) as never,
}),
).toThrow(/boom/i);
}); });
it("returns trimmed text from image-model responses", () => { it("returns trimmed text from image-model responses", () => {